From 25c83081e70bb0ebfef4c76183e5a19aae25f8ef Mon Sep 17 00:00:00 2001 From: egg-orchestrator Date: Sun, 26 Apr 2026 23:05:33 +0000 Subject: [PATCH 01/26] Initialize SDLC contract for issue #1931 --- .egg-state/contracts/issue-1931.json | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 .egg-state/contracts/issue-1931.json diff --git a/.egg-state/contracts/issue-1931.json b/.egg-state/contracts/issue-1931.json new file mode 100644 index 0000000000..bb568d5b2d --- /dev/null +++ b/.egg-state/contracts/issue-1931.json @@ -0,0 +1,23 @@ +{ + "schemaVersion": "1.0", + "issue": { + "number": 1931, + "title": "Issue #1931", + "url": "https://github.com/jwbron/egg/issues/1931" + }, + "pipeline_id": "issue-1931", + "current_phase": "refine", + "acceptance_criteria": [], + "phases": [], + "decisions": [], + "workflow_owner": null, + "audit_log": [], + "refine_review_cycles": 0, + "refine_review_feedback": "", + "plan_review_cycles": 0, + "plan_review_feedback": "", + "pr": null, + "feedback": null, + "phase_configs": null, + "agent_executions": [] +} From 5ca30533755811b35d68d9235cb4aa53dd1e9c48 Mon Sep 17 00:00:00 2001 From: egg Date: Sun, 26 Apr 2026 23:17:01 +0000 Subject: [PATCH 02/26] refine: analysis for #1931 (Confluence gateway read-only v1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drafts the refine-phase analysis for issue #1931 — Confluence gateway read-only support, mirroring the Jira gateway pattern from #1556. Captures the Confluence v1/v2 API split, the v2-first hybrid (with v1 CQL search and v1 fallbacks for known v2 comment bugs), the space allowlist + verb allowlist, the private-mode-only restriction, and the shared Atlassian credential strategy with the Jira gateway. All fourteen multiple-choice decisions and ten free-form feedback items are registered against the contract. Co-Authored-By: Claude Opus 4.7 --- .egg-state/drafts/1931-analysis.md | 478 +++++++++++++++++++++++++++++ 1 file changed, 478 insertions(+) create mode 100644 .egg-state/drafts/1931-analysis.md diff --git a/.egg-state/drafts/1931-analysis.md b/.egg-state/drafts/1931-analysis.md new file mode 100644 index 0000000000..cb5eb04d80 --- /dev/null +++ b/.egg-state/drafts/1931-analysis.md @@ -0,0 +1,478 @@ +# Analysis: Add Confluence gateway support (read-only v1) + +> Issue: #1931 | Phase: refine + +## Problem Statement + +Sandboxed egg agents today have no way to read Confluence pages. The host-side `mcp__confluence__*` MCP that bundles Atlassian (Jira + Confluence) is unusable from the sandbox for the same reasons #1556 enumerated for Jira: (1) the MCP runs in the host Claude Code process and is unreachable from isolated agent containers, and (2) it exposes the owning human's full Atlassian API surface (writes, deletes, permission changes) with no space or verb allowlist — a contradiction of egg's "infrastructure beats config" security thesis. + +#1556 has already landed the Jira gateway. Issue #1557 (Jira-epic SDLC pipelines) and the [`/impact-analysis` skill](../../docs/index.md) need to read Confluence pages **linked from Jira tickets** during the refine phase. Today they cannot, because Confluence remains host-only. #1931 is the infrastructure-only v1: **read-only** Confluence access for sandboxed agents, delivered through the existing gateway sidecar, **mirroring the Jira wrapper one-for-one**, with Atlassian credentials held exclusively in the gateway and shared with the Jira gateway (same Atlassian tenant). + +Desired outcome: + +1. Sandboxed agents can fetch a page, list pages in a space, fetch page descendants, read footer + inline comments, list spaces, and search via CQL — all via the gateway. +2. Atlassian credentials **never** enter the agent container (zero-credential invariant preserved — see [`docs/architecture/credential-injection.md`](../../docs/architecture/credential-injection.md)). +3. Confluence endpoints are only reachable when the agent session is in **private network mode** (see [`docs/architecture/network-isolation.md`](../../docs/architecture/network-isolation.md)); in public mode the gateway fails closed. +4. Policy is keyed on **space allowlist + verb allowlist** — agents cannot read spaces or call verbs the operator has not granted. +5. v1 endpoints, policy, and credential scopes are shaped so the future write verbs (page create / update, comment create) drop in as pure extensions. Deletions, space admin, and permission/restriction changes are **out of scope ever**. +6. **No per-agent env var** is required (unlike `EGG_JIRA_TICKET`). Confluence is consulted as reference material from ticket / epic links, not as a primary unit of work. +7. Future write verbs are designed in but not implemented: same private-mode restriction, same allowlist plumbing, same audit shape. + +## Current Behavior + +### Gateway sidecar as choke point — Jira pattern is now the template + +The gateway ([`gateway/gateway.py`](../../gateway/gateway.py)) already exposes a Jira read-only surface delivered by #1556 ([reference doc](../../docs/reference/jira-wrapper.md)). Confluence v1 will be a **structural copy**: + +| Jira primitive (extant) | Confluence primitive (to add) | Reuse strategy | +|-------------------------|-------------------------------|----------------| +| `gateway/jira_client.py` (HTTPX, Basic auth, 429 retry, 404-envelope) | `gateway/confluence_client.py` | Same shape; same retry / envelope conventions | +| `gateway/jira_credentials.py` (mtime-cached, thread-safe) | `gateway/confluence_credentials.py` | Same `parse_env_file` import from `anthropic_credentials.py`; same `JiraCredentialsManager` pattern | +| `gateway/jira_policy.py` (project allowlist) | `gateway/confluence_policy.py` (space allowlist) | Same YAML mtime cache, same fail-closed semantics | +| `gateway/jira_search.py` (conservative JQL extractor) | `gateway/confluence_search.py` (CQL extractor) | New module, but same deny-on-ambiguity stance | +| `gateway/mode_gate.py` `@require_private_mode` | (unchanged) | Decorator already generic — apply to every Confluence route | +| `config/context-filters.yaml` — `jira.projects: [...]` | New `confluence.spaces: [...]` section in same file | Operators already edit this file for Jira and GitHub filtering | +| `config/secrets.template.env` `JIRA_BASE_URL` / `JIRA_USERNAME` / `JIRA_API_TOKEN` | Existing `CONFLUENCE_BASE_URL` / `CONFLUENCE_USERNAME` / `CONFLUENCE_API_TOKEN` placeholders | Already scaffolded; question is whether to share or split (see Open Questions) | +| `sandbox/scripts/jira` (bash → gateway POST) | `sandbox/scripts/confluence` (bash → gateway POST) | Same `call_gateway()` shape, same `EGG_SESSION_TOKEN` Bearer auth | +| Squid `allowed_domains.txt` excludes `*.atlassian.net` | (unchanged) | Already excluded; Confluence cannot bypass via direct egress for the same reason | +| `gateway/tests/test_jira_routes.py` (route enumeration, private-mode regression, allowlist tests) | `gateway/tests/test_confluence_routes.py` | Same fixtures, same per-route 403/200 grid | + +The **mode-gate decorator and Squid allowlist** are already generic and need no change — the same fence that blocks public-mode Jira blocks public-mode Confluence, and the same Squid exclusion that prevents direct Atlassian egress applies. + +### Existing Confluence footprint in the codebase + +Partial scaffolding is already in place but unused for live API: + +- [`config/secrets.template.env`](../../config/secrets.template.env) lines 92–99 define `CONFLUENCE_BASE_URL`, `CONFLUENCE_USERNAME`, `CONFLUENCE_API_TOKEN`, `CONFLUENCE_SPACE_KEYS`. Nothing reads them today (the existing context-sync syncer is out of tree / out of scope). +- [`sandbox/agent-config/rules/environment.md`](../../sandbox/agent-config/rules/environment.md) line 80 mentions `~/context-sync/confluence/` as an optional read-only cache (snapshot, not live API). +- [`sandbox/agent-config/commands/show-metrics.md`](../../sandbox/agent-config/commands/show-metrics.md) line 12 references `ls ~/context-sync/confluence/` for diagnostic purposes. +- The Atlassian MCP referenced in the issue (`mcp__confluence__*`) runs **only on the host**. It is irrelevant to sandbox plumbing. + +### What `mcp__confluence__*` looks like (shape we are asked to align with) + +The issue explicitly asks the v1 verb surface to match the host MCP tool names so consumers port cleanly: + +| MCP tool name (host) | Atlassian endpoint family | +|-----------------------|----------------------------| +| `getConfluencePage` | `GET /wiki/api/v2/pages/{id}` | +| `getPagesInConfluenceSpace` | `GET /wiki/api/v2/spaces/{space-id}/pages` | +| `getConfluencePageDescendants` | `GET /wiki/api/v2/pages/{id}/descendants` | +| `getConfluencePageFooterComments` | `GET /wiki/api/v2/pages/{id}/footer-comments` | +| `getConfluencePageInlineComments` | `GET /wiki/api/v2/pages/{id}/inline-comments` | +| `getConfluenceSpaces` | `GET /wiki/api/v2/spaces` | +| `searchConfluenceUsingCql` | `GET /wiki/rest/api/search` (v1 — see API-version note) | + +The verb names appear in MCP tool surface. Whether the **gateway URL paths** mirror that shape literally (`/api/v1/confluence/getConfluencePage`) or follow the Jira convention (`/api/v1/confluence/page/get`) is an open question (see below — the wrappers preserve the consumer-facing names regardless). + +### Atlassian Confluence API — the v1 / v2 split + +This is the largest external constraint and the place v1 must make a concrete decision. The Confluence Cloud REST API is split across two coexisting versions: + +- **v2** (`/wiki/api/v2/...`): preferred by Atlassian for new development. Cleaner cursor pagination, distinct content types per endpoint, ADF body format by default. v2 covers `pages`, `spaces`, `descendants`, `footer-comments`, `inline-comments`. ([Atlassian REST v2 intro](https://developer.atlassian.com/cloud/confluence/rest/v2/intro/)) +- **v1** (`/wiki/rest/api/...`): older, broader. **CQL search remains on v1 with no v2 equivalent**, and Atlassian has stated [no plans to deprecate](https://developer.atlassian.com/cloud/confluence/rest/v1/api-group-search/) the v1 search endpoint. v1 also retains a few endpoints v2 hasn't reached parity on. + +Known v2 quirks that affect v1: + +- `/wiki/api/v2/pages/{id}/footer-comments` **does not return nested replies** — only top-level. Workaround is a separate `/wiki/api/v2/footer-comments` query or v1 fall-back. ([community report](https://community.developer.atlassian.com/t/confluence-rest-api-v2-get-footer-nested-comments-for-page/82487)) +- v2 inline-comments have a known bug returning 404 where v1 succeeds. ([community report](https://community.developer.atlassian.com/t/confluence-rest-api-v2-doesnt-return-inline-comment-404-instead-v1-works-bug/86668)) +- v2 has cursor pagination; v1 has both cursor and limit-based; the gateway will need to wrap both shapes. + +Atlassian commits to ≥6 months notice before any v1 endpoint is removed and currently has no firm sunset date. ([Confluence v2 vs v1 community thread](https://community.atlassian.com/forums/Confluence-questions/Confluence-API-v1-versus-v2/qaq-p/2978171)) + +**Implication**: v1 of our wrapper is **inherently a hybrid** — read endpoints prefer v2, but CQL search must use v1. Comments may need v1 fallback for inline / nested. We should pin every endpoint per-verb in the wrapper rather than declare a single API version. + +### How `session_mode == "private"` already encodes the gate we want + +`session_mode == "private"` already couples (a) Squid network lockdown to Anthropic-only egress, (b) private-repo-only access, and (c) the Jira route gate. Adding Confluence to (c) is a one-line decorator addition per route. The decorator stamps `__egg_requires_private_mode__ = True` so the Jira-style **route-enumeration regression test** can prove every `/api/v1/confluence/*` route carries the gate. + +### How Confluence MCP fits today (host-side reality) + +`mcp__confluence__*` runs on the host, inside the human's Claude Code session, with OAuth 2.0 creds stored in `~/.claude.json`. It is available for host-side interactive work and for issue-triage scripts run by the human, but it is **not** available to sandboxed agents — the MCP process is not reachable across the k8s NetworkPolicies, and running it inside the sandbox would violate the zero-credential invariant. The argument is identical to #1556's "Why not just use the Atlassian MCP?" and does not need to be re-litigated. + +## Constraints + +**Security / architectural:** + +- Zero credentials in the sandbox container (hard invariant). Atlassian credentials must live only in the gateway process. +- All requests from the sandbox must flow through the gateway. `*.atlassian.net` is **already excluded** from the Squid allowlist; do not add a Confluence-specific entry, or containers could bypass the space allowlist via direct REST calls. The existing Jira regression test in [`gateway/tests/test_allowed_domains.py`](../../gateway/tests/test_allowed_domains.py) already enforces this and covers Confluence by extension. +- Read-only in v1. The gateway must refuse Confluence write verbs even if the upstream API would accept them. Enforcement is at the gateway (infrastructure), not in agent instructions. +- Private-mode-only: public-mode sessions get a 403 on any `/api/v1/confluence/*` endpoint. This must be enforced at the route layer via `@require_private_mode`, not left to downstream policy. +- **Space allowlist + verb allowlist**. Agents can only query spaces the operator has sanctioned and only with verbs from the configured set. +- Future-verb compatibility: v1 design must support `page create`, `page update`, `comment create` as drop-in narrow routes — no re-architecting. Deletions, space admin, permission/restriction changes are **permanently** denied at the path validator (the analogue of Jira's `JIRA_WRITE_VERBS_DENIED`). +- Permanent denylist (path-validator level): `restrictions`, `permissions`, `space.admin`, `attachments` (debatable — see Open Questions), and any HTTP `DELETE` / `PUT` / `PATCH` (matching the Jira convention). Update verbs in the future use `POST` against Atlassian's edit endpoints. + +**Operational:** + +- Credential lifecycle: Atlassian API tokens don't auto-rotate. Mtime-based reload (already used for Anthropic + Jira) extends to Confluence with no new mechanism. +- Single-tenant for v1 is acceptable; multi-tenant should not be architected out. +- Test strategy: gateway tests today mock upstream Atlassian with `responses` / `pytest` monkeypatching for Jira; same harness applies to Confluence. +- Rate limiting: Atlassian Cloud applies per-site, per-user quotas. Reuse the Jira client's "retry once on 429 with `min(Retry-After, 30)` and emit `confluence_upstream_rate_limited` audit on both attempts" approach. +- Body format: ADF JSON is unusable for an agent without rendering. Reuse Jira's `expand=renderedBody,renderedFields` pattern — for Confluence v2 the analogous parameter is `body-format=storage` or `body-format=atlas_doc_format`; for full HTML we likely want both `storage` and the rendered view. (Decision below.) + +**Dependencies / coupling:** + +- **Hard dependency on #1556** (Jira gateway v1, merged). Confluence reuses the credential / mode-gate / context-filter / Squid plumbing established there. No new infrastructure layer is introduced. +- **Consumer #1557** (Jira-epic SDLC pipelines) — its refine phase pulls linked Confluence pages from sandboxed agents. #1557 cannot land its refine path until #1931 is merged. +- **Consumer `/impact-analysis` skill** — currently runs host-side and uses `mcp__confluence__*`. Once #1931 lands, the sandboxed-agent variant of impact analysis can call the gateway. The host-side use is not affected. +- Confluence shares Atlassian credentials with Jira (same tenant). Open question: share secret keys or duplicate. + +**External (Atlassian):** + +- v1 + v2 hybrid (above). CQL is v1-only. +- v2 is **better for pagination** but has known bugs in inline / nested comments. +- Body formats: ADF (`atlas_doc_format`), `storage` (Confluence's XHTML-like markup), `view` (rendered HTML), `export_view` (HTML for export). [Storage format ref](https://developer.atlassian.com/cloud/confluence/storage-format/). +- CQL has a 200-result hard limit per query; pagination required beyond that. Quirks: `text ~ "..."` is contains-search, not regex. ([CQL quirks summary](https://cotera.co/articles/confluence-api-integration-guide)) +- Auth: Same Atlassian Cloud API token works for both Confluence and Jira. Granular OAuth scopes for read-only would be `read:page:confluence`, `read:space:confluence`, `read:comment:confluence` (v2) — but #1556's B1 (Basic auth + bot account) was the chosen direction and the same applies here. +- v1 search endpoint: `GET /wiki/rest/api/search?cql=...` returns paginated results with `_links.next`. + +## Options Considered + +### Endpoint surface — gateway path naming + +#### A1. Path-based shape mirroring Jira (`/api/v1/confluence/page/get`, `/api/v1/confluence/search`, etc.) — **preferred** + +**Approach**: The gateway URL paths use the Jira-style verb-noun shape. The sandbox wrapper script (`sandbox/scripts/confluence`) translates user-friendly subcommands (`confluence page get `, `confluence search ''`, `confluence space pages `) into POSTs against these paths. The wrapper-level subcommand names can mirror the `mcp__confluence__*` shape (`getConfluencePage`-style aliases) so call sites that currently use the host MCP can be ported with minimal text edits. + +Concrete v1 routes: + +- `POST /api/v1/confluence/page/get` → `GET /wiki/api/v2/pages/{id}` +- `POST /api/v1/confluence/page/descendants` → `GET /wiki/api/v2/pages/{id}/descendants` +- `POST /api/v1/confluence/page/footer-comments` → `GET /wiki/api/v2/pages/{id}/footer-comments` (with v1 fallback for nested replies, see below) +- `POST /api/v1/confluence/page/inline-comments` → `GET /wiki/api/v2/pages/{id}/inline-comments` (with v1 fallback for known 404 bug) +- `POST /api/v1/confluence/space/pages` → `GET /wiki/api/v2/spaces/{space-id}/pages` +- `POST /api/v1/confluence/space/list` → `GET /wiki/api/v2/spaces` +- `POST /api/v1/confluence/search` → `GET /wiki/rest/api/search` (v1; CQL) +- `POST /api/v1/confluence/execute` → bounded `GET`-only regex-allowlisted passthrough (mirrors Jira) + +**Pros**: +- Exact mirror of `/api/v1/jira/*` shape. Reviewers, tests, audit logs, and operators see the same nouns. +- Path validator regex is straightforward and aligns with Jira's `validate_jira_api_path`. +- Future write verbs (`page/create`, `page/update`, `comment/create`) drop in symmetrically. +- The MCP-style names live in the **wrapper subcommand layer**, where consumer porting is easy — they don't bleed into URL design. + +**Cons**: +- Two name shapes (gateway path vs wrapper subcommand) to keep aligned in docs. + +#### A2. URL paths that literally mirror MCP names (`/api/v1/confluence/getConfluencePage`, `/api/v1/confluence/searchConfluenceUsingCql`, …) + +**Approach**: One route per MCP tool, with the URL path equal to the MCP name. + +**Pros**: +- One mental model: the URL path is the MCP tool name. + +**Cons**: +- Diverges from `/api/v1/jira/*` which uses verb-noun. Inconsistent gateway URL conventions. +- camelCase URLs are unusual for REST and complicate path-validator regexes. +- Future write verbs would have to match the (yet-to-exist) MCP write tool names; harder to plan. + +#### A3. Verb-as-payload (`POST /api/v1/confluence` with `{verb: "getConfluencePage", ...}`) + +**Approach**: Single endpoint dispatching on `verb` field. + +**Pros**: Smallest URL diff. + +**Cons**: Loses route-level audit / mode-gate granularity that the Jira surface relies on; harder to reason about per-verb tests; rejected. + +### Atlassian API version + +#### B1. v2-first hybrid: v2 for reads, v1 for CQL search and known-buggy comment endpoints — **preferred** + +**Approach**: Pin every endpoint per-verb in the client (`get_page` → v2, `search_cql` → v1, `get_inline_comments` → v2 with v1 fallback if 404). The wrapper exposes a single API to the agent regardless of which Atlassian version backs each verb. + +**Pros**: +- Best response shape per verb (v2 for cursor pagination, v1 for the only working CQL). +- Migration-friendly: when Atlassian closes the v1 search gap, we flip a single line. +- Matches the [community-recommended hybrid](https://community.atlassian.com/forums/Confluence-questions/Confluence-API-v1-versus-v2/qaq-p/2978171). + +**Cons**: +- Two response shapes to wrap (v1's `_links.next` vs v2's cursor + Link header). + +#### B2. v1-only + +**Pros**: One shape, fewer code paths. + +**Cons**: Atlassian is steering integrations toward v2. v1 read endpoints are slated for eventual deprecation (≥6 months notice but still on the roadmap). Locks us into legacy. + +#### B3. v2-only + +**Pros**: Future-proof on read endpoints. + +**Cons**: Breaks `searchConfluenceUsingCql` — there is no v2 search. Rejected for v1. + +### CQL scope extraction + +#### C1. Conservative static space-scope extractor — **preferred** (mirrors Jira's JQL extractor) + +**Approach**: `gateway/confluence_search.py` accepts CQL only if it matches a narrow shape that statically proves the space is allowlisted. Accepted forms (top-level, AND-combined only): + +- `space = KEY` (bare uppercase key) +- `space IN (K1, K2, ...)` with every key in `confluence.spaces` + +Rejected (deny-on-ambiguity, with a recorded reason): + +- No `space` clause. +- `space` under `OR`. +- Quoted space keys. +- CQL functions (`currentUser()`, `recentlyViewedContent()`, etc.). +- `id =` clauses without `space =`. +- Unicode homoglyph / mixed-script keys. + +**Pros**: +- Direct port of Jira's `extract_search_projects()` logic — single semantic model for operators ("scope must be statically provable"). +- Hard-rejects adversarial CQL. +- Audit entry records `spaces_extracted` on acceptance. + +**Cons**: +- Some legitimate CQL patterns (e.g., `(space = ENG OR space = DOCS)` for a known multi-space search) will be rejected. Workaround: agent issues two single-space searches and merges. + +#### C2. Permissive CQL with post-hoc filter + +**Approach**: Pass arbitrary CQL through; filter results so only allowlisted-space hits return. + +**Pros**: Full CQL flexibility. + +**Cons**: Rejected — Atlassian's response counts and pagination would still leak result counts from non-allowlisted spaces; the JQL analogue was rejected in #1556 for the same reason. + +### Comment retrieval — handling v2 quirks + +#### D1. v2-first with v1 fallback on 404 / nested-reply gap — **preferred** + +**Approach**: +- `getConfluencePageFooterComments` calls v2; if the response is missing nested replies and the agent asked for them (a `?include_replies=true` flag), the gateway fetches `/wiki/api/v2/footer-comments` filtered by `pageId` to get the full tree. +- `getConfluencePageInlineComments` calls v2; if v2 returns 404 (the known bug), the gateway transparently retries v1 (`/wiki/rest/api/content/{id}/child/comment?location=inline`) and returns the v1 response under a normalized envelope. + +**Pros**: Agents get correct data without knowing about Atlassian bugs. + +**Cons**: Two endpoint families per comment verb to maintain. Test coverage doubles. + +#### D2. v2-only, document the gaps + +**Pros**: Simpler. + +**Cons**: Pushes the bug onto agents; refine/plan workflows that quote inline comments will silently miss content. + +#### D3. v1-only for comments + +**Pros**: One endpoint family, no fallback complexity. + +**Cons**: Locks comments into legacy when v2 is fixed. We'd have to migrate later. + +### Body format default + +#### E1. Both `storage` (Confluence XHTML) and `atlas_doc_format` (ADF JSON) — **preferred** + +**Approach**: Set default `body-format=storage,atlas_doc_format` (Confluence v2 accepts a comma list) so the agent gets both: storage format for HTML-like display / parsing, ADF for structured tree access. Mirrors Jira's default-`expand=renderedBody,renderedFields`. Caller may override. + +**Pros**: +- Agent doesn't need to make a second call for the alternate format. +- Storage is human-readable HTML-ish; ADF is the structured form. Both have legitimate uses. + +**Cons**: +- Larger response payloads. If body size becomes a problem, callers can override. + +#### E2. Storage only + +**Pros**: Smallest payload. + +**Cons**: Loses programmatic access for agents that want to traverse the document tree. + +#### E3. ADF only + +**Pros**: Most-structured. + +**Cons**: Hard to read in transcripts / logs; rendering work pushed to the agent. + +#### E4. View (rendered HTML) only + +**Pros**: Easiest to render directly in human-facing UI. + +**Cons**: Lossy — view doesn't always preserve macro inputs / layout structure that storage and ADF do. + +### Tenant config — credential sharing with Jira + +#### F1. Share Atlassian credentials between Jira and Confluence (single set of secrets) — **preferred** + +**Approach**: Add a shared `ATLASSIAN_BASE_URL` / `ATLASSIAN_USERNAME` / `ATLASSIAN_API_TOKEN` triple. `gateway/jira_credentials.py` and `gateway/confluence_credentials.py` both read from it. Existing `JIRA_*` env names stay supported for back-compat with #1556 deployments (loader prefers `ATLASSIAN_*` if present, else falls back to `JIRA_*` or `CONFLUENCE_*` per-service). + +The Jira and Confluence base URLs differ slightly (`https://co.atlassian.net` vs `https://co.atlassian.net/wiki`) — the gateway can derive the Confluence base by appending `/wiki` if `CONFLUENCE_BASE_URL` is unset, or accept both as overrides. + +**Pros**: +- Operators provision one Atlassian bot account and one API token, not two. +- Reflects reality (same Atlassian Cloud tenant, same OAuth/API-token surface). +- Aligns with the issue text: "Shares credential infrastructure with the Jira gateway (same Atlassian tenant)." + +**Cons**: +- Slightly more loader logic (prefer-shared-then-fall-back). +- Migration path required for installs that already populated `JIRA_*`-only. + +#### F2. Independent credential blocks (status quo of the template file) + +**Approach**: Keep `JIRA_*` and `CONFLUENCE_*` triples fully independent. Operators populate both with the same value if their tenant is shared. + +**Pros**: Smallest code change. Allows fully separate Atlassian accounts per service if any operator wants that (e.g., split read-only bots). + +**Cons**: +- Duplicates secrets in `secrets.env`; operators must remember to update both on token rotation. +- Doesn't reflect the issue text's intent. + +### Network-mode gating — same decorator + +#### G1. Apply `@require_private_mode` to every `/api/v1/confluence/*` route — **preferred (and effectively mandatory)** + +**Approach**: Identical to Jira. Each handler chains `@require_session_auth → @require_private_mode → handler body`. The route-enumeration regression test in `gateway/tests/test_confluence_routes.py` walks `app.url_map` and asserts every `/api/v1/confluence/*` view has `__egg_requires_private_mode__ = True`. + +**Pros**: Uniform. Already proven in #1556. The decorator is generic — no Confluence-specific change needed. + +**Cons**: None — this is the "same as Jira" case. + +(Alternatives like blueprint-level `before_request` were rejected in #1556 and for the same reasons here: gateway doesn't use blueprints today.) + +### Space allowlist location + +#### H1. New `confluence:` section in `config/context-filters.yaml` — **preferred** (mirrors Jira's `jira:` section) + +**Approach**: + +```yaml +confluence: + spaces: ["ENG", "PLATFORM"] # Atlassian space keys agents may read +``` + +- Authoritative key is `spaces` (parallel to Jira's `projects`). +- Default is empty list — every Confluence call rejected until an operator populates. "Installed but inert" v1 rollout posture. +- Fail-closed: missing file / missing section / malformed YAML → empty set, no crash. +- Mtime reload + `POST /api/v1/config/reload` hook (same as Jira). + +**Pros**: Operators already edit this file for Jira and GitHub filtering — one allowlist surface. Self-contained from Jira. + +**Cons**: `context-filters.yaml` was authored for a syncer; we'd be adding to it. Tolerable, given Jira already did so. + +#### H2. New `config/confluence.yaml` + +**Pros**: Clean separation. + +**Cons**: Yet another config file. Rejected for the same reason as the Jira analogue. + +#### H3. Env var (`CONFLUENCE_SPACE_KEYS`) — already in the secrets template + +**Approach**: Use the existing `CONFLUENCE_SPACE_KEYS` placeholder as the allowlist source. + +**Pros**: Already scaffolded. + +**Cons**: Mixing secrets and policy in one file. Jira chose YAML for a reason; Confluence should follow. + +### Future-write extension shape + +The v1 design needs to leave room for the future write verbs without re-architecting. Recommended layout (informational — not implemented in v1): + +- `POST /api/v1/confluence/page/create` → `POST /wiki/api/v2/pages` (new narrow route). +- `POST /api/v1/confluence/page/update` → `PUT /wiki/api/v2/pages/{id}` (Confluence's edit endpoint is `PUT`, distinct from Jira's POST-edit). Path validator must allow `PUT` for this single path family in the future-writes phase, while keeping `DELETE` permanently denied. +- `POST /api/v1/confluence/comment/create` → `POST /wiki/api/v2/footer-comments` and / or `POST /wiki/api/v2/inline-comments`. + +All three land under the same `@require_session_auth → @require_private_mode → space-allowlist` chain. The `/execute` regex stays GET-only forever — future writes always go through narrow routes. + +Permanent denylist (path validator) — never permitted, even when writes land: + +- Anything matching `restrictions`, `permissions`, `space.admin`, `users` (we're not editing principals). +- HTTP `DELETE` (no archive / no purge from agents). +- Page move / lineage-changing endpoints. + +## Recommended Approach + +Adopt **A1 + B1 + C1 + D1 + E1 + F1 + G1 + H1**: + +1. **Endpoint surface (A1)** — `/api/v1/confluence/{page,space,search,execute}/...` paths in the gateway, `mcp__confluence__*`-aligned subcommand names in the sandbox wrapper. Mirrors Jira; provides porting ergonomics for `mcp__confluence__*` callers without warping URL conventions. +2. **API version (B1)** — v2-first hybrid; pin per-verb. `searchConfluenceUsingCql` stays on v1 (no v2 equivalent). Inline / nested comments use v1 fallback for the known v2 bugs. +3. **CQL scope (C1)** — conservative static `space =` / `space IN (...)` extractor; deny-on-ambiguity. Direct port of `gateway/jira_search.py`. +4. **Comment quirks (D1)** — v2-first with transparent v1 fallback when v2 misses nested replies / 404s on inline comments. The gateway hides the bug; agents get correct data. +5. **Body format (E1)** — default `body-format=storage,atlas_doc_format`. Caller may override. +6. **Credential sharing (F1)** — shared `ATLASSIAN_BASE_URL` / `_USERNAME` / `_API_TOKEN` with backward-compat fall-back to existing `JIRA_*` / `CONFLUENCE_*` placeholders. One bot account for the tenant. +7. **Network-mode gate (G1)** — `@require_private_mode` on every Confluence route, plus a route-enumeration regression test asserting the marker. +8. **Space allowlist (H1)** — new `confluence.spaces:` section in `config/context-filters.yaml`, fail-closed, mtime-reloaded, hooked into `POST /api/v1/config/reload`. + +Ancillary: + +- **No `EGG_CONFLUENCE_*` env var in v1.** The issue is explicit. Rationale: Confluence is reference material; the agent doesn't operate on a single page as its unit of work. The audit log can still record `pageId` / `spaceKey` from each request body for reconciliation. +- **Audit**: every Confluence op logs `verb`, `pageId` (when present), `spaceKey` (extracted), `session_mode`, `pipeline_id`, `agent_role`, `bot_account`. Same shape as Jira's audit entries. +- **Squid**: do **not** add `*.atlassian.net` (already excluded; verified by existing regression test). +- **404 envelope**: page-get / page-descendants / comment-get all return `{"status": "not_found", "id": "...", "upstream_status": 404}` on upstream 404, mirroring Jira's `not_found` envelope. CQL search and `/execute` surface upstream 404 as real errors. +- **Tests**: gateway unit tests with `httpx` mocks + private-mode enforcement tests + space-allowlist tests + route-enumeration regression + adversarial CQL suite + v2-comment-fallback test. Sandbox wrapper smoke tests assert `EGG_SESSION_TOKEN` and gateway URL fall-through. +- **Docs**: new `docs/reference/confluence-wrapper.md` (mirrors `docs/reference/jira-wrapper.md`); update `docs/architecture/network-isolation.md` (add `/api/v1/confluence/*` to endpoint table); update `docs/architecture/credential-injection.md` (extend the Atlassian section to cover Confluence); update `sandbox/agent-config/rules/environment.md` (mention `confluence` wrapper alongside `jira` and `gh`). +- **Future-write readiness**: `page/create`, `page/update`, `comment/create` plug in as three new narrow routes under the same plumbing. `PUT` allowed only for `pages/{id}` in the writes phase. `DELETE`, restrictions, permissions, space-admin verbs permanently denied. + +## Complexity Assessment + +**medium** — multi-file change across `gateway/`, `sandbox/scripts/`, `config/`, and docs, with a clean analogue (`/api/v1/jira/*`) to follow line-by-line. Slightly more nuance than the Jira wrapper because of the Confluence v1 / v2 hybrid and the comment-fallback logic, but no architectural departure — all reused infrastructure was generalised by #1556. + +## Open Questions + +**All questions below are registered as contract decisions / feedback items via the `mcp__sdlc__*` MCP tools (see "Registered decisions" section below). They are reproduced here for reviewer legibility; the authoritative copy is in the contract.** + +### Registered decisions (multiple-choice) + +- [ ] **Option A (Recommended)**: Endpoint surface — Jira-style verb-noun paths (`/api/v1/confluence/page/get`, `/api/v1/confluence/search`, …); MCP-aligned names live in the sandbox wrapper subcommand layer. +- [ ] **Option B**: Literal MCP-name URL paths (`/api/v1/confluence/getConfluencePage`, `/api/v1/confluence/searchConfluenceUsingCql`, …). +- [ ] **Option C**: Single endpoint with `verb` payload field. + +- [ ] **Option A (Recommended)**: API version strategy — v2-first hybrid (v2 reads + v1 CQL search + v1 fallback for known v2 comment bugs). +- [ ] **Option B**: v1-only across the board (simpler shape but legacy). +- [ ] **Option C**: v2-only across the board (no CQL search would be possible — would require shipping our own search index). + +- [ ] **Option A (Recommended)**: CQL scope extraction — conservative static `space =` / `space IN (...)` extractor; deny-on-ambiguity. +- [ ] **Option B**: Permissive CQL with post-hoc result filter. + +- [ ] **Option A (Recommended)**: Comment-quirk handling — v2-first with transparent v1 fallback for nested replies / inline-404 bugs. +- [ ] **Option B**: v2-only; document the gaps and let agents retry. +- [ ] **Option C**: v1-only for comments; lock in legacy behaviour until Atlassian fixes v2. + +- [ ] **Option A (Recommended)**: Body format default — both `storage` and `atlas_doc_format`. +- [ ] **Option B**: `storage` only. +- [ ] **Option C**: `atlas_doc_format` only. +- [ ] **Option D**: `view` (rendered HTML) only. + +- [ ] **Option A (Recommended)**: Credential sharing — shared `ATLASSIAN_BASE_URL` / `_USERNAME` / `_API_TOKEN` triple, with backward-compat fall-back to `JIRA_*` and `CONFLUENCE_*` placeholders. +- [ ] **Option B**: Independent `JIRA_*` and `CONFLUENCE_*` triples (status quo). + +- [ ] **Option A (Recommended)**: Network-mode gate — `@require_private_mode` per route, with a route-enumeration regression test. +- [ ] **Option B**: Move to a Flask blueprint with `before_request` (would also require migrating Jira; out of scope here). + +- [ ] **Option A (Recommended)**: Space allowlist location — new `confluence.spaces:` section in `config/context-filters.yaml`. +- [ ] **Option B**: Dedicated new `config/confluence.yaml`. +- [ ] **Option C**: `CONFLUENCE_SPACE_KEYS` env var in `secrets.env` (the placeholder already exists). + +- [ ] **Option A (Recommended)**: Bot identity — same dedicated Atlassian bot account used for Jira (single principal owns both `read:jira-work` and `read:page:confluence`-equivalent access). +- [ ] **Option B**: Separate bot accounts per service. +- [ ] **Option C**: Reuse the operator's personal Atlassian account. + +- [ ] **Option A (Recommended)**: Audit-log redaction — strip `accountId`, `emailAddress`, and `_links.webui` user-profile URLs from responses before they reach the sandbox (parallels Jira's redaction stance). +- [ ] **Option B**: Pass responses through verbatim. + +- [ ] **Option A (Recommended)**: `getConfluenceSpaces` — filter response so only allowlisted spaces are returned (agents cannot enumerate the full tenant space set). +- [ ] **Option B**: Pass through Atlassian's full space list and rely on per-page allowlist to deny later access. + +- [ ] **Option A (Recommended)**: `attachments` endpoints — keep them on the **permanent denylist** for v1 and future writes (Confluence attachments are arbitrary-file uploads / downloads with an unbounded payload surface; if needed later, scope them as a separate ticket). +- [ ] **Option B**: Defer the decision to the future-writes phase. +- [ ] **Option C**: Allow read-only attachment metadata (no body) in v1. + +- [ ] **Option A (Recommended)**: `EGG_CONFLUENCE_*` env vars — none in v1 (matches issue text). Audit recovers `pageId` / `spaceKey` from each request body. +- [ ] **Option B**: Ship `EGG_CONFLUENCE_PAGE` / `EGG_CONFLUENCE_SPACE` as observational env vars (parallel to `EGG_JIRA_TICKET`). + +- [ ] **Option A (Recommended)**: `/execute` passthrough — include it (GET-only, regex-allowlisted), parallel to Jira's escape hatch for read verbs not yet promoted to narrow routes. +- [ ] **Option B**: Skip `/execute` entirely; add narrow routes only. + +### Registered open-ended questions (free-form) + +- Which Atlassian Confluence spaces should be on the v1 allowlist (space keys, comma-separated)? +- What is the expected request volume per pipeline (peak CQL searches/min, page reads/min, comment reads/min)? This informs rate-limit defaults and 429 retry behaviour. +- Are there custom Confluence fields, macros, or page properties known to hold PII or secrets that should be redacted before sandbox-visible responses (in addition to the `accountId` / `emailAddress` defaults)? +- For the future write phase (out of scope here), should the gateway enforce idempotency on `comment/create` (refuse duplicate within N seconds) or leave that to Atlassian's own semantics? (Same question as Jira; surfacing now so the design-once decision is consistent.) +- What pageId / page-link patterns should the orchestrator extract from Jira tickets to feed Confluence reads? (E.g., should it scan ticket descriptions for `https://.atlassian.net/wiki/spaces//pages//...` and pre-resolve, or leave URL parsing to the agent?) — This informs whether we need a `page/resolve-by-url` verb in v1. +- Should `getConfluencePageDescendants` enforce a maximum depth (e.g., 3) to prevent runaway responses on deeply nested page trees, or pass Atlassian's parameter through verbatim? +- Should the audit log treat reads of pages whose space is *technically* in the allowlist but whose page hierarchy is restricted at the Atlassian permission layer as a special audit category (e.g., `confluence_upstream_403` separate from `confluence_upstream_error`) — useful for operators tuning the bot account's access? +- For long-lived pages with extensive version history: should `getConfluencePage` default to `version=current` only (recommended) or expose a `version` query parameter? Either way, history-listing endpoints stay out of scope for v1. +- The host-side `mcp__confluence__*` MCP authenticates as the consenting human user; the gateway will authenticate as a bot. Are there documents in operator workspaces that the human reads but the bot would not be able to (or vice versa)? If so, do we need a "diagnostic" command that surfaces the bot's effective access per space? +- Should the sandbox `confluence` wrapper expose the MCP-style alias names (`confluence get-page`, `confluence search-cql`) **in addition to** Jira-style subcommands (`confluence page get`, `confluence search`), or pick one shape only? + +--- + +*Authored-by: egg* From 07c91856d2fa6c8490093346ce253ca7507d0f3f Mon Sep 17 00:00:00 2001 From: egg-orchestrator Date: Sun, 26 Apr 2026 23:20:18 +0000 Subject: [PATCH 03/26] Persist statefiles after refine phase --- .egg-state/brc-history/1931-refine.json | 711 ++++++++++++++ .egg-state/brc-history/1931-refine.md | 853 +++++++++++++++++ .egg-state/contracts/issue-1931.json | 1143 ++++++++++++++++++++++- 3 files changed, 2704 insertions(+), 3 deletions(-) create mode 100644 .egg-state/brc-history/1931-refine.json create mode 100644 .egg-state/brc-history/1931-refine.md diff --git a/.egg-state/brc-history/1931-refine.json b/.egg-state/brc-history/1931-refine.json new file mode 100644 index 0000000000..331b37b623 --- /dev/null +++ b/.egg-state/brc-history/1931-refine.json @@ -0,0 +1,711 @@ +[ + { + "id": "8063c139-40d9-4a", + "pipeline_id": "issue-1931", + "from_role": "reviewer_agent_design", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_ON_ROLE", + "body": "", + "metadata": { + "state": "WAITING_ON_ROLE", + "waiting_on": "refiner" + }, + "timestamp": "2026-04-26T23:06:25.350629+00:00", + "phase": "refine" + }, + { + "id": "96259c12-81dd-47", + "pipeline_id": "issue-1931", + "from_role": "reviewer_agent_design", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_PROPOSE from=refiner", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-04-26T23:06:25.356486+00:00" + }, + "timestamp": "2026-04-26T23:06:25.395201+00:00", + "phase": "refine" + }, + { + "id": "5d4027d5-f9af-48", + "pipeline_id": "issue-1931", + "from_role": "reviewer_refine", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_PROPOSE from=refiner", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-04-26T23:06:27.192663+00:00" + }, + "timestamp": "2026-04-26T23:06:27.227464+00:00", + "phase": "refine" + }, + { + "id": "0c94c5b3-8520-49", + "pipeline_id": "issue-1931", + "from_role": "reviewer_agent_design", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_PROPOSE from=refiner", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-04-26T23:06:25.356486+00:00" + }, + "timestamp": "2026-04-26T23:07:25.436219+00:00", + "phase": "refine" + }, + { + "id": "164a59f1-d68b-4b", + "pipeline_id": "issue-1931", + "from_role": "reviewer_refine", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_PROPOSE from=refiner", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-04-26T23:06:27.192663+00:00" + }, + "timestamp": "2026-04-26T23:07:27.310053+00:00", + "phase": "refine" + }, + { + "id": "cae6ad56-69d3-42", + "pipeline_id": "issue-1931", + "from_role": "reviewer_agent_design", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_PROPOSE from=refiner", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-04-26T23:06:25.356486+00:00" + }, + "timestamp": "2026-04-26T23:08:26.036323+00:00", + "phase": "refine" + }, + { + "id": "6456dcf3-de19-44", + "pipeline_id": "issue-1931", + "from_role": "reviewer_refine", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_PROPOSE from=refiner", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-04-26T23:06:27.192663+00:00" + }, + "timestamp": "2026-04-26T23:08:27.666292+00:00", + "phase": "refine" + }, + { + "id": "922889cc-6e47-43", + "pipeline_id": "issue-1931", + "from_role": "reviewer_agent_design", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_PROPOSE from=refiner", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-04-26T23:06:25.356486+00:00" + }, + "timestamp": "2026-04-26T23:09:26.213935+00:00", + "phase": "refine" + }, + { + "id": "167050eb-2c65-43", + "pipeline_id": "issue-1931", + "from_role": "reviewer_refine", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_PROPOSE from=refiner", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-04-26T23:06:27.192663+00:00" + }, + "timestamp": "2026-04-26T23:09:27.847293+00:00", + "phase": "refine" + }, + { + "id": "c21d83ae-6c51-45", + "pipeline_id": "issue-1931", + "from_role": "reviewer_agent_design", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_PROPOSE from=refiner", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-04-26T23:06:25.356486+00:00" + }, + "timestamp": "2026-04-26T23:10:26.273807+00:00", + "phase": "refine" + }, + { + "id": "6a95cac8-e7c8-40", + "pipeline_id": "issue-1931", + "from_role": "reviewer_refine", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_PROPOSE from=refiner", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-04-26T23:06:27.192663+00:00" + }, + "timestamp": "2026-04-26T23:10:28.234997+00:00", + "phase": "refine" + }, + { + "id": "d3d42e27-e498-45", + "pipeline_id": "issue-1931", + "from_role": "reviewer_agent_design", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_PROPOSE from=refiner", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-04-26T23:06:25.356486+00:00" + }, + "timestamp": "2026-04-26T23:11:26.400214+00:00", + "phase": "refine" + }, + { + "id": "509252e5-e4f2-41", + "pipeline_id": "issue-1931", + "from_role": "reviewer_refine", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_PROPOSE from=refiner", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-04-26T23:06:27.192663+00:00" + }, + "timestamp": "2026-04-26T23:11:28.387980+00:00", + "phase": "refine" + }, + { + "id": "ef65d828-1d52-4a", + "pipeline_id": "issue-1931", + "from_role": "reviewer_agent_design", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_PROPOSE from=refiner", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-04-26T23:06:25.356486+00:00" + }, + "timestamp": "2026-04-26T23:12:26.499171+00:00", + "phase": "refine" + }, + { + "id": "8155c13a-7ac2-43", + "pipeline_id": "issue-1931", + "from_role": "reviewer_refine", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_PROPOSE from=refiner", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-04-26T23:06:27.192663+00:00" + }, + "timestamp": "2026-04-26T23:12:28.593052+00:00", + "phase": "refine" + }, + { + "id": "4986db2b-b2a4-4e", + "pipeline_id": "issue-1931", + "from_role": "reviewer_agent_design", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_PROPOSE from=refiner", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-04-26T23:06:25.356486+00:00" + }, + "timestamp": "2026-04-26T23:13:26.619823+00:00", + "phase": "refine" + }, + { + "id": "0afdda58-f4e4-45", + "pipeline_id": "issue-1931", + "from_role": "reviewer_refine", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_PROPOSE from=refiner", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-04-26T23:06:27.192663+00:00" + }, + "timestamp": "2026-04-26T23:13:28.829888+00:00", + "phase": "refine" + }, + { + "id": "c5d49345-4c4b-42", + "pipeline_id": "issue-1931", + "from_role": "reviewer_agent_design", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_PROPOSE from=refiner", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-04-26T23:06:25.356486+00:00" + }, + "timestamp": "2026-04-26T23:14:26.712660+00:00", + "phase": "refine" + }, + { + "id": "26de2a56-9d60-4c", + "pipeline_id": "issue-1931", + "from_role": "reviewer_refine", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_PROPOSE from=refiner", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-04-26T23:06:27.192663+00:00" + }, + "timestamp": "2026-04-26T23:14:29.001749+00:00", + "phase": "refine" + }, + { + "id": "3faed350-eda6-46", + "pipeline_id": "issue-1931", + "from_role": "overseer", + "to_role": "all", + "message_type": "OVERSEER_ALERT", + "subject": "stuck-phase-transition [medium]", + "body": "Refiner (issue-1931) has been WORKING for 514s (~8.6 min) without emitting CONSENSUS_PROPOSE; reviewers blocked in wait_loop for ~8 min\n\nDetail:\nPipeline issue-1931, refine phase. Container c32916dc (refiner) started at 23:05:39 UTC and remains in producer_phase=WORKING at 514s elapsed. No CONSENSUS_PROPOSE has been emitted. The refiner has sent zero of its own heartbeats \u2014 all escalation heartbeats originate from reviewer_agent_design and reviewer_refine, both in WAITING_FOR_EVENT since 23:06:25. Reviewers have cycled through 9 wait_loop rounds (60s cadence) with no signal from the producer. Monitoring script shows heartbeat_ok=true and alerts=0 at platform level, so the container is alive but the BRC proposal is delayed beyond expected norms (~5 min). Possible causes: (1) refiner is doing deep codebase analysis on a complex issue; (2) refiner hit a slow/stalled LLM call; (3) refiner tool-call loop is iterating without forward progress.\n\nRecommended action:\nCheck container c32916dc logs for the refiner to determine if it is making forward progress (tool calls, reads) or looping/stalled. If no meaningful progress in next 2 cycles (~4 min), consider restarting the refiner container for pipeline issue-1931.", + "metadata": {}, + "timestamp": "2026-04-26T23:14:36.801119+00:00", + "phase": "refine" + }, + { + "id": "8ff108d1-10f8-4a", + "pipeline_id": "issue-1931", + "from_role": "reviewer_agent_design", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_PROPOSE from=refiner", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-04-26T23:06:25.356486+00:00" + }, + "timestamp": "2026-04-26T23:15:26.850724+00:00", + "phase": "refine" + }, + { + "id": "6a60478e-8819-42", + "pipeline_id": "issue-1931", + "from_role": "reviewer_refine", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_PROPOSE from=refiner", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-04-26T23:06:27.192663+00:00" + }, + "timestamp": "2026-04-26T23:15:29.272007+00:00", + "phase": "refine" + }, + { + "id": "630677da-f446-43", + "pipeline_id": "issue-1931", + "from_role": "reviewer_agent_design", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_PROPOSE from=refiner", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-04-26T23:06:25.356486+00:00" + }, + "timestamp": "2026-04-26T23:16:26.941797+00:00", + "phase": "refine" + }, + { + "id": "29110801-ca63-46", + "pipeline_id": "issue-1931", + "from_role": "reviewer_refine", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_PROPOSE from=refiner", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-04-26T23:06:27.192663+00:00" + }, + "timestamp": "2026-04-26T23:16:29.487516+00:00", + "phase": "refine" + }, + { + "id": "97ef80ed-ba3a-43", + "pipeline_id": "issue-1931", + "from_role": "reviewer_refine", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_PROPOSE from=refiner", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-04-26T23:06:27.192663+00:00" + }, + "timestamp": "2026-04-26T23:17:30.031095+00:00", + "phase": "refine" + }, + { + "id": "381169d0-2959-40", + "pipeline_id": "issue-1931", + "from_role": "overseer", + "to_role": "all", + "message_type": "OVERSEER_ALERT", + "subject": "agent-heartbeat-stall [high]", + "body": "ESCALATION: Refiner stalled for 690s (~11.5 min) \u2014 detector fired heartbeat_timeout + progress_stall; advisor gate now OPEN for issue-1931\n\nDetail:\nEscalating from medium (alert 3faed350) to HIGH. At cycle 19 (23:17:10 UTC), the monitoring script now reports alerts=2 with advisor_gate.gate_open=true: (1) heartbeat_timeout id=d6d9a433 \"No heartbeat for 120s\" and (2) progress_stall id=7a175707 \"No progress for 122s\". Refiner container c32916dc has been WORKING for 690s with no CONSENSUS_PROPOSE, no self-emitted heartbeats, and no progress events. The platform-level detector has now independently confirmed the stall condition. Both reviewer containers (aa29efd7, d888d4ad) remain alive with 60s heartbeat cadence (13 rounds), fully blocked waiting on refiner. The refiner container is running but no productive output has been observed through any channel.\n\nRecommended action:\nIMMEDIATE ACTION: Restart refiner container c32916dc for pipeline issue-1931. The container is alive but unproductive for 11.5 min. After restart, monitor for CONSENSUS_PROPOSE within 5 min. If a second restart also fails to produce a proposal, consider aborting the refine phase and re-queuing the pipeline.", + "metadata": {}, + "timestamp": "2026-04-26T23:17:38.627404+00:00", + "phase": "refine" + }, + { + "id": "8ec63fb5-b690-4a", + "pipeline_id": "issue-1931", + "from_role": "refiner", + "to_role": "all", + "message_type": "CONSENSUS_PROPOSE", + "subject": "Proposal from refiner", + "body": "Refine-phase analysis for #1931 (Confluence gateway, read-only v1). Mirrors the Jira gateway pattern landed in #1556 \u2014 same private-mode gate, same Squid exclusion, same mtime-cached credential loader, same fail-closed allowlist semantics \u2014 with three Confluence-specific deltas captured and surfaced as decisions: (1) the Confluence v1/v2 API split (v2-first reads + v1 CQL search + v1 fallback for known v2 comment bugs), (2) a space allowlist (parallel to jira.projects) lives in a new confluence.spaces section of config/context-filters.yaml, and (3) shared Atlassian credentials between Jira and Confluence (single bot, single API token, ATLASSIAN_* shared envs with backward-compat fall-back). The draft also covers: endpoint surface options, CQL scope-extractor mirroring JQL deny-on-ambiguity, body-format defaults (storage + atlas_doc_format), comment-quirk handling, attachments permanently denylisted, no EGG_CONFLUENCE_* env var (per issue), and the future-write extension shape (page/create, page/update via PUT, comment/create) under the same plumbing. 14 multiple-choice decisions and 10 free-form feedback items are registered against the contract via mcp__sdlc__register_open_question / mcp__sdlc__request_feedback. Complexity assessed as medium.", + "metadata": { + "payload": { + "summary": "Refine-phase analysis for #1931 (Confluence gateway, read-only v1). Mirrors the Jira gateway pattern landed in #1556 \u2014 same private-mode gate, same Squid exclusion, same mtime-cached credential loader, same fail-closed allowlist semantics \u2014 with three Confluence-specific deltas captured and surfaced as decisions: (1) the Confluence v1/v2 API split (v2-first reads + v1 CQL search + v1 fallback for known v2 comment bugs), (2) a space allowlist (parallel to jira.projects) lives in a new confluence.spaces section of config/context-filters.yaml, and (3) shared Atlassian credentials between Jira and Confluence (single bot, single API token, ATLASSIAN_* shared envs with backward-compat fall-back). The draft also covers: endpoint surface options, CQL scope-extractor mirroring JQL deny-on-ambiguity, body-format defaults (storage + atlas_doc_format), comment-quirk handling, attachments permanently denylisted, no EGG_CONFLUENCE_* env var (per issue), and the future-write extension shape (page/create, page/update via PUT, comment/create) under the same plumbing. 14 multiple-choice decisions and 10 free-form feedback items are registered against the contract via mcp__sdlc__register_open_question / mcp__sdlc__request_feedback. Complexity assessed as medium.", + "attestation": {}, + "artifacts": [ + ".egg-state/drafts/1931-analysis.md" + ], + "risk_considered": "Confluence v1/v2 API split is the largest external risk \u2014 surfaced via decision-2 (API version strategy), decision-4 (comment quirk handling), and feedback Q5/Q6 (depth limits / page-link resolution). Allowlist bypass risk addressed by mirroring Jira's CQL extractor (decision-3) and reusing the existing Squid exclusion for *.atlassian.net (regression-tested, no change needed). Credential rotation pain mitigated by sharing Atlassian creds across Jira + Confluence (decision-6) so operators rotate once. PII leakage covered by decision-10 (response redaction) and feedback Q3 (custom-field PII).", + "commit_sha": "5ca30533755811b35d68d9235cb4aa53dd1e9c48", + "files_changed": [ + ".egg-state/drafts/1931-analysis.md" + ], + "tests_run": [], + "tasks_satisfied": [] + }, + "version": 1, + "commit_sha": "5ca30533755811b35d68d9235cb4aa53dd1e9c48" + }, + "timestamp": "2026-04-26T23:17:51.590266+00:00", + "phase": "refine" + }, + { + "id": "118402b6-7cd7-45", + "pipeline_id": "issue-1931", + "from_role": "reviewer_refine", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "wait_loop exited", + "metadata": { + "state": "WORKING" + }, + "timestamp": "2026-04-26T23:17:51.845903+00:00", + "phase": "refine" + }, + { + "id": "cef3b4dd-6747-4f", + "pipeline_id": "issue-1931", + "from_role": "reviewer_agent_design", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "wait_loop exited", + "metadata": { + "state": "WORKING" + }, + "timestamp": "2026-04-26T23:17:51.857465+00:00", + "phase": "refine" + }, + { + "id": "9845ce4e-e007-47", + "pipeline_id": "issue-1931", + "from_role": "refiner", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_ACK,CONSENSUS_NACK,CONSENSUS_RE_REVIEW,OVERSEER_ALERT", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-04-26T23:17:55.927319+00:00" + }, + "timestamp": "2026-04-26T23:17:55.974019+00:00", + "phase": "refine" + }, + { + "id": "f7cbb2b5-a815-41", + "pipeline_id": "issue-1931", + "from_role": "refiner", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_ACK,CONSENSUS_NACK,CONSENSUS_RE_REVIEW,OVERSEER_ALERT", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-04-26T23:17:55.927319+00:00" + }, + "timestamp": "2026-04-26T23:18:56.620473+00:00", + "phase": "refine" + }, + { + "id": "4c54f1d4-12c5-47", + "pipeline_id": "issue-1931", + "from_role": "reviewer_agent_design", + "to_role": "refiner", + "message_type": "CONSENSUS_ACK", + "subject": "ACK from reviewer_agent_design for refiner", + "body": "\nReviewed `.egg-state/drafts/1931-analysis.md` end-to-end against the agent-mode design criteria in `docs/guides/agent-mode-design.md`. No agent-design anti-patterns flagged.\n\n### Why this design is well-aligned with agent-mode principles\n\n1. **Infrastructure beats config \u2014 pervasive.** Every constraint that matters is enforced at a layer the agent cannot subvert:\n - Line 98: *\"Read-only in v1. The gateway must refuse Confluence write verbs even if the upstream API would accept them. Enforcement is at the gateway (infrastructure), not in agent instructions.\"*\n - Line 99: `@require_private_mode` at the route layer, with a route-enumeration regression test (G1) \u2014 not a prompt-level \"don't call this in public mode.\"\n - Line 101\u2013102: permanent denylist at the path validator (`restrictions`, `permissions`, `space.admin`, `DELETE`/`PUT`/`PATCH` except the future writes carve-out for `pages/{id}` `PUT`).\n - Line 397: Squid perimeter already excludes `*.atlassian.net` \u2014 agents cannot bypass the space allowlist via direct egress, and the existing regression test in `gateway/tests/test_allowed_domains.py` already covers Confluence by extension. The draft correctly resists the temptation to \"add `*.atlassian.net`\" to fix reachability.\n\n2. **Static security checks over prompted-behaviour checks.** The CQL scope extractor (C1, lines 204\u2013227) is a direct port of `gateway/jira_search.py`'s deny-on-ambiguity model \u2014 it statically rejects CQL that does not provably scope to an allowlisted space, rather than asking the agent to \"only query allowed spaces.\" Adversarial CQL (functions, OR clauses, quoted keys, homoglyphs) is rejected at the gateway, not at the agent prompt. Line 234 explicitly rejects the \"permissive CQL with post-hoc filter\" alternative because it would still leak result counts from non-allowlisted spaces \u2014 same reasoning that #1556 used for JQL.\n\n3. **Zero-credential invariant preserved.** Line 14 and the F1 recommendation (lines 293\u2013307) keep Atlassian credentials in the gateway and let agents authenticate with `EGG_SESSION_TOKEN` only. There is no proposal to surface credentials via prompt or env var in the sandbox.\n\n4. **No structured-output-for-humans anti-pattern.** All structured envelopes (404 envelope on line 398, audit fields on line 396) are consumed by machines (gateway audit, agent retry logic), not by humans reading PR comments.\n\n5. **No post-processing pipeline.** The design does not introduce any orchestrator-side script that parses agent output \u2014 Confluence reads happen via direct gateway calls from the sandbox using `sandbox/scripts/confluence`.\n\n6. **No direct LLM API or Agent SDK bypass.** This is HTTP-gateway plumbing. EGG200/EGG201 lints are not engaged.\n\n7. **No hardcoded model IDs.** N/A for this surface.\n\n8. **No micromanagement of agent procedure.** The design exposes verbs the agent can use freely within enforced bounds (space allowlist, verb allowlist, private mode). It does not prescribe a step-by-step \"first call X then Y\" pipeline. The transparent v1 fallback on comment endpoints (D1) is gateway-internal \u2014 agents see one stable verb whose contract is \"return the comments correctly\" and don't need to know about Atlassian's v2 quirks.\n\n### Specific design choices worth highlighting (positive)\n\n- **A1 (Jira-style verb-noun URLs with MCP-aligned wrapper subcommands)**: keeps the gateway path-validator regex simple and uniform with `/api/v1/jira/*`, and pushes the `mcp__confluence__*`-shaped naming into the wrapper layer where consumer porting cost is paid once. This is the right place for the \"feel like the MCP\" affordance \u2014 it's an ergonomics knob in the user-facing wrapper, not a leakage of host-MCP shape into the gateway URL design.\n- **D1 (v2-first hybrid with v1 fallback)**: hides Atlassian's v2 inline-comment 404 bug and missing-nested-replies behaviour from the agent. The agent doesn't need to know which Atlassian API version backs each verb \u2014 that's exactly the level of detail that should not bleed into agent-visible behaviour.\n- **H1 (`confluence.spaces:` in `config/context-filters.yaml`)**: operators already edit this file for Jira and GitHub filtering. Adding the section keeps a single allowlist surface \u2014 no new file means no new operator confusion about where the gate lives.\n- **F1 (shared `ATLASSIAN_*` triple with back-compat fall-back)**: reflects the actual Atlassian Cloud reality (one tenant, one bot, one token) without breaking deployments that already populated `JIRA_*` from #1556. Migration path is loader-only, not operator-facing.\n- **No `EGG_CONFLUENCE_*` env var (line 395, decision row 458)**: explicitly aligns with the issue text. Confluence is reference material, not a unit of work, so the agent need not have an injected page identity. Audit recovers `pageId` / `spaceKey` from the request body \u2014 observability without env-var coupling.\n- **`getConfluenceSpaces` allowlist filter (decision row 450)**: prevents agents from enumerating the full tenant space catalogue. This is a small but important detail \u2014 it stops a sandbox-side reconnaissance vector even when the per-page allowlist would have denied later access.\n- **Permanent attachment denylist (decision row 453)**: scoping arbitrary-file uploads/downloads to a separate ticket is the right call. Attachments are a categorically larger payload surface than HTML/ADF page bodies, and admitting them under v1 would force a different threat model.\n\n### Non-blocking observations\n\n- **Line 264 \u2014 `body-format=storage,atlas_doc_format` doubles response size.** Not an agent-design issue per se; just flag it for the planning phase if any downstream consumer (e.g. #1557 refine context-pull) is sensitive to context-window budget. The \"Caller may override\" escape hatch handles it.\n- **Line 372 \u2014 `/execute` regex-allowlisted GET passthrough.** Already correctly scoped to GET-only forever. Worth ensuring the planning phase pins down the exact regex up front (analogous to Jira's) so reviewers can audit it \u2014 but this is implementation detail, not an agent-design concern.\n\nNo blocking issues. The design correctly inherits #1556's security posture and extends it without introducing any agent-mode anti-patterns.\n", + "metadata": { + "payload": { + "artifact_references": [ + ".egg-state/drafts/1931-analysis.md" + ], + "reason": "\nReviewed `.egg-state/drafts/1931-analysis.md` end-to-end against the agent-mode design criteria in `docs/guides/agent-mode-design.md`. No agent-design anti-patterns flagged.\n\n### Why this design is well-aligned with agent-mode principles\n\n1. **Infrastructure beats config \u2014 pervasive.** Every constraint that matters is enforced at a layer the agent cannot subvert:\n - Line 98: *\"Read-only in v1. The gateway must refuse Confluence write verbs even if the upstream API would accept them. Enforcement is at the gateway (infrastructure), not in agent instructions.\"*\n - Line 99: `@require_private_mode` at the route layer, with a route-enumeration regression test (G1) \u2014 not a prompt-level \"don't call this in public mode.\"\n - Line 101\u2013102: permanent denylist at the path validator (`restrictions`, `permissions`, `space.admin`, `DELETE`/`PUT`/`PATCH` except the future writes carve-out for `pages/{id}` `PUT`).\n - Line 397: Squid perimeter already excludes `*.atlassian.net` \u2014 agents cannot bypass the space allowlist via direct egress, and the existing regression test in `gateway/tests/test_allowed_domains.py` already covers Confluence by extension. The draft correctly resists the temptation to \"add `*.atlassian.net`\" to fix reachability.\n\n2. **Static security checks over prompted-behaviour checks.** The CQL scope extractor (C1, lines 204\u2013227) is a direct port of `gateway/jira_search.py`'s deny-on-ambiguity model \u2014 it statically rejects CQL that does not provably scope to an allowlisted space, rather than asking the agent to \"only query allowed spaces.\" Adversarial CQL (functions, OR clauses, quoted keys, homoglyphs) is rejected at the gateway, not at the agent prompt. Line 234 explicitly rejects the \"permissive CQL with post-hoc filter\" alternative because it would still leak result counts from non-allowlisted spaces \u2014 same reasoning that #1556 used for JQL.\n\n3. **Zero-credential invariant preserved.** Line 14 and the F1 recommendation (lines 293\u2013307) keep Atlassian credentials in the gateway and let agents authenticate with `EGG_SESSION_TOKEN` only. There is no proposal to surface credentials via prompt or env var in the sandbox.\n\n4. **No structured-output-for-humans anti-pattern.** All structured envelopes (404 envelope on line 398, audit fields on line 396) are consumed by machines (gateway audit, agent retry logic), not by humans reading PR comments.\n\n5. **No post-processing pipeline.** The design does not introduce any orchestrator-side script that parses agent output \u2014 Confluence reads happen via direct gateway calls from the sandbox using `sandbox/scripts/confluence`.\n\n6. **No direct LLM API or Agent SDK bypass.** This is HTTP-gateway plumbing. EGG200/EGG201 lints are not engaged.\n\n7. **No hardcoded model IDs.** N/A for this surface.\n\n8. **No micromanagement of agent procedure.** The design exposes verbs the agent can use freely within enforced bounds (space allowlist, verb allowlist, private mode). It does not prescribe a step-by-step \"first call X then Y\" pipeline. The transparent v1 fallback on comment endpoints (D1) is gateway-internal \u2014 agents see one stable verb whose contract is \"return the comments correctly\" and don't need to know about Atlassian's v2 quirks.\n\n### Specific design choices worth highlighting (positive)\n\n- **A1 (Jira-style verb-noun URLs with MCP-aligned wrapper subcommands)**: keeps the gateway path-validator regex simple and uniform with `/api/v1/jira/*`, and pushes the `mcp__confluence__*`-shaped naming into the wrapper layer where consumer porting cost is paid once. This is the right place for the \"feel like the MCP\" affordance \u2014 it's an ergonomics knob in the user-facing wrapper, not a leakage of host-MCP shape into the gateway URL design.\n- **D1 (v2-first hybrid with v1 fallback)**: hides Atlassian's v2 inline-comment 404 bug and missing-nested-replies behaviour from the agent. The agent doesn't need to know which Atlassian API version backs each verb \u2014 that's exactly the level of detail that should not bleed into agent-visible behaviour.\n- **H1 (`confluence.spaces:` in `config/context-filters.yaml`)**: operators already edit this file for Jira and GitHub filtering. Adding the section keeps a single allowlist surface \u2014 no new file means no new operator confusion about where the gate lives.\n- **F1 (shared `ATLASSIAN_*` triple with back-compat fall-back)**: reflects the actual Atlassian Cloud reality (one tenant, one bot, one token) without breaking deployments that already populated `JIRA_*` from #1556. Migration path is loader-only, not operator-facing.\n- **No `EGG_CONFLUENCE_*` env var (line 395, decision row 458)**: explicitly aligns with the issue text. Confluence is reference material, not a unit of work, so the agent need not have an injected page identity. Audit recovers `pageId` / `spaceKey` from the request body \u2014 observability without env-var coupling.\n- **`getConfluenceSpaces` allowlist filter (decision row 450)**: prevents agents from enumerating the full tenant space catalogue. This is a small but important detail \u2014 it stops a sandbox-side reconnaissance vector even when the per-page allowlist would have denied later access.\n- **Permanent attachment denylist (decision row 453)**: scoping arbitrary-file uploads/downloads to a separate ticket is the right call. Attachments are a categorically larger payload surface than HTML/ADF page bodies, and admitting them under v1 would force a different threat model.\n\n### Non-blocking observations\n\n- **Line 264 \u2014 `body-format=storage,atlas_doc_format` doubles response size.** Not an agent-design issue per se; just flag it for the planning phase if any downstream consumer (e.g. #1557 refine context-pull) is sensitive to context-window budget. The \"Caller may override\" escape hatch handles it.\n- **Line 372 \u2014 `/execute` regex-allowlisted GET passthrough.** Already correctly scoped to GET-only forever. Worth ensuring the planning phase pins down the exact regex up front (analogous to Jira's) so reviewers can audit it \u2014 but this is implementation detail, not an agent-design concern.\n\nNo blocking issues. The design correctly inherits #1556's security posture and extends it without introducing any agent-mode anti-patterns.\n" + }, + "version": 1 + }, + "timestamp": "2026-04-26T23:19:17.769497+00:00", + "phase": "refine" + }, + { + "id": "f5019ce9-1522-4d", + "pipeline_id": "issue-1931", + "from_role": "refiner", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "wait_loop exited", + "metadata": { + "state": "WORKING" + }, + "timestamp": "2026-04-26T23:19:17.870320+00:00", + "phase": "refine" + }, + { + "id": "2198b761-6104-4c", + "pipeline_id": "issue-1931", + "from_role": "reviewer_agent_design", + "to_role": "all", + "message_type": "CONSENSUS_CONFIRMED", + "subject": "Confirmed by reviewer_agent_design", + "body": "", + "metadata": { + "consensus_reached": false + }, + "timestamp": "2026-04-26T23:19:21.002316+00:00", + "phase": "refine" + }, + { + "id": "28fc4aa5-a9c0-40", + "pipeline_id": "issue-1931", + "from_role": "refiner", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_ACK,CONSENSUS_NACK,CONSENSUS_RE_REVIEW,OVERSEER_ALERT from=reviewer_refine", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-04-26T23:19:22.903403+00:00" + }, + "timestamp": "2026-04-26T23:19:22.996977+00:00", + "phase": "refine" + }, + { + "id": "f44dbf2a-9534-4a", + "pipeline_id": "issue-1931", + "from_role": "reviewer_agent_design", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_PROPOSE,CONSENSUS_RE_REVIEW,CONSENSUS_CONFIRMED,OVERSEER_ALERT", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-04-26T23:19:31.547486+00:00" + }, + "timestamp": "2026-04-26T23:19:31.662830+00:00", + "phase": "refine" + }, + { + "id": "31e86fee-b5ac-4a", + "pipeline_id": "issue-1931", + "from_role": "reviewer_agent_design", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "wait_loop exited", + "metadata": { + "state": "WORKING" + }, + "timestamp": "2026-04-26T23:19:31.826506+00:00", + "phase": "refine" + }, + { + "id": "f3b88259-22f9-42", + "pipeline_id": "issue-1931", + "from_role": "reviewer_agent_design", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_PROPOSE,CONSENSUS_RE_REVIEW,CONSENSUS_CONFIRMED,OVERSEER_ALERT", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-04-26T23:19:35.881990+00:00" + }, + "timestamp": "2026-04-26T23:19:35.939196+00:00", + "phase": "refine" + }, + { + "id": "fe773f32-abb9-41", + "pipeline_id": "issue-1931", + "from_role": "reviewer_refine", + "to_role": "refiner", + "message_type": "CONSENSUS_ACK", + "subject": "ACK from reviewer_refine for refiner", + "body": "\nReviewed `.egg-state/drafts/1931-analysis.md` (478 lines, ~38 KB) section-by-section against all seven review criteria, and cross-checked the contract (14 HITL decisions + 10 free-form feedback questions registered via `mcp__sdlc__*`). I spot-verified every concrete code/file claim cited in the draft.\n\n### Section-by-section evaluation\n\n**1. Problem Understanding \u2014 STRONG**\nThe Problem Statement and Desired Outcomes 1\u20137 (lines 5\u201319) faithfully reproduce the issue's seven-point scope (read-only verb surface, zero-credential invariant, private-mode-only, space+verb allowlist, future-verb readiness, no `EGG_CONFLUENCE_*`, permanent denylist for deletes/admin). Lines 7\u20139 correctly re-justify \"why not the Atlassian MCP\" with reachability + access-control arguments parallel to #1556, without re-litigating.\n\n**2. Research Quality \u2014 STRONG**\nVerified every concrete reference:\n- `gateway/jira_client.py` (567 lines), `jira_credentials.py` (213), `jira_policy.py` (253), `jira_search.py` (250) \u2014 all exist as cited.\n- `gateway/mode_gate.py` exposes `require_private_mode` decorator (line 60) and `__egg_requires_private_mode__ = True` marker attr (line 39) \u2014 exactly as the draft claims for the route-enumeration regression test.\n- `extract_search_projects` exists in `gateway/jira_search.py:55`; `JIRA_WRITE_VERBS_DENIED` referenced in `gateway/gateway.py:4560`. The draft's \"direct port of `gateway/jira_search.py`\" is well-grounded.\n- `gateway/tests/test_allowed_domains.py` lines 40\u201346 enforce absence of `atlassian.net`/`atlassian.com`/`api.atlassian.com`/`jira.atlassian.com` from the Squid allowlist \u2014 confirms the draft's \"*.atlassian.net already excluded; do not add\" claim.\n- `config/secrets.template.env` lines 90\u201399 carry the `CONFLUENCE_BASE_URL`/`USERNAME`/`API_TOKEN`/`SPACE_KEYS` placeholders the draft cites (line cite 92\u201399 \u2248 correct).\n- `sandbox/agent-config/commands/show-metrics.md` line 12 references `ls ~/context-sync/confluence/` \u2014 confirms.\n- `docs/reference/jira-wrapper.md`, `docs/architecture/network-isolation.md`, `docs/architecture/credential-injection.md`, `sandbox/scripts/jira`, `config/context-filters.yaml` \u2014 all exist as cited.\n\nThe Atlassian v1/v2 split research (lines 67\u201382) accurately captures the actual community-known issues: CQL is v1-only with no deprecation roadmap, v2 footer-comments misses nested replies, v2 inline-comments has a 404 bug. Citations include real Atlassian developer-community URLs.\n\n**3. Options Analysis \u2014 STRONG**\nEight decision dimensions (endpoint surface A1/A2/A3, API version B1/B2/B3, CQL extraction C1/C2, comment quirks D1/D2/D3, body format E1/E2/E3/E4, credential sharing F1/F2, network gate G1, allowlist location H1/H2/H3) are meaningfully different (not strawmen), each with pros/cons. The recommendation `A1+B1+C1+D1+E1+F1+G1+H1` is consistent with stated rationale at every dimension.\n\n**4. Constraints & Dependencies \u2014 STRONG**\nConstraints (lines 92\u2013127) split cleanly into Security/architectural, Operational, Dependencies, External (Atlassian). Hard dep on #1556 is explicit (line 114); consumer #1557 explicit (line 115); `/impact-analysis` consumer noted (line 116). Permanent denylist (line 102) anticipates the future-write phase by enumerating which verbs/methods stay denied forever.\n\n**5. Open Questions \u2014 STRONG**\n14 multi-choice decisions + 10 free-form feedback questions are surfaced. Each is specific, actionable, and addresses a genuine ambiguity (URL shape, API version, body format, credential sharing, allowlist source, attachments stance, /execute passthrough, descendant-depth cap, version querystring, bot-account access drift vs human, wrapper subcommand naming, etc.). No silent assumptions detected.\n\n**6. Recommendation Quality \u2014 STRONG**\nThe Recommended Approach (lines 380\u2013402) explicitly enumerates each chosen option, the ancillary decisions (no `EGG_CONFLUENCE_*`, audit shape, Squid stance, 404 envelope, test surface, doc updates, future-write extension shape), and a Complexity Assessment (\"medium\"). Reasoning aligns with the analysis findings throughout.\n\n**7. HITL Decision Registration \u2014 VERIFIED**\n`mcp__sdlc__show_contract` returns 14 `decisions` (decision-1 \u2026 decision-14) and 1 `feedback` block with 10 questions (Q1\u2013Q10) \u2014 every option-set and free-form question in the draft's \"Registered decisions\" / \"Registered open-ended questions\" sections (lines 411\u2013474) has a matching contract entry. The on-disk contract file (`.egg-state/contracts/issue-1931.json`) is an older snapshot and lacks them, but the live gateway contract is correctly populated, which is what the orchestrator/HITL surface reads. No prose-only open questions were found.\n\n### Non-blocking\n\n- **`.egg-state/drafts/1931-analysis.md:48`** \u2014 minor inaccuracy: claims `sandbox/agent-config/rules/environment.md:80` \"mentions `~/context-sync/confluence/`\", but the actual line says `~/context-sync/` with \"Confluence/JIRA cache\" as purpose; the path component `confluence/` only appears in `show-metrics.md:12`. Suggest tightening to \"`~/context-sync/` (Confluence/JIRA cache)\" so the reader who jumps to line 80 isn't confused.\n- **`.egg-state/drafts/1931-analysis.md:102` vs `:369`** \u2014 internal phrasing tension: line 102 lists \"any HTTP `PUT` / `PATCH`\" under \"Permanent denylist (path-validator level)\", but line 369 (Future-write extension) says \"Path validator must allow `PUT` for this single path family [`pages/{id}`] in the future-writes phase\". The intent is clearly \"PUT denied today; narrow allow when writes land\", but as written the two statements contradict at face value. Suggest adjusting line 102 wording to \"any HTTP `PUT` / `PATCH` (until the future-writes phase carves a narrow `pages/{id}` exception \u2014 see below)\" to make the future state explicit.\n- **`.egg-state/drafts/1931-analysis.md:238\u2013246` (Comment quirks D1)** \u2014 the v1 fallback on a v2 404 risks masking *legitimate* 404s (page truly has no inline comments) as \"v2 bug \u2192 retry on v1\". Worth surfacing in the planning phase as a behaviour question \u2014 e.g., \"always fall back\" vs \"fall back only when the page is known to have inline comments per a separate check\". Not blocking; the option set already records the choice between D1/D2/D3.\n- **`.egg-state/drafts/1931-analysis.md:124` (CQL hard limit)** \u2014 analysis notes \"CQL has a 200-result hard limit per query; pagination required beyond that\" but the recommended approach doesn't pin whether the wrapper truncates, paginates transparently, or surfaces the cursor to callers. Worth adding a 15th decision OR resolving in plan; non-blocking because a sensible default (surface the cursor) can land without operator input.\n- **`.egg-state/drafts/1931-analysis.md:296\u2013297` (F1)** \u2014 base-URL derivation claim \"Confluence base by appending `/wiki` if `CONFLUENCE_BASE_URL` is unset\" assumes the standard Atlassian Cloud path; some self-managed Server / Data Center installs put Confluence at a different host entirely. Issue is Cloud-only by implication, but worth noting that auto-derivation should be the *fallback*, not the default-and-only path. Plan-phase concern.\n\nNo blocking issues \u2014 the draft accurately frames the problem, grounds every cited file, surfaces all material design choices as HITL items, and presents a justified recommendation. ACKing.\n", + "metadata": { + "payload": { + "artifact_references": [ + ".egg-state/drafts/1931-analysis.md", + ".egg-state/contracts/issue-1931.json", + "gateway/jira_client.py", + "gateway/jira_credentials.py", + "gateway/jira_policy.py", + "gateway/jira_search.py", + "gateway/mode_gate.py", + "gateway/tests/test_allowed_domains.py", + "config/secrets.template.env", + "sandbox/agent-config/rules/environment.md", + "sandbox/agent-config/commands/show-metrics.md", + "docs/reference/jira-wrapper.md" + ], + "reason": "\nReviewed `.egg-state/drafts/1931-analysis.md` (478 lines, ~38 KB) section-by-section against all seven review criteria, and cross-checked the contract (14 HITL decisions + 10 free-form feedback questions registered via `mcp__sdlc__*`). I spot-verified every concrete code/file claim cited in the draft.\n\n### Section-by-section evaluation\n\n**1. Problem Understanding \u2014 STRONG**\nThe Problem Statement and Desired Outcomes 1\u20137 (lines 5\u201319) faithfully reproduce the issue's seven-point scope (read-only verb surface, zero-credential invariant, private-mode-only, space+verb allowlist, future-verb readiness, no `EGG_CONFLUENCE_*`, permanent denylist for deletes/admin). Lines 7\u20139 correctly re-justify \"why not the Atlassian MCP\" with reachability + access-control arguments parallel to #1556, without re-litigating.\n\n**2. Research Quality \u2014 STRONG**\nVerified every concrete reference:\n- `gateway/jira_client.py` (567 lines), `jira_credentials.py` (213), `jira_policy.py` (253), `jira_search.py` (250) \u2014 all exist as cited.\n- `gateway/mode_gate.py` exposes `require_private_mode` decorator (line 60) and `__egg_requires_private_mode__ = True` marker attr (line 39) \u2014 exactly as the draft claims for the route-enumeration regression test.\n- `extract_search_projects` exists in `gateway/jira_search.py:55`; `JIRA_WRITE_VERBS_DENIED` referenced in `gateway/gateway.py:4560`. The draft's \"direct port of `gateway/jira_search.py`\" is well-grounded.\n- `gateway/tests/test_allowed_domains.py` lines 40\u201346 enforce absence of `atlassian.net`/`atlassian.com`/`api.atlassian.com`/`jira.atlassian.com` from the Squid allowlist \u2014 confirms the draft's \"*.atlassian.net already excluded; do not add\" claim.\n- `config/secrets.template.env` lines 90\u201399 carry the `CONFLUENCE_BASE_URL`/`USERNAME`/`API_TOKEN`/`SPACE_KEYS` placeholders the draft cites (line cite 92\u201399 \u2248 correct).\n- `sandbox/agent-config/commands/show-metrics.md` line 12 references `ls ~/context-sync/confluence/` \u2014 confirms.\n- `docs/reference/jira-wrapper.md`, `docs/architecture/network-isolation.md`, `docs/architecture/credential-injection.md`, `sandbox/scripts/jira`, `config/context-filters.yaml` \u2014 all exist as cited.\n\nThe Atlassian v1/v2 split research (lines 67\u201382) accurately captures the actual community-known issues: CQL is v1-only with no deprecation roadmap, v2 footer-comments misses nested replies, v2 inline-comments has a 404 bug. Citations include real Atlassian developer-community URLs.\n\n**3. Options Analysis \u2014 STRONG**\nEight decision dimensions (endpoint surface A1/A2/A3, API version B1/B2/B3, CQL extraction C1/C2, comment quirks D1/D2/D3, body format E1/E2/E3/E4, credential sharing F1/F2, network gate G1, allowlist location H1/H2/H3) are meaningfully different (not strawmen), each with pros/cons. The recommendation `A1+B1+C1+D1+E1+F1+G1+H1` is consistent with stated rationale at every dimension.\n\n**4. Constraints & Dependencies \u2014 STRONG**\nConstraints (lines 92\u2013127) split cleanly into Security/architectural, Operational, Dependencies, External (Atlassian). Hard dep on #1556 is explicit (line 114); consumer #1557 explicit (line 115); `/impact-analysis` consumer noted (line 116). Permanent denylist (line 102) anticipates the future-write phase by enumerating which verbs/methods stay denied forever.\n\n**5. Open Questions \u2014 STRONG**\n14 multi-choice decisions + 10 free-form feedback questions are surfaced. Each is specific, actionable, and addresses a genuine ambiguity (URL shape, API version, body format, credential sharing, allowlist source, attachments stance, /execute passthrough, descendant-depth cap, version querystring, bot-account access drift vs human, wrapper subcommand naming, etc.). No silent assumptions detected.\n\n**6. Recommendation Quality \u2014 STRONG**\nThe Recommended Approach (lines 380\u2013402) explicitly enumerates each chosen option, the ancillary decisions (no `EGG_CONFLUENCE_*`, audit shape, Squid stance, 404 envelope, test surface, doc updates, future-write extension shape), and a Complexity Assessment (\"medium\"). Reasoning aligns with the analysis findings throughout.\n\n**7. HITL Decision Registration \u2014 VERIFIED**\n`mcp__sdlc__show_contract` returns 14 `decisions` (decision-1 \u2026 decision-14) and 1 `feedback` block with 10 questions (Q1\u2013Q10) \u2014 every option-set and free-form question in the draft's \"Registered decisions\" / \"Registered open-ended questions\" sections (lines 411\u2013474) has a matching contract entry. The on-disk contract file (`.egg-state/contracts/issue-1931.json`) is an older snapshot and lacks them, but the live gateway contract is correctly populated, which is what the orchestrator/HITL surface reads. No prose-only open questions were found.\n\n### Non-blocking\n\n- **`.egg-state/drafts/1931-analysis.md:48`** \u2014 minor inaccuracy: claims `sandbox/agent-config/rules/environment.md:80` \"mentions `~/context-sync/confluence/`\", but the actual line says `~/context-sync/` with \"Confluence/JIRA cache\" as purpose; the path component `confluence/` only appears in `show-metrics.md:12`. Suggest tightening to \"`~/context-sync/` (Confluence/JIRA cache)\" so the reader who jumps to line 80 isn't confused.\n- **`.egg-state/drafts/1931-analysis.md:102` vs `:369`** \u2014 internal phrasing tension: line 102 lists \"any HTTP `PUT` / `PATCH`\" under \"Permanent denylist (path-validator level)\", but line 369 (Future-write extension) says \"Path validator must allow `PUT` for this single path family [`pages/{id}`] in the future-writes phase\". The intent is clearly \"PUT denied today; narrow allow when writes land\", but as written the two statements contradict at face value. Suggest adjusting line 102 wording to \"any HTTP `PUT` / `PATCH` (until the future-writes phase carves a narrow `pages/{id}` exception \u2014 see below)\" to make the future state explicit.\n- **`.egg-state/drafts/1931-analysis.md:238\u2013246` (Comment quirks D1)** \u2014 the v1 fallback on a v2 404 risks masking *legitimate* 404s (page truly has no inline comments) as \"v2 bug \u2192 retry on v1\". Worth surfacing in the planning phase as a behaviour question \u2014 e.g., \"always fall back\" vs \"fall back only when the page is known to have inline comments per a separate check\". Not blocking; the option set already records the choice between D1/D2/D3.\n- **`.egg-state/drafts/1931-analysis.md:124` (CQL hard limit)** \u2014 analysis notes \"CQL has a 200-result hard limit per query; pagination required beyond that\" but the recommended approach doesn't pin whether the wrapper truncates, paginates transparently, or surfaces the cursor to callers. Worth adding a 15th decision OR resolving in plan; non-blocking because a sensible default (surface the cursor) can land without operator input.\n- **`.egg-state/drafts/1931-analysis.md:296\u2013297` (F1)** \u2014 base-URL derivation claim \"Confluence base by appending `/wiki` if `CONFLUENCE_BASE_URL` is unset\" assumes the standard Atlassian Cloud path; some self-managed Server / Data Center installs put Confluence at a different host entirely. Issue is Cloud-only by implication, but worth noting that auto-derivation should be the *fallback*, not the default-and-only path. Plan-phase concern.\n\nNo blocking issues \u2014 the draft accurately frames the problem, grounds every cited file, surfaces all material design choices as HITL items, and presents a justified recommendation. ACKing.\n" + }, + "version": 1 + }, + "timestamp": "2026-04-26T23:20:03.789712+00:00", + "phase": "refine" + }, + { + "id": "5aebbe49-3e31-4b", + "pipeline_id": "issue-1931", + "from_role": "orchestrator", + "to_role": "refiner", + "message_type": "STATUS", + "subject": "Ready to confirm \u2014 all confirm preconditions satisfied", + "body": "Your proposal (version 1) is ready to confirm \u2014 all blocking reviews are clear and global confirm preconditions are met. Run `egg-orch consensus confirmed` to confirm.", + "metadata": { + "ready_to_confirm": true, + "version": 1 + }, + "timestamp": "2026-04-26T23:20:03.789745+00:00", + "phase": "refine" + }, + { + "id": "9e970d96-f991-4d", + "pipeline_id": "issue-1931", + "from_role": "refiner", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "wait_loop exited", + "metadata": { + "state": "WORKING" + }, + "timestamp": "2026-04-26T23:20:03.897986+00:00", + "phase": "refine" + }, + { + "id": "5b7c3d21-c3d4-4d", + "pipeline_id": "issue-1931", + "from_role": "reviewer_refine", + "to_role": "all", + "message_type": "CONSENSUS_CONFIRMED", + "subject": "Confirmed by reviewer_refine", + "body": "", + "metadata": { + "consensus_reached": false + }, + "timestamp": "2026-04-26T23:20:12.668971+00:00", + "phase": "refine" + }, + { + "id": "8e04124c-410a-43", + "pipeline_id": "issue-1931", + "from_role": "reviewer_agent_design", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "wait_loop exited", + "metadata": { + "state": "WORKING" + }, + "timestamp": "2026-04-26T23:20:12.770763+00:00", + "phase": "refine" + }, + { + "id": "49400a75-2d38-4e", + "pipeline_id": "issue-1931", + "from_role": "refiner", + "to_role": "all", + "message_type": "CONSENSUS_CONFIRMED", + "subject": "Confirmed by refiner", + "body": "", + "metadata": { + "consensus_reached": true + }, + "timestamp": "2026-04-26T23:20:14.981447+00:00", + "phase": "refine" + }, + { + "id": "cd88dfab-efc3-4f", + "pipeline_id": "issue-1931", + "from_role": "reviewer_agent_design", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_PROPOSE,CONSENSUS_RE_REVIEW,CONSENSUS_CONFIRMED,OVERSEER_ALERT", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-04-26T23:20:15.674566+00:00" + }, + "timestamp": "2026-04-26T23:20:15.759444+00:00", + "phase": "refine" + }, + { + "id": "a8b06537-123e-40", + "pipeline_id": "issue-1931", + "from_role": "reviewer_agent_design", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "wait_loop exited", + "metadata": { + "state": "WORKING" + }, + "timestamp": "2026-04-26T23:20:15.881465+00:00", + "phase": "refine" + } +] \ No newline at end of file diff --git a/.egg-state/brc-history/1931-refine.md b/.egg-state/brc-history/1931-refine.md new file mode 100644 index 0000000000..bc8bb5ced5 --- /dev/null +++ b/.egg-state/brc-history/1931-refine.md @@ -0,0 +1,853 @@ +# BRC Consensus History — refine phase + +Generated: 2026-04-26T23:20:15Z +Pipeline: issue-1931 + +### [2026-04-26T23:06:25Z] reviewer_agent_design (HEARTBEAT): heartbeat: WAITING_ON_ROLE + +````yaml +id: 8063c139-40d9-4a +phase: refine +metadata: + state: WAITING_ON_ROLE + waiting_on: refiner +```` + +### [2026-04-26T23:06:25Z] reviewer_agent_design (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_PROPOSE from=refiner + +````yaml +id: 96259c12-81dd-47 +phase: refine +metadata: + state: WAITING_FOR_EVENT + since: '2026-04-26T23:06:25.356486+00:00' +```` + +### [2026-04-26T23:06:27Z] reviewer_refine (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_PROPOSE from=refiner + +````yaml +id: 5d4027d5-f9af-48 +phase: refine +metadata: + state: WAITING_FOR_EVENT + since: '2026-04-26T23:06:27.192663+00:00' +```` + +### [2026-04-26T23:07:25Z] reviewer_agent_design (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_PROPOSE from=refiner + +````yaml +id: 0c94c5b3-8520-49 +phase: refine +metadata: + state: WAITING_FOR_EVENT + since: '2026-04-26T23:06:25.356486+00:00' +```` + +### [2026-04-26T23:07:27Z] reviewer_refine (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_PROPOSE from=refiner + +````yaml +id: 164a59f1-d68b-4b +phase: refine +metadata: + state: WAITING_FOR_EVENT + since: '2026-04-26T23:06:27.192663+00:00' +```` + +### [2026-04-26T23:08:26Z] reviewer_agent_design (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_PROPOSE from=refiner + +````yaml +id: cae6ad56-69d3-42 +phase: refine +metadata: + state: WAITING_FOR_EVENT + since: '2026-04-26T23:06:25.356486+00:00' +```` + +### [2026-04-26T23:08:27Z] reviewer_refine (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_PROPOSE from=refiner + +````yaml +id: 6456dcf3-de19-44 +phase: refine +metadata: + state: WAITING_FOR_EVENT + since: '2026-04-26T23:06:27.192663+00:00' +```` + +### [2026-04-26T23:09:26Z] reviewer_agent_design (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_PROPOSE from=refiner + +````yaml +id: 922889cc-6e47-43 +phase: refine +metadata: + state: WAITING_FOR_EVENT + since: '2026-04-26T23:06:25.356486+00:00' +```` + +### [2026-04-26T23:09:27Z] reviewer_refine (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_PROPOSE from=refiner + +````yaml +id: 167050eb-2c65-43 +phase: refine +metadata: + state: WAITING_FOR_EVENT + since: '2026-04-26T23:06:27.192663+00:00' +```` + +### [2026-04-26T23:10:26Z] reviewer_agent_design (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_PROPOSE from=refiner + +````yaml +id: c21d83ae-6c51-45 +phase: refine +metadata: + state: WAITING_FOR_EVENT + since: '2026-04-26T23:06:25.356486+00:00' +```` + +### [2026-04-26T23:10:28Z] reviewer_refine (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_PROPOSE from=refiner + +````yaml +id: 6a95cac8-e7c8-40 +phase: refine +metadata: + state: WAITING_FOR_EVENT + since: '2026-04-26T23:06:27.192663+00:00' +```` + +### [2026-04-26T23:11:26Z] reviewer_agent_design (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_PROPOSE from=refiner + +````yaml +id: d3d42e27-e498-45 +phase: refine +metadata: + state: WAITING_FOR_EVENT + since: '2026-04-26T23:06:25.356486+00:00' +```` + +### [2026-04-26T23:11:28Z] reviewer_refine (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_PROPOSE from=refiner + +````yaml +id: 509252e5-e4f2-41 +phase: refine +metadata: + state: WAITING_FOR_EVENT + since: '2026-04-26T23:06:27.192663+00:00' +```` + +### [2026-04-26T23:12:26Z] reviewer_agent_design (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_PROPOSE from=refiner + +````yaml +id: ef65d828-1d52-4a +phase: refine +metadata: + state: WAITING_FOR_EVENT + since: '2026-04-26T23:06:25.356486+00:00' +```` + +### [2026-04-26T23:12:28Z] reviewer_refine (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_PROPOSE from=refiner + +````yaml +id: 8155c13a-7ac2-43 +phase: refine +metadata: + state: WAITING_FOR_EVENT + since: '2026-04-26T23:06:27.192663+00:00' +```` + +### [2026-04-26T23:13:26Z] reviewer_agent_design (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_PROPOSE from=refiner + +````yaml +id: 4986db2b-b2a4-4e +phase: refine +metadata: + state: WAITING_FOR_EVENT + since: '2026-04-26T23:06:25.356486+00:00' +```` + +### [2026-04-26T23:13:28Z] reviewer_refine (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_PROPOSE from=refiner + +````yaml +id: 0afdda58-f4e4-45 +phase: refine +metadata: + state: WAITING_FOR_EVENT + since: '2026-04-26T23:06:27.192663+00:00' +```` + +### [2026-04-26T23:14:26Z] reviewer_agent_design (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_PROPOSE from=refiner + +````yaml +id: c5d49345-4c4b-42 +phase: refine +metadata: + state: WAITING_FOR_EVENT + since: '2026-04-26T23:06:25.356486+00:00' +```` + +### [2026-04-26T23:14:29Z] reviewer_refine (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_PROPOSE from=refiner + +````yaml +id: 26de2a56-9d60-4c +phase: refine +metadata: + state: WAITING_FOR_EVENT + since: '2026-04-26T23:06:27.192663+00:00' +```` + +### [2026-04-26T23:14:36Z] overseer (OVERSEER_ALERT): stuck-phase-transition [medium] + +Refiner (issue-1931) has been WORKING for 514s (~8.6 min) without emitting CONSENSUS_PROPOSE; reviewers blocked in wait_loop for ~8 min + +Detail: +Pipeline issue-1931, refine phase. Container c32916dc (refiner) started at 23:05:39 UTC and remains in producer_phase=WORKING at 514s elapsed. No CONSENSUS_PROPOSE has been emitted. The refiner has sent zero of its own heartbeats — all escalation heartbeats originate from reviewer_agent_design and reviewer_refine, both in WAITING_FOR_EVENT since 23:06:25. Reviewers have cycled through 9 wait_loop rounds (60s cadence) with no signal from the producer. Monitoring script shows heartbeat_ok=true and alerts=0 at platform level, so the container is alive but the BRC proposal is delayed beyond expected norms (~5 min). Possible causes: (1) refiner is doing deep codebase analysis on a complex issue; (2) refiner hit a slow/stalled LLM call; (3) refiner tool-call loop is iterating without forward progress. + +Recommended action: +Check container c32916dc logs for the refiner to determine if it is making forward progress (tool calls, reads) or looping/stalled. If no meaningful progress in next 2 cycles (~4 min), consider restarting the refiner container for pipeline issue-1931. + +````yaml +id: 3faed350-eda6-46 +phase: refine +```` + +### [2026-04-26T23:15:26Z] reviewer_agent_design (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_PROPOSE from=refiner + +````yaml +id: 8ff108d1-10f8-4a +phase: refine +metadata: + state: WAITING_FOR_EVENT + since: '2026-04-26T23:06:25.356486+00:00' +```` + +### [2026-04-26T23:15:29Z] reviewer_refine (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_PROPOSE from=refiner + +````yaml +id: 6a60478e-8819-42 +phase: refine +metadata: + state: WAITING_FOR_EVENT + since: '2026-04-26T23:06:27.192663+00:00' +```` + +### [2026-04-26T23:16:26Z] reviewer_agent_design (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_PROPOSE from=refiner + +````yaml +id: 630677da-f446-43 +phase: refine +metadata: + state: WAITING_FOR_EVENT + since: '2026-04-26T23:06:25.356486+00:00' +```` + +### [2026-04-26T23:16:29Z] reviewer_refine (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_PROPOSE from=refiner + +````yaml +id: 29110801-ca63-46 +phase: refine +metadata: + state: WAITING_FOR_EVENT + since: '2026-04-26T23:06:27.192663+00:00' +```` + +### [2026-04-26T23:17:30Z] reviewer_refine (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_PROPOSE from=refiner + +````yaml +id: 97ef80ed-ba3a-43 +phase: refine +metadata: + state: WAITING_FOR_EVENT + since: '2026-04-26T23:06:27.192663+00:00' +```` + +### [2026-04-26T23:17:38Z] overseer (OVERSEER_ALERT): agent-heartbeat-stall [high] + +ESCALATION: Refiner stalled for 690s (~11.5 min) — detector fired heartbeat_timeout + progress_stall; advisor gate now OPEN for issue-1931 + +Detail: +Escalating from medium (alert 3faed350) to HIGH. At cycle 19 (23:17:10 UTC), the monitoring script now reports alerts=2 with advisor_gate.gate_open=true: (1) heartbeat_timeout id=d6d9a433 "No heartbeat for 120s" and (2) progress_stall id=7a175707 "No progress for 122s". Refiner container c32916dc has been WORKING for 690s with no CONSENSUS_PROPOSE, no self-emitted heartbeats, and no progress events. The platform-level detector has now independently confirmed the stall condition. Both reviewer containers (aa29efd7, d888d4ad) remain alive with 60s heartbeat cadence (13 rounds), fully blocked waiting on refiner. The refiner container is running but no productive output has been observed through any channel. + +Recommended action: +IMMEDIATE ACTION: Restart refiner container c32916dc for pipeline issue-1931. The container is alive but unproductive for 11.5 min. After restart, monitor for CONSENSUS_PROPOSE within 5 min. If a second restart also fails to produce a proposal, consider aborting the refine phase and re-queuing the pipeline. + +````yaml +id: 381169d0-2959-40 +phase: refine +```` + +### [2026-04-26T23:17:51Z] refiner (CONSENSUS_PROPOSE): Proposal from refiner + +Refine-phase analysis for #1931 (Confluence gateway, read-only v1). Mirrors the Jira gateway pattern landed in #1556 — same private-mode gate, same Squid exclusion, same mtime-cached credential loader, same fail-closed allowlist semantics — with three Confluence-specific deltas captured and surfaced as decisions: (1) the Confluence v1/v2 API split (v2-first reads + v1 CQL search + v1 fallback for known v2 comment bugs), (2) a space allowlist (parallel to jira.projects) lives in a new confluence.spaces section of config/context-filters.yaml, and (3) shared Atlassian credentials between Jira and Confluence (single bot, single API token, ATLASSIAN_* shared envs with backward-compat fall-back). The draft also covers: endpoint surface options, CQL scope-extractor mirroring JQL deny-on-ambiguity, body-format defaults (storage + atlas_doc_format), comment-quirk handling, attachments permanently denylisted, no EGG_CONFLUENCE_* env var (per issue), and the future-write extension shape (page/create, page/update via PUT, comment/create) under the same plumbing. 14 multiple-choice decisions and 10 free-form feedback items are registered against the contract via mcp__sdlc__register_open_question / mcp__sdlc__request_feedback. Complexity assessed as medium. + +````yaml +id: 8ec63fb5-b690-4a +phase: refine +metadata: + payload: + summary: "Refine-phase analysis for #1931 (Confluence gateway, read-only v1).\ + \ Mirrors the Jira gateway pattern landed in #1556 \u2014 same private-mode\ + \ gate, same Squid exclusion, same mtime-cached credential loader, same fail-closed\ + \ allowlist semantics \u2014 with three Confluence-specific deltas captured\ + \ and surfaced as decisions: (1) the Confluence v1/v2 API split (v2-first reads\ + \ + v1 CQL search + v1 fallback for known v2 comment bugs), (2) a space allowlist\ + \ (parallel to jira.projects) lives in a new confluence.spaces section of config/context-filters.yaml,\ + \ and (3) shared Atlassian credentials between Jira and Confluence (single bot,\ + \ single API token, ATLASSIAN_* shared envs with backward-compat fall-back).\ + \ The draft also covers: endpoint surface options, CQL scope-extractor mirroring\ + \ JQL deny-on-ambiguity, body-format defaults (storage + atlas_doc_format),\ + \ comment-quirk handling, attachments permanently denylisted, no EGG_CONFLUENCE_*\ + \ env var (per issue), and the future-write extension shape (page/create, page/update\ + \ via PUT, comment/create) under the same plumbing. 14 multiple-choice decisions\ + \ and 10 free-form feedback items are registered against the contract via mcp__sdlc__register_open_question\ + \ / mcp__sdlc__request_feedback. Complexity assessed as medium." + attestation: {} + artifacts: + - .egg-state/drafts/1931-analysis.md + risk_considered: "Confluence v1/v2 API split is the largest external risk \u2014\ + \ surfaced via decision-2 (API version strategy), decision-4 (comment quirk\ + \ handling), and feedback Q5/Q6 (depth limits / page-link resolution). Allowlist\ + \ bypass risk addressed by mirroring Jira's CQL extractor (decision-3) and reusing\ + \ the existing Squid exclusion for *.atlassian.net (regression-tested, no change\ + \ needed). Credential rotation pain mitigated by sharing Atlassian creds across\ + \ Jira + Confluence (decision-6) so operators rotate once. PII leakage covered\ + \ by decision-10 (response redaction) and feedback Q3 (custom-field PII)." + commit_sha: 5ca30533755811b35d68d9235cb4aa53dd1e9c48 + files_changed: + - .egg-state/drafts/1931-analysis.md + tests_run: [] + tasks_satisfied: [] + version: 1 + commit_sha: 5ca30533755811b35d68d9235cb4aa53dd1e9c48 +```` + +### [2026-04-26T23:17:51Z] reviewer_refine (HEARTBEAT): heartbeat: WORKING + +wait_loop exited + +````yaml +id: 118402b6-7cd7-45 +phase: refine +metadata: + state: WORKING +```` + +### [2026-04-26T23:17:51Z] reviewer_agent_design (HEARTBEAT): heartbeat: WORKING + +wait_loop exited + +````yaml +id: cef3b4dd-6747-4f +phase: refine +metadata: + state: WORKING +```` + +### [2026-04-26T23:17:55Z] refiner (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_ACK,CONSENSUS_NACK,CONSENSUS_RE_REVIEW,OVERSEER_ALERT + +````yaml +id: 9845ce4e-e007-47 +phase: refine +metadata: + state: WAITING_FOR_EVENT + since: '2026-04-26T23:17:55.927319+00:00' +```` + +### [2026-04-26T23:18:56Z] refiner (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_ACK,CONSENSUS_NACK,CONSENSUS_RE_REVIEW,OVERSEER_ALERT + +````yaml +id: f7cbb2b5-a815-41 +phase: refine +metadata: + state: WAITING_FOR_EVENT + since: '2026-04-26T23:17:55.927319+00:00' +```` + +### [2026-04-26T23:19:17Z] reviewer_agent_design → refiner (CONSENSUS_ACK): ACK from reviewer_agent_design for refiner + + +Reviewed `.egg-state/drafts/1931-analysis.md` end-to-end against the agent-mode design criteria in `docs/guides/agent-mode-design.md`. No agent-design anti-patterns flagged. + +### Why this design is well-aligned with agent-mode principles + +1. **Infrastructure beats config — pervasive.** Every constraint that matters is enforced at a layer the agent cannot subvert: + - Line 98: *"Read-only in v1. The gateway must refuse Confluence write verbs even if the upstream API would accept them. Enforcement is at the gateway (infrastructure), not in agent instructions."* + - Line 99: `@require_private_mode` at the route layer, with a route-enumeration regression test (G1) — not a prompt-level "don't call this in public mode." + - Line 101–102: permanent denylist at the path validator (`restrictions`, `permissions`, `space.admin`, `DELETE`/`PUT`/`PATCH` except the future writes carve-out for `pages/{id}` `PUT`). + - Line 397: Squid perimeter already excludes `*.atlassian.net` — agents cannot bypass the space allowlist via direct egress, and the existing regression test in `gateway/tests/test_allowed_domains.py` already covers Confluence by extension. The draft correctly resists the temptation to "add `*.atlassian.net`" to fix reachability. + +2. **Static security checks over prompted-behaviour checks.** The CQL scope extractor (C1, lines 204–227) is a direct port of `gateway/jira_search.py`'s deny-on-ambiguity model — it statically rejects CQL that does not provably scope to an allowlisted space, rather than asking the agent to "only query allowed spaces." Adversarial CQL (functions, OR clauses, quoted keys, homoglyphs) is rejected at the gateway, not at the agent prompt. Line 234 explicitly rejects the "permissive CQL with post-hoc filter" alternative because it would still leak result counts from non-allowlisted spaces — same reasoning that #1556 used for JQL. + +3. **Zero-credential invariant preserved.** Line 14 and the F1 recommendation (lines 293–307) keep Atlassian credentials in the gateway and let agents authenticate with `EGG_SESSION_TOKEN` only. There is no proposal to surface credentials via prompt or env var in the sandbox. + +4. **No structured-output-for-humans anti-pattern.** All structured envelopes (404 envelope on line 398, audit fields on line 396) are consumed by machines (gateway audit, agent retry logic), not by humans reading PR comments. + +5. **No post-processing pipeline.** The design does not introduce any orchestrator-side script that parses agent output — Confluence reads happen via direct gateway calls from the sandbox using `sandbox/scripts/confluence`. + +6. **No direct LLM API or Agent SDK bypass.** This is HTTP-gateway plumbing. EGG200/EGG201 lints are not engaged. + +7. **No hardcoded model IDs.** N/A for this surface. + +8. **No micromanagement of agent procedure.** The design exposes verbs the agent can use freely within enforced bounds (space allowlist, verb allowlist, private mode). It does not prescribe a step-by-step "first call X then Y" pipeline. The transparent v1 fallback on comment endpoints (D1) is gateway-internal — agents see one stable verb whose contract is "return the comments correctly" and don't need to know about Atlassian's v2 quirks. + +### Specific design choices worth highlighting (positive) + +- **A1 (Jira-style verb-noun URLs with MCP-aligned wrapper subcommands)**: keeps the gateway path-validator regex simple and uniform with `/api/v1/jira/*`, and pushes the `mcp__confluence__*`-shaped naming into the wrapper layer where consumer porting cost is paid once. This is the right place for the "feel like the MCP" affordance — it's an ergonomics knob in the user-facing wrapper, not a leakage of host-MCP shape into the gateway URL design. +- **D1 (v2-first hybrid with v1 fallback)**: hides Atlassian's v2 inline-comment 404 bug and missing-nested-replies behaviour from the agent. The agent doesn't need to know which Atlassian API version backs each verb — that's exactly the level of detail that should not bleed into agent-visible behaviour. +- **H1 (`confluence.spaces:` in `config/context-filters.yaml`)**: operators already edit this file for Jira and GitHub filtering. Adding the section keeps a single allowlist surface — no new file means no new operator confusion about where the gate lives. +- **F1 (shared `ATLASSIAN_*` triple with back-compat fall-back)**: reflects the actual Atlassian Cloud reality (one tenant, one bot, one token) without breaking deployments that already populated `JIRA_*` from #1556. Migration path is loader-only, not operator-facing. +- **No `EGG_CONFLUENCE_*` env var (line 395, decision row 458)**: explicitly aligns with the issue text. Confluence is reference material, not a unit of work, so the agent need not have an injected page identity. Audit recovers `pageId` / `spaceKey` from the request body — observability without env-var coupling. +- **`getConfluenceSpaces` allowlist filter (decision row 450)**: prevents agents from enumerating the full tenant space catalogue. This is a small but important detail — it stops a sandbox-side reconnaissance vector even when the per-page allowlist would have denied later access. +- **Permanent attachment denylist (decision row 453)**: scoping arbitrary-file uploads/downloads to a separate ticket is the right call. Attachments are a categorically larger payload surface than HTML/ADF page bodies, and admitting them under v1 would force a different threat model. + +### Non-blocking observations + +- **Line 264 — `body-format=storage,atlas_doc_format` doubles response size.** Not an agent-design issue per se; just flag it for the planning phase if any downstream consumer (e.g. #1557 refine context-pull) is sensitive to context-window budget. The "Caller may override" escape hatch handles it. +- **Line 372 — `/execute` regex-allowlisted GET passthrough.** Already correctly scoped to GET-only forever. Worth ensuring the planning phase pins down the exact regex up front (analogous to Jira's) so reviewers can audit it — but this is implementation detail, not an agent-design concern. + +No blocking issues. The design correctly inherits #1556's security posture and extends it without introducing any agent-mode anti-patterns. + + +````yaml +id: 4c54f1d4-12c5-47 +phase: refine +metadata: + payload: + artifact_references: + - .egg-state/drafts/1931-analysis.md + reason: "\nReviewed `.egg-state/drafts/1931-analysis.md` end-to-end against the\ + \ agent-mode design criteria in `docs/guides/agent-mode-design.md`. No agent-design\ + \ anti-patterns flagged.\n\n### Why this design is well-aligned with agent-mode\ + \ principles\n\n1. **Infrastructure beats config \u2014 pervasive.** Every constraint\ + \ that matters is enforced at a layer the agent cannot subvert:\n - Line 98:\ + \ *\"Read-only in v1. The gateway must refuse Confluence write verbs even if\ + \ the upstream API would accept them. Enforcement is at the gateway (infrastructure),\ + \ not in agent instructions.\"*\n - Line 99: `@require_private_mode` at the\ + \ route layer, with a route-enumeration regression test (G1) \u2014 not a prompt-level\ + \ \"don't call this in public mode.\"\n - Line 101\u2013102: permanent denylist\ + \ at the path validator (`restrictions`, `permissions`, `space.admin`, `DELETE`/`PUT`/`PATCH`\ + \ except the future writes carve-out for `pages/{id}` `PUT`).\n - Line 397:\ + \ Squid perimeter already excludes `*.atlassian.net` \u2014 agents cannot bypass\ + \ the space allowlist via direct egress, and the existing regression test in\ + \ `gateway/tests/test_allowed_domains.py` already covers Confluence by extension.\ + \ The draft correctly resists the temptation to \"add `*.atlassian.net`\" to\ + \ fix reachability.\n\n2. **Static security checks over prompted-behaviour checks.**\ + \ The CQL scope extractor (C1, lines 204\u2013227) is a direct port of `gateway/jira_search.py`'s\ + \ deny-on-ambiguity model \u2014 it statically rejects CQL that does not provably\ + \ scope to an allowlisted space, rather than asking the agent to \"only query\ + \ allowed spaces.\" Adversarial CQL (functions, OR clauses, quoted keys, homoglyphs)\ + \ is rejected at the gateway, not at the agent prompt. Line 234 explicitly rejects\ + \ the \"permissive CQL with post-hoc filter\" alternative because it would still\ + \ leak result counts from non-allowlisted spaces \u2014 same reasoning that\ + \ #1556 used for JQL.\n\n3. **Zero-credential invariant preserved.** Line 14\ + \ and the F1 recommendation (lines 293\u2013307) keep Atlassian credentials\ + \ in the gateway and let agents authenticate with `EGG_SESSION_TOKEN` only.\ + \ There is no proposal to surface credentials via prompt or env var in the sandbox.\n\ + \n4. **No structured-output-for-humans anti-pattern.** All structured envelopes\ + \ (404 envelope on line 398, audit fields on line 396) are consumed by machines\ + \ (gateway audit, agent retry logic), not by humans reading PR comments.\n\n\ + 5. **No post-processing pipeline.** The design does not introduce any orchestrator-side\ + \ script that parses agent output \u2014 Confluence reads happen via direct\ + \ gateway calls from the sandbox using `sandbox/scripts/confluence`.\n\n6. **No\ + \ direct LLM API or Agent SDK bypass.** This is HTTP-gateway plumbing. EGG200/EGG201\ + \ lints are not engaged.\n\n7. **No hardcoded model IDs.** N/A for this surface.\n\ + \n8. **No micromanagement of agent procedure.** The design exposes verbs the\ + \ agent can use freely within enforced bounds (space allowlist, verb allowlist,\ + \ private mode). It does not prescribe a step-by-step \"first call X then Y\"\ + \ pipeline. The transparent v1 fallback on comment endpoints (D1) is gateway-internal\ + \ \u2014 agents see one stable verb whose contract is \"return the comments\ + \ correctly\" and don't need to know about Atlassian's v2 quirks.\n\n### Specific\ + \ design choices worth highlighting (positive)\n\n- **A1 (Jira-style verb-noun\ + \ URLs with MCP-aligned wrapper subcommands)**: keeps the gateway path-validator\ + \ regex simple and uniform with `/api/v1/jira/*`, and pushes the `mcp__confluence__*`-shaped\ + \ naming into the wrapper layer where consumer porting cost is paid once. This\ + \ is the right place for the \"feel like the MCP\" affordance \u2014 it's an\ + \ ergonomics knob in the user-facing wrapper, not a leakage of host-MCP shape\ + \ into the gateway URL design.\n- **D1 (v2-first hybrid with v1 fallback)**:\ + \ hides Atlassian's v2 inline-comment 404 bug and missing-nested-replies behaviour\ + \ from the agent. The agent doesn't need to know which Atlassian API version\ + \ backs each verb \u2014 that's exactly the level of detail that should not\ + \ bleed into agent-visible behaviour.\n- **H1 (`confluence.spaces:` in `config/context-filters.yaml`)**:\ + \ operators already edit this file for Jira and GitHub filtering. Adding the\ + \ section keeps a single allowlist surface \u2014 no new file means no new operator\ + \ confusion about where the gate lives.\n- **F1 (shared `ATLASSIAN_*` triple\ + \ with back-compat fall-back)**: reflects the actual Atlassian Cloud reality\ + \ (one tenant, one bot, one token) without breaking deployments that already\ + \ populated `JIRA_*` from #1556. Migration path is loader-only, not operator-facing.\n\ + - **No `EGG_CONFLUENCE_*` env var (line 395, decision row 458)**: explicitly\ + \ aligns with the issue text. Confluence is reference material, not a unit of\ + \ work, so the agent need not have an injected page identity. Audit recovers\ + \ `pageId` / `spaceKey` from the request body \u2014 observability without env-var\ + \ coupling.\n- **`getConfluenceSpaces` allowlist filter (decision row 450)**:\ + \ prevents agents from enumerating the full tenant space catalogue. This is\ + \ a small but important detail \u2014 it stops a sandbox-side reconnaissance\ + \ vector even when the per-page allowlist would have denied later access.\n\ + - **Permanent attachment denylist (decision row 453)**: scoping arbitrary-file\ + \ uploads/downloads to a separate ticket is the right call. Attachments are\ + \ a categorically larger payload surface than HTML/ADF page bodies, and admitting\ + \ them under v1 would force a different threat model.\n\n### Non-blocking observations\n\ + \n- **Line 264 \u2014 `body-format=storage,atlas_doc_format` doubles response\ + \ size.** Not an agent-design issue per se; just flag it for the planning phase\ + \ if any downstream consumer (e.g. #1557 refine context-pull) is sensitive to\ + \ context-window budget. The \"Caller may override\" escape hatch handles it.\n\ + - **Line 372 \u2014 `/execute` regex-allowlisted GET passthrough.** Already\ + \ correctly scoped to GET-only forever. Worth ensuring the planning phase pins\ + \ down the exact regex up front (analogous to Jira's) so reviewers can audit\ + \ it \u2014 but this is implementation detail, not an agent-design concern.\n\ + \nNo blocking issues. The design correctly inherits #1556's security posture\ + \ and extends it without introducing any agent-mode anti-patterns.\n" + version: 1 +```` + +### [2026-04-26T23:19:17Z] refiner (HEARTBEAT): heartbeat: WORKING + +wait_loop exited + +````yaml +id: f5019ce9-1522-4d +phase: refine +metadata: + state: WORKING +```` + +### [2026-04-26T23:19:21Z] reviewer_agent_design (CONSENSUS_CONFIRMED): Confirmed by reviewer_agent_design + +````yaml +id: 2198b761-6104-4c +phase: refine +metadata: + consensus_reached: false +```` + +### [2026-04-26T23:19:22Z] refiner (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_ACK,CONSENSUS_NACK,CONSENSUS_RE_REVIEW,OVERSEER_ALERT from=reviewer_refine + +````yaml +id: 28fc4aa5-a9c0-40 +phase: refine +metadata: + state: WAITING_FOR_EVENT + since: '2026-04-26T23:19:22.903403+00:00' +```` + +### [2026-04-26T23:19:31Z] reviewer_agent_design (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_PROPOSE,CONSENSUS_RE_REVIEW,CONSENSUS_CONFIRMED,OVERSEER_ALERT + +````yaml +id: f44dbf2a-9534-4a +phase: refine +metadata: + state: WAITING_FOR_EVENT + since: '2026-04-26T23:19:31.547486+00:00' +```` + +### [2026-04-26T23:19:31Z] reviewer_agent_design (HEARTBEAT): heartbeat: WORKING + +wait_loop exited + +````yaml +id: 31e86fee-b5ac-4a +phase: refine +metadata: + state: WORKING +```` + +### [2026-04-26T23:19:35Z] reviewer_agent_design (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_PROPOSE,CONSENSUS_RE_REVIEW,CONSENSUS_CONFIRMED,OVERSEER_ALERT + +````yaml +id: f3b88259-22f9-42 +phase: refine +metadata: + state: WAITING_FOR_EVENT + since: '2026-04-26T23:19:35.881990+00:00' +```` + +### [2026-04-26T23:20:03Z] reviewer_refine → refiner (CONSENSUS_ACK): ACK from reviewer_refine for refiner + + +Reviewed `.egg-state/drafts/1931-analysis.md` (478 lines, ~38 KB) section-by-section against all seven review criteria, and cross-checked the contract (14 HITL decisions + 10 free-form feedback questions registered via `mcp__sdlc__*`). I spot-verified every concrete code/file claim cited in the draft. + +### Section-by-section evaluation + +**1. Problem Understanding — STRONG** +The Problem Statement and Desired Outcomes 1–7 (lines 5–19) faithfully reproduce the issue's seven-point scope (read-only verb surface, zero-credential invariant, private-mode-only, space+verb allowlist, future-verb readiness, no `EGG_CONFLUENCE_*`, permanent denylist for deletes/admin). Lines 7–9 correctly re-justify "why not the Atlassian MCP" with reachability + access-control arguments parallel to #1556, without re-litigating. + +**2. Research Quality — STRONG** +Verified every concrete reference: +- `gateway/jira_client.py` (567 lines), `jira_credentials.py` (213), `jira_policy.py` (253), `jira_search.py` (250) — all exist as cited. +- `gateway/mode_gate.py` exposes `require_private_mode` decorator (line 60) and `__egg_requires_private_mode__ = True` marker attr (line 39) — exactly as the draft claims for the route-enumeration regression test. +- `extract_search_projects` exists in `gateway/jira_search.py:55`; `JIRA_WRITE_VERBS_DENIED` referenced in `gateway/gateway.py:4560`. The draft's "direct port of `gateway/jira_search.py`" is well-grounded. +- `gateway/tests/test_allowed_domains.py` lines 40–46 enforce absence of `atlassian.net`/`atlassian.com`/`api.atlassian.com`/`jira.atlassian.com` from the Squid allowlist — confirms the draft's "*.atlassian.net already excluded; do not add" claim. +- `config/secrets.template.env` lines 90–99 carry the `CONFLUENCE_BASE_URL`/`USERNAME`/`API_TOKEN`/`SPACE_KEYS` placeholders the draft cites (line cite 92–99 ≈ correct). +- `sandbox/agent-config/commands/show-metrics.md` line 12 references `ls ~/context-sync/confluence/` — confirms. +- `docs/reference/jira-wrapper.md`, `docs/architecture/network-isolation.md`, `docs/architecture/credential-injection.md`, `sandbox/scripts/jira`, `config/context-filters.yaml` — all exist as cited. + +The Atlassian v1/v2 split research (lines 67–82) accurately captures the actual community-known issues: CQL is v1-only with no deprecation roadmap, v2 footer-comments misses nested replies, v2 inline-comments has a 404 bug. Citations include real Atlassian developer-community URLs. + +**3. Options Analysis — STRONG** +Eight decision dimensions (endpoint surface A1/A2/A3, API version B1/B2/B3, CQL extraction C1/C2, comment quirks D1/D2/D3, body format E1/E2/E3/E4, credential sharing F1/F2, network gate G1, allowlist location H1/H2/H3) are meaningfully different (not strawmen), each with pros/cons. The recommendation `A1+B1+C1+D1+E1+F1+G1+H1` is consistent with stated rationale at every dimension. + +**4. Constraints & Dependencies — STRONG** +Constraints (lines 92–127) split cleanly into Security/architectural, Operational, Dependencies, External (Atlassian). Hard dep on #1556 is explicit (line 114); consumer #1557 explicit (line 115); `/impact-analysis` consumer noted (line 116). Permanent denylist (line 102) anticipates the future-write phase by enumerating which verbs/methods stay denied forever. + +**5. Open Questions — STRONG** +14 multi-choice decisions + 10 free-form feedback questions are surfaced. Each is specific, actionable, and addresses a genuine ambiguity (URL shape, API version, body format, credential sharing, allowlist source, attachments stance, /execute passthrough, descendant-depth cap, version querystring, bot-account access drift vs human, wrapper subcommand naming, etc.). No silent assumptions detected. + +**6. Recommendation Quality — STRONG** +The Recommended Approach (lines 380–402) explicitly enumerates each chosen option, the ancillary decisions (no `EGG_CONFLUENCE_*`, audit shape, Squid stance, 404 envelope, test surface, doc updates, future-write extension shape), and a Complexity Assessment ("medium"). Reasoning aligns with the analysis findings throughout. + +**7. HITL Decision Registration — VERIFIED** +`mcp__sdlc__show_contract` returns 14 `decisions` (decision-1 … decision-14) and 1 `feedback` block with 10 questions (Q1–Q10) — every option-set and free-form question in the draft's "Registered decisions" / "Registered open-ended questions" sections (lines 411–474) has a matching contract entry. The on-disk contract file (`.egg-state/contracts/issue-1931.json`) is an older snapshot and lacks them, but the live gateway contract is correctly populated, which is what the orchestrator/HITL surface reads. No prose-only open questions were found. + +### Non-blocking + +- **`.egg-state/drafts/1931-analysis.md:48`** — minor inaccuracy: claims `sandbox/agent-config/rules/environment.md:80` "mentions `~/context-sync/confluence/`", but the actual line says `~/context-sync/` with "Confluence/JIRA cache" as purpose; the path component `confluence/` only appears in `show-metrics.md:12`. Suggest tightening to "`~/context-sync/` (Confluence/JIRA cache)" so the reader who jumps to line 80 isn't confused. +- **`.egg-state/drafts/1931-analysis.md:102` vs `:369`** — internal phrasing tension: line 102 lists "any HTTP `PUT` / `PATCH`" under "Permanent denylist (path-validator level)", but line 369 (Future-write extension) says "Path validator must allow `PUT` for this single path family [`pages/{id}`] in the future-writes phase". The intent is clearly "PUT denied today; narrow allow when writes land", but as written the two statements contradict at face value. Suggest adjusting line 102 wording to "any HTTP `PUT` / `PATCH` (until the future-writes phase carves a narrow `pages/{id}` exception — see below)" to make the future state explicit. +- **`.egg-state/drafts/1931-analysis.md:238–246` (Comment quirks D1)** — the v1 fallback on a v2 404 risks masking *legitimate* 404s (page truly has no inline comments) as "v2 bug → retry on v1". Worth surfacing in the planning phase as a behaviour question — e.g., "always fall back" vs "fall back only when the page is known to have inline comments per a separate check". Not blocking; the option set already records the choice between D1/D2/D3. +- **`.egg-state/drafts/1931-analysis.md:124` (CQL hard limit)** — analysis notes "CQL has a 200-result hard limit per query; pagination required beyond that" but the recommended approach doesn't pin whether the wrapper truncates, paginates transparently, or surfaces the cursor to callers. Worth adding a 15th decision OR resolving in plan; non-blocking because a sensible default (surface the cursor) can land without operator input. +- **`.egg-state/drafts/1931-analysis.md:296–297` (F1)** — base-URL derivation claim "Confluence base by appending `/wiki` if `CONFLUENCE_BASE_URL` is unset" assumes the standard Atlassian Cloud path; some self-managed Server / Data Center installs put Confluence at a different host entirely. Issue is Cloud-only by implication, but worth noting that auto-derivation should be the *fallback*, not the default-and-only path. Plan-phase concern. + +No blocking issues — the draft accurately frames the problem, grounds every cited file, surfaces all material design choices as HITL items, and presents a justified recommendation. ACKing. + + +````yaml +id: fe773f32-abb9-41 +phase: refine +metadata: + payload: + artifact_references: + - .egg-state/drafts/1931-analysis.md + - .egg-state/contracts/issue-1931.json + - gateway/jira_client.py + - gateway/jira_credentials.py + - gateway/jira_policy.py + - gateway/jira_search.py + - gateway/mode_gate.py + - gateway/tests/test_allowed_domains.py + - config/secrets.template.env + - sandbox/agent-config/rules/environment.md + - sandbox/agent-config/commands/show-metrics.md + - docs/reference/jira-wrapper.md + reason: "\nReviewed `.egg-state/drafts/1931-analysis.md` (478 lines, ~38 KB) section-by-section\ + \ against all seven review criteria, and cross-checked the contract (14 HITL\ + \ decisions + 10 free-form feedback questions registered via `mcp__sdlc__*`).\ + \ I spot-verified every concrete code/file claim cited in the draft.\n\n###\ + \ Section-by-section evaluation\n\n**1. Problem Understanding \u2014 STRONG**\n\ + The Problem Statement and Desired Outcomes 1\u20137 (lines 5\u201319) faithfully\ + \ reproduce the issue's seven-point scope (read-only verb surface, zero-credential\ + \ invariant, private-mode-only, space+verb allowlist, future-verb readiness,\ + \ no `EGG_CONFLUENCE_*`, permanent denylist for deletes/admin). Lines 7\u2013\ + 9 correctly re-justify \"why not the Atlassian MCP\" with reachability + access-control\ + \ arguments parallel to #1556, without re-litigating.\n\n**2. Research Quality\ + \ \u2014 STRONG**\nVerified every concrete reference:\n- `gateway/jira_client.py`\ + \ (567 lines), `jira_credentials.py` (213), `jira_policy.py` (253), `jira_search.py`\ + \ (250) \u2014 all exist as cited.\n- `gateway/mode_gate.py` exposes `require_private_mode`\ + \ decorator (line 60) and `__egg_requires_private_mode__ = True` marker attr\ + \ (line 39) \u2014 exactly as the draft claims for the route-enumeration regression\ + \ test.\n- `extract_search_projects` exists in `gateway/jira_search.py:55`;\ + \ `JIRA_WRITE_VERBS_DENIED` referenced in `gateway/gateway.py:4560`. The draft's\ + \ \"direct port of `gateway/jira_search.py`\" is well-grounded.\n- `gateway/tests/test_allowed_domains.py`\ + \ lines 40\u201346 enforce absence of `atlassian.net`/`atlassian.com`/`api.atlassian.com`/`jira.atlassian.com`\ + \ from the Squid allowlist \u2014 confirms the draft's \"*.atlassian.net already\ + \ excluded; do not add\" claim.\n- `config/secrets.template.env` lines 90\u2013\ + 99 carry the `CONFLUENCE_BASE_URL`/`USERNAME`/`API_TOKEN`/`SPACE_KEYS` placeholders\ + \ the draft cites (line cite 92\u201399 \u2248 correct).\n- `sandbox/agent-config/commands/show-metrics.md`\ + \ line 12 references `ls ~/context-sync/confluence/` \u2014 confirms.\n- `docs/reference/jira-wrapper.md`,\ + \ `docs/architecture/network-isolation.md`, `docs/architecture/credential-injection.md`,\ + \ `sandbox/scripts/jira`, `config/context-filters.yaml` \u2014 all exist as\ + \ cited.\n\nThe Atlassian v1/v2 split research (lines 67\u201382) accurately\ + \ captures the actual community-known issues: CQL is v1-only with no deprecation\ + \ roadmap, v2 footer-comments misses nested replies, v2 inline-comments has\ + \ a 404 bug. Citations include real Atlassian developer-community URLs.\n\n\ + **3. Options Analysis \u2014 STRONG**\nEight decision dimensions (endpoint surface\ + \ A1/A2/A3, API version B1/B2/B3, CQL extraction C1/C2, comment quirks D1/D2/D3,\ + \ body format E1/E2/E3/E4, credential sharing F1/F2, network gate G1, allowlist\ + \ location H1/H2/H3) are meaningfully different (not strawmen), each with pros/cons.\ + \ The recommendation `A1+B1+C1+D1+E1+F1+G1+H1` is consistent with stated rationale\ + \ at every dimension.\n\n**4. Constraints & Dependencies \u2014 STRONG**\nConstraints\ + \ (lines 92\u2013127) split cleanly into Security/architectural, Operational,\ + \ Dependencies, External (Atlassian). Hard dep on #1556 is explicit (line 114);\ + \ consumer #1557 explicit (line 115); `/impact-analysis` consumer noted (line\ + \ 116). Permanent denylist (line 102) anticipates the future-write phase by\ + \ enumerating which verbs/methods stay denied forever.\n\n**5. Open Questions\ + \ \u2014 STRONG**\n14 multi-choice decisions + 10 free-form feedback questions\ + \ are surfaced. Each is specific, actionable, and addresses a genuine ambiguity\ + \ (URL shape, API version, body format, credential sharing, allowlist source,\ + \ attachments stance, /execute passthrough, descendant-depth cap, version querystring,\ + \ bot-account access drift vs human, wrapper subcommand naming, etc.). No silent\ + \ assumptions detected.\n\n**6. Recommendation Quality \u2014 STRONG**\nThe\ + \ Recommended Approach (lines 380\u2013402) explicitly enumerates each chosen\ + \ option, the ancillary decisions (no `EGG_CONFLUENCE_*`, audit shape, Squid\ + \ stance, 404 envelope, test surface, doc updates, future-write extension shape),\ + \ and a Complexity Assessment (\"medium\"). Reasoning aligns with the analysis\ + \ findings throughout.\n\n**7. HITL Decision Registration \u2014 VERIFIED**\n\ + `mcp__sdlc__show_contract` returns 14 `decisions` (decision-1 \u2026 decision-14)\ + \ and 1 `feedback` block with 10 questions (Q1\u2013Q10) \u2014 every option-set\ + \ and free-form question in the draft's \"Registered decisions\" / \"Registered\ + \ open-ended questions\" sections (lines 411\u2013474) has a matching contract\ + \ entry. The on-disk contract file (`.egg-state/contracts/issue-1931.json`)\ + \ is an older snapshot and lacks them, but the live gateway contract is correctly\ + \ populated, which is what the orchestrator/HITL surface reads. No prose-only\ + \ open questions were found.\n\n### Non-blocking\n\n- **`.egg-state/drafts/1931-analysis.md:48`**\ + \ \u2014 minor inaccuracy: claims `sandbox/agent-config/rules/environment.md:80`\ + \ \"mentions `~/context-sync/confluence/`\", but the actual line says `~/context-sync/`\ + \ with \"Confluence/JIRA cache\" as purpose; the path component `confluence/`\ + \ only appears in `show-metrics.md:12`. Suggest tightening to \"`~/context-sync/`\ + \ (Confluence/JIRA cache)\" so the reader who jumps to line 80 isn't confused.\n\ + - **`.egg-state/drafts/1931-analysis.md:102` vs `:369`** \u2014 internal phrasing\ + \ tension: line 102 lists \"any HTTP `PUT` / `PATCH`\" under \"Permanent denylist\ + \ (path-validator level)\", but line 369 (Future-write extension) says \"Path\ + \ validator must allow `PUT` for this single path family [`pages/{id}`] in the\ + \ future-writes phase\". The intent is clearly \"PUT denied today; narrow allow\ + \ when writes land\", but as written the two statements contradict at face value.\ + \ Suggest adjusting line 102 wording to \"any HTTP `PUT` / `PATCH` (until the\ + \ future-writes phase carves a narrow `pages/{id}` exception \u2014 see below)\"\ + \ to make the future state explicit.\n- **`.egg-state/drafts/1931-analysis.md:238\u2013\ + 246` (Comment quirks D1)** \u2014 the v1 fallback on a v2 404 risks masking\ + \ *legitimate* 404s (page truly has no inline comments) as \"v2 bug \u2192 retry\ + \ on v1\". Worth surfacing in the planning phase as a behaviour question \u2014\ + \ e.g., \"always fall back\" vs \"fall back only when the page is known to have\ + \ inline comments per a separate check\". Not blocking; the option set already\ + \ records the choice between D1/D2/D3.\n- **`.egg-state/drafts/1931-analysis.md:124`\ + \ (CQL hard limit)** \u2014 analysis notes \"CQL has a 200-result hard limit\ + \ per query; pagination required beyond that\" but the recommended approach\ + \ doesn't pin whether the wrapper truncates, paginates transparently, or surfaces\ + \ the cursor to callers. Worth adding a 15th decision OR resolving in plan;\ + \ non-blocking because a sensible default (surface the cursor) can land without\ + \ operator input.\n- **`.egg-state/drafts/1931-analysis.md:296\u2013297` (F1)**\ + \ \u2014 base-URL derivation claim \"Confluence base by appending `/wiki` if\ + \ `CONFLUENCE_BASE_URL` is unset\" assumes the standard Atlassian Cloud path;\ + \ some self-managed Server / Data Center installs put Confluence at a different\ + \ host entirely. Issue is Cloud-only by implication, but worth noting that auto-derivation\ + \ should be the *fallback*, not the default-and-only path. Plan-phase concern.\n\ + \nNo blocking issues \u2014 the draft accurately frames the problem, grounds\ + \ every cited file, surfaces all material design choices as HITL items, and\ + \ presents a justified recommendation. ACKing.\n" + version: 1 +```` + +### [2026-04-26T23:20:03Z] orchestrator → refiner (STATUS): Ready to confirm — all confirm preconditions satisfied + +Your proposal (version 1) is ready to confirm — all blocking reviews are clear and global confirm preconditions are met. Run `egg-orch consensus confirmed` to confirm. + +````yaml +id: 5aebbe49-3e31-4b +phase: refine +metadata: + ready_to_confirm: true + version: 1 +```` + +### [2026-04-26T23:20:03Z] refiner (HEARTBEAT): heartbeat: WORKING + +wait_loop exited + +````yaml +id: 9e970d96-f991-4d +phase: refine +metadata: + state: WORKING +```` + +### [2026-04-26T23:20:12Z] reviewer_refine (CONSENSUS_CONFIRMED): Confirmed by reviewer_refine + +````yaml +id: 5b7c3d21-c3d4-4d +phase: refine +metadata: + consensus_reached: false +```` + +### [2026-04-26T23:20:12Z] reviewer_agent_design (HEARTBEAT): heartbeat: WORKING + +wait_loop exited + +````yaml +id: 8e04124c-410a-43 +phase: refine +metadata: + state: WORKING +```` + +### [2026-04-26T23:20:14Z] refiner (CONSENSUS_CONFIRMED): Confirmed by refiner + +````yaml +id: 49400a75-2d38-4e +phase: refine +metadata: + consensus_reached: true +```` + +### [2026-04-26T23:20:15Z] reviewer_agent_design (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_PROPOSE,CONSENSUS_RE_REVIEW,CONSENSUS_CONFIRMED,OVERSEER_ALERT + +````yaml +id: cd88dfab-efc3-4f +phase: refine +metadata: + state: WAITING_FOR_EVENT + since: '2026-04-26T23:20:15.674566+00:00' +```` + +### [2026-04-26T23:20:15Z] reviewer_agent_design (HEARTBEAT): heartbeat: WORKING + +wait_loop exited + +````yaml +id: a8b06537-123e-40 +phase: refine +metadata: + state: WORKING +```` diff --git a/.egg-state/contracts/issue-1931.json b/.egg-state/contracts/issue-1931.json index bb568d5b2d..2bca906cae 100644 --- a/.egg-state/contracts/issue-1931.json +++ b/.egg-state/contracts/issue-1931.json @@ -9,15 +9,1152 @@ "current_phase": "refine", "acceptance_criteria": [], "phases": [], - "decisions": [], + "decisions": [ + { + "id": "decision-1", + "question": "Endpoint surface \u2014 what URL shape should the gateway use for Confluence routes?", + "type": "hitl", + "phase": "refine", + "options": [ + { + "id": "opt-1", + "label": "Jira-style verb-noun paths (/api/v1/confluence/page/get, /api/v1/confluence/search, ...); MCP-aligned names live in the sandbox wrapper subcommand layer (Recommended)", + "description": null + }, + { + "id": "opt-2", + "label": "Literal MCP-name URL paths (/api/v1/confluence/getConfluencePage, /api/v1/confluence/searchConfluenceUsingCql, ...)", + "description": null + }, + { + "id": "opt-3", + "label": "Single endpoint with verb payload field (POST /api/v1/confluence with {verb: ...})", + "description": null + }, + { + "id": "opt-4", + "label": "Other (explain in reply)", + "description": null + } + ], + "resolved": false, + "resolution": null, + "resolved_by": null, + "resolved_at": null, + "debounce_until": null + }, + { + "id": "decision-2", + "question": "Atlassian API version strategy for v1 of the wrapper?", + "type": "hitl", + "phase": "refine", + "options": [ + { + "id": "opt-1", + "label": "v2-first hybrid: v2 for reads + v1 for CQL search + v1 fallback for known v2 comment bugs (Recommended)", + "description": null + }, + { + "id": "opt-2", + "label": "v1-only across the board (simpler shape, locks into legacy)", + "description": null + }, + { + "id": "opt-3", + "label": "v2-only across the board (CQL search would not be possible without shipping our own search index)", + "description": null + }, + { + "id": "opt-4", + "label": "Other (explain in reply)", + "description": null + } + ], + "resolved": false, + "resolution": null, + "resolved_by": null, + "resolved_at": null, + "debounce_until": null + }, + { + "id": "decision-3", + "question": "CQL scope extraction \u2014 how should we constrain the CQL query surface that the /search endpoint accepts?", + "type": "hitl", + "phase": "refine", + "options": [ + { + "id": "opt-1", + "label": "Conservative static space = / space IN (...) extractor; deny-on-ambiguity (mirrors Jira's JQL extractor) (Recommended)", + "description": null + }, + { + "id": "opt-2", + "label": "Permissive CQL with post-hoc result filter (rejected for Jira; same risks here)", + "description": null + }, + { + "id": "opt-3", + "label": "Other (explain in reply)", + "description": null + } + ], + "resolved": false, + "resolution": null, + "resolved_by": null, + "resolved_at": null, + "debounce_until": null + }, + { + "id": "decision-4", + "question": "Comment-quirk handling \u2014 how should the wrapper deal with v2 missing nested replies / inline 404s?", + "type": "hitl", + "phase": "refine", + "options": [ + { + "id": "opt-1", + "label": "v2-first with transparent v1 fallback for nested replies and inline-404 bugs (Recommended)", + "description": null + }, + { + "id": "opt-2", + "label": "v2-only; document the gaps and let agents retry", + "description": null + }, + { + "id": "opt-3", + "label": "v1-only for comments; lock in legacy behaviour until Atlassian fixes v2", + "description": null + }, + { + "id": "opt-4", + "label": "Other (explain in reply)", + "description": null + } + ], + "resolved": false, + "resolution": null, + "resolved_by": null, + "resolved_at": null, + "debounce_until": null + }, + { + "id": "decision-5", + "question": "Default body format for getConfluencePage and related reads?", + "type": "hitl", + "phase": "refine", + "options": [ + { + "id": "opt-1", + "label": "Both storage and atlas_doc_format (Recommended \u2014 agent gets HTML-ish + ADF in one call)", + "description": null + }, + { + "id": "opt-2", + "label": "storage only (smallest payload)", + "description": null + }, + { + "id": "opt-3", + "label": "atlas_doc_format only (most structured)", + "description": null + }, + { + "id": "opt-4", + "label": "view (rendered HTML) only (lossy on macros / layout)", + "description": null + }, + { + "id": "opt-5", + "label": "Other (explain in reply)", + "description": null + } + ], + "resolved": false, + "resolution": null, + "resolved_by": null, + "resolved_at": null, + "debounce_until": null + }, + { + "id": "decision-6", + "question": "Credential sharing between Jira and Confluence?", + "type": "hitl", + "phase": "refine", + "options": [ + { + "id": "opt-1", + "label": "Shared ATLASSIAN_BASE_URL / _USERNAME / _API_TOKEN triple, with backward-compat fall-back to JIRA_* and CONFLUENCE_* placeholders (Recommended)", + "description": null + }, + { + "id": "opt-2", + "label": "Independent JIRA_* and CONFLUENCE_* triples (status quo of secrets.template.env)", + "description": null + }, + { + "id": "opt-3", + "label": "Other (explain in reply)", + "description": null + } + ], + "resolved": false, + "resolution": null, + "resolved_by": null, + "resolved_at": null, + "debounce_until": null + }, + { + "id": "decision-7", + "question": "Network-mode gating shape for /api/v1/confluence/* routes?", + "type": "hitl", + "phase": "refine", + "options": [ + { + "id": "opt-1", + "label": "@require_private_mode per route + route-enumeration regression test (Recommended \u2014 mirrors Jira)", + "description": null + }, + { + "id": "opt-2", + "label": "Move to a Flask blueprint with before_request rejection (would also require migrating Jira; out of scope here)", + "description": null + }, + { + "id": "opt-3", + "label": "Other (explain in reply)", + "description": null + } + ], + "resolved": false, + "resolution": null, + "resolved_by": null, + "resolved_at": null, + "debounce_until": null + }, + { + "id": "decision-8", + "question": "Where should the Confluence space allowlist live?", + "type": "hitl", + "phase": "refine", + "options": [ + { + "id": "opt-1", + "label": "New confluence.spaces: section in config/context-filters.yaml (Recommended \u2014 mirrors Jira's jira.projects)", + "description": null + }, + { + "id": "opt-2", + "label": "Dedicated new config/confluence.yaml", + "description": null + }, + { + "id": "opt-3", + "label": "CONFLUENCE_SPACE_KEYS env var in secrets.env (placeholder already exists)", + "description": null + }, + { + "id": "opt-4", + "label": "Other (explain in reply)", + "description": null + } + ], + "resolved": false, + "resolution": null, + "resolved_by": null, + "resolved_at": null, + "debounce_until": null + }, + { + "id": "decision-9", + "question": "Bot identity for Confluence access?", + "type": "hitl", + "phase": "refine", + "options": [ + { + "id": "opt-1", + "label": "Same dedicated Atlassian bot account used for Jira (single principal owns both Jira and Confluence read scopes) (Recommended)", + "description": null + }, + { + "id": "opt-2", + "label": "Separate bot accounts per service", + "description": null + }, + { + "id": "opt-3", + "label": "Reuse the operator's personal Atlassian account", + "description": null + }, + { + "id": "opt-4", + "label": "Other (explain in reply)", + "description": null + } + ], + "resolved": false, + "resolution": null, + "resolved_by": null, + "resolved_at": null, + "debounce_until": null + }, + { + "id": "decision-10", + "question": "Sandbox-visible response redaction stance?", + "type": "hitl", + "phase": "refine", + "options": [ + { + "id": "opt-1", + "label": "Strip accountId, emailAddress, and _links.webui user-profile URLs from responses before they reach the sandbox (parallels Jira) (Recommended)", + "description": null + }, + { + "id": "opt-2", + "label": "Pass responses through verbatim", + "description": null + }, + { + "id": "opt-3", + "label": "Other (explain in reply)", + "description": null + } + ], + "resolved": false, + "resolution": null, + "resolved_by": null, + "resolved_at": null, + "debounce_until": null + }, + { + "id": "decision-11", + "question": "getConfluenceSpaces response \u2014 should we filter to allowlisted spaces?", + "type": "hitl", + "phase": "refine", + "options": [ + { + "id": "opt-1", + "label": "Filter response so only allowlisted spaces are returned (agents cannot enumerate the full tenant space set) (Recommended)", + "description": null + }, + { + "id": "opt-2", + "label": "Pass through Atlassian's full space list and rely on per-page allowlist to deny later access", + "description": null + }, + { + "id": "opt-3", + "label": "Other (explain in reply)", + "description": null + } + ], + "resolved": false, + "resolution": null, + "resolved_by": null, + "resolved_at": null, + "debounce_until": null + }, + { + "id": "decision-12", + "question": "Attachments endpoints \u2014 what is the v1 stance?", + "type": "hitl", + "phase": "refine", + "options": [ + { + "id": "opt-1", + "label": "Permanent denylist for v1 and future writes (attachments are arbitrary-file uploads / downloads with unbounded payload surface; if needed later, scope as a separate ticket) (Recommended)", + "description": null + }, + { + "id": "opt-2", + "label": "Defer the decision to the future-writes phase", + "description": null + }, + { + "id": "opt-3", + "label": "Allow read-only attachment metadata (no body) in v1", + "description": null + }, + { + "id": "opt-4", + "label": "Other (explain in reply)", + "description": null + } + ], + "resolved": false, + "resolution": null, + "resolved_by": null, + "resolved_at": null, + "debounce_until": null + }, + { + "id": "decision-13", + "question": "EGG_CONFLUENCE_* environment variables in the sandbox?", + "type": "hitl", + "phase": "refine", + "options": [ + { + "id": "opt-1", + "label": "None in v1 (matches issue text \u2014 Confluence is reference material, not a unit of work). Audit recovers pageId / spaceKey from each request body (Recommended)", + "description": null + }, + { + "id": "opt-2", + "label": "Ship EGG_CONFLUENCE_PAGE / EGG_CONFLUENCE_SPACE as observational env vars (parallel to EGG_JIRA_TICKET)", + "description": null + }, + { + "id": "opt-3", + "label": "Other (explain in reply)", + "description": null + } + ], + "resolved": false, + "resolution": null, + "resolved_by": null, + "resolved_at": null, + "debounce_until": null + }, + { + "id": "decision-14", + "question": "/execute passthrough endpoint?", + "type": "hitl", + "phase": "refine", + "options": [ + { + "id": "opt-1", + "label": "Include it (GET-only, regex-allowlisted), parallel to Jira's escape hatch for read verbs not yet promoted to narrow routes (Recommended)", + "description": null + }, + { + "id": "opt-2", + "label": "Skip /execute entirely; add narrow routes only", + "description": null + }, + { + "id": "opt-3", + "label": "Other (explain in reply)", + "description": null + } + ], + "resolved": false, + "resolution": null, + "resolved_by": null, + "resolved_at": null, + "debounce_until": null + } + ], "workflow_owner": null, - "audit_log": [], + "audit_log": [ + { + "timestamp": "2026-04-26T23:16:04.440580Z", + "actor": "egg", + "role": "implementer", + "action": "update", + "field_path": "decisions.0", + "old_value": null, + "new_value": { + "id": "decision-1", + "question": "Endpoint surface \u2014 what URL shape should the gateway use for Confluence routes?", + "type": "hitl", + "phase": "refine", + "options": [ + { + "id": "opt-1", + "label": "Jira-style verb-noun paths (/api/v1/confluence/page/get, /api/v1/confluence/search, ...); MCP-aligned names live in the sandbox wrapper subcommand layer (Recommended)", + "description": null + }, + { + "id": "opt-2", + "label": "Literal MCP-name URL paths (/api/v1/confluence/getConfluencePage, /api/v1/confluence/searchConfluenceUsingCql, ...)", + "description": null + }, + { + "id": "opt-3", + "label": "Single endpoint with verb payload field (POST /api/v1/confluence with {verb: ...})", + "description": null + }, + { + "id": "opt-4", + "label": "Other (explain in reply)", + "description": null + } + ], + "resolved": false, + "resolution": null, + "resolved_by": null, + "resolved_at": null, + "debounce_until": null + }, + "reason": "Created HITL decision: Endpoint surface \u2014 what URL shape should the gatew...", + "checkpoint_id": null + }, + { + "timestamp": "2026-04-26T23:16:04.465649Z", + "actor": "egg", + "role": "implementer", + "action": "update", + "field_path": "decisions.1", + "old_value": null, + "new_value": { + "id": "decision-2", + "question": "Atlassian API version strategy for v1 of the wrapper?", + "type": "hitl", + "phase": "refine", + "options": [ + { + "id": "opt-1", + "label": "v2-first hybrid: v2 for reads + v1 for CQL search + v1 fallback for known v2 comment bugs (Recommended)", + "description": null + }, + { + "id": "opt-2", + "label": "v1-only across the board (simpler shape, locks into legacy)", + "description": null + }, + { + "id": "opt-3", + "label": "v2-only across the board (CQL search would not be possible without shipping our own search index)", + "description": null + }, + { + "id": "opt-4", + "label": "Other (explain in reply)", + "description": null + } + ], + "resolved": false, + "resolution": null, + "resolved_by": null, + "resolved_at": null, + "debounce_until": null + }, + "reason": "Created HITL decision: Atlassian API version strategy for v1 of the wrapp...", + "checkpoint_id": null + }, + { + "timestamp": "2026-04-26T23:16:04.490392Z", + "actor": "egg", + "role": "implementer", + "action": "update", + "field_path": "decisions.2", + "old_value": null, + "new_value": { + "id": "decision-3", + "question": "CQL scope extraction \u2014 how should we constrain the CQL query surface that the /search endpoint accepts?", + "type": "hitl", + "phase": "refine", + "options": [ + { + "id": "opt-1", + "label": "Conservative static space = / space IN (...) extractor; deny-on-ambiguity (mirrors Jira's JQL extractor) (Recommended)", + "description": null + }, + { + "id": "opt-2", + "label": "Permissive CQL with post-hoc result filter (rejected for Jira; same risks here)", + "description": null + }, + { + "id": "opt-3", + "label": "Other (explain in reply)", + "description": null + } + ], + "resolved": false, + "resolution": null, + "resolved_by": null, + "resolved_at": null, + "debounce_until": null + }, + "reason": "Created HITL decision: CQL scope extraction \u2014 how should we constrain the...", + "checkpoint_id": null + }, + { + "timestamp": "2026-04-26T23:16:04.516001Z", + "actor": "egg", + "role": "implementer", + "action": "update", + "field_path": "decisions.3", + "old_value": null, + "new_value": { + "id": "decision-4", + "question": "Comment-quirk handling \u2014 how should the wrapper deal with v2 missing nested replies / inline 404s?", + "type": "hitl", + "phase": "refine", + "options": [ + { + "id": "opt-1", + "label": "v2-first with transparent v1 fallback for nested replies and inline-404 bugs (Recommended)", + "description": null + }, + { + "id": "opt-2", + "label": "v2-only; document the gaps and let agents retry", + "description": null + }, + { + "id": "opt-3", + "label": "v1-only for comments; lock in legacy behaviour until Atlassian fixes v2", + "description": null + }, + { + "id": "opt-4", + "label": "Other (explain in reply)", + "description": null + } + ], + "resolved": false, + "resolution": null, + "resolved_by": null, + "resolved_at": null, + "debounce_until": null + }, + "reason": "Created HITL decision: Comment-quirk handling \u2014 how should the wrapper de...", + "checkpoint_id": null + }, + { + "timestamp": "2026-04-26T23:16:04.538367Z", + "actor": "egg", + "role": "implementer", + "action": "update", + "field_path": "decisions.4", + "old_value": null, + "new_value": { + "id": "decision-5", + "question": "Default body format for getConfluencePage and related reads?", + "type": "hitl", + "phase": "refine", + "options": [ + { + "id": "opt-1", + "label": "Both storage and atlas_doc_format (Recommended \u2014 agent gets HTML-ish + ADF in one call)", + "description": null + }, + { + "id": "opt-2", + "label": "storage only (smallest payload)", + "description": null + }, + { + "id": "opt-3", + "label": "atlas_doc_format only (most structured)", + "description": null + }, + { + "id": "opt-4", + "label": "view (rendered HTML) only (lossy on macros / layout)", + "description": null + }, + { + "id": "opt-5", + "label": "Other (explain in reply)", + "description": null + } + ], + "resolved": false, + "resolution": null, + "resolved_by": null, + "resolved_at": null, + "debounce_until": null + }, + "reason": "Created HITL decision: Default body format for getConfluencePage and rela...", + "checkpoint_id": null + }, + { + "timestamp": "2026-04-26T23:16:18.616076Z", + "actor": "egg", + "role": "implementer", + "action": "update", + "field_path": "decisions.5", + "old_value": null, + "new_value": { + "id": "decision-6", + "question": "Credential sharing between Jira and Confluence?", + "type": "hitl", + "phase": "refine", + "options": [ + { + "id": "opt-1", + "label": "Shared ATLASSIAN_BASE_URL / _USERNAME / _API_TOKEN triple, with backward-compat fall-back to JIRA_* and CONFLUENCE_* placeholders (Recommended)", + "description": null + }, + { + "id": "opt-2", + "label": "Independent JIRA_* and CONFLUENCE_* triples (status quo of secrets.template.env)", + "description": null + }, + { + "id": "opt-3", + "label": "Other (explain in reply)", + "description": null + } + ], + "resolved": false, + "resolution": null, + "resolved_by": null, + "resolved_at": null, + "debounce_until": null + }, + "reason": "Created HITL decision: Credential sharing between Jira and Confluence?", + "checkpoint_id": null + }, + { + "timestamp": "2026-04-26T23:16:18.654712Z", + "actor": "egg", + "role": "implementer", + "action": "update", + "field_path": "decisions.6", + "old_value": null, + "new_value": { + "id": "decision-7", + "question": "Network-mode gating shape for /api/v1/confluence/* routes?", + "type": "hitl", + "phase": "refine", + "options": [ + { + "id": "opt-1", + "label": "@require_private_mode per route + route-enumeration regression test (Recommended \u2014 mirrors Jira)", + "description": null + }, + { + "id": "opt-2", + "label": "Move to a Flask blueprint with before_request rejection (would also require migrating Jira; out of scope here)", + "description": null + }, + { + "id": "opt-3", + "label": "Other (explain in reply)", + "description": null + } + ], + "resolved": false, + "resolution": null, + "resolved_by": null, + "resolved_at": null, + "debounce_until": null + }, + "reason": "Created HITL decision: Network-mode gating shape for /api/v1/confluence/*...", + "checkpoint_id": null + }, + { + "timestamp": "2026-04-26T23:16:18.683707Z", + "actor": "egg", + "role": "implementer", + "action": "update", + "field_path": "decisions.7", + "old_value": null, + "new_value": { + "id": "decision-8", + "question": "Where should the Confluence space allowlist live?", + "type": "hitl", + "phase": "refine", + "options": [ + { + "id": "opt-1", + "label": "New confluence.spaces: section in config/context-filters.yaml (Recommended \u2014 mirrors Jira's jira.projects)", + "description": null + }, + { + "id": "opt-2", + "label": "Dedicated new config/confluence.yaml", + "description": null + }, + { + "id": "opt-3", + "label": "CONFLUENCE_SPACE_KEYS env var in secrets.env (placeholder already exists)", + "description": null + }, + { + "id": "opt-4", + "label": "Other (explain in reply)", + "description": null + } + ], + "resolved": false, + "resolution": null, + "resolved_by": null, + "resolved_at": null, + "debounce_until": null + }, + "reason": "Created HITL decision: Where should the Confluence space allowlist live?", + "checkpoint_id": null + }, + { + "timestamp": "2026-04-26T23:16:18.697016Z", + "actor": "egg", + "role": "implementer", + "action": "update", + "field_path": "decisions.8", + "old_value": null, + "new_value": { + "id": "decision-9", + "question": "Bot identity for Confluence access?", + "type": "hitl", + "phase": "refine", + "options": [ + { + "id": "opt-1", + "label": "Same dedicated Atlassian bot account used for Jira (single principal owns both Jira and Confluence read scopes) (Recommended)", + "description": null + }, + { + "id": "opt-2", + "label": "Separate bot accounts per service", + "description": null + }, + { + "id": "opt-3", + "label": "Reuse the operator's personal Atlassian account", + "description": null + }, + { + "id": "opt-4", + "label": "Other (explain in reply)", + "description": null + } + ], + "resolved": false, + "resolution": null, + "resolved_by": null, + "resolved_at": null, + "debounce_until": null + }, + "reason": "Created HITL decision: Bot identity for Confluence access?", + "checkpoint_id": null + }, + { + "timestamp": "2026-04-26T23:16:18.720504Z", + "actor": "egg", + "role": "implementer", + "action": "update", + "field_path": "decisions.9", + "old_value": null, + "new_value": { + "id": "decision-10", + "question": "Sandbox-visible response redaction stance?", + "type": "hitl", + "phase": "refine", + "options": [ + { + "id": "opt-1", + "label": "Strip accountId, emailAddress, and _links.webui user-profile URLs from responses before they reach the sandbox (parallels Jira) (Recommended)", + "description": null + }, + { + "id": "opt-2", + "label": "Pass responses through verbatim", + "description": null + }, + { + "id": "opt-3", + "label": "Other (explain in reply)", + "description": null + } + ], + "resolved": false, + "resolution": null, + "resolved_by": null, + "resolved_at": null, + "debounce_until": null + }, + "reason": "Created HITL decision: Sandbox-visible response redaction stance?", + "checkpoint_id": null + }, + { + "timestamp": "2026-04-26T23:16:48.002914Z", + "actor": "egg", + "role": "implementer", + "action": "update", + "field_path": "decisions.10", + "old_value": null, + "new_value": { + "id": "decision-11", + "question": "getConfluenceSpaces response \u2014 should we filter to allowlisted spaces?", + "type": "hitl", + "phase": "refine", + "options": [ + { + "id": "opt-1", + "label": "Filter response so only allowlisted spaces are returned (agents cannot enumerate the full tenant space set) (Recommended)", + "description": null + }, + { + "id": "opt-2", + "label": "Pass through Atlassian's full space list and rely on per-page allowlist to deny later access", + "description": null + }, + { + "id": "opt-3", + "label": "Other (explain in reply)", + "description": null + } + ], + "resolved": false, + "resolution": null, + "resolved_by": null, + "resolved_at": null, + "debounce_until": null + }, + "reason": "Created HITL decision: getConfluenceSpaces response \u2014 should we filter to...", + "checkpoint_id": null + }, + { + "timestamp": "2026-04-26T23:16:48.038851Z", + "actor": "egg", + "role": "implementer", + "action": "update", + "field_path": "decisions.11", + "old_value": null, + "new_value": { + "id": "decision-12", + "question": "Attachments endpoints \u2014 what is the v1 stance?", + "type": "hitl", + "phase": "refine", + "options": [ + { + "id": "opt-1", + "label": "Permanent denylist for v1 and future writes (attachments are arbitrary-file uploads / downloads with unbounded payload surface; if needed later, scope as a separate ticket) (Recommended)", + "description": null + }, + { + "id": "opt-2", + "label": "Defer the decision to the future-writes phase", + "description": null + }, + { + "id": "opt-3", + "label": "Allow read-only attachment metadata (no body) in v1", + "description": null + }, + { + "id": "opt-4", + "label": "Other (explain in reply)", + "description": null + } + ], + "resolved": false, + "resolution": null, + "resolved_by": null, + "resolved_at": null, + "debounce_until": null + }, + "reason": "Created HITL decision: Attachments endpoints \u2014 what is the v1 stance?", + "checkpoint_id": null + }, + { + "timestamp": "2026-04-26T23:16:48.077355Z", + "actor": "egg", + "role": "implementer", + "action": "update", + "field_path": "decisions.12", + "old_value": null, + "new_value": { + "id": "decision-13", + "question": "EGG_CONFLUENCE_* environment variables in the sandbox?", + "type": "hitl", + "phase": "refine", + "options": [ + { + "id": "opt-1", + "label": "None in v1 (matches issue text \u2014 Confluence is reference material, not a unit of work). Audit recovers pageId / spaceKey from each request body (Recommended)", + "description": null + }, + { + "id": "opt-2", + "label": "Ship EGG_CONFLUENCE_PAGE / EGG_CONFLUENCE_SPACE as observational env vars (parallel to EGG_JIRA_TICKET)", + "description": null + }, + { + "id": "opt-3", + "label": "Other (explain in reply)", + "description": null + } + ], + "resolved": false, + "resolution": null, + "resolved_by": null, + "resolved_at": null, + "debounce_until": null + }, + "reason": "Created HITL decision: EGG_CONFLUENCE_* environment variables in the sand...", + "checkpoint_id": null + }, + { + "timestamp": "2026-04-26T23:16:48.108937Z", + "actor": "egg", + "role": "implementer", + "action": "update", + "field_path": "decisions.13", + "old_value": null, + "new_value": { + "id": "decision-14", + "question": "/execute passthrough endpoint?", + "type": "hitl", + "phase": "refine", + "options": [ + { + "id": "opt-1", + "label": "Include it (GET-only, regex-allowlisted), parallel to Jira's escape hatch for read verbs not yet promoted to narrow routes (Recommended)", + "description": null + }, + { + "id": "opt-2", + "label": "Skip /execute entirely; add narrow routes only", + "description": null + }, + { + "id": "opt-3", + "label": "Other (explain in reply)", + "description": null + } + ], + "resolved": false, + "resolution": null, + "resolved_by": null, + "resolved_at": null, + "debounce_until": null + }, + "reason": "Created HITL decision: /execute passthrough endpoint?", + "checkpoint_id": null + }, + { + "timestamp": "2026-04-26T23:16:48.215885Z", + "actor": "egg", + "role": "implementer", + "action": "update", + "field_path": "feedback", + "old_value": null, + "new_value": { + "id": "feedback-1", + "phase": "refine", + "questions": [ + { + "id": "Q1", + "question": "Which Atlassian Confluence spaces should be on the v1 allowlist (space keys, comma-separated)?", + "answer": null + }, + { + "id": "Q2", + "question": "What is the expected request volume per pipeline (peak CQL searches/min, page reads/min, comment reads/min)? This informs rate-limit defaults and 429 retry behaviour.", + "answer": null + }, + { + "id": "Q3", + "question": "Are there custom Confluence fields, macros, or page properties known to hold PII or secrets that should be redacted before sandbox-visible responses (in addition to the accountId / emailAddress defaults)?", + "answer": null + }, + { + "id": "Q4", + "question": "For the future write phase (out of scope here), should the gateway enforce idempotency on comment/create (refuse duplicate within N seconds) or leave that to Atlassian's own semantics? Surfacing now so the design-once decision is consistent with Jira.", + "answer": null + }, + { + "id": "Q5", + "question": "What pageId / page-link patterns should the orchestrator extract from Jira tickets to feed Confluence reads? (E.g., should it scan ticket descriptions for https://.atlassian.net/wiki/spaces//pages//... and pre-resolve, or leave URL parsing to the agent?) This informs whether v1 needs a page/resolve-by-url verb.", + "answer": null + }, + { + "id": "Q6", + "question": "Should getConfluencePageDescendants enforce a maximum depth (e.g., 3) to prevent runaway responses on deeply nested page trees, or pass Atlassian's depth parameter through verbatim?", + "answer": null + }, + { + "id": "Q7", + "question": "Should the audit log treat reads of pages whose space is technically in the allowlist but whose page hierarchy is restricted at the Atlassian permission layer as a special audit category (e.g., confluence_upstream_403 separate from confluence_upstream_error)?", + "answer": null + }, + { + "id": "Q8", + "question": "For long-lived pages with extensive version history: is there a use case for exposing a version query parameter on getConfluencePage in v1, or should we default to current-version-only?", + "answer": null + }, + { + "id": "Q9", + "question": "The host-side mcp__confluence__* MCP authenticates as the consenting human user; the gateway will authenticate as a bot. Are there documents in operator workspaces that the human reads but the bot would not be able to (or vice versa)? If so, do we need a diagnostic command that surfaces the bot's effective access per space?", + "answer": null + }, + { + "id": "Q10", + "question": "Should the sandbox confluence wrapper expose MCP-style alias names (confluence get-page, confluence search-cql) in addition to Jira-style subcommands (confluence page get, confluence search), or pick one shape only?", + "answer": null + } + ], + "submitted": false, + "submitted_by": null, + "submitted_at": null, + "comment_id": null, + "debounce_until": null + }, + "reason": "Created feedback request with 10 question(s)", + "checkpoint_id": null + } + ], "refine_review_cycles": 0, "refine_review_feedback": "", "plan_review_cycles": 0, "plan_review_feedback": "", "pr": null, - "feedback": null, + "feedback": { + "id": "feedback-1", + "phase": "refine", + "questions": [ + { + "id": "Q1", + "question": "Which Atlassian Confluence spaces should be on the v1 allowlist (space keys, comma-separated)?", + "answer": null + }, + { + "id": "Q2", + "question": "What is the expected request volume per pipeline (peak CQL searches/min, page reads/min, comment reads/min)? This informs rate-limit defaults and 429 retry behaviour.", + "answer": null + }, + { + "id": "Q3", + "question": "Are there custom Confluence fields, macros, or page properties known to hold PII or secrets that should be redacted before sandbox-visible responses (in addition to the accountId / emailAddress defaults)?", + "answer": null + }, + { + "id": "Q4", + "question": "For the future write phase (out of scope here), should the gateway enforce idempotency on comment/create (refuse duplicate within N seconds) or leave that to Atlassian's own semantics? Surfacing now so the design-once decision is consistent with Jira.", + "answer": null + }, + { + "id": "Q5", + "question": "What pageId / page-link patterns should the orchestrator extract from Jira tickets to feed Confluence reads? (E.g., should it scan ticket descriptions for https://.atlassian.net/wiki/spaces//pages//... and pre-resolve, or leave URL parsing to the agent?) This informs whether v1 needs a page/resolve-by-url verb.", + "answer": null + }, + { + "id": "Q6", + "question": "Should getConfluencePageDescendants enforce a maximum depth (e.g., 3) to prevent runaway responses on deeply nested page trees, or pass Atlassian's depth parameter through verbatim?", + "answer": null + }, + { + "id": "Q7", + "question": "Should the audit log treat reads of pages whose space is technically in the allowlist but whose page hierarchy is restricted at the Atlassian permission layer as a special audit category (e.g., confluence_upstream_403 separate from confluence_upstream_error)?", + "answer": null + }, + { + "id": "Q8", + "question": "For long-lived pages with extensive version history: is there a use case for exposing a version query parameter on getConfluencePage in v1, or should we default to current-version-only?", + "answer": null + }, + { + "id": "Q9", + "question": "The host-side mcp__confluence__* MCP authenticates as the consenting human user; the gateway will authenticate as a bot. Are there documents in operator workspaces that the human reads but the bot would not be able to (or vice versa)? If so, do we need a diagnostic command that surfaces the bot's effective access per space?", + "answer": null + }, + { + "id": "Q10", + "question": "Should the sandbox confluence wrapper expose MCP-style alias names (confluence get-page, confluence search-cql) in addition to Jira-style subcommands (confluence page get, confluence search), or pick one shape only?", + "answer": null + } + ], + "submitted": false, + "submitted_by": null, + "submitted_at": null, + "comment_id": null, + "debounce_until": null + }, "phase_configs": null, "agent_executions": [] } From 6015a88ada08eedeff62790670085ec0d66bd71a Mon Sep 17 00:00:00 2001 From: egg-orchestrator Date: Sun, 26 Apr 2026 23:42:46 +0000 Subject: [PATCH 04/26] Persist HITL resolution after refine phase gate --- .egg-state/contracts/issue-1931.json | 138 +++++++++++++-------------- 1 file changed, 69 insertions(+), 69 deletions(-) diff --git a/.egg-state/contracts/issue-1931.json b/.egg-state/contracts/issue-1931.json index 2bca906cae..6d7d09cf0a 100644 --- a/.egg-state/contracts/issue-1931.json +++ b/.egg-state/contracts/issue-1931.json @@ -37,10 +37,10 @@ "description": null } ], - "resolved": false, - "resolution": null, - "resolved_by": null, - "resolved_at": null, + "resolved": true, + "resolution": "{\"action\": \"select\", \"selected\": \"Jira-style verb-noun paths (/api/v1/confluence/page/get, /api/v1/confluence/search, ...); MCP-aligned names live in the sandbox wrapper subcommand layer (Recommended)\"}", + "resolved_by": "human", + "resolved_at": "2026-04-26T23:31:07.604178Z", "debounce_until": null }, { @@ -70,10 +70,10 @@ "description": null } ], - "resolved": false, - "resolution": null, - "resolved_by": null, - "resolved_at": null, + "resolved": true, + "resolution": "{\"action\": \"select\", \"selected\": \"v2-first hybrid: v2 for reads + v1 for CQL search + v1 fallback for known v2 comment bugs (Recommended)\"}", + "resolved_by": "human", + "resolved_at": "2026-04-26T23:35:05.413727Z", "debounce_until": null }, { @@ -98,10 +98,10 @@ "description": null } ], - "resolved": false, - "resolution": null, - "resolved_by": null, - "resolved_at": null, + "resolved": true, + "resolution": "{\"action\": \"select\", \"selected\": \"Conservative static space = / space IN (...) extractor; deny-on-ambiguity (mirrors Jira's JQL extractor) (Recommended)\"}", + "resolved_by": "human", + "resolved_at": "2026-04-26T23:35:05.457130Z", "debounce_until": null }, { @@ -131,10 +131,10 @@ "description": null } ], - "resolved": false, - "resolution": null, - "resolved_by": null, - "resolved_at": null, + "resolved": true, + "resolution": "{\"action\": \"select\", \"selected\": \"v2-first with transparent v1 fallback for nested replies and inline-404 bugs (Recommended)\"}", + "resolved_by": "human", + "resolved_at": "2026-04-26T23:35:05.489382Z", "debounce_until": null }, { @@ -169,10 +169,10 @@ "description": null } ], - "resolved": false, - "resolution": null, - "resolved_by": null, - "resolved_at": null, + "resolved": true, + "resolution": "{\"action\": \"select\", \"selected\": \"Default body-format=storage; support atlas_doc_format and view via caller override. Single body shape on the wire by default to keep payloads small; planners that need ADF tree traversal pass the override per-call.\"}", + "resolved_by": "human", + "resolved_at": "2026-04-26T23:39:03.293140Z", "debounce_until": null }, { @@ -197,10 +197,10 @@ "description": null } ], - "resolved": false, - "resolution": null, - "resolved_by": null, - "resolved_at": null, + "resolved": true, + "resolution": "{\"action\": \"select\", \"selected\": \"Shared ATLASSIAN_BASE_URL / _USERNAME / _API_TOKEN triple, with backward-compat fall-back to JIRA_* and CONFLUENCE_* placeholders (Recommended)\"}", + "resolved_by": "human", + "resolved_at": "2026-04-26T23:39:08.343287Z", "debounce_until": null }, { @@ -225,10 +225,10 @@ "description": null } ], - "resolved": false, - "resolution": null, - "resolved_by": null, - "resolved_at": null, + "resolved": true, + "resolution": "{\"action\": \"select\", \"selected\": \"@require_private_mode per route + route-enumeration regression test (Recommended \u2014 mirrors Jira)\"}", + "resolved_by": "human", + "resolved_at": "2026-04-26T23:39:08.376643Z", "debounce_until": null }, { @@ -258,10 +258,10 @@ "description": null } ], - "resolved": false, - "resolution": null, - "resolved_by": null, - "resolved_at": null, + "resolved": true, + "resolution": "{\"action\": \"select\", \"selected\": \"New confluence.spaces: section in config/context-filters.yaml (Recommended \u2014 mirrors Jira's jira.projects)\"}", + "resolved_by": "human", + "resolved_at": "2026-04-26T23:39:13.434398Z", "debounce_until": null }, { @@ -291,10 +291,10 @@ "description": null } ], - "resolved": false, - "resolution": null, - "resolved_by": null, - "resolved_at": null, + "resolved": true, + "resolution": "{\"action\": \"select\", \"selected\": \"Same dedicated Atlassian bot account used for Jira (single principal owns both Jira and Confluence read scopes) (Recommended)\"}", + "resolved_by": "human", + "resolved_at": "2026-04-26T23:39:18.518731Z", "debounce_until": null }, { @@ -319,10 +319,10 @@ "description": null } ], - "resolved": false, - "resolution": null, - "resolved_by": null, - "resolved_at": null, + "resolved": true, + "resolution": "{\"action\": \"select\", \"selected\": \"Strip accountId, emailAddress, and _links.webui user-profile URLs from responses before they reach the sandbox (parallels Jira) (Recommended)\"}", + "resolved_by": "human", + "resolved_at": "2026-04-26T23:39:23.645447Z", "debounce_until": null }, { @@ -347,10 +347,10 @@ "description": null } ], - "resolved": false, - "resolution": null, - "resolved_by": null, - "resolved_at": null, + "resolved": true, + "resolution": "{\"action\": \"select\", \"selected\": \"Filter response so only allowlisted spaces are returned (agents cannot enumerate the full tenant space set) (Recommended)\"}", + "resolved_by": "human", + "resolved_at": "2026-04-26T23:40:04.193744Z", "debounce_until": null }, { @@ -380,10 +380,10 @@ "description": null } ], - "resolved": false, - "resolution": null, - "resolved_by": null, - "resolved_at": null, + "resolved": true, + "resolution": "{\"action\": \"select\", \"selected\": \"Permanent denylist for v1 and future writes (attachments are arbitrary-file uploads / downloads with unbounded payload surface; if needed later, scope as a separate ticket) (Recommended)\"}", + "resolved_by": "human", + "resolved_at": "2026-04-26T23:40:09.238382Z", "debounce_until": null }, { @@ -408,10 +408,10 @@ "description": null } ], - "resolved": false, - "resolution": null, - "resolved_by": null, - "resolved_at": null, + "resolved": true, + "resolution": "{\"action\": \"select\", \"selected\": \"None in v1 (matches issue text \u2014 Confluence is reference material, not a unit of work). Audit recovers pageId / spaceKey from each request body (Recommended)\"}", + "resolved_by": "human", + "resolved_at": "2026-04-26T23:40:09.263247Z", "debounce_until": null }, { @@ -436,10 +436,10 @@ "description": null } ], - "resolved": false, - "resolution": null, - "resolved_by": null, - "resolved_at": null, + "resolved": true, + "resolution": "{\"action\": \"select\", \"selected\": \"Include it (GET-only, regex-allowlisted), parallel to Jira's escape hatch for read verbs not yet promoted to narrow routes (Recommended)\"}", + "resolved_by": "human", + "resolved_at": "2026-04-26T23:40:09.289899Z", "debounce_until": null } ], @@ -1101,57 +1101,57 @@ { "id": "Q1", "question": "Which Atlassian Confluence spaces should be on the v1 allowlist (space keys, comma-separated)?", - "answer": null + "answer": "Defer to operator at deploy time. Ship with confluence.spaces: [] (empty allowlist, fail-closed) in config/context-filters.yaml; operators populate before enabling Confluence routes. Do not hard-code spaces." }, { "id": "Q2", "question": "What is the expected request volume per pipeline (peak CQL searches/min, page reads/min, comment reads/min)? This informs rate-limit defaults and 429 retry behaviour.", - "answer": null + "answer": "Reuse Jira's rate-limit defaults (single retry on 429 with min(Retry-After, 30); audit confluence_upstream_rate_limited on both attempts). No Confluence-specific tuning in v1; revisit if 429s observed in production." }, { "id": "Q3", "question": "Are there custom Confluence fields, macros, or page properties known to hold PII or secrets that should be redacted before sandbox-visible responses (in addition to the accountId / emailAddress defaults)?", - "answer": null + "answer": "None known; defaults only. Ship with accountId / emailAddress / _links.webui redaction. Add a follow-up ticket if a sensitive macro or page property is identified post-rollout." }, { "id": "Q4", "question": "For the future write phase (out of scope here), should the gateway enforce idempotency on comment/create (refuse duplicate within N seconds) or leave that to Atlassian's own semantics? Surfacing now so the design-once decision is consistent with Jira.", - "answer": null + "answer": "Defer to the future-writes phase; do not pre-design idempotency in v1. Atlassian's create-comment endpoint is not naturally idempotent. Design the idempotency stance at the write-phase ticket alongside Jira's equivalent so the design-once choice stays consistent." }, { "id": "Q5", "question": "What pageId / page-link patterns should the orchestrator extract from Jira tickets to feed Confluence reads? (E.g., should it scan ticket descriptions for https://.atlassian.net/wiki/spaces//pages//... and pre-resolve, or leave URL parsing to the agent?) This informs whether v1 needs a page/resolve-by-url verb.", - "answer": null + "answer": "Do not add a page/resolve-by-url verb in v1. Agents that have a Confluence URL parse the pageId themselves and call /api/v1/confluence/page/get. Add resolve-by-url only if URL-parsing in the agent becomes a recurring pain point; not in v1 scope." }, { "id": "Q6", "question": "Should getConfluencePageDescendants enforce a maximum depth (e.g., 3) to prevent runaway responses on deeply nested page trees, or pass Atlassian's depth parameter through verbatim?", - "answer": null + "answer": "Pass Atlassian's depth parameter through verbatim with no gateway-imposed cap in v1. Add a depth ceiling if a runaway response is observed; not pre-emptively. Document the parameter in the wrapper reference." }, { "id": "Q7", "question": "Should the audit log treat reads of pages whose space is technically in the allowlist but whose page hierarchy is restricted at the Atlassian permission layer as a special audit category (e.g., confluence_upstream_403 separate from confluence_upstream_error)?", - "answer": null + "answer": "Yes, separate confluence_upstream_403 audit category from generic upstream errors. Parallels the not_found vs error split. Helps operators distinguish space-allowlist denials, page-permission denials, and generic upstream errors at audit time." }, { "id": "Q8", "question": "For long-lived pages with extensive version history: is there a use case for exposing a version query parameter on getConfluencePage in v1, or should we default to current-version-only?", - "answer": null + "answer": "Default to current-version only in v1. Do not expose a ?version= query parameter until a use case appears. Document the default in the wrapper reference so future expansion is unsurprising." }, { "id": "Q9", "question": "The host-side mcp__confluence__* MCP authenticates as the consenting human user; the gateway will authenticate as a bot. Are there documents in operator workspaces that the human reads but the bot would not be able to (or vice versa)? If so, do we need a diagnostic command that surfaces the bot's effective access per space?", - "answer": null + "answer": "Operator concern; ship a diagnostic command in a follow-up rather than v1. v1 documents the bot-vs-human access caveat in the wrapper reference. Operators can manually verify the bot's space access at deploy time using the host-side mcp__confluence__* MCP under the bot's token." }, { "id": "Q10", "question": "Should the sandbox confluence wrapper expose MCP-style alias names (confluence get-page, confluence search-cql) in addition to Jira-style subcommands (confluence page get, confluence search), or pick one shape only?", - "answer": null + "answer": "Jira-style subcommands only (e.g., confluence page get, confluence search-cql). Mirrors the existing jira wrapper shape. Do not ship MCP-style aliases in v1; add only if a real porting pain point appears post-rollout." } ], - "submitted": false, - "submitted_by": null, - "submitted_at": null, + "submitted": true, + "submitted_by": "human", + "submitted_at": "2026-04-26T23:42:46.709275Z", "comment_id": null, "debounce_until": null }, From 256521c4b5c59341e9366cb87c08cfebc8a853f9 Mon Sep 17 00:00:00 2001 From: egg Date: Sun, 26 Apr 2026 23:50:35 +0000 Subject: [PATCH 05/26] plan(architect): add architecture analysis for #1931 Confluence gateway Mirrors the Jira gateway pattern from #1556 with Confluence-specific adaptations (v2-first / v1-fallback hybrid, CQL static-scope extractor, shared ATLASSIAN_* credential triple). Records all 14 HITL decision resolutions and 10 free-form feedback answers from refine. Includes component breakdown, route surface, data-flow walkthrough, and 4 plan- phase open questions for the task_planner / risk_analyst. Co-Authored-By: Claude Opus 4.7 --- .../agent-outputs/1931-architect-output.json | 713 ++++++++++++++++++ 1 file changed, 713 insertions(+) create mode 100644 .egg-state/agent-outputs/1931-architect-output.json diff --git a/.egg-state/agent-outputs/1931-architect-output.json b/.egg-state/agent-outputs/1931-architect-output.json new file mode 100644 index 0000000000..a18bf2f9c6 --- /dev/null +++ b/.egg-state/agent-outputs/1931-architect-output.json @@ -0,0 +1,713 @@ +{ + "issue": 1931, + "phase": "plan", + "agent": "architect", + "title": "Add Confluence gateway support (read-only v1)", + + "summary": "Architecture analysis for the Confluence v1 gateway wrapper. The work is a structural copy of the #1556 Jira wrapper: same gateway sidecar, same @require_session_auth + @require_private_mode chain, same context-filters.yaml-driven allowlist, same audit shape, same test harness. The Confluence-specific complexity sits in three places: (a) a per-verb v2-first / v1-fallback hybrid driven by the Atlassian API split (CQL search is v1-only, comment endpoints have known v2 bugs); (b) a CQL static-scope extractor that mirrors gateway/jira_search.py at the semantic level but parses CQL grammar instead of JQL; and (c) a shared Atlassian credential triple (ATLASSIAN_BASE_URL / _USERNAME / _API_TOKEN) with backward-compat fallback to JIRA_* and CONFLUENCE_* placeholders. All HITL design decisions were resolved in refine; this analysis records the architectural consequences.", + + "problem_statement": { + "description": "Sandboxed egg agents cannot read Confluence today. The host-side mcp__confluence__* MCP runs in the human's Claude Code process, holds the human's full Atlassian read+write+admin scope, and is unreachable from sandbox containers. #1556 solved the analogous problem for Jira. #1931 asks for the matching Confluence v1 wrapper so #1557 (Jira-epic SDLC pipelines) and the /impact-analysis skill can read Confluence pages linked from Jira tickets without putting Atlassian credentials in the sandbox.", + "goals": [ + "Sandboxed agents reach Confluence only through the gateway sidecar; no Confluence credentials in the sandbox.", + "Read-only v1: page get, list pages in space, page descendants, footer/inline comments, list spaces, CQL search.", + "Private-mode-only: every /api/v1/confluence/* route fails closed in public network mode (matches Jira).", + "Space allowlist is the policy boundary; CQL searches must statically prove their space scope.", + "Future write verbs (page create, page update, comment create) plug in as drop-in narrow routes under the same plumbing — no re-architecting; deletions / restrictions / permissions / space admin / attachments stay permanently denied at the path validator.", + "No EGG_CONFLUENCE_* env vars — Confluence is reference material, not a unit of work." + ], + "non_goals": [ + "Write verbs (page create/update, comment create) — scoped to a follow-up ticket.", + "Deletions, restrictions, permissions, space-admin, attachments — permanent denylist.", + "Multi-tenant Atlassian — single-tenant in v1; design must not architect multi-tenant out.", + "v2 search index — the gateway uses Atlassian's v1 CQL endpoint, not a self-hosted index." + ], + "consumers_unblocked": [ + "#1557 — Jira-epic SDLC pipelines (refine phase pulls linked Confluence pages).", + "/impact-analysis skill — when run inside a sandboxed agent it can use the gateway instead of mcp__confluence__*." + ] + }, + + "current_architecture": { + "description": "Gateway sidecar is the existing choke point between sandbox and external services. #1556 generalised the Jira plumbing — credentials, mode-gate, context-filters, audit log, route-enumeration regression test — so adding Confluence is a structural copy, not a new infrastructure layer.", + "reusable_infrastructure": [ + { + "component": "@require_session_auth → @require_private_mode decorator chain", + "location": "gateway/auth.py + gateway/mode_gate.py", + "reuse": "Apply unchanged. mode_gate.py stamps __egg_requires_private_mode__ = True so a route-enumeration regression test can prove every /api/v1/confluence/* route carries the gate." + }, + { + "component": "Squid network-allowlist with *.atlassian.net excluded", + "location": "gateway/allowed_domains.txt (lines 31-36)", + "reuse": "No change. Confluence cannot bypass the gateway via direct egress for the same reason Jira cannot. The existing gateway/tests/test_allowed_domains.py regression covers Confluence by extension." + }, + { + "component": "Mtime-cached, thread-safe credential loader pattern", + "location": "gateway/anthropic_credentials.py (parse_env_file) + gateway/jira_credentials.py (manager class)", + "reuse": "New gateway/confluence_credentials.py imports parse_env_file from anthropic_credentials and copies the JiraCredentialsManager pattern. Differs only in env-var precedence (ATLASSIAN_* preferred, CONFLUENCE_* fallback)." + }, + { + "component": "Mtime-cached YAML allowlist loader pattern", + "location": "gateway/jira_policy.py", + "reuse": "New gateway/confluence_policy.py copies the loader, points at the new confluence.spaces section, exposes allowed_spaces() / is_space_allowed() helpers." + }, + { + "component": "Conservative static-scope extractor pattern", + "location": "gateway/jira_search.py (extract_search_projects)", + "reuse": "New gateway/confluence_search.py implements extract_search_spaces(cql, allowed) with the same parse-then-validate stance: strip comments and string literals, tokenise, accept exactly two shapes (`space = KEY` and `space IN (K1, K2, …)`), deny on ambiguity. The CQL grammar differs from JQL but the semantic model is identical." + }, + { + "component": "Audit-log helper", + "location": "gateway/gateway.py (audit_log())", + "reuse": "Unchanged. New event names follow the jira_* convention: confluence_page_get, confluence_search, confluence_search_rejected, confluence_*_denied, confluence_*_upstream_error, confluence_upstream_rate_limited, confluence_upstream_403." + }, + { + "component": "Route-enumeration regression test pattern", + "location": "gateway/tests/test_jira_routes.py", + "reuse": "Copy structure into gateway/tests/test_confluence_routes.py. Walks app.url_map for /api/v1/confluence/* and asserts every view function has __egg_requires_private_mode__ = True." + }, + { + "component": "POST /api/v1/config/reload hook", + "location": "gateway/gateway.py reload_jira_credentials() / reload_jira_policy() invocations (around lines 880-910)", + "reuse": "Extend the same handler to call reload_confluence_credentials() and reload_confluence_policy() — one-line additions per." + }, + { + "component": "Sandbox bash wrapper pattern", + "location": "sandbox/scripts/jira", + "reuse": "New sandbox/scripts/confluence copies the call_gateway() shape, EGG_SESSION_TOKEN Bearer auth, and gateway-availability probe." + }, + { + "component": "Wrapper reference doc pattern", + "location": "docs/reference/jira-wrapper.md", + "reuse": "New docs/reference/confluence-wrapper.md copies the structure: endpoint table, validation rules, not_found envelope, audit events, configuration." + } + ], + "existing_confluence_footprint": [ + { + "file": "config/secrets.template.env", + "lines": "92-99", + "current_state": "Defines CONFLUENCE_BASE_URL / CONFLUENCE_USERNAME / CONFLUENCE_API_TOKEN / CONFLUENCE_SPACE_KEYS placeholders. Nothing reads them today.", + "v1_change": "Header retitled 'Atlassian Integration (shared by Jira + Confluence)'. New ATLASSIAN_BASE_URL / ATLASSIAN_USERNAME / ATLASSIAN_API_TOKEN keys. The CONFLUENCE_* triple stays for back-compat fallback. CONFLUENCE_SPACE_KEYS is removed (allowlist moves to context-filters.yaml per refine decision-8)." + }, + { + "file": "sandbox/agent-config/rules/environment.md", + "lines": "80", + "current_state": "Mentions ~/context-sync/confluence/ as an optional read-only cache.", + "v1_change": "Add a Confluence wrapper subsection alongside the Jira wrapper subsection (lines 50-65). No EGG_CONFLUENCE_* env var documented (refine decision-13)." + }, + { + "file": "config/context-filters.yaml", + "lines": "11-24 (jira section)", + "current_state": "Has only the jira: section with projects: [].", + "v1_change": "Add a confluence: section with spaces: [] (default empty, fail-closed)." + } + ] + }, + + "external_constraints": { + "atlassian_v1_v2_split": { + "v2_endpoints_used": [ + "GET /wiki/api/v2/pages/{id}", + "GET /wiki/api/v2/spaces/{space-id}/pages", + "GET /wiki/api/v2/pages/{id}/descendants", + "GET /wiki/api/v2/pages/{id}/footer-comments", + "GET /wiki/api/v2/pages/{id}/inline-comments", + "GET /wiki/api/v2/spaces" + ], + "v1_endpoints_used": [ + "GET /wiki/rest/api/search (CQL — no v2 equivalent)", + "GET /wiki/rest/api/content/{id}/child/comment?location=footer (fallback for missing-nested-replies bug)", + "GET /wiki/rest/api/content/{id}/child/comment?location=inline (fallback for v2 inline-comments 404 bug)" + ], + "consequence": "ConfluenceClient pins each verb to a specific Atlassian endpoint. Wrapper methods normalise both response shapes to a single envelope so /api/v1/confluence/* responses are version-agnostic for the agent. v1 search and v1 comment fallbacks are explicit, not implicit retries — they fire only on the documented failure modes." + }, + "rate_limiting": { + "policy": "Reuse Jira's 'retry once on 429 with min(Retry-After, 30s)' approach. Emit confluence_upstream_rate_limited audit on both attempts.", + "configurable": false, + "rationale": "Per refine feedback Q2, no Confluence-specific tuning in v1; revisit only if production 429s are observed." + }, + "body_format_default": { + "default": "body-format=storage (Confluence XHTML-like markup) — single body shape on the wire.", + "override": "Caller may pass body-format=atlas_doc_format or body-format=view per call.", + "rationale": "Per HITL resolution on decision-5: default storage to keep payloads small; planners that need ADF tree traversal pass the override per-call. (Note: this differs from the analysis draft's E1 recommendation; the resolution narrowed it to storage-default with override.)" + }, + "cql_quirks": { + "hard_result_limit": "200 per query; pagination required beyond that.", + "scope_extraction": "Conservative static `space =` / `space IN (...)` extractor; deny on ambiguity (refine decision-3).", + "passthrough_grammar": "AND-combined additional clauses are allowed at top level; OR / unparented `space` / quoted keys / CQL functions / mixed-script keys are rejected." + }, + "redaction": { + "stripped_fields": ["accountId", "emailAddress", "_links.webui (when it points at user-profile URLs)"], + "applied": "Before responses leave the gateway (refine decision-10).", + "rationale": "Parallels Jira's redaction stance; PII / user-tracking surface." + }, + "space_list_filtering": { + "behavior": "GET /wiki/api/v2/spaces response is filtered by the gateway so only allowlisted spaces are returned (refine decision-11). Agents cannot enumerate the full tenant space set." + } + }, + + "proposed_architecture": { + "module_breakdown": [ + { + "module": "gateway/confluence_client.py", + "purpose": "Thin httpx-based REST wrapper around Atlassian Cloud Confluence. Mirrors gateway/jira_client.py.", + "key_classes_or_functions": [ + "ConfluenceClient (dataclass, configurable creds_provider + http_client)", + "ConfluenceUpstreamError (RuntimeError carrying status_code, body, path)", + "validate_confluence_api_path(path, method) — regex allowlist for /execute passthrough; mirrors validate_jira_api_path", + "validate_body_format(body_format) — caller override of body-format=storage default", + "ALLOWED_METHODS = frozenset({'GET'})", + "CONFLUENCE_WRITE_VERBS_DENIED = frozenset({'restrictions', 'permissions', 'space.admin', 'users', 'attachments', 'DELETE', 'PUT', 'PATCH'}) — permanent denylist, includes 'attachments' per refine decision-12", + "ConfluenceClient.get_page(page_id, body_format=None) → v2", + "ConfluenceClient.list_pages_in_space(space_id, ...) → v2", + "ConfluenceClient.get_page_descendants(page_id, depth=None, ...) → v2 (depth passes through verbatim per refine feedback Q6)", + "ConfluenceClient.get_footer_comments(page_id, include_replies=False) → v2; v1 fallback when include_replies=True or v2 returns no nested replies", + "ConfluenceClient.get_inline_comments(page_id) → v2; v1 fallback on 404 (known bug)", + "ConfluenceClient.list_spaces(...) → v2 (response filtered to allowlist by route layer)", + "ConfluenceClient.search_cql(cql, limit, cursor) → v1 (the only working CQL surface)", + "ConfluenceClient.execute_raw(method, path, query, body) → GET-only passthrough" + ], + "404_envelope": "get_page / list_pages_in_space / get_page_descendants / get_footer_comments / get_inline_comments translate upstream 404 into {'status': 'not_found', 'id': '...', 'upstream_status': 404}. CQL search and execute_raw surface upstream 404 as real ConfluenceUpstreamError.", + "redaction": "Response post-processing strips accountId / emailAddress / _links.webui user-profile URLs before returning to the route layer (per refine decision-10). Implemented as a private _redact() helper applied on every method's return path.", + "v1_fallback_logic": { + "footer_comments_nested_replies": "If caller asks for include_replies=true, gateway issues a second v2 call to /wiki/api/v2/footer-comments?page-id={id} (the workaround Atlassian documents) and merges the tree. If that still misses replies, falls back to v1 /wiki/rest/api/content/{id}/child/comment?location=footer.", + "inline_comments_404": "If v2 returns 404 (the documented bug), the client transparently retries against v1 /wiki/rest/api/content/{id}/child/comment?location=inline and returns the v1 response under a normalised envelope (same field names as v2). The fallback is logged as an audit event confluence_v1_fallback so operators can see how often the bug fires." + }, + "atlas_403_audit": "When upstream returns 403, the client raises ConfluenceUpstreamError(status_code=403, ...) and the route layer emits a distinct confluence_upstream_403 audit event (per refine feedback Q7) so operators can distinguish space-allowlist denials, page-permission denials, and generic upstream errors at audit time.", + "approximate_size": "550-650 lines (a touch larger than jira_client.py because of the v1-fallback paths and per-verb response normalisation)." + }, + { + "module": "gateway/confluence_credentials.py", + "purpose": "Mtime-cached, thread-safe loader for Atlassian credentials. Mirrors gateway/jira_credentials.py.", + "key_classes_or_functions": [ + "ConfluenceCredentials (dataclass: base_url, username, api_token, basic_auth_header())", + "ConfluenceCredentialsUnavailable (RuntimeError; route translates to HTTP 503)", + "ConfluenceCredentialsManager (mtime-cached + threading.Lock)", + "get_confluence_credentials() (module-level helper)", + "reload_confluence_credentials() (force cache clear; called by /api/v1/config/reload)" + ], + "credential_precedence": [ + "1. ATLASSIAN_BASE_URL / ATLASSIAN_USERNAME / ATLASSIAN_API_TOKEN (preferred — refine decision-6).", + "2. CONFLUENCE_BASE_URL / CONFLUENCE_USERNAME / CONFLUENCE_API_TOKEN (back-compat for installs that already populated the placeholders).", + "3. Derive Confluence base URL from ATLASSIAN_BASE_URL by appending /wiki when only ATLASSIAN_* is set and the base URL doesn't already end in /wiki." + ], + "shared_credential_invariant": "Both confluence_credentials.py and jira_credentials.py read the same secrets.env file. When ATLASSIAN_* is set, both use it (Jira gets the bare base, Confluence appends /wiki). When only the legacy *_* triples are set, each loader uses its own. Operators are not required to migrate.", + "rationale": "Per refine decision-6 + decision-9: one Atlassian bot account, one API token, one principal owns Jira read + Confluence read scopes. Reduces token-rotation toil.", + "approximate_size": "230-260 lines." + }, + { + "module": "gateway/confluence_policy.py", + "purpose": "Mtime-cached, fail-closed loader for the Confluence space allowlist. Mirrors gateway/jira_policy.py.", + "key_classes_or_functions": [ + "allowed_spaces() → frozenset[str]", + "is_space_allowed(space_key) → bool", + "extract_space_from_page_response(page_json) → str | None (parses spaceId / space.key from a v2 page response so route layer can verify the page's space is allowlisted before returning)", + "reload_confluence_policy() — force cache clear" + ], + "yaml_shape": "config/context-filters.yaml gains a new top-level confluence: section with a single key spaces: [...] (refine decision-8). Default is [] (fail-closed: every Confluence call rejected until populated).", + "fail_closed_semantics": "Missing file → empty set. Missing confluence: section → empty set. Malformed YAML → empty set, parse error logged once per load cycle (not re-raised — bad config must not crash the gateway).", + "approximate_size": "240-270 lines." + }, + { + "module": "gateway/confluence_search.py", + "purpose": "Conservative static CQL space-scope extractor. Mirrors gateway/jira_search.py at the semantic level; CQL grammar differs from JQL.", + "key_classes_or_functions": [ + "extract_search_spaces(cql, allowed) → ScopeResult(spaces, reason)", + "_normalise_strings(cql) — strip / sanitise quoted literals before tokenisation", + "Internal: _SPACE_KEY_RE (Atlassian space-key shape: ^[A-Z][A-Z0-9_]*$ — same as Jira project keys)" + ], + "accepted_cql_shapes": [ + "`space = KEY` (bare uppercase key, KEY in allowed)", + "`space IN (K1, K2, ...)` (every Ki in allowed)", + "Either form AND-combined with arbitrary additional clauses at top level" + ], + "rejected_cql_shapes": [ + "No `space` clause anywhere.", + "`space` under a top-level OR.", + "Quoted space keys whose value is not allowlisted.", + "CQL functions (currentUser(), recentlyViewedContent(), favouriteSpaces(), etc.).", + "`id =` clauses without a `space =` clause.", + "Unicode / mixed-script keys (homoglyph defence).", + "Comment markers (/* */ // --) and forbidden chars (;).", + "OR at any level inside a top-level conjunct that contains `space`." + ], + "rationale": "Per refine decision-3. Permissive CQL with post-hoc result filter was rejected for the same reason JQL was: result counts and pagination metadata leak from non-allowlisted spaces.", + "approximate_size": "260-290 lines." + }, + { + "module": "gateway/gateway.py — Confluence route block", + "purpose": "Add a Confluence Routes section mirroring the Jira Routes section (currently lines 4180-4671).", + "new_routes": [ + { + "route": "POST /api/v1/confluence/page/get", + "decorators": ["@require_session_auth", "@require_private_mode"], + "request_body": "{\"page_id\": \"\", \"body_format\": \"storage|atlas_doc_format|view\" (optional)}", + "validation": [ + "page_id must be a non-empty digit string.", + "Resolve page → upstream — if response carries spaceId/spaceKey, verify against allowed_spaces; reject with confluence_page_get_denied if not allowlisted.", + "body_format if present must be one of {storage, atlas_doc_format, view}; default storage." + ], + "upstream": "GET /wiki/api/v2/pages/{id}?body-format=...", + "audit_event": "confluence_page_get / confluence_page_get_rejected / confluence_page_get_denied / confluence_page_get_upstream_error / confluence_upstream_403", + "404_envelope": true + }, + { + "route": "POST /api/v1/confluence/page/descendants", + "request_body": "{\"page_id\": \"...\", \"depth\": (optional)}", + "validation": "depth passes through verbatim (refine feedback Q6); other params validated as ints.", + "upstream": "GET /wiki/api/v2/pages/{id}/descendants?depth=...", + "audit_event": "confluence_page_descendants / *_denied / *_upstream_error", + "404_envelope": true + }, + { + "route": "POST /api/v1/confluence/page/footer-comments", + "request_body": "{\"page_id\": \"...\", \"include_replies\": false (optional)}", + "validation": "Verify the parent page's space is allowlisted (single upstream resolve before fetching comments).", + "upstream": "GET /wiki/api/v2/pages/{id}/footer-comments (with v1 fallback when include_replies=true and v2 misses replies)", + "audit_event": "confluence_footer_comments / *_denied / confluence_v1_fallback (when fallback fires) / *_upstream_error", + "404_envelope": true + }, + { + "route": "POST /api/v1/confluence/page/inline-comments", + "request_body": "{\"page_id\": \"...\"}", + "validation": "Verify the parent page's space is allowlisted.", + "upstream": "GET /wiki/api/v2/pages/{id}/inline-comments (with transparent v1 fallback on 404)", + "audit_event": "confluence_inline_comments / *_denied / confluence_v1_fallback / *_upstream_error", + "404_envelope": true + }, + { + "route": "POST /api/v1/confluence/space/pages", + "request_body": "{\"space_id\": \"\", \"limit\": (optional), \"cursor\": \"...\" (optional)}", + "validation": "Resolve space_id → space key (one upstream call or in-process map) and verify the key is in allowed_spaces.", + "upstream": "GET /wiki/api/v2/spaces/{space-id}/pages?limit=...&cursor=...", + "audit_event": "confluence_space_pages / *_denied / *_upstream_error" + }, + { + "route": "POST /api/v1/confluence/space/list", + "request_body": "{\"limit\": (optional), \"cursor\": \"...\" (optional)}", + "validation": "None (purely informational).", + "upstream": "GET /wiki/api/v2/spaces?limit=...&cursor=...", + "post_processing": "Filter response so only spaces whose key is in allowed_spaces appear (refine decision-11). Pagination links are recomputed on the filtered set.", + "audit_event": "confluence_space_list / *_upstream_error" + }, + { + "route": "POST /api/v1/confluence/search", + "request_body": "{\"cql\": \"space = ENG AND text ~ 'login'\", \"limit\": , \"cursor\": \"...\"}", + "validation": "extract_search_spaces(cql, allowed_spaces()) must return a non-None ScopeResult; otherwise 403 confluence_search_rejected. limit clamped to 100; default 50.", + "upstream": "GET /wiki/rest/api/search?cql=...&limit=...&cursor=...", + "audit_event": "confluence_search / confluence_search_rejected / confluence_search_upstream_error", + "spaces_extracted": "Logged in audit details on accept." + }, + { + "route": "POST /api/v1/confluence/execute", + "request_body": "{\"method\": \"GET\", \"path\": \"...\", \"query\": {...}, \"body\": null}", + "validation": [ + "Method must be GET.", + "path must match validate_confluence_api_path regex allowlist.", + "If path carries a space key, it must be in allowed_spaces.", + "Permanent denylist segments (restrictions, permissions, space.admin, users, attachments) reject with confluence_execute_denied." + ], + "upstream": "GET against the validated path", + "rationale": "Escape hatch for read verbs not yet promoted to narrow routes (refine decision-14)." + } + ], + "shared_helpers_to_add": [ + "_session_confluence_context() — session_mode, pipeline_id, agent_role (no jira_ticket equivalent; refine decision-13 said no EGG_CONFLUENCE_*).", + "_confluence_error_from_upstream(exc) — translate ConfluenceUpstreamError to HTTP response; 4xx pass-through, 5xx → 502 with details.", + "_confluence_not_configured_error(exc) — translate ConfluenceCredentialsUnavailable to HTTP 503.", + "_space_not_allowlisted_response(...) — emit structured audit, return canonical 403 with reason." + ], + "config_reload_hook": "reload_confluence_credentials() and reload_confluence_policy() are added to the existing reload handler in gateway.py around lines 880-910 alongside the Jira reloads.", + "approximate_added_lines": "~700-900 in gateway.py (matching the Jira block size; Jira routes are ~485 lines, Confluence has 8 routes vs 4 so larger)." + }, + { + "module": "sandbox/scripts/confluence", + "purpose": "Sandbox bash wrapper. Mirrors sandbox/scripts/jira.", + "subcommands": [ + "confluence page get [--body-format storage|atlas_doc_format|view]", + "confluence page descendants [--depth N]", + "confluence page footer-comments [--include-replies]", + "confluence page inline-comments ", + "confluence space pages [--limit N] [--cursor TOK]", + "confluence space list [--limit N] [--cursor TOK]", + "confluence search [--limit N] [--cursor TOK]", + "confluence execute [--query key=val,...]", + "confluence help" + ], + "naming_decision": "Jira-style subcommands only (refine feedback Q10). MCP-style aliases (confluence get-page, confluence search-cql) are NOT shipped in v1; can be added later if porting friction emerges.", + "shared_environment": "Same EGG_SESSION_TOKEN + GATEWAY_URL contract as the jira wrapper. Same gateway-availability probe, same fail-closed messaging.", + "approximate_size": "350-450 lines (matches sandbox/scripts/jira)." + }, + { + "module": "config/context-filters.yaml", + "addition": "New top-level `confluence:` section with a single `spaces: [...]` key, default empty. Comment block mirrors the existing `jira:` comment block.", + "fail_closed": "Empty list (or missing section) blocks every Confluence call until an operator populates." + }, + { + "module": "config/secrets.template.env", + "rewrite": "Promote the Atlassian section to a single shared block at the top of an Atlassian section. Layout: ATLASSIAN_BASE_URL / _USERNAME / _API_TOKEN as the canonical triple, with a comment that JIRA_* and CONFLUENCE_* placeholders are kept for back-compat. Remove CONFLUENCE_SPACE_KEYS (replaced by config/context-filters.yaml — refine decision-8)." + }, + { + "module": "sandbox/agent-config/rules/environment.md", + "addition": "New Confluence wrapper subsection alongside the existing Jira wrapper subsection (current lines 47-66). Documents the confluence subcommands, notes that no EGG_CONFLUENCE_* env var exists (refine decision-13), references docs/reference/confluence-wrapper.md." + }, + { + "module": "docs/reference/confluence-wrapper.md", + "purpose": "User-facing reference for the new gateway endpoints. Mirrors docs/reference/jira-wrapper.md.", + "sections": [ + "Endpoint surface (table of /api/v1/confluence/* routes + upstream Atlassian endpoints).", + "Per-route request body schema, validation rules, response shape, audit events.", + "CQL acceptance rules (the static-scope extractor's accepted shapes).", + "404 not_found envelope.", + "v1 fallback behaviour for footer / inline comments (operator-visible — confluence_v1_fallback events).", + "Configuration: secrets.env (ATLASSIAN_* vs CONFLUENCE_* fallback), context-filters.yaml (confluence.spaces).", + "Bot-vs-human access caveat (refine feedback Q9).", + "Default body format (storage; override per call)." + ] + }, + { + "module": "docs/architecture/network-isolation.md", + "addition": "Add /api/v1/confluence/* routes to the private-mode endpoint table. Note the exception is identical to /api/v1/jira/*." + }, + { + "module": "docs/architecture/credential-injection.md", + "addition": "Extend the Atlassian section to cover Confluence. Document the ATLASSIAN_* / CONFLUENCE_* / JIRA_* precedence and the implicit /wiki suffix derivation." + }, + { + "module": "gateway/tests/test_confluence_*.py", + "files_to_create": [ + "test_confluence_client.py — unit tests for ConfluenceClient (httpx mock, 429 retry, 404 envelope, v1 fallback paths, redaction).", + "test_confluence_credentials.py — manager loader (mtime cache, ATLASSIAN_* vs CONFLUENCE_* precedence, /wiki suffix derivation, missing-creds 503).", + "test_confluence_policy.py — context-filters.yaml loader (fail-closed, malformed YAML, empty section, mtime reload).", + "test_confluence_search.py — adversarial CQL suite (every accepted shape + every rejection reason).", + "test_confluence_routes.py — per-route 200 / 400 / 403 / 503 grid; route-enumeration regression that walks app.url_map and asserts every /api/v1/confluence/* view function has __egg_requires_private_mode__ = True; private-mode 403 for every route in public mode; space-allowlist 403 grid; CQL static-scope extractor integration; v1-fallback integration; redaction; space-list filtering." + ] + } + ], + "data_flow": { + "happy_path": [ + "1. Sandbox agent runs `confluence page get 1234567`.", + "2. sandbox/scripts/confluence POSTs JSON to GATEWAY_URL/api/v1/confluence/page/get with EGG_SESSION_TOKEN Bearer.", + "3. @require_session_auth populates g.session_mode and g.session.", + "4. @require_private_mode rejects unless g.session_mode == 'private' (with confluence_private_mode_required audit).", + "5. Route validates page_id shape and body_format (if provided).", + "6. Route calls ConfluenceClient.get_page(page_id, body_format).", + "7. Client loads creds via get_confluence_credentials() (ATLASSIAN_* preferred, CONFLUENCE_* fallback).", + "8. Client issues GET /wiki/api/v2/pages/{id}?body-format=... with Basic auth.", + "9. On 429, client retries once with min(Retry-After, 30s); on 404, returns not_found envelope; on 403, raises ConfluenceUpstreamError(403) → route emits confluence_upstream_403 audit.", + "10. Client redacts response (accountId / emailAddress / _links.webui).", + "11. Route extracts the page's spaceId/spaceKey from the response and verifies against allowed_spaces. (Per-page allowlist check happens AFTER fetching because v2 pages don't have a stable URL-level space key. Reject with confluence_page_get_denied + 403 if not allowlisted.)", + "12. Route emits confluence_page_get audit and returns make_success(...)." + ], + "search_path": [ + "1. Sandbox agent runs `confluence search 'space = ENG AND text ~ \"login\"'`.", + "2. POST → /api/v1/confluence/search.", + "3. extract_search_spaces(cql, allowed_spaces()) returns ScopeResult({'ENG'}, '') because the static `space = ENG` is allowlisted.", + "4. Client issues GET /wiki/rest/api/search?cql=... (v1).", + "5. Response redacted, audit emitted with spaces_extracted=['ENG']." + ], + "rejection_path_examples": [ + "Public-mode session: 403 private_mode_required, no upstream call, no creds load.", + "Sandbox sends `space = SECRET` and 'SECRET' is not in confluence.spaces: 403 confluence_search_rejected, reason 'space not allowlisted'.", + "Sandbox sends bare `text ~ 'foo'` (no space clause): 403 confluence_search_rejected, reason 'no space clause'.", + "Sandbox sends `space = ENG OR space = WIDE_OPEN`: 403, reason 'space under OR'.", + "Sandbox calls GET /api/v1/confluence/execute with path=`spaces/{id}/restrictions`: 403 confluence_execute_denied, reason 'denied write verb segment'." + ] + } + }, + + "key_technical_decisions": [ + { + "id": "TD1", + "decision": "Two-name shape: gateway URL paths use the Jira verb-noun convention (`/api/v1/confluence/page/get`); the sandbox wrapper uses Jira-style subcommands (`confluence page get `). MCP-aligned names do NOT bleed into URL design.", + "decided_by": "refine decision-1", + "rationale": "Mirrors /api/v1/jira/* exactly. Reviewers, tests, audit-log fields, and operators see the same nouns across both services. Path-validator regex aligns with validate_jira_api_path. Future write verbs (page/create, page/update, comment/create) drop in symmetrically. The MCP-style subcommand aliases (confluence get-page, confluence search-cql) were considered (feedback Q10) and rejected for v1 — adds a parallel naming surface with no proven porting need." + }, + { + "id": "TD2", + "decision": "Per-verb v2-first / v1-fallback hybrid. Each ConfluenceClient method pins its upstream Atlassian endpoint; clients never declare a single Atlassian API version.", + "decided_by": "refine decision-2 (v2-first hybrid) + decision-4 (v2-first with transparent v1 fallback for nested replies + inline-404 bug)", + "rationale": "v2 has cleaner cursor pagination and is Atlassian's strategic surface, but CQL search is v1-only (no v2 equivalent and no plans to add). Footer-comments v2 misses nested replies; inline-comments v2 has a 404 bug. Per-verb pinning lets us flip a single line per endpoint when Atlassian closes a v2 gap. The fallback for comments fires only on the documented failure modes (not on every 404), and emits a confluence_v1_fallback audit event so operators can monitor frequency." + }, + { + "id": "TD3", + "decision": "CQL static-scope extractor is a structural copy of gateway/jira_search.py; accepts only `space = KEY` and `space IN (K1, ...)` AND-combined with arbitrary clauses; deny on ambiguity.", + "decided_by": "refine decision-3", + "rationale": "Permissive CQL with post-hoc result filtering was rejected: result counts and pagination metadata still leak from non-allowlisted spaces. The static extractor is the only deny-path that holds against adversarial CQL. Some legitimate multi-space patterns (e.g., `(space = ENG OR space = DOCS)`) are rejected — the workaround is two single-space searches merged client-side. Documented in docs/reference/confluence-wrapper.md." + }, + { + "id": "TD4", + "decision": "Body-format=storage by default with caller override (atlas_doc_format / view). Single body shape on the wire by default.", + "decided_by": "HITL resolution on decision-5 (narrowed from the recommended 'both storage and ADF' to 'storage default with override')", + "rationale": "Smaller default payload. Most agent reads only need one body shape — XHTML-like storage format is human-readable in transcripts and parseable for simple traversal. Planners that need ADF tree access pass body_format=atlas_doc_format per-call. View (rendered HTML) stays available as a third override. The architect-recommended dual-format default was overruled to keep wire payloads small; this is recorded so reviewers understand the resolution differs from the analysis draft." + }, + { + "id": "TD5", + "decision": "Shared Atlassian credential triple ATLASSIAN_BASE_URL / _USERNAME / _API_TOKEN with backward-compat fallback to JIRA_* and CONFLUENCE_*. Single Atlassian bot account owns Jira + Confluence read scopes.", + "decided_by": "refine decision-6 + decision-9", + "rationale": "Reflects reality (single Atlassian Cloud tenant, single API token surface). Operators provision one bot, rotate one token, set one set of secrets. The CONFLUENCE_* placeholders already exist in secrets.template.env; we keep them as fallback so installs that already populated them on a forward-looking basis don't break. Confluence base URL is derived from ATLASSIAN_BASE_URL by appending /wiki when only the shared key is set. Migration is opt-in." + }, + { + "id": "TD6", + "decision": "@require_private_mode applied per-route (not via Flask blueprint). Route-enumeration regression test walks app.url_map for /api/v1/confluence/* and asserts every view function has __egg_requires_private_mode__ = True.", + "decided_by": "refine decision-7", + "rationale": "Identical to Jira. The decorator is already generic (no Confluence-specific change). Blueprint-level before_request was rejected in #1556 and the same reasoning applies — gateway.py doesn't use blueprints, and migrating Jira to a blueprint to keep parity is out of scope here." + }, + { + "id": "TD7", + "decision": "Space allowlist lives in config/context-filters.yaml under a new `confluence.spaces:` section. Default empty (fail-closed). Mtime-reloaded; hooked into POST /api/v1/config/reload.", + "decided_by": "refine decision-8 + feedback Q1 (operators set spaces at deploy time; do not hard-code)", + "rationale": "Mirrors the Jira `jira.projects` shape. Operators already edit context-filters.yaml for Jira and GitHub filtering; one allowlist surface. CONFLUENCE_SPACE_KEYS env var was rejected because it mixes secrets and policy. A standalone config/confluence.yaml was rejected to avoid file proliferation." + }, + { + "id": "TD8", + "decision": "Strip accountId, emailAddress, and _links.webui user-profile URLs from every Confluence response before it reaches the sandbox.", + "decided_by": "refine decision-10", + "rationale": "Parallels Jira's redaction stance. accountId is a stable Atlassian principal identifier; emailAddress is PII; _links.webui can deep-link to user profiles. Implemented in ConfluenceClient as a private _redact() helper applied on every method's return path so it cannot be forgotten at the route layer." + }, + { + "id": "TD9", + "decision": "GET /wiki/api/v2/spaces (the space-list endpoint) response is filtered by the gateway so only allowlisted spaces are returned; the agent cannot enumerate the full tenant space set.", + "decided_by": "refine decision-11", + "rationale": "Pass-through-with-per-page-deny was rejected — it leaks the existence of spaces an agent has no business knowing. Filtering at the gateway re-computes pagination metadata on the filtered set so cursor-based iteration also stays scoped." + }, + { + "id": "TD10", + "decision": "Attachments endpoints are on the permanent denylist for v1 and future writes. Implemented as a path-segment denylist in CONFLUENCE_WRITE_VERBS_DENIED in gateway/confluence_client.py.", + "decided_by": "refine decision-12", + "rationale": "Confluence attachments are arbitrary-file uploads / downloads with unbounded payload surface. If an attachment-read use case appears, it is scoped as a separate ticket with its own size limits and content-type allowlist — not a quiet expansion of the existing wrapper." + }, + { + "id": "TD11", + "decision": "No EGG_CONFLUENCE_* env vars in v1 sandbox. Audit recovers pageId / spaceKey from each request body.", + "decided_by": "refine decision-13", + "rationale": "Confluence is reference material (consulted from links inside Jira tickets), not a primary unit of work. There is no analogue to EGG_JIRA_TICKET that the orchestrator would set per-pipeline. Audit observability is preserved by extracting pageId / spaceKey from the request body or response payload." + }, + { + "id": "TD12", + "decision": "Include POST /api/v1/confluence/execute as a GET-only regex-allowlisted passthrough.", + "decided_by": "refine decision-14", + "rationale": "Same escape hatch as Jira's /api/v1/jira/execute: lets agents reach read endpoints not yet promoted to narrow routes without re-spinning a gateway release. Path-validator regex restricts the surface; permanent denylist (DELETE/PUT/PATCH, attachments, restrictions, permissions, space.admin, users) is enforced in validate_confluence_api_path." + }, + { + "id": "TD13", + "decision": "Distinct audit category confluence_upstream_403 separate from confluence_*_upstream_error.", + "decided_by": "refine feedback Q7", + "rationale": "Helps operators distinguish space-allowlist denials (gateway-side 403), page-permission denials (Atlassian-side 403 because the bot lacks scope), and generic upstream errors at audit time. Implementation: route layer inspects ConfluenceUpstreamError.status_code and emits the specific event." + }, + { + "id": "TD14", + "decision": "Pass Atlassian's depth parameter through verbatim on getConfluencePageDescendants; no gateway-imposed cap in v1.", + "decided_by": "refine feedback Q6", + "rationale": "Pre-emptive depth caps risk breaking legitimate deep-tree reads. Add a ceiling reactively if a runaway response is observed in production. Documented in the wrapper reference." + }, + { + "id": "TD15", + "decision": "Default to current-version only on getConfluencePage; no `version` query parameter exposed in v1.", + "decided_by": "refine feedback Q8", + "rationale": "Page-version history is not a v1 use case. Adds latency / response complexity for nothing. If a use case appears, it is a follow-up ticket with its own auth / audit considerations." + }, + { + "id": "TD16", + "decision": "No page/resolve-by-url verb in v1. Agents that have a Confluence URL parse the pageId themselves and call /api/v1/confluence/page/get.", + "decided_by": "refine feedback Q5", + "rationale": "URL parsing is trivial in the agent layer. Adding a server-side resolver introduces a one-way mapping (URL → pageId) that the gateway has to maintain and that the bot account has to be able to reach. If URL parsing in the agent becomes a recurring pain point, scoped as a follow-up." + } + ], + + "alternatives_considered_at_architecture_level": [ + { + "name": "Embed mcp__confluence__* in the sandbox container", + "rejected_because": "Violates the zero-credential invariant. The MCP runs as the human user with full read+write+admin scope; the sandbox cannot be granted that. Same reasoning as #1556's rejection of the in-sandbox Atlassian MCP for Jira." + }, + { + "name": "Use Atlassian OAuth 2.0 with per-pipeline tokens instead of Basic auth + bot account", + "rejected_because": "OAuth introduces refresh-token storage and rotation in the gateway, plus per-user consent flows that don't apply to a headless bot. #1556 chose Basic auth + dedicated bot for the same reason. v1.1 multi-tenant (currently out of scope) would revisit this." + }, + { + "name": "Keep JIRA_* and CONFLUENCE_* credentials fully independent (status quo of secrets template)", + "rejected_because": "Forces operators to maintain duplicated secrets for the same Atlassian tenant. Token rotation has to happen twice. Refine decision-6 chose the shared ATLASSIAN_* triple with back-compat fallback." + }, + { + "name": "Single endpoint POST /api/v1/confluence with verb payload field", + "rejected_because": "Loses route-level audit / mode-gate granularity. Harder to reason about per-verb tests. Diverges from /api/v1/jira/* shape. (refine decision-1 option 3)" + }, + { + "name": "v1-only across the board", + "rejected_because": "v1 read endpoints are slated for eventual deprecation (≥6 months notice but on the roadmap). Locks the wrapper into legacy. (refine decision-2 option 2)" + }, + { + "name": "v2-only across the board", + "rejected_because": "Breaks searchConfluenceUsingCql — there is no v2 search and no plans to add one. Would force shipping our own search index. (refine decision-2 option 3)" + }, + { + "name": "Permissive CQL with post-hoc result filter", + "rejected_because": "Result counts and pagination metadata still leak from non-allowlisted spaces. The JQL analogue was rejected in #1556 for the same reason. (refine decision-3 option 2)" + }, + { + "name": "Both `storage` and `atlas_doc_format` as default body format", + "rejected_because": "HITL resolution narrowed decision-5 to `storage` default with caller override. Smaller default payload was the deciding factor." + }, + { + "name": "Flask blueprint with before_request rejection", + "rejected_because": "Gateway doesn't use blueprints today; would require migrating Jira to a blueprint to keep the architecture uniform. Out of scope. (refine decision-7 option 2)" + }, + { + "name": "Standalone config/confluence.yaml for the space allowlist", + "rejected_because": "Yet another config file. Operators already edit context-filters.yaml for Jira + GitHub filtering. (refine decision-8 option 2)" + }, + { + "name": "CONFLUENCE_SPACE_KEYS env var as the allowlist source", + "rejected_because": "Mixes secrets and policy. Jira chose YAML for a reason; Confluence follows. (refine decision-8 option 3)" + }, + { + "name": "Allow read-only attachment metadata (no body) in v1", + "rejected_because": "Even metadata enables the agent to enumerate the file surface of pages it can already read. Adds a pagination + filter surface. (refine decision-12 option 3)" + }, + { + "name": "Ship EGG_CONFLUENCE_PAGE / EGG_CONFLUENCE_SPACE as observational env vars", + "rejected_because": "No primary unit-of-work model for Confluence; the env vars would be empty most of the time. Audit can recover the same fields from the request body. (refine decision-13 option 2)" + }, + { + "name": "Add page/resolve-by-url verb in v1", + "rejected_because": "URL parsing is trivial agent-side; server-side resolver adds permission complexity for marginal benefit. (refine feedback Q5)" + } + ], + + "implementation_workstream_overview": { + "note": "Detailed task breakdown belongs to the task_planner agent; this section gives the architect's view of the dependency graph so the task_planner can sequence correctly.", + "workstreams": [ + { + "id": "WS1", + "name": "Credentials + policy infrastructure", + "files": [ + "gateway/confluence_credentials.py (NEW)", + "gateway/confluence_policy.py (NEW)", + "config/secrets.template.env (MODIFY — add ATLASSIAN_* triple, retitle Atlassian section, remove CONFLUENCE_SPACE_KEYS)", + "config/context-filters.yaml (MODIFY — add confluence: section)", + "gateway/tests/test_confluence_credentials.py (NEW)", + "gateway/tests/test_confluence_policy.py (NEW)" + ], + "depends_on": [] + }, + { + "id": "WS2", + "name": "Confluence REST client + path validator", + "files": [ + "gateway/confluence_client.py (NEW)", + "gateway/tests/test_confluence_client.py (NEW)" + ], + "depends_on": ["WS1 (credentials)"] + }, + { + "id": "WS3", + "name": "CQL static-scope extractor", + "files": [ + "gateway/confluence_search.py (NEW)", + "gateway/tests/test_confluence_search.py (NEW)" + ], + "depends_on": [] + }, + { + "id": "WS4", + "name": "Gateway routes + reload hook + route-enumeration regression", + "files": [ + "gateway/gateway.py (MODIFY — add Confluence routes block, extend reload handler)", + "gateway/tests/test_confluence_routes.py (NEW)" + ], + "depends_on": ["WS1", "WS2", "WS3"] + }, + { + "id": "WS5", + "name": "Sandbox wrapper", + "files": [ + "sandbox/scripts/confluence (NEW, executable)", + "sandbox/tests/test_confluence_wrapper.py (NEW or extend an existing wrapper test file — task_planner decides)" + ], + "depends_on": ["WS4 (routes must exist for wrapper smoke tests)"] + }, + { + "id": "WS6", + "name": "Documentation", + "files": [ + "docs/reference/confluence-wrapper.md (NEW)", + "docs/architecture/network-isolation.md (MODIFY)", + "docs/architecture/credential-injection.md (MODIFY)", + "sandbox/agent-config/rules/environment.md (MODIFY)", + "docs/index.md (MODIFY — add confluence-wrapper link if the index has a 'gateway wrappers' section)" + ], + "depends_on": ["WS4"], + "note": "Doc updates can be authored in parallel with WS4 / WS5 from the architect's analysis but should be reviewed last so any route or wrapper changes during implementation are reflected." + } + ], + "implementation_order": [ + "WS1 (creds + policy + config) and WS3 (CQL extractor) — independent, can run in parallel.", + "WS2 (client) — after WS1.", + "WS4 (routes + tests) — after WS1, WS2, WS3.", + "WS5 (sandbox wrapper) — after WS4.", + "WS6 (docs) — after WS4 in lockstep." + ] + }, + + "key_files_touched_summary": { + "new_files": [ + "gateway/confluence_client.py", + "gateway/confluence_credentials.py", + "gateway/confluence_policy.py", + "gateway/confluence_search.py", + "gateway/tests/test_confluence_client.py", + "gateway/tests/test_confluence_credentials.py", + "gateway/tests/test_confluence_policy.py", + "gateway/tests/test_confluence_routes.py", + "gateway/tests/test_confluence_search.py", + "sandbox/scripts/confluence", + "docs/reference/confluence-wrapper.md" + ], + "modified_files": [ + "gateway/gateway.py (Confluence route block + reload-hook extension)", + "config/context-filters.yaml (new confluence: section)", + "config/secrets.template.env (Atlassian section retitle + ATLASSIAN_* triple + remove CONFLUENCE_SPACE_KEYS)", + "sandbox/agent-config/rules/environment.md (Confluence wrapper subsection)", + "docs/architecture/network-isolation.md (private-mode endpoint table)", + "docs/architecture/credential-injection.md (Atlassian section extension)", + "docs/index.md (link to confluence-wrapper.md, if applicable)" + ], + "files_intentionally_unchanged": [ + "gateway/allowed_domains.txt — *.atlassian.net is already excluded; the existing Squid regression test (gateway/tests/test_allowed_domains.py) covers Confluence by extension.", + "gateway/mode_gate.py — generic decorator already applied per-route.", + "gateway/auth.py — session auth unchanged.", + "gateway/jira_client.py / jira_credentials.py / jira_policy.py / jira_search.py — pure copies, not refactors. (A future follow-up MAY share parse_env_file-style helpers between jira_credentials and confluence_credentials, but v1 keeps each module self-contained for review clarity.)" + ] + }, + + "complexity_assessment": "MEDIUM — multi-file change across gateway/, sandbox/scripts/, config/, and docs/, with a clean analogue (/api/v1/jira/*) to follow line-by-line. Slightly more nuance than the Jira wrapper because of the v1/v2 hybrid (per-verb pinning) and the comment-fallback logic, but no architectural departure — all reused infrastructure was generalised by #1556.", + + "open_questions_for_plan_phase": [ + { + "id": "Q1", + "question": "Should the operator-deploy diagnostic command for bot-vs-human access (refine feedback Q9 deferred to follow-up) be referenced in docs/reference/confluence-wrapper.md as a known limitation, or is it expected to live entirely in a separate ticket?", + "context": "Refine feedback Q9 said v1 documents the bot-vs-human caveat in the wrapper reference; operators verify manually. The architect recommends including a Caveats section in the wrapper reference that names this explicitly so operators don't have to discover it.", + "recommendation": "Yes — include a 'Bot vs human access' subsection in docs/reference/confluence-wrapper.md (one paragraph)." + }, + { + "id": "Q2", + "question": "Does the route layer fetch the page body twice when the agent asks for footer / inline comments (once to verify space, once to fetch comments)?", + "context": "Per-page allowlist verification on /page/footer-comments and /page/inline-comments needs the page's space key. Two options: (a) a single upstream resolve call before the comments fetch, or (b) a small in-process pageId → spaceKey LRU cache populated by prior page reads.", + "recommendation": "Start with (a) for v1 — simpler, deterministic, no cache invalidation surface. Add (b) only if production traces show the double-fetch is a measurable cost." + }, + { + "id": "Q3", + "question": "Should the v1 confluence_v1_fallback audit event include the v2 status code that triggered the fallback?", + "context": "Lets operators monitor whether Atlassian has fixed the v2 inline-404 bug.", + "recommendation": "Yes — record fields {endpoint: 'inline_comments'|'footer_comments_nested', v2_status: 404|null, v1_status: 200}." + }, + { + "id": "Q4", + "question": "Should the architecture pre-design a shared Atlassian credential module that both confluence_credentials.py and jira_credentials.py reuse, or keep them as parallel single-file modules in v1?", + "context": "ATLASSIAN_* loading logic is duplicated across the two loaders. Extracting a shared shared/atlassian_credentials.py is cleaner long-term but adds a refactor surface to the Jira module.", + "recommendation": "Defer shared extraction to a follow-up (note in v1.5 / v2 backlog). Keep the two loaders parallel for v1 — minimises the diff that reviewers have to validate against #1556's existing Jira plumbing." + } + ], + + "summary_recommendation": "Implement the Confluence wrapper as a structural copy of the Jira wrapper across four new gateway modules (client, credentials, policy, search), one route block, one sandbox bash wrapper, two config additions, and four doc additions. All HITL design decisions resolved in refine. The architectural risk surface is bounded by following the Jira pattern line-for-line; the Confluence-specific risk is the v2-first / v1-fallback comment paths and the CQL-vs-JQL grammar difference. The risk_analyst and task_planner agents have everything they need to enumerate detailed risks and tasks from this analysis." +} From 4151ef6b35de33b17551f5c6dbde9e45e277e88c Mon Sep 17 00:00:00 2001 From: egg Date: Sun, 26 Apr 2026 23:50:33 +0000 Subject: [PATCH 06/26] plan: risk assessment for #1931 (Confluence gateway read-only v1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan-phase risk_analyst output: 18 risks across security (6), performance (3), compatibility (2), operational (3), data-privacy (1), external-dependency (1), test-coverage (1), and future-write (1). Severity distribution: 1 high (CQL extractor adversarial coverage), 7 medium (most ride on the #1556 Jira-gateway scaffolding so the implementation surface is well-understood), 10 low. Three risks flagged for human review at implement time: - R1: CQL-extractor parity with the JQL adversarial suite must target CQL-specific grammar (text ~ contains, space.category(), etc.). - R14: attachments / restrictions / permissions permanent-denylist enforcement across both narrow-route and /execute paths. - R15: bot-account effective-access asymmetry (per feedback Q9) — must surface a structured forbidden envelope so agents do not retry. External research covered Atlassian's April 2026 v1 deprecation status (endpoint-specific, CQL search has no v2 successor), the March 2026 points-based rate-limit rollout, and confirmed no public CQL-injection CVE for 2025-2026. Includes a four-level rollback plan (config-only -> route-disable -> credential-revoke -> full-revert) and an 18-item implement-phase checklist that maps each risk to a concrete reviewer-verifiable mitigation. Co-Authored-By: Claude Opus 4.7 --- .../1931-risk_analyst-output.json | 533 ++++++++++++++++++ 1 file changed, 533 insertions(+) create mode 100644 .egg-state/agent-outputs/1931-risk_analyst-output.json diff --git a/.egg-state/agent-outputs/1931-risk_analyst-output.json b/.egg-state/agent-outputs/1931-risk_analyst-output.json new file mode 100644 index 0000000000..8b93c50847 --- /dev/null +++ b/.egg-state/agent-outputs/1931-risk_analyst-output.json @@ -0,0 +1,533 @@ +{ + "schemaVersion": "1.0", + "issue": 1931, + "phase": "plan", + "agent_role": "risk_analyst", + "summary": "Risk assessment for #1931 — Confluence gateway read-only v1. The change is medium-complexity and rides on the #1556 Jira-gateway scaffolding, so most architectural risk is already retired. Residual risk concentrates in three buckets: (1) **CQL-extractor correctness** — a one-off extractor that must reach Jira-search parity on adversarial inputs; (2) **v1/v2 API hybrid drift** — Atlassian's moving deprecation timeline, the v2 inline-comment 404 bug, and missing nested-reply fan-out under v2; (3) **payload size / token blow-up** — Confluence pages and ADF trees are larger than Jira tickets and can flood transcripts. Two areas are flagged for human review: the *attachments* permanent-denylist boundary (resolved by HITL decision-12 but worth a security re-look at implementation time) and the bot-account effective-access caveat (feedback Q9). No HIGH-severity unmitigated risks. Three MEDIUM-severity risks require explicit mitigation in the implement phase.", + "scoring_legend": { + "impact": { + "low": "User-visible inconvenience; no security/data exposure; recoverable via retry or wrapper override.", + "medium": "Functional regression, partial outage, or audit-log noise; recoverable via config or rollback within minutes.", + "high": "Security boundary breach, credential exposure, data leak across allowlist, or pipeline-blocking outage." + }, + "likelihood": { + "low": "Requires multiple independent failures or adversarial intent; not expected in normal operation.", + "medium": "Plausible under realistic operator config, agent behavior, or upstream churn within 6 months.", + "high": "Will occur in routine use unless explicitly mitigated." + }, + "severity": { + "low": "Track and document; no implement-phase action required.", + "medium": "Implement-phase mitigation required; reviewer must verify mitigation is in place before ACK.", + "high": "Block release until mitigation is verified end-to-end (test + audit) and operator runbook updated." + } + }, + "risks": [ + { + "id": "R1", + "title": "CQL space-scope extractor accepts an adversarial query that escapes the allowlist", + "category": "security", + "description": "gateway/confluence_search.py is a new module conceptually equivalent to gateway/jira_search.py but written against CQL grammar — different operators (~, !=, in, not in), different functions (currentUser(), recentlyViewedContent(), space.category()), different quoting/escape rules, and a v1-only endpoint surface. A regex bug or missed disjunction can let an agent enumerate or read pages outside the operator's allowlisted spaces. CQL also supports text ~ \"...\" (full-text contains) which the JQL extractor has no analogue for, so test fixtures cannot be a verbatim port from test_jira_search.py.", + "affected_components": [ + "gateway/confluence_search.py (new)", + "gateway/tests/test_confluence_search.py (new, adversarial suite)", + "POST /api/v1/confluence/search route in gateway/gateway.py" + ], + "impact": "high", + "likelihood": "medium", + "severity": "high", + "mitigations": [ + "Mirror gateway/jira_search.py's parse-then-validate stance (no regex-search-only). Reject anything other than top-level AND-combined `space = KEY` / `space IN (K, K, ...)` clauses; deny-on-ambiguity.", + "Permanently reject all CQL functions (currentUser(), recentlyViewedContent(), space.category(), etc.) at the path validator — do not enumerate exceptions.", + "Reject text ~ \"...\" combined with absent `space =` so a contains-search cannot widen scope.", + "Reject quoted space keys (analogue of Jira's quoted-project-key rejection).", + "Reject unicode / mixed-script / non-ASCII bytes in raw CQL (homoglyph defense).", + "Reject statement chaining (`;`) and comment markers (`/*`, `*/`, `--`, `//`).", + "Reject `id =` / `parent =` clauses without an enclosing `space =` — these widen scope without anchoring on space.", + "Adversarial test suite must include: empty CQL; quoted keys; OR at any depth; CQL functions; mixed-script keys; nested parentheses with `space` inside an OR; `text ~ \"foo\" AND space != BAD` (negation widens); CQL with embedded URL-encoded operators; CQL with leading whitespace / BOM bytes.", + "Audit on accept records `spaces_extracted`; audit on reject records `confluence_search_rejected` with the structured reason — same shape as `jira_search_rejected`.", + "Reviewer MUST run the adversarial suite against the implementation and read the rejection reasons (not just pass/fail) before ACK." + ], + "rollback": "Disable the `/api/v1/confluence/search` route via a kill-switch config flag (or empty `confluence.spaces:` allowlist — fail-closed already drops every search). Page reads/list-pages remain available.", + "requires_human_review": true, + "human_review_reason": "Adversarial CQL surface differs enough from JQL that a security reviewer should walk the rejection table at implement time and verify each test fixture targets a real CQL ambiguity, not a copy-pasted JQL edge case." + }, + { + "id": "R2", + "title": "/execute passthrough widens the gateway's effective surface beyond the narrow routes", + "category": "security", + "description": "Decision-14 keeps a GET-only regex-allowlisted /execute endpoint parallel to Jira's escape hatch. The risk is that the regex allowlist drifts wider than the narrow routes it backstops, or that an allowlisted path family overlaps a write-equivalent (e.g., a v1 endpoint that mutates state via a GET-with-side-effects pattern that Atlassian quietly added). Jira's reviewer_code cycle 1 already caught a search/jql bypass via /execute (see jira_client.py JIRA_API_ALLOWED_PATHS comment); the Confluence equivalent must not repeat that.", + "affected_components": [ + "gateway/confluence_client.py (validate_confluence_api_path)", + "POST /api/v1/confluence/execute route" + ], + "impact": "high", + "likelihood": "low", + "severity": "medium", + "mitigations": [ + "Path-validator regex starts narrow: only the v2 read endpoint families that don't already have a narrow route. Explicit exclusions for `search` (must go through /api/v1/confluence/search so the CQL extractor runs), `restrictions`, `permissions`, `space.admin`, `users`, `attachments`, and any v1 endpoint reachable via GET that has known side-effects.", + "HTTP method restricted to GET only (mirrors Jira); reject HEAD/OPTIONS too — they expose existence without going through audit.", + "Path-validator regex must use `re.fullmatch` against the normalized path (no leading/trailing slash, no `..`, no `//`).", + "Audit log records `confluence_upstream_path` (full normalized path) on every /execute call so post-facto detection is possible.", + "Test: route-enumeration regression must enumerate every narrow route AND assert that /execute rejects each narrow-route's path family (so /execute can never be used to bypass the dedicated extractor).", + "Test: adversarial paths — `..`/`%2e%2e`, double slashes, query-string-smuggled paths, mixed-case method (`Get`), URL-encoded path segments, unicode homoglyph paths." + ], + "rollback": "Set `/execute` route to return 503 unconditionally (single-line config flag); narrow routes continue to serve normal traffic.", + "requires_human_review": false + }, + { + "id": "R3", + "title": "v2 comment endpoints have known bugs (inline 404, missing nested replies); fallback path is untested in the wrapper", + "category": "compatibility", + "description": "Decision-4 chose v2-first with v1 fallback for inline-404 and nested-reply gaps. The fallback is new code with two distinct trigger conditions (404 on inline; absence of nested replies on footer) and two distinct upstream endpoints. Misclassifying a real 404 (page deleted) as the v2 bug would make a deleted page appear to have comments. Conversely, missing the v2-bug case would silently drop content from refine-phase context.", + "affected_components": [ + "gateway/confluence_client.py — get_inline_comments, get_footer_comments", + "gateway/tests/test_confluence_client.py" + ], + "impact": "medium", + "likelihood": "medium", + "severity": "medium", + "mitigations": [ + "Tag each fallback path with an audit category: `confluence_v2_inline_404_fallback`, `confluence_v2_footer_nested_fallback`. Operators can grep for unexpected fallback frequency.", + "Distinguish v2 inline-404 from a real not-found by checking the response body shape — Atlassian's v2 bug returns a specific error code in the JSON envelope; a deleted-page 404 returns the standard not-found shape. Document both shapes in code comments.", + "v1 fallback for nested replies must filter by pageId server-side (`/wiki/api/v2/footer-comments?body-format=...&page-id=...`), not client-side, to avoid pulling the entire site's comment stream.", + "Test coverage: mock both v2-bug shapes AND a real 404; assert wrapper returns the not_found envelope for the latter and the merged comment tree for the former.", + "When Atlassian fixes the v2 bug, the fallback path becomes dead code — schedule a re-test ticket once the v2 inline-comment changelog goes green." + ], + "rollback": "Force v1-only by hard-coding the fallback to fire on every call (single config flag); slower but correct. Or disable inline-comment endpoint entirely until the bug is fixed upstream.", + "requires_human_review": false + }, + { + "id": "R4", + "title": "private-mode-gate decorator drift — a future Confluence route lands without @require_private_mode", + "category": "security", + "description": "Every /api/v1/confluence/* route must carry @require_private_mode. The gate is one-line per route and easy to forget on a follow-up commit. Mirrors Jira's R4 from #1556. The route-enumeration regression test catches it, but only if the new route is captured by the test's URL pattern.", + "affected_components": [ + "gateway/gateway.py — @app.route('/api/v1/confluence/...') definitions", + "gateway/tests/test_confluence_routes.py — route-enumeration regression" + ], + "impact": "high", + "likelihood": "low", + "severity": "medium", + "mitigations": [ + "Implement test_confluence_routes.test_all_confluence_routes_require_private_mode mirroring the Jira version: walk app.url_map, filter by `/api/v1/confluence/`, assert every view's `__egg_requires_private_mode__` attribute is True.", + "The pattern in the regression test must match `/api/v1/confluence/*` with no trailing-slash assumptions and must include `/execute`.", + "Add a CONTRIBUTING note: 'New /api/v1/confluence/* routes MUST stack @require_session_auth → @require_private_mode → handler' (already implicit for Jira; restate for Confluence).", + "Reviewer MUST run `pytest gateway/tests/test_confluence_routes.py -k private_mode` before ACK." + ], + "rollback": "If a route is found without the gate post-merge, hot-patch by adding the decorator and re-deploying the gateway pod. The route-enumeration test catches it on PR CI before merge.", + "requires_human_review": false + }, + { + "id": "R5", + "title": "Squid allowlist accidentally widened to include *.atlassian.net", + "category": "security", + "description": "Confluence shares `*.atlassian.net` with Jira. The Squid allowlist deliberately excludes that domain so that sandboxed agents cannot bypass the gateway and hit Atlassian directly. A future maintainer might add `*.atlassian.net` thinking they're enabling Confluence — that would silently permit direct Confluence (and Jira) calls, bypassing both space and project allowlists.", + "affected_components": [ + "gateway/allowed_domains.txt", + "gateway/tests/test_allowed_domains.py — existing regression test (already covers Confluence by extension via the *.atlassian.net entry)" + ], + "impact": "high", + "likelihood": "low", + "severity": "medium", + "mitigations": [ + "Verify the existing test_allowed_domains.py asserts `*.atlassian.net` is NOT in allowlist — if so, no new test needed; the Confluence change rides on the Jira regression.", + "Add a comment in gateway/allowed_domains.txt: '# Atlassian (jira AND confluence) is intentionally NOT here; route through /api/v1/jira/* or /api/v1/confluence/* via the gateway.'", + "Implement-phase reviewer must spot-check the comment lands.", + "Audit log records the destination host on egress denials so an accidental config drift surfaces immediately in operator dashboards." + ], + "rollback": "One-line revert of allowed_domains.txt; the regression test catches it on CI before merge.", + "requires_human_review": false + }, + { + "id": "R6", + "title": "PII leakage to sandbox transcripts via accountId / emailAddress / _links.webui", + "category": "data-privacy", + "description": "Decision-10 chose to strip accountId, emailAddress, and _links.webui from responses. Confluence v2 returns these in many shapes — page authorship metadata (`authorId`), comment authorship, space lead, version-history createdBy, restriction owners. A redactor that targets only the top-level field path will miss nested occurrences. The redaction must apply to ADF tree mention nodes too (they embed accountIds inside the body).", + "affected_components": [ + "gateway/confluence_client.py — response post-processor / redactor", + "gateway/tests/test_confluence_client.py — redaction coverage" + ], + "impact": "medium", + "likelihood": "medium", + "severity": "medium", + "mitigations": [ + "Use a recursive walker that strips any key matching the documented redaction set (`accountId`, `emailAddress`, `displayName` is intentionally NOT redacted because human-readable names are useful for agent reasoning, but verify decision-10 didn't include it), regardless of nesting depth.", + "Apply redaction to ADF body tree mention nodes (`type: 'mention'`, `attrs.id`) — these embed accountIds inline in page bodies.", + "Strip `_links.webui` user-profile URLs (`/wiki/people/`) from the response payload — they're equivalent to the accountId.", + "Test fixtures must include nested cases: `version.createdBy.accountId`, `body.atlas_doc_format.value` containing mention nodes, `restrictions.user.accountId`.", + "Document the redaction set in `docs/reference/confluence-wrapper.md` so operators know what their bot account's response stream looks like in the sandbox.", + "Future ticket: surface a feedback-Q3-style channel for operators to add custom-property redactions if a tenant has macros that hold PII (issue text Q3)." + ], + "rollback": "Disable the redactor (return raw payload) — only acceptable in private-mode-only deployments where the operator explicitly accepts the leak. Default posture stays redact-on.", + "requires_human_review": false + }, + { + "id": "R7", + "title": "Page body / ADF payloads dwarf transcript token budgets", + "category": "performance", + "description": "Decision-5 ships both `storage` and `atlas_doc_format` body bodies by default (per the resolution). Confluence pages are routinely 100-500KB; some operations docs reach megabytes. With both formats, the gateway returns 2× the payload, all of which lands in the agent's context window. Even one 500KB page can exhaust a 200K-token transcript budget; a refine phase that reads three linked pages can OOM the agent.", + "affected_components": [ + "gateway/confluence_client.py — get_page default body-format", + "POST /api/v1/confluence/page/get", + "sandbox/scripts/confluence wrapper docs" + ], + "impact": "medium", + "likelihood": "high", + "severity": "medium", + "mitigations": [ + "Decision-5 resolution defaulted to body-format=storage only (per HITL answer, not the recommended option). Implement-phase: ship default `body-format=storage`, support `atlas_doc_format` and `view` via caller override. **Do not ship both by default.**", + "Document a soft size warning in `docs/reference/confluence-wrapper.md`: 'Pages over ~50KB are likely to consume 25K+ tokens; consider fetching specific page sections via descendants instead.'", + "Implement a hard payload-size cap in the route layer (e.g., 2MB). Above the cap, return an error envelope with the page metadata + a `body_truncated: true` flag instead of the body. Agents can fall back to a metadata-only listing.", + "Audit log records `body_bytes` per response so operators can identify pages that systematically blow past the budget.", + "Agent-side guidance in `sandbox/agent-config/rules/environment.md`: 'Prefer descendant titles over full bodies when scoping; only fetch full body when needed.'" + ], + "rollback": "Lower the size cap or default to a smaller body format (`view` is rendered HTML and often smaller than `storage` for macro-heavy pages, but lossy); ship a hot-patch.", + "requires_human_review": false + }, + { + "id": "R8", + "title": "getConfluencePageDescendants on a deeply nested space tree returns runaway response", + "category": "performance", + "description": "Feedback Q6 resolution: pass Atlassian's `depth` parameter through verbatim with no gateway-imposed cap. Confluence space root pages with hundreds of nested children can return responses with 1000+ entries. Combined with R7 (large bodies if the agent then iterates), this is a token-budget hazard. Atlassian itself paginates but does not cap depth.", + "affected_components": [ + "gateway/confluence_client.py — get_page_descendants", + "POST /api/v1/confluence/page/descendants" + ], + "impact": "low", + "likelihood": "medium", + "severity": "low", + "mitigations": [ + "Default `depth=1` if the caller omits it (Atlassian's default is unbounded). Caller may explicitly request a deeper traversal.", + "Default `limit=25` per page (Atlassian's default is 25 anyway).", + "Cursor pagination must be passed back to the caller verbatim so multi-page traversal is opt-in.", + "Document the implicit ceiling in `docs/reference/confluence-wrapper.md` and note that runaway depth is the caller's responsibility past depth=3.", + "Audit log records `descendant_count` so operators can identify space trees where descendants is misused." + ], + "rollback": "Hot-patch a hard depth cap (e.g., 3) if a runaway is observed. Feedback Q6 explicitly preserved the `depth` knob; the cap is a safety net, not a removal.", + "requires_human_review": false + }, + { + "id": "R9", + "title": "v1 API endpoint deprecation timeline is volatile; CQL search has no v2 equivalent", + "category": "external_dependency", + "description": "Per Atlassian's developer-community thread (April 2026), the v1 deprecation has been postponed multiple times and is endpoint-specific rather than sweeping. CQL search remains v1-only with no v2 replacement on the roadmap. However, Atlassian publishes new endpoint-specific deprecations on a rolling basis — `Convert content body v1` is now slated for Aug 5, 2026, and an internal API loses API-token access on Apr 14, 2026. Our v1 dependencies (CQL search and possibly inline-comment fallback) could be hit by a future deprecation with as little as 6 months notice.", + "affected_components": [ + "gateway/confluence_client.py — search() (v1), get_inline_comments fallback (v1)" + ], + "impact": "medium", + "likelihood": "low", + "severity": "low", + "mitigations": [ + "Pin v1 endpoints per-verb in client constants (search uses `/wiki/rest/api/search`, inline fallback uses `/wiki/rest/api/content/{id}/child/comment?location=inline`). When Atlassian announces deprecation, we change one constant.", + "Subscribe (manually or via doc-updater) to Atlassian's [Confluence Cloud changelog](https://developer.atlassian.com/cloud/confluence/changelog/) so deprecation notices reach operators before the cliff.", + "Document the v1 dependency surface in `docs/reference/confluence-wrapper.md` with a 'last reviewed against Atlassian docs: ' marker.", + "Add an integration smoke test that runs against a sandbox tenant in CI nightly (out of scope for this ticket; track in a follow-up) so a 410 Gone or 404 from Atlassian fires before customers see it.", + "Future-proof: when v2 search lands, swap the search() implementation; the sandbox wrapper's `confluence search` subcommand stays the same." + ], + "rollback": "If v1 search disappears with insufficient notice, the gateway returns 503 for `/api/v1/confluence/search`; page reads continue. A follow-up ticket scopes a v2-equivalent (likely a denormalized `pages?title-contains=` query — limited but functional).", + "requires_human_review": false + }, + { + "id": "R10", + "title": "Atlassian Cloud points-based rate-limit enforcement (March 2026) hits a refine phase mid-flight", + "category": "performance", + "description": "Atlassian announced phased enforcement of a points-based quota rate-limit model starting March 2026 for Cloud REST APIs. Until rollout completes, the existing Retry-After-based 429 behavior covers us, but the points model can return 429 even on under-quota tenants during the rollout window. A refine phase that issues 5-10 Confluence reads in series may hit unexpected 429s.", + "affected_components": [ + "gateway/confluence_client.py — _request 429 retry", + "audit category `confluence_upstream_rate_limited`" + ], + "impact": "medium", + "likelihood": "medium", + "severity": "medium", + "mitigations": [ + "Mirror Jira's retry policy (decision is feedback-Q2 — single retry on 429 with min(Retry-After, 30); audit on both attempts). Verified equivalent to the Jira logic.", + "Cap the retry to one attempt; do not exponential-backoff a third time — propagate the 429 with the Retry-After header to the agent so the agent / orchestrator can decide whether to back off the broader pipeline.", + "Audit log records `confluence_upstream_rate_limited` with the Retry-After value and `attempt` index. Operators can grep for clusters indicative of quota throttling.", + "Document operator runbook entry in `docs/reference/confluence-wrapper.md`: 'If 429s become routine, contact Atlassian to verify tenant points quota, or scope refine reads narrower.'", + "Future ticket (out of scope): central rate-limiter that pools Jira+Confluence requests against the shared Atlassian tenant's points quota." + ], + "rollback": "Increase retry count to 2 with exponential backoff (single config flag) if pipelines stall on 429. Operator-facing runbook covers this.", + "requires_human_review": false + }, + { + "id": "R11", + "title": "Shared Atlassian credentials block coupling — Jira credential rotation breaks Confluence", + "category": "operational", + "description": "Decision-6 shares `ATLASSIAN_BASE_URL` / `_USERNAME` / `_API_TOKEN` between Jira and Confluence. A botched token rotation (typo, paste error, expired token) blocks both Jira and Confluence simultaneously. Today only Jira breaks. Conversely, the shared token simplifies operations — only one secret to rotate.", + "affected_components": [ + "gateway/confluence_credentials.py — fall-back chain ATLASSIAN_* → CONFLUENCE_* → JIRA_*", + "gateway/jira_credentials.py — same fall-back chain (must be updated to read ATLASSIAN_* preferentially)", + "config/secrets.template.env" + ], + "impact": "medium", + "likelihood": "medium", + "severity": "low", + "mitigations": [ + "Both credential modules implement the documented preference order: `ATLASSIAN_*` (preferred) → service-specific (`CONFLUENCE_*` / `JIRA_*`) fall-back. Loader logs which set was used at boot.", + "Mtime-based reload remains intact — a bad rotation only affects calls after the mtime change. Pre-existing in-flight calls continue with cached credentials.", + "Add a `POST /api/v1/atlassian/diagnostics` endpoint (or extend an existing one) that returns `{jira_creds_loaded: bool, confluence_creds_loaded: bool}` without echoing the secrets. Operators can verify rotation succeeded before declaring a pipeline healthy.", + "Document the rotation playbook in `docs/architecture/credential-injection.md`: 'After rotating ATLASSIAN_API_TOKEN, expect the next request to refresh both Jira and Confluence credentials. Verify via the diagnostics endpoint.'", + "Tests: credential-loader unit tests must cover all six combinations (only ATLASSIAN_*, only JIRA_*, only CONFLUENCE_*, ATLASSIAN_* + JIRA_*, ATLASSIAN_* + CONFLUENCE_*, all three) for both modules." + ], + "rollback": "If shared-credential migration breaks #1556 deployments, ship a feature flag `EGG_ATLASSIAN_SHARED_CREDS=0` that forces the legacy `JIRA_*`/`CONFLUENCE_*`-only loader path. Default flag is on.", + "requires_human_review": false + }, + { + "id": "R12", + "title": "context-filters.yaml fail-closed default lands an empty allowlist; operators expect Confluence to 'just work' post-deploy", + "category": "operational", + "description": "Per the analysis, `confluence.spaces:` defaults to `[]` and fails closed. Feedback Q1 resolution explicitly accepts this — operators must populate at deploy time. Risk: operators who upgrade and don't notice the new section assume Confluence is broken and file a bug. Or worse, copy-paste a typo (`SPACES` vs `spaces`) and silently fail closed.", + "affected_components": [ + "config/context-filters.yaml", + "gateway/confluence_policy.py — YAML parser", + "docs/reference/confluence-wrapper.md (operator quick-start)" + ], + "impact": "low", + "likelihood": "high", + "severity": "low", + "mitigations": [ + "Strict YAML schema validation: fail-closed AND log an ERROR on schema mismatch (`confluence` section is not a mapping; `spaces` is not a list; entries are not strings; entries don't match the `[A-Z][A-Z0-9_]*` shape). Mirror Jira's `gateway/jira_policy.py` ERROR-on-malformed semantics.", + "Boot-time log line: 'confluence.spaces allowlist loaded: [ENG, PLATFORM] (3 keys)' or 'confluence.spaces allowlist EMPTY — all Confluence calls will return 403' — operators see the message in their gateway logs.", + "Operator quick-start in `docs/reference/confluence-wrapper.md` includes: ```yaml\\nconfluence:\\n spaces: [\"YOUR_SPACE_KEY\"]\\n```", + "Diagnostics endpoint surfaces the loaded allowlist (already exists for Jira if implemented; extend or mirror).", + "POST /api/v1/config/reload also reloads Confluence allowlist (mirrors Jira). Test coverage required." + ], + "rollback": "Hot-patch the config file; reload via `POST /api/v1/config/reload`. No rebuild required.", + "requires_human_review": false + }, + { + "id": "R13", + "title": "getConfluenceSpaces filtering bypasses agent's view of operator-allowlisted spaces", + "category": "security", + "description": "Decision-11 filters the spaces response so only allowlisted spaces are returned. The risk is the inverse: a buggy filter returns MORE spaces than allowlisted (e.g., the filter compares lowercase keys but the allowlist is uppercase, so nothing matches and the wrapper returns the full list as a 'no filter applied' fall-through). Atlassian's space response can include spaces the bot account has 'read' permission on but the operator hasn't allowlisted — those must be stripped.", + "affected_components": [ + "gateway/confluence_client.py — list_spaces / get_spaces", + "POST /api/v1/confluence/space/list" + ], + "impact": "medium", + "likelihood": "low", + "severity": "low", + "mitigations": [ + "Filter is a strict allowlist intersection: `[s for s in upstream_spaces if s['key'] in confluence.spaces]`. Atlassian space keys are uppercase ASCII; allowlist is uppercase. No case-fold needed.", + "Test: mock upstream returns 5 spaces (3 allowlisted, 2 not); assert response contains exactly 3.", + "Test: mock upstream returns 0 allowlisted spaces; assert response is `{results: [], _links: ...}` not a 403 (the call succeeded; the empty response is correct).", + "Test: case-mismatch fixture (lowercase upstream key vs uppercase allowlist) — assert the lowercase key is excluded (no fuzzy match).", + "Audit log: `confluence_spaces_filtered` records `upstream_count` and `returned_count` for post-hoc review." + ], + "rollback": "Disable the filter (pass through full upstream response) — acceptable only as a debugging aid. Default posture stays filter-on.", + "requires_human_review": false + }, + { + "id": "R14", + "title": "attachments permanent denylist is enforced at the path validator, not at policy", + "category": "security", + "description": "Decision-12 puts attachments on the permanent denylist. Risk: a follow-up `POST /api/v1/confluence/page/attachments` route accidentally registers without going through the path validator. Or a v2 page-get response embeds attachment URLs that an agent then tries to fetch via /execute. The denylist must be enforced at the path-validator layer (so /execute can't bypass) AND at the route layer (so a future narrow route can't bypass).", + "affected_components": [ + "gateway/confluence_client.py — validate_confluence_api_path", + "gateway/gateway.py — no `attachments`-suffixed route", + "gateway/tests/test_confluence_client.py — denylist regression" + ], + "impact": "high", + "likelihood": "low", + "severity": "medium", + "mitigations": [ + "Path-validator regex denylist: any path segment matching `attachments`, `restrictions`, `permissions`, `space.admin`, `users` returns `(False, reason)` regardless of method.", + "Test: every denylisted segment under every plausible path prefix (e.g., `pages/123/attachments`, `pages/123/attachments/456`, `attachments/456`, `spaces/123/permissions`).", + "Test: case variants (`Attachments`, `ATTACHMENTS`) — must also be rejected (the validator should case-fold the segment OR reject any non-canonical case as `path contains non-canonical segment`).", + "Test: URL-encoded variants (`%61ttachments`).", + "/execute regex must NOT contain `attachments` even by accident — explicit anti-test asserts every denylist entry is rejected by /execute.", + "Document the permanent denylist in `docs/reference/confluence-wrapper.md` and link from `docs/architecture/network-isolation.md`.", + "When the future-write phase lands, the denylist must be re-affirmed in the writes-phase reviewer's checklist." + ], + "rollback": "If a denylist breakage is discovered, hot-patch the validator and ship as a security-fix release (single-file change).", + "requires_human_review": true, + "human_review_reason": "Per decision-12, attachments are permanently out of scope. A security reviewer at implement time should verify the denylist enforcement covers both the narrow-route and /execute paths, and that the test matrix exercises every variant." + }, + { + "id": "R15", + "title": "Bot account's effective access differs from operator expectations (per-page restrictions, space-permission gaps)", + "category": "operational", + "description": "Feedback Q9 surfaced that the host-side `mcp__confluence__*` MCP authenticates as the human operator, while the gateway authenticates as a dedicated bot account (decision-9). Pages inside an allowlisted space may carry per-page restrictions that grant read access to the human but not the bot — agents that read those pages get 403. Worse, agents may be unaware why and retry endlessly.", + "affected_components": [ + "gateway/confluence_client.py — get_page (403 handling)", + "Atlassian-side bot-account permissions (out of repo)" + ], + "impact": "low", + "likelihood": "medium", + "severity": "low", + "mitigations": [ + "Decision-9 chose a separate audit category `confluence_upstream_403` (per feedback Q7), distinct from generic `confluence_upstream_error`. Operators can grep for 403 clusters and adjust bot-account access.", + "Translate upstream 403 to a structured envelope `{status: 'forbidden', upstream_status: 403, reason: 'bot_account_lacks_read_access'}` so agents distinguish it from `not_found` and don't retry.", + "Document the caveat in `docs/reference/confluence-wrapper.md`: 'The gateway authenticates as a dedicated bot account; pages with restricted view permissions may return 403 even when the operator can read them via the host MCP.'", + "Feedback Q9 explicitly deferred a diagnostic command to a follow-up ticket; track separately. v1 documents the caveat only.", + "Test: mock upstream 403 on a page-get; assert wrapper returns the structured forbidden envelope, not a JiraUpstreamError-equivalent." + ], + "rollback": "If 403 frequency is high, the operator must either (a) widen bot-account permissions in Atlassian, (b) add a per-space permission audit to the bot-onboarding script, or (c) drop the affected space from the allowlist.", + "requires_human_review": true, + "human_review_reason": "The bot-vs-human access asymmetry is an operational concern that is not enforceable at the gateway layer. A human reviewer at implement time should verify the documented caveat is clear in the wrapper reference and the structured 403 envelope reaches the agent." + }, + { + "id": "R16", + "title": "ADF body content carries scriptable / link-based payloads that mislead the agent", + "category": "security", + "description": "Confluence pages can embed JavaScript-equivalent macros, external link previews, and embedded iframes (Smart Links). When the agent reads the body and treats macro output as instruction text, an unfiltered link or macro could inject prompt-injection content from a Confluence page authored by an untrusted user (e.g., a contractor with edit access to the space). This is the standard Confluence-as-untrusted-source risk; the gateway is the right place to surface it.", + "affected_components": [ + "gateway/confluence_client.py — get_page response", + "docs/reference/confluence-wrapper.md (agent-facing guidance)" + ], + "impact": "medium", + "likelihood": "low", + "severity": "low", + "mitigations": [ + "Document the prompt-injection risk in `docs/reference/confluence-wrapper.md` for agent authors: 'Confluence page bodies are user-authored content. Treat them as untrusted input — do not follow embedded URLs or execute embedded code suggestions without independent verification.'", + "Audit log records `body_format` and `body_bytes` so if a prompt-injection incident occurs post-hoc, operators can identify the source page.", + "Future ticket (out of scope): consider stripping `extension`-typed macro content (script-like macros) from ADF responses by default. v1 ships with full bodies; the agent-side defenses in `sandbox/agent-config/rules/security.md` are the primary control.", + "No code-level redaction in v1 — this is an agent-prompt-hardening problem, not a gateway problem." + ], + "rollback": "N/A — documentation-only. If a real prompt injection lands, follow-up ticket adds an ADF-tree macro-stripping pass.", + "requires_human_review": false + }, + { + "id": "R17", + "title": "Test coverage gap on Confluence-specific scenarios (vs Jira-pattern reuse)", + "category": "test_coverage", + "description": "The risk_analyst's strongest worry is that the implement phase ports test_jira_*.py files line-for-line and misses CQL-specific edge cases (vs JQL), v1/v2 endpoint shape differences, comment fallback paths, and ADF redaction nesting. A test suite that passes for Jira-port reasons can still leave Confluence-specific bugs.", + "affected_components": [ + "gateway/tests/test_confluence_search.py (new)", + "gateway/tests/test_confluence_client.py (new)", + "gateway/tests/test_confluence_routes.py (new)", + "gateway/tests/test_confluence_credentials.py (new)", + "gateway/tests/test_confluence_policy.py (new)" + ], + "impact": "medium", + "likelihood": "high", + "severity": "medium", + "mitigations": [ + "Tester role is explicitly required to write Confluence-original adversarial fixtures rather than copy from test_jira_search.py. Each adversarial fixture must justify why it targets a CQL grammar feature absent from JQL (text ~ contains-search, space.category() function, parent = clause, etc.).", + "Coverage gates: every public method on confluence_client.py and confluence_search.py must have ≥1 happy-path test, ≥1 error-path test, ≥1 adversarial test.", + "Route-enumeration regression test for /api/v1/confluence/* must enumerate every route (including /execute) and assert the @require_private_mode marker.", + "Body-redaction tests must include ADF tree mention nodes (not just top-level accountId fields).", + "Comment-fallback tests must include both v2-bug shapes AND a real not_found 404.", + "Reviewer checklist: assert each test_confluence_*.py file exercises features that are NOT one-to-one with test_jira_*.py." + ], + "rollback": "If post-merge a test gap is exposed, follow-up ticket adds the missing fixture and re-runs CI. No rollback of production behavior required.", + "requires_human_review": false + }, + { + "id": "R18", + "title": "Future-write extension shape locks in suboptimal route boundaries", + "category": "compatibility", + "description": "v1 design must support `page/create`, `page/update`, `comment/create` as drop-in narrow routes (per analysis). Confluence's edit endpoint is `PUT /wiki/api/v2/pages/{id}` (distinct from Jira's POST). If the v1 path validator unconditionally rejects `PUT`, the future-write phase has to re-architect the validator. The risk is that a v1 over-narrow path validator forces a v2 redesign; conversely, a v1 under-narrow validator accidentally enables `PUT` on read endpoints today.", + "affected_components": [ + "gateway/confluence_client.py — validate_confluence_api_path", + "future ticket: writes-phase implementation" + ], + "impact": "low", + "likelihood": "low", + "severity": "low", + "mitigations": [ + "v1 path validator: ALLOWED_METHODS = frozenset({'GET'}) (mirrors Jira). The future-writes phase widens this to `{'GET', 'POST', 'PUT'}` with `PUT` allowed only for `pages/{id}` and `POST` allowed only for `pages` and `footer-comments` / `inline-comments` paths.", + "Document the future-write extension contract in `docs/reference/confluence-wrapper.md`: 'Permanently denied: DELETE, restrictions, permissions, space.admin, users, attachments. Future writes use POST for create, PUT for update.'", + "v1 is intentionally strict — the writes phase is a deliberate, reviewed widening.", + "Tests: assert PUT/DELETE/PATCH all return `(False, ...)` from the validator in v1.", + "When writes-phase reviewer arrives, they re-examine this extension surface against decision-12 (attachments) and decision-7 (private-mode) — both must hold." + ], + "rollback": "v1 stays read-only; future-write decisions can re-architect the validator without breaking v1.", + "requires_human_review": false + } + ], + "summary_stats": { + "total_risks": 18, + "by_severity": { + "high": 1, + "medium": 7, + "low": 10 + }, + "by_category": { + "security": 6, + "performance": 3, + "compatibility": 2, + "operational": 3, + "data_privacy": 1, + "external_dependency": 1, + "test_coverage": 1, + "future_compat": 1 + }, + "requires_human_review": 3 + }, + "areas_for_human_review": [ + { + "risk_id": "R1", + "area": "CQL extractor adversarial coverage", + "review_question": "Has a security reviewer walked the CQL rejection table at implement time and verified each test fixture targets a real CQL ambiguity (vs a copy-pasted JQL edge case)?" + }, + { + "risk_id": "R14", + "area": "Attachments permanent denylist enforcement", + "review_question": "Has the path-validator denylist been verified to cover both narrow-route and /execute access paths, with test variants for case, URL-encoding, and nested path positions?" + }, + { + "risk_id": "R15", + "area": "Bot account effective access caveat", + "review_question": "Is the documented bot-vs-human access asymmetry clear in the wrapper reference, and does the structured 403 envelope reach the agent so it does not retry endlessly?" + } + ], + "global_rollback_plan": { + "level_1_config_only": [ + "Set `confluence.spaces: []` in config/context-filters.yaml — fails every Confluence call closed; agents see 403 with `confluence_space_not_allowlisted`.", + "POST /api/v1/config/reload — propagates without restart." + ], + "level_2_route_disable": [ + "Hot-patch gateway to return 503 on `/api/v1/confluence/*` (single-file change in gateway.py); roll the gateway pod.", + "Jira routes continue to serve traffic; `/impact-analysis` skill in sandbox falls back to host-side mcp__confluence__* (no behavioural regression for host invocations)." + ], + "level_3_credential_revoke": [ + "Revoke the Atlassian bot-account API token in Atlassian admin UI.", + "Gateway returns 503 on next call (credential loader fails); audit log records the failure mode.", + "Both Jira and Confluence go dark together (per decision-6 shared-creds coupling). Document this as the trade-off of consolidation." + ], + "level_4_full_revert": [ + "git revert the merge commit; redeploy the gateway image.", + "config/context-filters.yaml retains the `confluence:` section but no code reads it (no-op until re-rolled).", + "Future re-land via a new PR after the issue is identified and patched." + ] + }, + "implement_phase_checklist": [ + "[R1] CQL extractor adversarial test suite passes (all rejection reasons match the documented table).", + "[R2] /execute path-validator regex passes route-enumeration anti-bypass test (no narrow route's path family matches /execute).", + "[R3] Comment fallback test fixtures distinguish v2-bug from real 404; both code paths exercised.", + "[R4] Route-enumeration regression test (test_confluence_routes.py) asserts every /api/v1/confluence/* view has __egg_requires_private_mode__ = True.", + "[R5] gateway/allowed_domains.txt comment names both Jira AND Confluence as intentionally excluded.", + "[R6] Body-redaction recursive walker covers ADF mention nodes; test fixtures include nested cases.", + "[R7] body-format default is `storage`-only (per HITL decision-5 resolution); hard payload-size cap in route layer.", + "[R8] descendants endpoint defaults `depth=1` and `limit=25` when caller omits.", + "[R9] v1 endpoint constants are pinned per-verb in confluence_client.py; docs/reference/confluence-wrapper.md notes 'last reviewed against Atlassian docs: '.", + "[R10] 429 retry caps at 1 attempt; audit on both attempts; runbook entry in confluence-wrapper.md.", + "[R11] Credential loader preference order ATLASSIAN_* → service-specific; both Jira and Confluence updated; tests cover all six combinations.", + "[R12] Boot-time log line announces the loaded confluence.spaces allowlist size; ERROR on schema mismatch.", + "[R13] getConfluenceSpaces filter is strict allowlist intersection; case-mismatch test asserts no fuzzy match.", + "[R14] Attachments / restrictions / permissions denylist exercised by every variant test (case, URL-encoded, nested); /execute anti-test asserts denylist enforcement.", + "[R15] Upstream 403 returns structured forbidden envelope (not a generic upstream error); agent sees `bot_account_lacks_read_access` reason.", + "[R16] docs/reference/confluence-wrapper.md documents the prompt-injection caveat for page bodies.", + "[R17] Each test_confluence_*.py file has at least one fixture justified as Confluence-original (not a JQL → CQL transliteration).", + "[R18] Path validator ALLOWED_METHODS = {GET}; PUT/DELETE/PATCH explicitly tested as rejected." + ], + "external_research": { + "sources_consulted": [ + "https://community.developer.atlassian.com/t/update-to-confluence-v1-api-deprecation-timeline/79687", + "https://developer.atlassian.com/cloud/confluence/changelog/", + "https://confluence.atlassian.com/security/security-bulletin-december-11-2025-1689616574.html", + "https://confluence.atlassian.com/security/security-bulletin-november-18-2025-1671463469.html", + "https://community.developer.atlassian.com/t/confluence-rest-api-v2-doesnt-return-inline-comment-404-instead-v1-works-bug/86668", + "https://community.developer.atlassian.com/t/confluence-rest-api-v2-get-footer-nested-comments-for-page/82487" + ], + "key_findings": [ + "v1 deprecation is endpoint-specific, not sweeping. Atlassian has postponed the v1 cliff multiple times. CQL search remains v1-only with no v2 equivalent on the public roadmap. Earliest specific 2026 deprecations affect endpoints we don't use (Convert content body — Aug 5 2026; Cloud Admin v1 APIs — Jun 30 2026; an internal API token-access removal on Apr 14 2026).", + "March 2026 began phased rollout of points-based quota rate-limit enforcement for Cloud REST APIs. Existing 429 + Retry-After remains the user-visible interface; our retry policy is unchanged.", + "No public CQL-injection CVE exists. Atlassian explicitly clarified CQL is not SQL and CQL parameter manipulation does not equal SQL injection. The 2025 monthly security bulletins were dominated by third-party dependency CVEs in Confluence Server/Data Center, not Confluence Cloud REST surface.", + "v2 inline-comment 404 bug and v2 footer-comment nested-reply gap remain unresolved per the Atlassian developer community as of April 2026. v1 fallback remains necessary." + ] + }, + "tasks_satisfied": [], + "produced_at": "2026-04-26T23:45:00Z" +} From 1b0af9121845a568ae8e2ad11bbf1c3e95aca3db Mon Sep 17 00:00:00 2001 From: egg Date: Sun, 26 Apr 2026 23:58:26 +0000 Subject: [PATCH 07/26] plan: add task plan for #1931 (Confluence gateway read-only v1) Decomposes the architecture analysis into a single-PR plan with 6 phases and 28 tasks, mirroring the #1556 Jira-gateway scaffolding. Incorporates all 14 refine HITL decisions (verb-noun URL paths, v2-first hybrid, conservative CQL extractor, body-format=storage default per the operator tweak, shared ATLASSIAN_* triple, per-route private-mode gate, context-filters.yaml allowlist, attachments denylist, no per-pipeline env vars, GET-only /execute) plus the architect's per-verb endpoint pinning, page->space resolution caching, and the risk analyst's R1-R18 mitigations (5 MiB payload cap, descendants depth=1/limit=25 default, bot_account_lacks_read_access reason on 403, confluence_v1_fallback audit, ADF redaction, attachments-denylist case/encoding/nesting tests, route-vs-execute anti-bypass test, prompt-injection caveat in docs). Co-Authored-By: Claude Opus 4.7 --- .egg-state/drafts/1931-plan.md | 1342 ++++++++++++++++++++++++++++++++ 1 file changed, 1342 insertions(+) create mode 100644 .egg-state/drafts/1931-plan.md diff --git a/.egg-state/drafts/1931-plan.md b/.egg-state/drafts/1931-plan.md new file mode 100644 index 0000000000..10b132dde5 --- /dev/null +++ b/.egg-state/drafts/1931-plan.md @@ -0,0 +1,1342 @@ +# Plan: Add Confluence gateway support (read-only v1) + +> Issue: #1931 | Phase: plan | Single PR + +## Approach + +We are adding a **read-only** Confluence wrapper to the gateway +sidecar that mirrors the existing `/api/v1/jira/*` shape (#1556) one +component at a time. Sandboxed agents reach Confluence through the +gateway; Atlassian credentials never enter the sandbox; Confluence +routes are fail-closed in public network mode and gated by a **space +allowlist** plus a verb allowlist. + +The refine phase resolved all 14 multiple-choice decisions and 10 +free-form questions in favour of the recommended options (with the +operator tweak on Decision 5 — body-format defaults to **`storage` +only**, with `atlas_doc_format` and `view` available via per-call +override). The architect output and risk analyst output (when both +land) pin the module layout and mitigations we carry into the task +list. This plan can be tightened during the BRC review window if +either peer surfaces new constraints. + +| # | Decision | Resolved | +|---|----------|----------| +| 1 | Endpoint surface (URL shape) | A1 — Jira-style verb-noun paths (`/api/v1/confluence/page/get`, `/space/list`, `/search`, `/execute`) | +| 2 | Atlassian API version strategy | B1 — v2-first hybrid; v1 for CQL search; v1 fallback for known v2 comment bugs | +| 3 | CQL scope extraction | C1 — conservative static `space = / space IN (...)` extractor; deny-on-ambiguity | +| 4 | Comment-quirk handling | D1 — v2-first with transparent v1 fallback for nested replies + inline-404 bug | +| 5 | Default body format | **Tweaked** — default `body-format=storage` only; caller may override to `atlas_doc_format` or `view` per call | +| 6 | Credential sharing | F1 — shared `ATLASSIAN_*` triple with backward-compat fall-back to `JIRA_*` / `CONFLUENCE_*` placeholders | +| 7 | Network-mode gate | G1 — `@require_private_mode` per route + route-enumeration regression test | +| 8 | Space allowlist location | H1 — new `confluence:` section in `config/context-filters.yaml` (key: `spaces`) | +| 9 | Bot identity | Same dedicated Atlassian bot account used for Jira (single principal owns Jira + Confluence read scopes) | +| 10 | Sandbox-visible response redaction | Strip `accountId`, `emailAddress`, and `_links.webui` user-profile URLs before responses reach the sandbox | +| 11 | `getConfluenceSpaces` filtering | Filter response to allowlisted spaces (agents cannot enumerate the full tenant space set) | +| 12 | Attachments endpoints | **Permanent denylist** for v1 and future writes (separate ticket if ever needed) | +| 13 | `EGG_CONFLUENCE_*` env vars | None in v1 — Confluence is reference material; audit recovers `pageId` / `spaceKey` from each request body | +| 14 | `/execute` passthrough | Include it (GET-only, regex-allowlisted) — parallels Jira's escape hatch | + +Free-form refinement answers (Q1–Q10) carried into the design: + +- **Q1** — initial allowlist is **empty** (`confluence.spaces: []`); operators populate before enabling the feature. Plan ships installed-but-inert. +- **Q2** — reuse Jira's 429 retry semantics (single retry on 429 with `min(Retry-After, 30)`; emit `confluence_upstream_rate_limited` audit on both attempts). No Confluence-specific tuning in v1. +- **Q3** — defaults only (accountId / emailAddress / webui-link redaction). Add a follow-up ticket if a PII macro is identified post-rollout. +- **Q4** — defer write idempotency to the future-writes phase; v1 is read-only. +- **Q5** — no `page/resolve-by-url` verb in v1; agents pass `pageId` directly. +- **Q6** — pass `depth` parameter through verbatim to `getConfluencePageDescendants`; no gateway cap in v1. +- **Q7** — separate `confluence_upstream_403` audit category from generic upstream errors so operators can distinguish space-allowlist denials, page-permission denials, and other upstream errors. +- **Q8** — default current-version only; no `?version=` parameter in v1. +- **Q9** — bot-vs-human access caveat documented in the wrapper reference; no diagnostic command in v1. +- **Q10** — Jira-style subcommands only (`confluence page get`, `confluence search`, …); no MCP-style aliases in v1. + +The plan decomposes the work into six phases, each a logical commit, +all delivered in the single PR for issue #1931. + +--- + +## Phase 1 — Gateway foundation + +**Goal**: Introduce the Confluence-specific building blocks the routes will +compose: shared Atlassian credential loader, Confluence REST client (v2-first +hybrid + v1 fallback + redaction), space-allowlist loader, and CQL-scope +extractor. No HTTP handlers yet. Each piece is independently testable. + +### Task 1-1 — Confluence credential loader (`gateway/confluence_credentials.py`) + +- **Mirror** `gateway/jira_credentials.py` (mtime-based cache refresh from `~/.config/egg/secrets.env`, `EGG_SECRETS_PATH` override, thread-safe singleton). +- Expose `get_confluence_credentials() -> ConfluenceCredentials` returning a frozen dataclass with `base_url: str`, `username: str`, `api_token: str`, plus a `basic_auth_header()` helper that emits the base64-encoded Basic auth value. +- **Credential precedence (decision F1)**: prefer `ATLASSIAN_BASE_URL` / `ATLASSIAN_USERNAME` / `ATLASSIAN_API_TOKEN`; fall back to `CONFLUENCE_BASE_URL` / `CONFLUENCE_USERNAME` / `CONFLUENCE_API_TOKEN` per-key. The two name shapes can be mixed at the per-key level (e.g., `ATLASSIAN_USERNAME` + `CONFLUENCE_BASE_URL` is a valid combination — Atlassian accounts are tenant-wide). +- **Base-URL derivation**: if `CONFLUENCE_BASE_URL` is unset and `ATLASSIAN_BASE_URL` is set, derive Confluence base as `/wiki`. If `CONFLUENCE_BASE_URL` is set, use it verbatim (operators have already added `/wiki`). +- Raise typed `ConfluenceCredentialsUnavailable` when any of the three resolved values is missing/blank; route layer translates to HTTP 503. +- Expose `reload_confluence_credentials()` for the hot-reload hook (Task 2-9). + +**Acceptance**: Unit test (4-1) points `EGG_SECRETS_PATH` at a tmp file containing `ATLASSIAN_*` and asserts the header string + base URL with `/wiki` appended; same test with `CONFLUENCE_*`-only secrets uses the verbatim URL; a third case with both prefers `ATLASSIAN_*` per-key and falls back to `CONFLUENCE_*` for any missing one; touching the tmp file invalidates the cache on the next call; missing values raise the typed exception; `reload_confluence_credentials()` clears the cache immediately. + +**Files**: +- `gateway/confluence_credentials.py` (new) + +### Task 1-2 — Confluence REST client (`gateway/confluence_client.py`) + +- Exposed as a class — `ConfluenceClient(creds_provider, http_client)` — so a second Atlassian site is a single-file drop-in. Module exports `get_confluence_client()` for routes. +- All requests use `creds_provider().basic_auth_header()` and `Accept: application/json`; httpx underneath, mirroring `gateway/jira_client.py`. +- **Per-verb endpoint pinning (decision B1)** — every method declares which Atlassian API version it targets: + - `get_page(page_id, body_format=("storage",), expand=None)` → `GET /wiki/api/v2/pages/{id}` with `body-format=storage` by default. Caller may override `body_format` to a list/tuple containing any of `("storage", "atlas_doc_format", "view", "export_view")`. Comma-joined into the v2 query string. + - `get_page_descendants(page_id, depth=None, limit=None, cursor=None)` → `GET /wiki/api/v2/pages/{id}/descendants`. Pass `depth`, `limit`, `cursor` through verbatim (Q6). + - `get_page_footer_comments(page_id, body_format=("storage",), include_replies=False)` → `GET /wiki/api/v2/pages/{id}/footer-comments` (decision D1). When `include_replies=True`, follow up with `GET /wiki/api/v2/footer-comments?page-id={id}&depth=all` and merge the nested replies into the response under a normalized envelope `{"results": [...], "_replies": {...}}`. + - `get_page_inline_comments(page_id, body_format=("storage",))` → `GET /wiki/api/v2/pages/{id}/inline-comments`. **v2 → v1 fallback**: if v2 returns 404, retry transparently against `GET /wiki/rest/api/content/{id}/child/comment?location=inline&expand=body.view` (the known v2 inline-comment 404 bug per the analysis). Return the v1 response normalized into a `{"results": [...]}` envelope. + - `list_spaces(allowed_spaces, limit=None, cursor=None)` → `GET /wiki/api/v2/spaces`. **Filter the response so only spaces whose `key` is in `allowed_spaces` are returned (decision 11)**. The cursor `next` is preserved if any allowlisted spaces were filtered out so callers can paginate. + - `get_space_pages(space_id, limit=None, cursor=None, body_format=("storage",))` → `GET /wiki/api/v2/spaces/{space-id}/pages`. (Note: v2 uses numeric `space-id`; the route layer maps a `spaceKey` from the request body to the numeric id by calling `list_spaces` first if the agent supplied a key.) + - `search_cql(cql, limit=None, cursor=None)` → `GET /wiki/rest/api/search?cql=...&limit=...&cursor=...`. v1-only — there is no v2 CQL endpoint. + - `execute_raw(method, path, query, body)` — passthrough used by the `/execute` route. +- **`validate_confluence_api_path(path: str, method: str) -> tuple[bool, str]`** — regex allowlist mirroring `validate_jira_api_path` in `gateway/jira_client.py`. Before matching: strip leading/trailing `/`, strip query string, reject any `..` segment, reject duplicate slashes, reject non-ASCII / non-normalised Unicode. Allowed path families (GET only in v1): + - `^api/v2/pages/\d+$` + - `^api/v2/pages/\d+/descendants$` + - `^api/v2/pages/\d+/footer-comments$` + - `^api/v2/pages/\d+/inline-comments$` + - `^api/v2/footer-comments$` + - `^api/v2/inline-comments$` + - `^api/v2/spaces$` + - `^api/v2/spaces/\d+/pages$` + - `^rest/api/search$` + - `^rest/api/content/\d+/child/comment$` *(v1 fallback for inline comments — same endpoint family v1)* +- `CONFLUENCE_DENIED_VERBS`: explicit frozenset — `"restrictions"`, `"permissions"`, `"space.admin"`, `"users"`, `"attachments"` (decision 12), plus HTTP `"DELETE"`, `"PUT"`, `"PATCH"`. `validate_confluence_api_path` returns `(False, reason)` whenever the path contains any denied verb or the method is not GET. This is the permanent "out of scope ever" fence. +- **429 handling (Q2)**: `_request(...)` retries **once** on HTTP 429, sleeping `min(int(response.headers.get("Retry-After", "1")), 30)` seconds. Retry is GET-only. After the second 429, pass it through verbatim. Emit a structured `audit_log("confluence_upstream_rate_limited", ..., details={"retry_after": ..., "path": ...})` on both 429s. Identical to the Jira client's stance. +- **404 envelope**: for `get_page`, `get_page_descendants`, `get_page_footer_comments`, `get_page_inline_comments`, and `get_space_pages`, on upstream 404 the client returns `{"status": "not_found", "id": "...", "upstream_status": 404}` instead of raising. `search_cql` and `execute_raw` still raise `ConfluenceUpstreamError` for 404. +- **403 envelope (Q7, risk R15)**: on upstream 403 from any read method, the client raises `ConfluenceUpstreamForbidden(status_code=403, body=...)`. Route handlers translate this to HTTP 403 with audit event `confluence_upstream_403` (distinct from generic `confluence_upstream_error`) and a structured response body `{"status": "forbidden", "reason": "bot_account_lacks_read_access", "pageId"|"spaceKey": "..."}` so the agent can act on the precise denial cause without ambiguity. +- **v1-fallback audit (architect Q3)**: every transparent v1 fallback (inline-comment 404, footer-comment nested-reply gap) emits a `confluence_v1_fallback` audit entry with `{endpoint: "inline_comments"|"footer_comments_nested", v2_status: , page_id: ...}` so operators can monitor whether Atlassian has fixed the v2 bugs and we can retire the fallback later. +- **Boot-time observability (risk R12)**: at module import, the credential / policy / client managers log a single INFO line each summarising the loaded state — credential precedence resolved, allowlist size, and `body-format=storage` default. Schema-mismatch reads (e.g., `confluence: {spaces: "ENG"}` instead of a list) emit ERROR-level audit entries so operators see the misconfiguration in the gateway logs immediately rather than discovering it via failed agent calls. +- Other upstream 4xx/5xx raise `ConfluenceUpstreamError(status_code=..., body=...)` for the route layer to translate. +- **Response redaction (decision 10)**: `redact_response(payload)` walks the JSON response and strips: + - `accountId` keys at any depth (replace with `""`). + - `emailAddress` keys at any depth (replace with `""`). + - `_links.webui` user-profile URLs (any URL whose path begins with `/people/` or matches a user-profile link shape). Page / space `_links.webui` URLs are preserved — they're addressable by the agent. + - Helper applied uniformly by the client before returning to routes; routes do not need to remember to call it. + +**Files**: +- `gateway/confluence_client.py` (new — class + validation helpers + redaction) + +**Acceptance** (paired with Task 4-2): URL/header/body construction per method, default `body-format=storage` (decision E5 tweak), `body_format` override accepted, `validate_confluence_api_path` positive (every family above) + negative (`restrictions`, `permissions`, `space.admin`, `users`, `attachments`, DELETE, PUT, PATCH, `..`, duplicate slashes, non-ASCII), 429 single retry honouring `Retry-After`, second 429 surfaces error, 404 envelope on read methods, 403 raises `ConfluenceUpstreamForbidden`, v1 inline-comment fallback fires on v2 404, `list_spaces` filtering excludes non-allowlisted entries, `redact_response` strips the three default keys recursively. + +### Task 1-3 — Space-allowlist loader (`gateway/confluence_policy.py`) + +- Mirror `gateway/jira_policy.py` exactly. Reads a new `confluence:` section from `config/context-filters.yaml`: + ```yaml + confluence: + spaces: ["ENG", "DOCS"] # Atlassian space keys allowed for read access + ``` + — authoritative key is **`spaces`** (parallel to Jira's `projects`). mtime-based cache refresh. +- Expose `is_space_allowed(space_key: str) -> bool`, `allowed_spaces() -> frozenset[str]`, and `reload_confluence_policy()` for the hot-reload hook (Task 2-9). +- Validation: each entry must match `^[a-zA-Z][a-zA-Z0-9_]*$` (Atlassian space keys are conventionally uppercase but the API accepts mixed case); non-string entries are dropped with a warning. +- Fail-closed: missing file / missing `confluence:` section / missing `spaces:` key / malformed YAML / non-list shape → empty set, no crash. + +**Acceptance** (paired with Task 4-3): allowlist round-trip from a tmp YAML, mtime reload picks up edits, missing file → empty set, missing `confluence:` section → empty set, malformed YAML → empty set + logged error (no crash), `reload_confluence_policy()` forces immediate re-read. + +**Files**: +- `gateway/confluence_policy.py` (new) + +### Task 1-4 — CQL scope extractor (`gateway/confluence_search.py`) + +- Conservative static `space =` / `space IN (...)` extractor with deny-on-ambiguity (decision C1). Direct port of `gateway/jira_search.py`, adapted for CQL grammar. +- API: `extract_search_spaces(cql: str, allowed: frozenset[str]) -> ScopeResult` returning `(spaces: frozenset[str] | None, reason: str)`. +- Rejection cases (each yields a specific `reason` string passed through verbatim into the `confluence_search_rejected` audit line): + - Empty / non-string CQL. + - Non-ASCII (unicode homoglyph guard). + - Forbidden characters (`;`). + - Comment markers (`/* */`, `--`, `//`). + - Top-level `OR` at any depth (CQL boolean operator). + - Bare `id =` / `content =` / `title ~` clauses without a `space` clause. + - `space` clause under any `OR`. + - Capitalisation variants (`SPACE = ENG` rejected — canonical lowercase only). + - Quoted space keys (`space = "ENG"` rejected — even if `ENG` is allowlisted; matches Jira's stance). + - CQL functions (`currentUser()`, `recentlyViewedContent()`, `now()`, etc.) inside the `space` operand. + - `space IN (K1, K2)` containing any non-allowlisted key. + - No `space` clause at all (returns `"no space clause"`). +- Acceptance shapes (the only two that pass): + - `space = KEY` (case-sensitive `space`, key matching `^[a-zA-Z][a-zA-Z0-9_]*$`). + - `space IN (KEY1, KEY2, ...)` with every key in `allowed`. + - …optionally AND-combined at top level with arbitrary additional clauses (e.g., `space = ENG AND text ~ "rfc"`). + +**Files**: +- `gateway/confluence_search.py` (new) + +**Acceptance** (paired with Task 4-4): all positive cases pass, all rejection cases above return `(None, reason)`. Mirrors the Jira `extract_search_projects` test grid one-for-one. + +--- + +## Phase 2 — Gateway routes + +**Goal**: Wire the Phase 1 pieces into the eight `POST /api/v1/confluence/*` +endpoints on the existing Flask app in `gateway/gateway.py`. Each route +composes `@require_session_auth → @require_private_mode → space-allowlist +check → fields/args validation → client call → redaction (already in client) +→ audit_log → response`. Plus a reload-hook extension. + +All routes share the audit shape `{event: "confluence_", pageId?, +spaceKey?, session_mode, pipeline_id, agent_role, success, ...}`. Per +decision 13 there is no per-session `session.confluence_*` field — `pageId` +and `spaceKey` are recovered from the request body or response per call. + +**Payload-size cap (risk R7)**: every route response (post-redaction) is +checked against a hard limit of **5 MiB**; oversized responses are refused +with HTTP 413 `confluence_response_too_large` carrying `{pageId|spaceKey, +size_bytes}` so the agent can request a narrower scope (different +`bodyFormat`, smaller `limit`, etc.). This protects the agent transcript +budget from arbitrarily large Confluence pages / ADF trees. The cap is a +constant in `gateway/confluence_client.py` (`CONFLUENCE_RESPONSE_MAX_BYTES`) +so it tunes in one place. + +**Page → space resolution caching (architect Q2)**: the post-fetch space- +allowlist check needs the page's `spaceKey`. To avoid double-fetching, the +client caches the `spaceId → spaceKey` mapping for 60 s in-process. The +comment routes (2-3, 2-4) re-use that cache so they do not refetch the +page just to verify the space. + +### Task 2-1 — `POST /api/v1/confluence/page/get` + +- Body: `{"pageId": "", "bodyFormat": ["storage"], "expand": null}` — `bodyFormat` and `expand` optional. +- Validate `pageId` matches `^\d+$`; reject otherwise with 400. +- Call `ConfluenceClient.get_page(...)`; on the not-found envelope return HTTP 200 with the envelope body; on `ConfluenceUpstreamForbidden` return HTTP 403 audit event `confluence_upstream_403`. +- The space allowlist check happens **after** the upstream call: if the response includes a `spaceId` and the resolved `spaceKey` is not in `allowed_spaces()`, the gateway returns HTTP 403 `confluence_space_denied` and does **not** forward the response (so allowlist denials never leak page bodies). The page→space resolution uses `list_spaces` (cached for 60 s in-process) to translate `spaceId` → `spaceKey`. +- Audit: `{event: "confluence_page_get", pageId, spaceKey, session_mode, pipeline_id, agent_role, success}`. + +**Acceptance** (covered by 4-5): happy-path returns redacted JSON with body-format=storage; allowlist denial returns 403 with no body fields leaked; not-found envelope passes through as HTTP 200; upstream 403 → HTTP 403 with `confluence_upstream_403`. + +### Task 2-2 — `POST /api/v1/confluence/page/descendants` + +- Body: `{"pageId": "", "depth": null, "limit": null, "cursor": null}` — depth / limit / cursor optional. **Per risk R8**: when caller omits, the route applies a sensible default of `depth=1` and `limit=25` to bound runaway responses on deeply nested space trees. Caller-supplied values are passed through verbatim (Q6 — no hard cap). +- Same `pageId` shape validation + post-fetch space-allowlist check as 2-1. +- Audit: `{event: "confluence_page_descendants", pageId, spaceKey, depth, limit, ...}`. + +### Task 2-3 — `POST /api/v1/confluence/page/footer-comments` + +- Body: `{"pageId": "", "bodyFormat": ["storage"], "includeReplies": false, "limit": null, "cursor": null}`. +- Same `pageId` validation. Calls `ConfluenceClient.get_page_footer_comments(..., include_replies=...)` which handles the v1 fallback for nested replies (decision D1). +- Same post-fetch space-allowlist check as 2-1 (uses the comment's `pageId` → `spaceId` resolution). +- Audit: `{event: "confluence_page_footer_comments", pageId, spaceKey, includeReplies, ...}`. + +### Task 2-4 — `POST /api/v1/confluence/page/inline-comments` + +- Body: `{"pageId": "", "bodyFormat": ["storage"], "limit": null, "cursor": null}`. +- Calls `ConfluenceClient.get_page_inline_comments(...)` which transparently falls back to v1 on v2's known 404 bug (decision D1). +- Post-fetch space-allowlist check as 2-1. +- Audit: `{event: "confluence_page_inline_comments", pageId, spaceKey, used_fallback: bool, ...}` — the `used_fallback` flag lets operators see how often the v1 path is exercised. + +### Task 2-5 — `POST /api/v1/confluence/space/pages` + +- Body: `{"spaceKey": "ENG", "limit": null, "cursor": null, "bodyFormat": ["storage"]}`. +- Validate `spaceKey` matches `^[a-zA-Z][a-zA-Z0-9_]*$`; check it is in `allowed_spaces()`. **This route enforces the allowlist on the way in**, before any upstream call, because the agent supplied the spaceKey directly (no risk of bypass via response shape). +- Resolve `spaceKey` → `spaceId` via `ConfluenceClient.list_spaces(allowed_spaces=allowed_spaces())` (the call already filters to allowlisted spaces); if no match, return HTTP 404 with `{"status": "not_found", "spaceKey": "..."}`. +- Call `ConfluenceClient.get_space_pages(space_id, ...)`. +- Audit: `{event: "confluence_space_pages", spaceKey, ...}`. + +### Task 2-6 — `POST /api/v1/confluence/space/list` + +- Body: `{"limit": null, "cursor": null}` (no input keys today). +- Calls `ConfluenceClient.list_spaces(allowed_spaces=allowed_spaces(), ...)`. Per decision 11 the response only contains spaces in the allowlist — agents cannot enumerate the full tenant set. +- Audit: `{event: "confluence_space_list", spaces_returned: N, ...}` (where N is the count after filtering). + +### Task 2-7 — `POST /api/v1/confluence/search` + +- Body: `{"cql": "...", "limit": null, "cursor": null}`. +- Run `extract_search_spaces(cql, allowed_spaces())`. On rejection return HTTP 403 `confluence_search_rejected` with the specific reason in the audit details (per Task 1-4 grid). +- On accept, clamp `limit` to **100** (default 50 — mirrors Jira `maxResults` clamp; CQL search has a 200-result hard limit per query upstream, so 100 is safely under that). +- Call `ConfluenceClient.search_cql(cql, limit, cursor)`. +- Audit: `{event: "confluence_search", spaces_extracted: [...], cql_length: N, session_mode, pipeline_id, agent_role, success}`. `pageId` is intentionally absent. + +**Acceptance** (covered by 4-5): positive CQL `space = ENG AND text ~ "RFC"` returns 200; the 10+-case adversarial CQL suite returns 403 with the matched reason; clamp test confirms `limit > 100` becomes `100`. + +### Task 2-8 — `POST /api/v1/confluence/execute` + +- Body: `{"method": "GET", "path": "api/v2/pages/12345", "query": {...}, "body": null}` — shape mirrors `/api/v1/jira/execute`. +- Call `validate_confluence_api_path(path, method)`; refuse non-GET, unknown paths, and `CONFLUENCE_DENIED_VERBS` terms (`restrictions`, `permissions`, `space.admin`, `users`, `attachments`) with HTTP 403 + audit entry (`confluence_execute_denied`, reason included). +- After path validation, if the path targets a `pages/{id}` or `spaces/{id}` endpoint, perform the same post-fetch space-allowlist check as Task 2-1 once the upstream response arrives. For path families that don't carry an obvious `spaceId` (e.g., `api/v2/footer-comments`), require a `spaceKey` query parameter and validate it up-front. +- Call `ConfluenceClient.execute_raw(...)` and return the redacted body. +- Audit success: `{event: "confluence_execute", method, path, spaceKey?, session_mode, ...}`. + +### Task 2-9 — Hot-reload wiring in `gateway/gateway.py::_reload_all_config()` + +- Extend the existing `_reload_all_config()` helper (invoked by `POST /api/v1/config/reload`) to also call `reload_confluence_credentials()` and `reload_confluence_policy()` alongside the existing Jira reloads. +- Audit a single structured entry covering both reloads (`confluence_config_reloaded`). + +**Acceptance (2-1 through 2-9, paired with 4-5)**: Every route returns 403 in public mode (route-enumeration regression test in 4-5); 403 on disallowed spaces / paths; 200 on happy paths with mocked upstream; structured audit records on every outcome (including the new `confluence_upstream_403` and `confluence_search_rejected` events). `POST /api/v1/config/reload` picks up secrets.env and context-filters.yaml changes without a gateway restart. Manual smoke via `curl`: allowlisted page in private mode → 200; public mode → 403; `execute` with `method=DELETE` or path containing `restrictions` → 403; `space/list` returns only allowlisted spaces. + +**Files**: +- `gateway/gateway.py` (add eight route handlers in the `/api/v1/*` region — between the existing `/api/v1/jira/*` block and `/api/v1/checkpoints/*`; extend `_reload_all_config`). + +--- + +## Phase 3 — Sandbox wrapper + +**Goal**: Give sandboxed agents a CLI wrapper analogous to `sandbox/scripts/jira`, +exposing the gateway routes via Jira-style verb-noun subcommands. Per decision +13 no per-pipeline env vars or session fields are added — Confluence is +stateless reference material from the agent's perspective. + +### Task 3-1 — `sandbox/scripts/confluence` bash wrapper + +- **Bash script** mirroring `sandbox/scripts/jira` exactly — `#!/bin/bash`, `set -euo pipefail`, gateway-health check, `EGG_SESSION_TOKEN` Bearer auth, heredoc-Python for JSON construction, JSON-on-stdout / errors-on-stderr / non-zero exit on non-2xx. +- Supported verbs (Jira-style only per Q10): + - `confluence page get [--body-format storage,atlas_doc_format] [--expand ...]` → `/api/v1/confluence/page/get` + - `confluence page descendants [--depth N] [--limit N] [--cursor TOK]` → `/api/v1/confluence/page/descendants` + - `confluence page footer-comments [--include-replies] [--body-format ...] [--limit N] [--cursor TOK]` → `/api/v1/confluence/page/footer-comments` + - `confluence page inline-comments [--body-format ...] [--limit N] [--cursor TOK]` → `/api/v1/confluence/page/inline-comments` + - `confluence space pages [--limit N] [--cursor TOK] [--body-format ...]` → `/api/v1/confluence/space/pages` + - `confluence space list [--limit N] [--cursor TOK]` → `/api/v1/confluence/space/list` + - `confluence search '' [--limit N] [--cursor TOK]` → `/api/v1/confluence/search` + - `confluence execute [--query k=v,...] [--body-file path]` → `/api/v1/confluence/execute` + - `confluence help` — usage text +- Reuse the same `check_gateway_available` / `call_gateway` shell helpers shape as `sandbox/scripts/jira`. Inline (do not factor out a shared library in v1). + +**Files**: +- `sandbox/scripts/confluence` (new, executable, bash) + +**Acceptance** (paired with Task 4-6): integration tests subprocess-invoke the wrapper against a mock gateway and assert request body / path / `Authorization` header / stdout / exit codes for each verb (happy + failure path). + +--- + +## Phase 4 — Tests + +**Goal**: Cover each piece built in Phases 1–3 with `pytest` suites that +mirror the existing Jira test layout in `gateway/tests/`. All upstream +Atlassian calls are mocked via `respx` / `httpx.MockTransport`. The Squid +allowlist regression test in `gateway/tests/test_allowed_domains.py` +already excludes `*.atlassian.*`; we extend it with a Confluence-targeted +assertion comment so a future grep for "confluence" finds it. + +### Task 4-1 — `gateway/tests/test_confluence_credentials.py` + +- mtime cache refresh; missing values raise typed error; `basic_auth_header()` base64 shape; `reload_confluence_credentials()` clears cache. +- **F1 precedence cases**: `ATLASSIAN_*` triple alone yields the correct base URL with `/wiki` appended; `CONFLUENCE_*` triple alone uses verbatim URL; mixed (`ATLASSIAN_USERNAME` + `CONFLUENCE_BASE_URL` etc.) resolves per-key. + +**Role**: tester. + +**Files**: +- `gateway/tests/test_confluence_credentials.py` (new) + +### Task 4-2 — `gateway/tests/test_confluence_client.py` + +- Each method builds the correct URL, headers, body — mocked via `respx` / `httpx.MockTransport`. +- **Default `body-format=storage`** on `get_page` / `get_space_pages` / comment methods (decision E5 tweak); override accepted. +- `validate_confluence_api_path`: positive cases for every allowed family in Task 1-2; negative cases (`restrictions`, `permissions`, `space.admin`, `users`, `attachments`, DELETE, PUT, PATCH, `..`, duplicate slashes, non-ASCII, random 404 paths, `pages/abc/...` non-numeric). +- Pagination: `cursor` round-trip across `get_page_descendants`, `get_space_pages`, `list_spaces`, `search_cql`. +- **429 retry**: first 429 with `Retry-After: 1` → retried once; second 429 → surfaces `ConfluenceUpstreamError(429)`; write verbs do NOT retry. Audit entries (monkeypatched) observed both 429s. +- **404 envelope**: read methods (`get_page`, `get_page_descendants`, `get_page_footer_comments`, `get_page_inline_comments`, `get_space_pages`) return the envelope; `search_cql` and `execute_raw` raise `ConfluenceUpstreamError`. +- **403 envelope**: any read method on upstream 403 raises `ConfluenceUpstreamForbidden(403)`; route translation covered in 4-5. +- **v1 inline-comment fallback**: `get_page_inline_comments` on v2 404 fires the v1 `rest/api/content/{id}/child/comment?location=inline` request and returns the normalized envelope; `used_fallback=True` signal observable. +- **Footer-comment nested-reply fallback**: when `include_replies=True` and v2 returns top-level only, the secondary `api/v2/footer-comments?page-id={id}&depth=all` call merges the nested replies into the response. +- **`list_spaces` filtering (decision 11)**: response with three spaces `ENG`, `DOCS`, `LEAK` and allowlist `{"ENG", "DOCS"}` returns only `ENG` + `DOCS`. +- **`redact_response` (decision 10, risk R6)**: nested payload with `accountId`, `emailAddress`, and `_links.webui` (user-profile URL) is sanitised; page `_links.webui` URLs are preserved. Test fixtures include ADF mention nodes and nested `body.atlas_doc_format.content` structures so the recursive walker is exercised on real-shaped payloads. +- **Credential-precedence matrix (risk R11)**: the credential test suite covers all six combinations — `ATLASSIAN_*` only, `CONFLUENCE_*` only, both set (ATLASSIAN wins per key), missing each individual key with the other prefix providing it, and the back-compat `CONFLUENCE_*` + `JIRA_*` mixture. +- **Confluence-original fixtures (risk R17)**: at least one fixture per test file is justified in a comment as Confluence-original (not a JQL→CQL transliteration) — examples: CQL `text ~ "RFC"`, ADF mention node, footer-comment with nested replies, v2-404 inline-comment fixture. + +**Role**: tester. + +**Files**: +- `gateway/tests/test_confluence_client.py` (new) + +### Task 4-3 — `gateway/tests/test_confluence_policy.py` + +- Allowlist round-trip from a tmp `config/context-filters.yaml` using the `confluence.spaces` key. +- mtime reload; `reload_confluence_policy()` clears cache. +- Missing file / missing `confluence:` section / missing `spaces:` key / non-list shape / malformed YAML → empty set (fail-closed) without crashing. +- Mixed-case keys round-trip correctly (Atlassian space keys are case-sensitive — preserve exact case). + +**Role**: tester. + +**Files**: +- `gateway/tests/test_confluence_policy.py` (new) + +### Task 4-4 — `gateway/tests/test_confluence_search.py` + +- Positive: `space = ENG`, `space IN (ENG, DOCS)` with both allowlisted, combined with `AND text ~ "rfc"`. +- Negative / adversarial (must all return `(None, reason)`): + - `space = ENG OR space = SEC` + - `space = ENG OR id = "12345"` + - `SPACE = ENG` (uppercase) + - `space = "ENG"` (quoted, even if allowlisted) + - `space = currentUser()` + - `space = recentlyViewedContent()` + - `space = ENG ; drop table` + - `space = ENG /* comment */` + - `space IN (ENG, SEC)` where SEC not allowlisted + - missing `space` clause + - non-ASCII / unicode homoglyph keys (`ENG`) + - bare `id = "12345"` without space clause + - bare `title ~ "RFC"` without space clause + +**Role**: tester. + +**Files**: +- `gateway/tests/test_confluence_search.py` (new) + +### Task 4-5 — `gateway/tests/test_confluence_routes.py` + +- Use the existing `client` + `private_mode_auth_headers` fixtures (from `conftest.py` — same fixtures the Jira route tests use). +- For each of the eight routes: + - Public mode → 403 with `private_mode_required` audit entry. + - Private mode + disallowed space → 403 (`confluence_*_denied` or `confluence_space_denied` per route). + - Private mode + allowlisted space + mocked upstream → 200 with body. +- **Adversarial CQL suite for `/search`** — at least 10 negative cases mirroring Task 4-4's grid; every rejection logs `confluence_search_rejected` with a specific reason. +- **`/execute`** — rejects write methods, denied verbs (`restrictions`, `permissions`, `space.admin`, `users`, `attachments`), path traversal, disallowed spaces. **Per risk R14**: each denied-verb test runs in three variants — bare (`attachments`), URL-encoded (`%61ttachments`), and nested in path position (`pages/123/attachments`) — to ensure the regex catches every smuggling attempt. **Per risk R2**: a route-vs-execute anti-bypass test asserts that no narrow-route path family (`pages/{id}`, `spaces/{id}/pages`, `rest/api/search`) can be invoked via `/execute` to bypass the narrow-route policy checks; `/execute` only handles families the narrow routes don't cover. +- **Route-enumeration regression test (decision G7)** — iterate `app.url_map` for every `/api/v1/confluence/*` rule; assert each view function has `__egg_requires_private_mode__ = True`. +- **404 envelope end-to-end** — upstream 404 on each read route → HTTP 200 with `{"status":"not_found",...}` body. +- **`confluence_upstream_403` audit category (Q7)** — upstream 403 on `page/get` produces HTTP 403 with `event: confluence_upstream_403` (distinct from `confluence_space_denied`). +- **`list_spaces` filtering end-to-end (risk R13)** — mock upstream returns `[ENG, DOCS, LEAK]`, allowlist is `{ENG, DOCS}`, route response contains only `ENG` and `DOCS`. Case-mismatch test (`{eng}` allowlisted, `[ENG]` upstream) asserts strict case-sensitive intersection — no fuzzy match. +- **`redact_response` end-to-end** — mocked upstream payload with `accountId` / `emailAddress` returns `` to the sandbox. +- **v1 fallback observability** — `page/inline-comments` route response includes the `used_fallback` flag when v2 returned 404. +- Audit-log assertions: every event includes `pageId` or `spaceKey` per route, `session_mode`, `pipeline_id`, `agent_role`, `success`. Search audits include `spaces_extracted` and `cql_length`; ticket-shaped routes include `pageId`/`spaceKey`. + +**Role**: tester. + +**Files**: +- `gateway/tests/test_confluence_routes.py` (new) + +### Task 4-6 — Sandbox wrapper tests (`tests/sandbox/test_confluence_wrapper.py`) + +- File lives at `tests/sandbox/test_confluence_wrapper.py` (alongside `tests/sandbox/test_jira_wrapper.py`). +- Subprocess-invoke `sandbox/scripts/confluence` against a local mock gateway (httpretty / responses / a fixture Flask app). +- For each verb (8 total): happy path (assert request body, path, `Authorization` header, stdout JSON, exit 0), failure path (upstream 4xx/5xx → stderr + non-zero exit). +- Extra: `--include-replies` toggle for `page footer-comments` and `--depth` for `page descendants` reach the request body. + +**Role**: tester. + +**Files**: +- `tests/sandbox/test_confluence_wrapper.py` (new) + +### Task 4-7 — Network-policy sanity test extension (`gateway/tests/test_allowed_domains.py`) + +- The existing `test_atlassian_domains_absent` test already asserts `atlassian.net` / `atlassian.com` / `api.atlassian.com` / `jira.atlassian.com` are absent from `gateway/allowed_domains.txt`. **No new substring additions are required** — Confluence shares the `*.atlassian.net` exclusion. +- Add a docstring-level assertion comment to the existing test naming Confluence (so a future `grep -i confluence gateway/tests/` surfaces it) **plus** a small parametrized case that explicitly asserts `wiki.atlassian.net` and `confluence.atlassian.com` are not present (defence in depth even though they aren't real Atlassian Cloud hostnames; the cost is one parametrize entry). + +**Role**: tester. + +**Files**: +- `gateway/tests/test_allowed_domains.py` (edit — extend parametrize list, add Confluence-naming docstring) + +**Acceptance**: `make test` passes; new tests hit the lines added in Phases 1–3 (spot-check via `pytest --cov gateway/confluence_*`). No flaky network calls — everything upstream is mocked. + +--- + +## Phase 5 — Config scaffolding + k8s + +**Goal**: Add the operator-facing `confluence:` allowlist section, scaffold the +shared Atlassian credential keys in the secrets template, and confirm the +existing k8s `secrets.env` mount delivers them. No new k8s volumes are +required. + +### Task 5-1 — Edit `config/context-filters.yaml` + +- Add a `confluence:` section after the existing `jira:` section: + ```yaml + confluence: + # Atlassian Confluence space keys agents are allowed to read through + # the /api/v1/confluence/* endpoints. Space keys are case-sensitive. + # + # Example: + # spaces: ["ENG", "DOCS"] + # + # Any page whose space is not on this list returns HTTP 403 with + # `confluence_*_denied` (or `confluence_space_denied`) in the gateway + # audit log. CQL searches must be statically provable as scoped to + # the listed spaces — see gateway/confluence_search.py. + spaces: [] + ``` +- Default empty list (fail-closed). Operators populate before enabling the + feature in production. + +**Files**: +- `config/context-filters.yaml` (edit) + +### Task 5-2 — Edit `config/secrets.template.env` + +- Add a new `# Atlassian (shared)` block above the existing Jira / Confluence blocks containing the **shared** triple: + ```env + # Atlassian Cloud credentials shared by Jira and Confluence (decision F1). + # Populate this block when the same Atlassian bot account owns both + # services. Leaving JIRA_* / CONFLUENCE_* below populated also works and + # takes precedence per-key for back-compat. + ATLASSIAN_BASE_URL="" # e.g., https://yourcompany.atlassian.net (no trailing slash) + ATLASSIAN_USERNAME="" # bot account email + ATLASSIAN_API_TOKEN="" # https://id.atlassian.com/manage-profile/security/api-tokens + ``` +- Keep the existing `JIRA_*` and `CONFLUENCE_*` blocks intact for back-compat (the loader prefers `ATLASSIAN_*` per key but falls back to either prefix). +- **Drop** `CONFLUENCE_SPACE_KEYS` (line 99 today) — supplanted by `config/context-filters.yaml :: confluence.spaces` per decision H1. Replace it with a single comment line pointing operators at the YAML. +- Update the `CONFLUENCE_BASE_URL` comment to mention the `/wiki` suffix is still required when this key is set explicitly (the loader auto-appends `/wiki` only when deriving from `ATLASSIAN_BASE_URL`). + +**Files**: +- `config/secrets.template.env` (edit) + +### Task 5-3 — k8s sanity check + +- Confirm `k8s/base/gateway-deployment.yaml` already mounts `secrets.env` at `/secrets/secrets.env` and that the `ATLASSIAN_*` / `CONFLUENCE_*` keys in that file become env to the gateway process. No new volumes expected. +- Add an inline comment listing the new Atlassian/Confluence env keys alongside the existing GitHub / Anthropic / Jira ones for operator discoverability. + +**Files**: +- `k8s/base/gateway-deployment.yaml` (edit — comment only) + +**Acceptance**: Gateway starts cleanly with the default empty allowlist (`allowed_spaces()` returns `frozenset()` → every Confluence call rejected until operator edits the file). `kubectl apply --dry-run=client -f k8s/base/gateway-deployment.yaml` succeeds; diff is comment-only on the YAML. + +--- + +## Phase 6 — Documentation + +**Goal**: Make the new wrapper discoverable and connect it to the architecture +documents the analysis called out. + +### Task 6-1 — Update `docs/architecture/network-isolation.md` + +- Add `/api/v1/confluence/*` to the gateway endpoint table with a note: "private-mode only; fails closed in public mode". +- Restate in the egress-policy section that `*.atlassian.net` is **not** in the Squid allowlist — Confluence traffic flows through the gateway REST endpoints alongside Jira. + +**Files**: +- `docs/architecture/network-isolation.md` (edit) + +### Task 6-2 — Update `docs/architecture/credential-injection.md` + +- Extend the existing Atlassian section (the Jira row added by #1556) with a Confluence row: credentials live in `secrets.env` as the shared `ATLASSIAN_*` triple (with `CONFLUENCE_*` back-compat fall-back), loaded via `gateway/confluence_credentials.py` with mtime refresh; per-request Basic auth header; never reach the sandbox. +- Document the credential precedence (`ATLASSIAN_*` > `CONFLUENCE_*` per key) and the base-URL derivation (`/wiki` auto-appended when deriving from `ATLASSIAN_BASE_URL`). + +**Files**: +- `docs/architecture/credential-injection.md` (edit) + +### Task 6-3 — Update `sandbox/agent-config/rules/environment.md` + +- Add a `confluence` wrapper entry alongside `jira` and `gh`, with the eight verbs, a one-line example (`confluence page get 12345`, `confluence search 'space = ENG AND text ~ "RFC"'`), the body-format default note (`storage`), and a reminder that Confluence is private-mode-only. +- Note that **no per-pipeline env var** is set for Confluence (decision 13) — agents pass `pageId` / `spaceKey` directly in each call. + +**Files**: +- `sandbox/agent-config/rules/environment.md` (edit) + +### Task 6-4 — Add `docs/reference/confluence-wrapper.md` + +- Mirrors `docs/reference/jira-wrapper.md`. Covers: + - Endpoint surface (eight `POST /api/v1/confluence/*` routes). + - Request / response shapes (including the `not_found` envelope, redaction of `accountId` / `emailAddress` / `_links.webui`, and the v1 inline-comment fallback's `used_fallback` flag). + - Error cases (403 sub-events `confluence_space_denied` vs `confluence_upstream_403` vs `confluence_search_rejected` vs `confluence_execute_denied`). + - Space-allowlist semantics (`config/context-filters.yaml :: confluence.spaces`). + - Default `body-format=storage` behaviour and per-call overrides. + - **Bot-vs-human access caveat (Q9)** — operators must verify the bot's effective access before enabling the feature; surface this as a known limitation per architect Q1 (diagnostic command deferred to a follow-up ticket). + - Permanent denylist (attachments, restrictions, permissions, space.admin, users, DELETE/PUT/PATCH). + - Future-verb extension points (`page/create`, `page/update`, `comment/create`). + - **Prompt-injection caveat (risk R16)** — page bodies and ADF content are untrusted input and may carry instructions targeting the agent; the docs section explicitly warns reviewers and instructs callers to treat Confluence content as data, not directives. + - **Atlassian rate-limit runbook (risk R10)** — Atlassian Cloud's points-based throttling (March 2026 enforcement) details: the wrapper retries once on 429 honouring `Retry-After`; persistent throttling surfaces as `confluence_upstream_rate_limited` audit events; operator should provision a dedicated bot to avoid contention with human Confluence usage on the same Atlassian tenant. + - **Last-reviewed-date footer (risk R9)** — page footer carries `Last reviewed against Atlassian docs: ` so reviewers can spot stale endpoint-pinning. +- Cross-link from the two architecture docs above and from `docs/index.md` (lookup-table row alongside the Jira reference). + +**Files**: +- `docs/reference/confluence-wrapper.md` (new) +- `docs/index.md` (edit — add lookup-table row) + +**Acceptance**: `make docs` (or equivalent) builds cleanly; a human reader of `docs/index.md` can find the Confluence wrapper reference. + +--- + +## Dependencies + +``` +1-1 ─┐ +1-2 ─┼──► 2-1, 2-2, 2-3, 2-4, 2-5, 2-6, 2-7, 2-8, 2-9 ──► 4-5 +1-3 ─┤ +1-4 ─┘ + +1-1 ───► 4-1 +1-2 ───► 4-2 +1-3 ───► 4-3 +1-4 ───► 4-4 +2-* ───► 4-5 +3-1 ───► 4-6 + +5-1 (config scaffolding) — prerequisite for manual staging; unit tests build their own tmp yaml +5-2 (secrets template) — independent +5-3 (k8s comment) — independent +6-* documentation — independent of implementation; can land after Phase 2 is stable +4-7 (allowed_domains) — independent; can land any time in Phase 4 +``` + +- Phase 1 must land before Phase 2 (routes compose the foundation modules). +- Phase 3 (sandbox wrapper) depends on Phase 2 routes existing; it can be drafted in parallel and land in the same PR with mocked gateway tests in 4-6. +- Phase 4 tests land in the same PR as the code they cover (one commit per test file is fine). +- Phase 5 config is required for operator-side staging; not a blocker for unit tests (each test builds its own tmp YAML). +- Phase 6 docs are last, after final shape is stable. + +--- + +## Test Strategy + +**Automated** (Phase 4): + +- Gateway unit: `gateway/tests/test_confluence_credentials.py`, `test_confluence_client.py`, `test_confluence_policy.py`, `test_confluence_search.py` — pure-Python logic with `respx` / `httpx.MockTransport` and tmp config files. Cover the credential-precedence matrix, 429-retry, 404-envelope, 403 escalation, v1 fallback for inline comments, footer-comment nested-reply merge, `list_spaces` filtering, redaction, and the 13+-case adversarial CQL suite. +- Gateway route: `gateway/tests/test_confluence_routes.py` — Flask `client` + `private_mode_auth_headers` fixtures (same pattern as `test_jira_routes.py`). Includes a **route-enumeration regression test** asserting `__egg_requires_private_mode__` on every `/api/v1/confluence/*` view function (decision G7), the adversarial CQL suite end-to-end, the `confluence_upstream_403` audit-category split, redaction end-to-end, and the `list_spaces` allowlist filter end-to-end. +- Network-policy sanity: extend `gateway/tests/test_allowed_domains.py` parametrize list with `wiki.atlassian.net` and `confluence.atlassian.com`; the existing `*.atlassian.*` block-list invariant covers Confluence by extension. +- Sandbox wrapper: `tests/sandbox/test_confluence_wrapper.py` — subprocess against a mock gateway, one happy + one failure path per verb. +- Keep `make test` / CI green. No new CI job required. + +**Manual** (for the human reviewer): + +1. Copy `config/secrets.template.env` → `~/.config/egg/secrets.env` with real `ATLASSIAN_BASE_URL`, `ATLASSIAN_USERNAME`, `ATLASSIAN_API_TOKEN` (or the `CONFLUENCE_*` equivalents). Confirm the bot has read access to the spaces you intend to allowlist. +2. Add at least one space key under `confluence.spaces` in `config/context-filters.yaml`. +3. Start the gateway locally in **private mode** (`PRIVATE_MODE=1`); issue `curl -H "Authorization: Bearer " -d '{"pageId":""}' /api/v1/confluence/page/get` and assert you get the page JSON with `body.storage` populated and `accountId` / `emailAddress` redacted. +4. Repeat with `PRIVATE_MODE` unset / public mode and confirm 403 with `"endpoint requires private network mode"`. +5. Call `/api/v1/confluence/execute` with `method=DELETE` or `path=api/v2/pages/123/restrictions` and confirm 403. +6. Call `/api/v1/confluence/search` with CQL `space = ENG OR space = SEC` (SEC not allowlisted) and confirm 403 `confluence_search_rejected`. +7. Call `/api/v1/confluence/page/get` with a non-existent pageId in an allowlisted space and confirm HTTP 200 with `{"status":"not_found",...}`. +8. Call `/api/v1/confluence/space/list` and confirm the response contains only allowlisted spaces (decision 11). +9. Call `/api/v1/confluence/page/inline-comments` against a known-buggy v2 page (or use a test that forces v2 to 404) and confirm the `used_fallback` flag is set in the response. +10. Inside a sandbox container (or `docker compose` equivalent), run `confluence page get`, `confluence search`, `confluence space list`; confirm JSON returned. +11. Verify no Atlassian creds are visible inside the sandbox (`env | grep -Ei '^(ATLASSIAN_|CONFLUENCE_|JIRA_)'` should be empty). +12. `POST /api/v1/config/reload`; verify audit log shows `confluence_config_reloaded` fired. + +--- + +## Manual Pre/Post-Merge Steps + +**Pre-merge**: + +- Operator confirms the Atlassian bot account already used for Jira (per #1556) has read scope on the Confluence spaces being allowlisted. Atlassian's per-space permission UI lives under "Space settings → Permissions" — the bot needs at least "View" on every space in `confluence.spaces`. +- Operator decides which Confluence spaces to allowlist and edits `config/context-filters.yaml :: confluence.spaces` before enabling the feature in production. Leaving the list empty is a valid "install but don't use" state — every call will 403 until populated. +- Operator confirms `*.atlassian.net` is **not** in the Squid domain allowlist (`gateway/allowed_domains.txt`). The existing `test_atlassian_domains_absent` regression test enforces this; the parametrize-extension in Task 4-7 keeps Confluence-shaped hostnames in scope. +- If migrating off independent `JIRA_*` + `CONFLUENCE_*` triples to the shared `ATLASSIAN_*` triple, operators may copy the same value into all three triples during the cutover and remove the legacy keys later — the loader prefers `ATLASSIAN_*` per key. + +**Post-merge**: + +- Roll the gateway pod (`kubectl rollout restart deployment/gateway` or equivalent) so it reads the new `secrets.env` values and picks up the updated `config/context-filters.yaml`. (Or `POST /api/v1/config/reload` for a hot-reload.) +- Run the manual verification steps above against the live gateway. +- Notify the owners of `/impact-analysis` and any in-flight Jira-epic SDLC pipeline work (#1557) that the Confluence wrapper is available. + +--- + +```yaml +# yaml-tasks +pr: + title: |- + Add Confluence gateway wrapper with shared Atlassian creds (v1 read-only) + description: |- + Sandboxed egg agents currently have no way to read Confluence + pages. The host-side `mcp__confluence__*` MCP that bundles + Atlassian (Jira + Confluence) is unreachable from the agent + container and exposes the operator's full Atlassian API surface + with no space or verb allowlist — violating egg's zero-credential + and "infrastructure beats config" invariants. The `/impact-analysis` + skill and the in-flight Jira-epic SDLC pipeline work (#1557) need + to read Confluence pages linked from Jira tickets during the refine + phase, and they cannot until this lands. + + This PR adds read-only Confluence access through the existing + gateway sidecar, mirroring the `/api/v1/jira/*` pattern landed by + #1556 one component at a time: + + 1. **Gateway foundation** — new `gateway/confluence_credentials.py` + (Atlassian API-token loader with mtime refresh, prefers a shared + `ATLASSIAN_*` triple over per-service `CONFLUENCE_*` / + back-compat `JIRA_*`, derives Confluence base URL by appending + `/wiki` when only `ATLASSIAN_BASE_URL` is set); + `gateway/confluence_client.py` (`ConfluenceClient` class backed + by httpx, v2-first hybrid with v1 fallback for the known v2 + inline-comment 404 bug and v2 footer-comment nested-reply gap, + single-retry on HTTP 429 honouring `Retry-After`, synthesised + `not_found` envelope on 404 for read methods, distinct + `ConfluenceUpstreamForbidden(403)` so operators can spot + Atlassian permission denials separately, mandatory response + redaction stripping `accountId` / `emailAddress` / `_links.webui` + user-profile URLs, and a hardened `validate_confluence_api_path` + regex allowlist that refuses write verbs, `..` traversal, + non-ASCII, and the permanent `restrictions` / `permissions` / + `space.admin` / `users` / `attachments` denylist); + `gateway/confluence_policy.py` (space-allowlist reader backed + by a new `confluence:` section in `config/context-filters.yaml` + — key is `spaces`; fail-closed on missing/malformed YAML); and + `gateway/confluence_search.py` (conservative CQL extractor that + deny-on-ambiguity rejects any CQL it cannot statically prove is + scoped to allowlisted spaces — closes the regex-bypass path the + analysis flagged). + + 2. **Eight new routes plus a reload-hook extension in + `gateway/gateway.py`** — + `POST /api/v1/confluence/page/get`, + `POST /api/v1/confluence/page/descendants`, + `POST /api/v1/confluence/page/footer-comments` (with optional + `--include-replies` v1 fallback), + `POST /api/v1/confluence/page/inline-comments` (transparent v1 + fallback on v2 404 with a `used_fallback` flag in the response), + `POST /api/v1/confluence/space/pages`, + `POST /api/v1/confluence/space/list` (response filtered to + allowlisted spaces so agents cannot enumerate the full tenant + set), + `POST /api/v1/confluence/search` (backed by Atlassian's + v1-only `/wiki/rest/api/search`, with the conservative + `space = / space IN (...)` extractor), + `POST /api/v1/confluence/execute` (GET-only, regex-allowlisted + passthrough). `_reload_all_config()` is extended to call + `reload_confluence_credentials()` and `reload_confluence_policy()`. + All eight routes are `@require_session_auth` + + `@require_private_mode` + space-allowlist checked and produce + structured audit logs; the new `confluence_upstream_403` event + distinguishes Atlassian permission denials from gateway-side + allowlist denials. + + 3. **Sandbox wrapper** — new bash `sandbox/scripts/confluence` CLI + wrapper exposing the eight gateway verbs as Jira-style + subcommands (`page get`, `page descendants`, + `page footer-comments`, `page inline-comments`, `space pages`, + `space list`, `search`, `execute`) that calls the gateway with + `EGG_SESSION_TOKEN`, mirroring `sandbox/scripts/jira` exactly. + No per-pipeline env vars are added — Confluence is reference + material, not a unit of work, and audits recover `pageId` / + `spaceKey` from each request body. + + 4. **Tests + docs + config scaffolding** — unit + route + wrapper + tests using the existing `respx` / fixture pattern (including + 429-retry, 404 / 403-envelope, v1 inline-comment fallback, + footer-comment nested-reply merge, `list_spaces` filtering, + response redaction, the 13+-case adversarial CQL suite, and the + route-enumeration regression test); an extension to + `gateway/tests/test_allowed_domains.py` adding + `wiki.atlassian.net` / `confluence.atlassian.com` to the + block-list parametrize; a new `confluence:` section in + `config/context-filters.yaml`; a new `# Atlassian (shared)` + block in `config/secrets.template.env` plus removal of the + unused `CONFLUENCE_SPACE_KEYS` placeholder (replaced by the + YAML allowlist); updates to + `docs/architecture/network-isolation.md`, + `docs/architecture/credential-injection.md`, + `sandbox/agent-config/rules/environment.md`, and a new + `docs/reference/confluence-wrapper.md`. + + **Impact.** Sandboxed agents running in private network mode can + now read allowlisted Confluence spaces via the new `confluence` + wrapper. Atlassian credentials remain in the gateway exclusively + (zero additions to the sandbox env). Public-mode sessions cannot + reach Confluence — all eight routes return 403 before any upstream + call. The narrow verb surface plus the regex-allowlisted `execute` + escape hatch, combined with the permanent denylist (attachments, + restrictions, permissions, space.admin, users, DELETE / PUT / + PATCH), shape the code so the future writes scope (`page/create`, + `page/update`, `comment/create`) lands as three additional narrow + routes under the same decorator and policy plumbing, with no + re-architecting. Deferred to a future ticket: write idempotency + semantics (Q4), `page/resolve-by-url` (Q5), per-verb rate-limit + config, and any custom-macro PII redaction (Q3) once a real + payload is identified. + test_plan: |- + - Automated (Phase 4): + - gateway/tests/test_confluence_credentials.py — mtime refresh, missing-value error, base64 header shape, reload_confluence_credentials(); F1 precedence cases (ATLASSIAN_* alone with /wiki derivation, CONFLUENCE_* alone, mixed per-key fall-back). + - gateway/tests/test_confluence_client.py — URL/header/body construction per method, default body-format=storage and override accepted, validate_confluence_api_path positive (every allowed family) + negative (restrictions, permissions, space.admin, users, attachments, DELETE, PUT, PATCH, .., duplicate slashes, non-ASCII, non-numeric pageId), pagination cursor round-trip across descendants/space-pages/list-spaces/search-cql, 429 single retry honouring Retry-After (writes never retry), 404 envelope on read methods (search_cql + execute_raw raise instead), ConfluenceUpstreamForbidden on 403, v1 inline-comment fallback on v2 404 (used_fallback flag), v2 footer-comment nested-reply merge when include_replies=True, list_spaces filtering excludes non-allowlisted entries, redact_response strips accountId / emailAddress / _links.webui user-profile URLs while preserving page _links.webui. + - gateway/tests/test_confluence_policy.py — allowlist round-trip from confluence.spaces in tmp YAML, mtime reload, reload_confluence_policy(), missing file / missing section / missing spaces key / non-list shape / malformed YAML → empty set, mixed-case key preservation. + - gateway/tests/test_confluence_search.py — positive (space = ENG, space IN (ENG, DOCS), combined with AND text ~ "RFC"); negative grid (space under OR, mixed key/id clauses, uppercase SPACE, quoted key, CQL functions, semicolons, comment markers, IN-list with non-allowlisted key, missing clause, unicode homoglyphs, bare id/title clauses). + - gateway/tests/test_confluence_routes.py — for each of the eight routes, public-mode → 403 with private_mode_required audit, disallowed space → 403 (confluence_*_denied or confluence_space_denied), allowlisted happy path → 200 with body. 13+-case adversarial CQL suite end-to-end. /execute rejects write methods + denied verbs (restrictions, permissions, space.admin, users, attachments) + path traversal + disallowed spaces. Route-enumeration regression test asserts every /api/v1/confluence/* view function has __egg_requires_private_mode__=True. 404-envelope end-to-end. confluence_upstream_403 audit category surfaces for upstream 403s. list_spaces filtering end-to-end. redact_response end-to-end. used_fallback flag observable on inline-comment route. + - tests/sandbox/test_confluence_wrapper.py — subprocess-invoke sandbox/scripts/confluence against a mock gateway; one happy + one failure path per verb (8 verbs); --include-replies and --depth toggles reach the request body. + - gateway/tests/test_allowed_domains.py — extend parametrize list with wiki.atlassian.net + confluence.atlassian.com; existing assertion enforces atlassian.net / atlassian.com / api.atlassian.com / jira.atlassian.com remain absent (covers Confluence by extension). + - Manual: + 1. Fill ATLASSIAN_BASE_URL / ATLASSIAN_USERNAME / ATLASSIAN_API_TOKEN (or CONFLUENCE_* equivalents) in ~/.config/egg/secrets.env and add a space to config/context-filters.yaml :: confluence.spaces. Confirm the bot has read scope on that space in Atlassian's UI. + 2. Start the gateway in private mode and curl each of the eight routes with an allowlisted page; confirm JSON responses with body.storage populated and accountId / emailAddress redacted. + 3. Start in public mode; confirm every Confluence route returns 403 with private_mode_required. + 4. Call /api/v1/confluence/execute with method=DELETE and with path=api/v2/pages/123/restrictions; confirm 403 with confluence_execute_denied. + 5. Call /api/v1/confluence/search with CQL "space = ENG OR space = SEC" (SEC not allowlisted); confirm 403 confluence_search_rejected. + 6. Call /api/v1/confluence/page/get with a non-existent pageId in an allowlisted space; confirm HTTP 200 with a not_found envelope body. + 7. Call /api/v1/confluence/space/list; confirm the response contains only allowlisted spaces (no leak of the full tenant space set). + 8. Force /api/v1/confluence/page/inline-comments to hit the v2 404 bug (or use a test page known to trigger it); confirm the response contains used_fallback=true and the v1 payload. + 9. From inside a sandbox container, run confluence page get, confluence search, confluence space list; confirm JSON returned. + 10. env inside the sandbox shows no ATLASSIAN_* / CONFLUENCE_* / JIRA_* keys — only EGG_SESSION_TOKEN and the GATEWAY_URL. + 11. POST /api/v1/config/reload; confirm audit log shows confluence_config_reloaded fired. + manual_steps: |- + Pre-merge: + - Operator confirms the Atlassian bot account already used for Jira has read scope on every space being allowlisted (Atlassian: Space settings → Permissions → at least View for the bot user). + - Operator decides which Confluence spaces to allowlist and edits config/context-filters.yaml :: confluence.spaces before enabling the feature in production. Empty list is valid; keeps feature installed-but-inert. + - Operator confirms *.atlassian.net is NOT in the Squid domain allowlist (gateway/allowed_domains.txt). The extended test in Task 4-7 enforces this for Confluence-named hostnames. + - If migrating off independent JIRA_* / CONFLUENCE_* triples to the shared ATLASSIAN_* triple, copy the same value into all three triples during the cutover; the loader prefers ATLASSIAN_* per key. + Post-merge: + - Roll the gateway pod so it picks up the new secrets.env values and the updated context-filters.yaml, or POST /api/v1/config/reload for a hot-reload. + - Execute the manual verification steps from the test plan against the live gateway. + - Notify owners of #1557 (Jira-epic SDLC pipelines) and the /impact-analysis skill that the Confluence wrapper is available and unblocks their pipeline integration. +phases: + - id: 1 + name: |- + Gateway foundation + goal: |- + Confluence-specific credential loader (with shared `ATLASSIAN_*` precedence), Confluence REST client (v2-first hybrid plus v1 fallbacks plus redaction), space-allowlist loader, and CQL-scope extractor. No HTTP handlers yet; each module is independently testable. + tasks: + - id: TASK-1-1 + description: |- + Add `gateway/confluence_credentials.py` — mtime-cached Atlassian API-token loader mirroring + `gateway/jira_credentials.py`. Expose `get_confluence_credentials()` returning a dataclass with + `base_url` / `username` / `api_token` and a `basic_auth_header()` helper. + + Credential precedence (decision F1): prefer `ATLASSIAN_BASE_URL` / `ATLASSIAN_USERNAME` / + `ATLASSIAN_API_TOKEN` per key; fall back to `CONFLUENCE_BASE_URL` / `CONFLUENCE_USERNAME` / + `CONFLUENCE_API_TOKEN` per key (the two name shapes can be mixed). Base-URL derivation: if + `CONFLUENCE_BASE_URL` is unset and `ATLASSIAN_BASE_URL` is set, derive Confluence base as + `/wiki`; if `CONFLUENCE_BASE_URL` is set, use it verbatim (operators have + already added `/wiki`). + + Raise typed `ConfluenceCredentialsUnavailable` when any resolved value is missing. Expose + `reload_confluence_credentials()` for the gateway hot-reload hook (Task 2-9). + acceptance: |- + Unit test (4-1) loads creds from a tmp `secrets.env` and asserts the base64 Basic header; covers + `ATLASSIAN_*`-only with `/wiki` derivation, `CONFLUENCE_*`-only with verbatim URL, and mixed + per-key precedence; mtime invalidation triggers reload; missing values raise + `ConfluenceCredentialsUnavailable`; `reload_confluence_credentials()` clears the cache. + role: coder + files: + - gateway/confluence_credentials.py + - id: TASK-1-2 + description: |- + Add `gateway/confluence_client.py` — httpx-based Confluence REST client exposed as a + `ConfluenceClient(creds_provider, http_client)` class. Module exports a lazily-constructed + `get_confluence_client()` singleton for route handlers. + + Methods (per-verb endpoint pinning, decision B1): + `get_page(page_id, body_format=("storage",), expand=None)` → `GET /wiki/api/v2/pages/{id}`; + `get_page_descendants(page_id, depth=None, limit=None, cursor=None)` → `GET /wiki/api/v2/pages/{id}/descendants` (depth passed verbatim per Q6); + `get_page_footer_comments(page_id, body_format=("storage",), include_replies=False)` → `GET /wiki/api/v2/pages/{id}/footer-comments`, with v1-fallback merge from `GET /wiki/api/v2/footer-comments?page-id=...&depth=all` when `include_replies=True`; + `get_page_inline_comments(page_id, body_format=("storage",))` → `GET /wiki/api/v2/pages/{id}/inline-comments`, with transparent v1 fallback to `GET /wiki/rest/api/content/{id}/child/comment?location=inline&expand=body.view` on v2 404 (decision D1) and a `used_fallback` flag in the normalized envelope; + `list_spaces(allowed_spaces, limit=None, cursor=None)` → `GET /wiki/api/v2/spaces` filtered to `allowed_spaces` per decision 11; + `get_space_pages(space_id, limit=None, cursor=None, body_format=("storage",))` → `GET /wiki/api/v2/spaces/{space-id}/pages`; + `search_cql(cql, limit=None, cursor=None)` → `GET /wiki/rest/api/search` (v1-only); + `execute_raw(method, path, query, body)` passthrough. + + `validate_confluence_api_path(path, method)` — hardened regex allowlist mirroring + `validate_jira_api_path`. Strip leading/trailing slashes, strip query, reject `..` segments, + duplicate slashes, non-ASCII. Allowed families (GET only): `^api/v2/pages/\\d+$`, + `^api/v2/pages/\\d+/descendants$`, `^api/v2/pages/\\d+/footer-comments$`, + `^api/v2/pages/\\d+/inline-comments$`, `^api/v2/footer-comments$`, `^api/v2/inline-comments$`, + `^api/v2/spaces$`, `^api/v2/spaces/\\d+/pages$`, `^rest/api/search$`, + `^rest/api/content/\\d+/child/comment$`. `CONFLUENCE_DENIED_VERBS` frozenset: + `restrictions`, `permissions`, `space.admin`, `users`, `attachments` plus HTTP `DELETE` / + `PUT` / `PATCH` — return `(False, reason)` on any match. + + 429 handling (Q2): `_request` retries once on HTTP 429 sleeping `min(int(Retry-After header, + default 1), 30)` seconds; retry is GET-only; writes never retry. After the second 429 pass + through verbatim. Audit `confluence_upstream_rate_limited` on both 429s including the + `Retry-After` value and path. + + 404 envelope: `get_page` / `get_page_descendants` / `get_page_footer_comments` / + `get_page_inline_comments` / `get_space_pages` on upstream 404 return + `{"status": "not_found", "id": "...", "upstream_status": 404}` instead of raising. + `search_cql` and `execute_raw` still raise `ConfluenceUpstreamError` for 404. + + 403 handling (Q7, risk R15): on upstream 403 from any read method, raise + `ConfluenceUpstreamForbidden`. Route handlers translate to HTTP 403 with audit event + `confluence_upstream_403` (distinct from generic `confluence_upstream_error`) and a + structured response body + `{"status": "forbidden", "reason": "bot_account_lacks_read_access", "pageId"|"spaceKey": "..."}`. + Other 4xx/5xx raise `ConfluenceUpstreamError` preserving status + body. + + v1-fallback audit (architect Q3): each transparent v1 fallback emits a + `confluence_v1_fallback` audit entry `{endpoint, v2_status, page_id}` so operators can + monitor whether Atlassian has fixed the v2 bugs. + + Response redaction (decision 10, risk R6): apply `redact_response(payload)` to every + successful response before returning to routes. Recursively replace `accountId` and + `emailAddress` values with `""`; strip `_links.webui` URLs whose path begins + with `/people/` or otherwise resolves to a user-profile shape. Preserve page / space + `_links.webui` URLs. Walker covers ADF mention nodes and nested + `body.atlas_doc_format.content` structures. + + Payload-size cap (risk R7): expose + `CONFLUENCE_RESPONSE_MAX_BYTES = 5 * 1024 * 1024` so route handlers can refuse oversized + responses with HTTP 413 `confluence_response_too_large`. + + Boot-time observability (risk R12): at module import, emit a single INFO log line with + credential precedence resolved, allowlist size (looked up via `confluence_policy`), and + the `body-format=storage` default. Schema-mismatch reads emit ERROR-level audit entries + so operators see misconfiguration immediately. + + Page → space resolution caching (architect Q2): cache `spaceId → spaceKey` mappings for + 60 s in-process so the comment routes do not re-fetch the page just to verify the space. + acceptance: |- + Unit tests in Task 4-2 assert URL/header/body per method, default `body-format=storage` + (decision E5 tweak) plus override, positive + negative `validate_confluence_api_path` cases + (including `restrictions`, `permissions`, `space.admin`, `users`, `attachments`, DELETE, + PUT, PATCH, `..`, duplicate slashes, non-ASCII, non-numeric pageId), pagination cursor + round-trip, 429 single retry honouring `Retry-After`, writes never retry, 404 envelope on + read methods only, `ConfluenceUpstreamForbidden` raised on upstream 403, v1 inline-comment + fallback fires on v2 404 with `used_fallback` flag, footer-comment nested-reply merge when + `include_replies=True`, `list_spaces` filtering excludes non-allowlisted entries, + `redact_response` strips the three default keys recursively while preserving page + `_links.webui`. + role: coder + files: + - gateway/confluence_client.py + - id: TASK-1-3 + description: |- + Add `gateway/confluence_policy.py` — space-allowlist reader. Loads a `confluence.spaces` list + from `config/context-filters.yaml` with mtime-based refresh. Authoritative key is `spaces` + (not `space_allowlist`). Expose `allowed_spaces() -> frozenset`, `is_space_allowed(key)`, + and `reload_confluence_policy()` for the hot-reload hook. + + Validation: each entry must match `^[a-zA-Z][a-zA-Z0-9_]*$` (case-sensitive); non-string and + invalid entries dropped with a warning. Fail-closed when the file or `confluence:` / + `spaces:` section is missing or malformed — returns empty set and logs but does not crash. + acceptance: |- + Unit tests in Task 4-3 cover allowlist round-trip from `confluence.spaces`, mtime reload, + `reload_confluence_policy()` forces re-read, missing file / missing section / missing key / + non-list shape / malformed YAML → empty set without crash, mixed-case key preservation. + role: coder + files: + - gateway/confluence_policy.py + - id: TASK-1-4 + description: |- + Add `gateway/confluence_search.py` — conservative CQL space-scope extractor with + deny-on-ambiguity (decision C1). Direct port of `gateway/jira_search.py` adapted for CQL. + + API: `extract_search_spaces(cql, allowed) -> ScopeResult(spaces|None, reason)`. + + Reject: empty / non-string CQL, non-ASCII, forbidden char `;`, comment markers (`/* */`, + `--`, `//`), top-level `OR` at any depth, bare `id =` / `content =` / `title ~` clauses + without a `space` clause, `space` under any `OR`, capitalisation variants + (`SPACE = ENG`), quoted space keys (`space = "ENG"`), CQL functions in the `space` + operand (`currentUser()`, `recentlyViewedContent()`, `now()`, etc.), `space IN (K1, K2)` + containing any non-allowlisted key, missing `space` clause. + + Accept exactly two shapes (case-sensitive `space`): + `space = KEY` and `space IN (KEY1, KEY2, ...)`, optionally AND-combined at top level with + arbitrary additional clauses. + acceptance: |- + Unit tests in Task 4-4 cover positive (`space = ENG`, `space IN (ENG, DOCS)` with both + allowlisted, combined with `AND text ~ "RFC"`) and the full negative grid above. Each + rejection returns `(None, reason)` with the specific reason string passed through verbatim + to the route's `confluence_search_rejected` audit line. + role: coder + files: + - gateway/confluence_search.py + - id: 2 + name: |- + Gateway routes + goal: |- + Wire the Phase 1 pieces into eight `POST /api/v1/confluence/*` endpoints plus a reload hook; each route composes session auth, private-mode gate, space allowlist, client call, redaction (already in client), and audit log. + tasks: + - id: TASK-2-1 + description: |- + Add `POST /api/v1/confluence/page/get` to `gateway/gateway.py`. Body + `{"pageId": "", "bodyFormat": ["storage"], "expand": null}` — + validate `pageId` matches `^\\d+$`. Call `ConfluenceClient.get_page(...)`; pass the + not-found envelope through as HTTP 200; on `ConfluenceUpstreamForbidden` return HTTP 403 + with audit event `confluence_upstream_403` and structured body + `{"status":"forbidden","reason":"bot_account_lacks_read_access","pageId":"..."}` + (risk R15). Post-fetch space-allowlist check: resolve `spaceId` → `spaceKey` via the + 60 s in-process cache (architect Q2); if the resolved `spaceKey` is not in + `allowed_spaces()`, return HTTP 403 `confluence_space_denied` and do NOT forward the + response. Enforce the `CONFLUENCE_RESPONSE_MAX_BYTES = 5 MiB` payload cap (risk R7); + oversized responses return HTTP 413 `confluence_response_too_large`. Audit + `confluence_page_get` with `pageId`, `spaceKey`, `session_mode`, `pipeline_id`, + `agent_role`, `success`. + acceptance: |- + Happy path returns redacted JSON with `body.storage`; allowlist denial returns 403 with no + body fields leaked; not-found envelope passes through as HTTP 200; upstream 403 → HTTP 403 + with `confluence_upstream_403`. Covered by tests in Task 4-5. + role: coder + files: + - gateway/gateway.py + - id: TASK-2-2 + description: |- + Add `POST /api/v1/confluence/page/descendants` to `gateway/gateway.py`. Body + `{"pageId": "", "depth": null, "limit": null, "cursor": null}` — depth / limit / + cursor optional. Per risk R8: when caller omits, the route applies a default of + `depth=1` and `limit=25` to bound runaway responses on deeply nested space trees. + Caller-supplied values pass through verbatim (Q6 — no hard cap). Same `pageId` validation + and post-fetch space-allowlist check as Task 2-1. Audit `confluence_page_descendants` + with `pageId`, `spaceKey`, `depth`, `limit`. + acceptance: |- + Covered by tests in Task 4-5: 403 in public mode, 403 on disallowed space, 200 on happy + path with mocked descendants payload, depth parameter reaches the upstream URL verbatim. + role: coder + files: + - gateway/gateway.py + - id: TASK-2-3 + description: |- + Add `POST /api/v1/confluence/page/footer-comments` to `gateway/gateway.py`. Body + `{"pageId": "", "bodyFormat": ["storage"], "includeReplies": false, "limit": null, + "cursor": null}`. Calls `ConfluenceClient.get_page_footer_comments(..., include_replies=...)` + which handles the v1 fallback for nested replies (decision D1). Same post-fetch allowlist + check as 2-1. Audit `confluence_page_footer_comments`. + acceptance: |- + Covered by tests in Task 4-5: include_replies=True triggers the v2 footer-comments + nested + merge; include_replies=False returns only the top-level v2 response; allowlist enforced. + role: coder + files: + - gateway/gateway.py + - id: TASK-2-4 + description: |- + Add `POST /api/v1/confluence/page/inline-comments` to `gateway/gateway.py`. Body + `{"pageId": "", "bodyFormat": ["storage"], "limit": null, "cursor": null}`. Calls + `ConfluenceClient.get_page_inline_comments(...)` which transparently falls back to v1 on + v2's known 404 bug (decision D1). Post-fetch allowlist check as 2-1. Audit + `confluence_page_inline_comments` with a `used_fallback: bool` field exposing how often the + v1 path is exercised. + acceptance: |- + Covered by tests in Task 4-5: when v2 returns 404, response body contains + `used_fallback=true` and the v1 payload normalized into a `{"results": [...]}` envelope. + role: coder + files: + - gateway/gateway.py + - id: TASK-2-5 + description: |- + Add `POST /api/v1/confluence/space/pages` to `gateway/gateway.py`. Body + `{"spaceKey": "ENG", "limit": null, "cursor": null, "bodyFormat": ["storage"]}`. Validate + `spaceKey` matches `^[a-zA-Z][a-zA-Z0-9_]*$` and is in `allowed_spaces()` BEFORE any + upstream call. Resolve `spaceKey` → `spaceId` via `ConfluenceClient.list_spaces(allowed_spaces=allowed_spaces())`; + if no match, return HTTP 404 with `{"status":"not_found","spaceKey":"..."}`. Then call + `ConfluenceClient.get_space_pages(space_id, ...)`. Audit `confluence_space_pages`. + acceptance: |- + Covered by tests in Task 4-5: allowlist enforced on input; spaceKey → spaceId resolution + uses the allowlist-filtered `list_spaces` cache; 404 envelope when spaceKey is allowlisted + but Atlassian doesn't have it. + role: coder + files: + - gateway/gateway.py + - id: TASK-2-6 + description: |- + Add `POST /api/v1/confluence/space/list` to `gateway/gateway.py`. Body + `{"limit": null, "cursor": null}` (no input keys today). Calls + `ConfluenceClient.list_spaces(allowed_spaces=allowed_spaces(), ...)` so the response only + contains spaces the operator has allowlisted (decision 11). Audit `confluence_space_list` + with `spaces_returned: N`. + acceptance: |- + Covered by tests in Task 4-5: mocked upstream returns `[ENG, DOCS, LEAK]`; with allowlist + `{ENG, DOCS}` the route response contains only `ENG` + `DOCS`; cursor preserved when items + are filtered out. + role: coder + files: + - gateway/gateway.py + - id: TASK-2-7 + description: |- + Add `POST /api/v1/confluence/search` to `gateway/gateway.py`. Body + `{"cql": "...", "limit": null, "cursor": null}`. Run + `extract_search_spaces(cql, allowed_spaces())`; on rejection return HTTP 403 + `confluence_search_rejected` with the specific reason in the audit details. On accept, + clamp `limit` to 100 (default 50); call `ConfluenceClient.search_cql(cql, limit, cursor)`. + Audit `confluence_search` with `spaces_extracted`, `cql_length`, `session_mode`, + `pipeline_id`, `agent_role`, `success`. `pageId` is intentionally absent on search audits. + acceptance: |- + Covered by tests in Task 4-5: positive `space = ENG AND text ~ "RFC"` returns 200; the + 13+-case adversarial CQL suite returns 403 with the matched reason; clamp test confirms + `limit > 100` becomes 100. + role: coder + files: + - gateway/gateway.py + - id: TASK-2-8 + description: |- + Add `POST /api/v1/confluence/execute` to `gateway/gateway.py`. Body shape mirrors + `/api/v1/jira/execute` (`method`, `path`, `query`, `body`). Call + `validate_confluence_api_path(path, method)`; refuse non-GET, denied verbs (`restrictions`, + `permissions`, `space.admin`, `users`, `attachments`), path traversal, duplicate slashes, + non-ASCII with HTTP 403 `confluence_execute_denied` including reason. After path validation, + if the path targets a `pages/{id}` or `spaces/{id}` family, perform the same post-fetch + space-allowlist check as 2-1; for path families without an obvious `spaceId` + (e.g., `api/v2/footer-comments`), require a `spaceKey` query parameter and validate it + up-front. Call `ConfluenceClient.execute_raw(...)` and return the redacted body. Audit + `confluence_execute` on success. + acceptance: |- + Covered by tests in Task 4-5: allowlisted GET passes; any POST/PUT/PATCH/DELETE returns 403; + `restrictions` / `permissions` / `space.admin` / `users` / `attachments` paths return 403 + even with GET; path containing `..` returns 403; disallowed space returns 403; audit + entries recorded. + role: coder + files: + - gateway/gateway.py + - id: TASK-2-9 + description: |- + Extend `_reload_all_config()` in `gateway/gateway.py` to call + `reload_confluence_credentials()` and `reload_confluence_policy()` alongside the existing + Jira reloads. Log a single structured audit entry covering both reloads + (`confluence_config_reloaded`). + acceptance: |- + `POST /api/v1/config/reload` triggers both reloads; updates to `secrets.env` and + `context-filters.yaml` are visible to the next Confluence request without restarting the + gateway process. Covered by tests in Task 4-5. + role: coder + files: + - gateway/gateway.py + - id: 3 + name: |- + Sandbox wrapper + goal: |- + Ship a bash `sandbox/scripts/confluence` wrapper exposing the eight gateway verbs as Jira-style subcommands; no per-pipeline env vars or session fields are added (decision 13). + tasks: + - id: TASK-3-1 + description: |- + Add `sandbox/scripts/confluence` — bash script (shebang `/bin/bash`) mirroring + `sandbox/scripts/jira`. Verbs (Jira-style only per Q10): + + `confluence page get [--body-format storage,atlas_doc_format] [--expand ...]`, + `confluence page descendants [--depth N] [--limit N] [--cursor TOK]`, + `confluence page footer-comments [--include-replies] [--body-format ...] [--limit N] [--cursor TOK]`, + `confluence page inline-comments [--body-format ...] [--limit N] [--cursor TOK]`, + `confluence space pages [--limit N] [--cursor TOK] [--body-format ...]`, + `confluence space list [--limit N] [--cursor TOK]`, + `confluence search '' [--limit N] [--cursor TOK]`, + `confluence execute [--query k=v,...] [--body-file path]`, + `confluence help`. + + All calls POST to the gateway with `Authorization: Bearer $EGG_SESSION_TOKEN` and + heredoc-Python JSON construction, matching the `sandbox/scripts/jira` pattern exactly. + Print JSON on stdout, errors on stderr, non-zero exit on non-2xx. Self-contained — do + not factor out a shared helper library. + acceptance: |- + Integration tests in Task 4-6 invoke the wrapper as a subprocess against a mocked gateway + and assert request body, path, `Authorization` header, stdout, and exit codes for each + verb (happy and failure paths). `--include-replies` and `--depth` reach the request body. + role: coder + files: + - sandbox/scripts/confluence + - id: 4 + name: |- + Tests + goal: |- + Cover Phases 1-3 with automated suites plus the route-enumeration regression test, adversarial-CQL suite, redaction end-to-end, and the allowed_domains parametrize extension for Confluence-named hostnames. + tasks: + - id: TASK-4-1 + description: |- + Add `gateway/tests/test_confluence_credentials.py`. Cover mtime cache refresh, missing-value + typed exception, `basic_auth_header()` base64 shape, `reload_confluence_credentials()` + clears cache, and the F1 precedence cases: `ATLASSIAN_*` triple alone yields the correct + base URL with `/wiki` appended; `CONFLUENCE_*` triple alone uses the verbatim URL; mixed + per-key fall-back resolves the right value for each key. + acceptance: |- + Tests pass under `make test` / `pytest gateway/tests/test_confluence_credentials.py`; + coverage hits the new precedence branches in `gateway/confluence_credentials.py`. + role: tester + files: + - gateway/tests/test_confluence_credentials.py + - id: TASK-4-2 + description: |- + Add `gateway/tests/test_confluence_client.py`. Cover URL/header/body construction per + method (mocked via `respx` or `httpx.MockTransport`); default `body-format=storage` and + override accepted; `validate_confluence_api_path` positive (every allowed family) and + negative (`restrictions`, `permissions`, `space.admin`, `users`, `attachments`, DELETE, + PUT, PATCH, `..`, duplicate slashes, non-ASCII, non-numeric pageId, random 404 paths); + attachments-denylist variants per risk R14 (bare `attachments`, URL-encoded + `%61ttachments`, nested `pages/123/attachments`); pagination cursor round-trip across + descendants / space-pages / list-spaces / search-cql; 429 retry (first 429 with + `Retry-After` → retried; second 429 → `ConfluenceUpstreamError` status 429; writes never + retry); 404 envelope (read methods return dict; `search_cql` and `execute_raw` raise); + `ConfluenceUpstreamForbidden` raised on upstream 403; v1 inline-comment fallback fires on + v2 404 with `used_fallback` flag observable AND emits `confluence_v1_fallback` audit + entry with `v2_status` (architect Q3); v2 footer-comment nested-reply merge when + `include_replies=True`; `list_spaces` filtering excludes non-allowlisted entries with + strict case-sensitive intersection (risk R13 — case-mismatch test asserts no fuzzy + match); `redact_response` strips `accountId` / `emailAddress` / `_links.webui` + user-profile URLs while preserving page `_links.webui`, with fixtures including ADF + mention nodes and nested `body.atlas_doc_format.content` structures (risk R6); credential + precedence matrix exercises all six combinations (risk R11); each test file carries at + least one Confluence-original fixture justified in a comment (risk R17 — e.g. + `text ~ "RFC"`, ADF mention node, footer-comment with nested replies, v2-404 inline- + comment fixture). + acceptance: |- + Tests pass; both allowlist positive and negative branches covered; pagination verified; + 429 retry, 404 envelope, 403 escalation, v1 fallback, footer-comment merge, + `list_spaces` filtering, and redaction invariants enforced. + role: tester + files: + - gateway/tests/test_confluence_client.py + - id: TASK-4-3 + description: |- + Add `gateway/tests/test_confluence_policy.py`. Cover allowlist round-trip from a tmp + `context-filters.yaml` using key `spaces`; mtime reload; `reload_confluence_policy()` + forces re-read; missing file / missing `confluence:` section / missing `spaces:` key / + non-list shape / malformed YAML → empty set (fail-closed) without raising; mixed-case + key preservation. + acceptance: |- + Tests pass; fail-closed behaviour asserted; reload forces re-read. + role: tester + files: + - gateway/tests/test_confluence_policy.py + - id: TASK-4-4 + description: |- + Add `gateway/tests/test_confluence_search.py`. Positive cases (`space = ENG`, + `space IN (ENG, DOCS)` with both allowlisted, combined with `AND text ~ "RFC"`) and the + negative grid (`space = ENG OR space = SEC`, `space = ENG OR id = "12345"`, + `SPACE = ENG`, `space = "ENG"`, `space = currentUser()`, + `space = recentlyViewedContent()`, `space = ENG ; drop table`, + `space = ENG /* comment */`, `space IN (ENG, SEC)` with SEC not allowlisted, + missing clause, unicode homoglyph keys, bare `id =` clause, bare `title ~` clause). + acceptance: |- + Tests pass; every positive case returns the extracted space set, every negative case + returns `(None, reason)` with the matched reason. + role: tester + files: + - gateway/tests/test_confluence_search.py + - id: TASK-4-5 + description: |- + Add `gateway/tests/test_confluence_routes.py`. Use the existing `client` and + `private_mode_auth_headers` fixtures. For each of the eight routes, assert public-mode + → 403 with `private_mode_required` audit; private-mode + disallowed space → 403; private- + mode + allowlisted space + mocked upstream → 200 with body. For `/search`, run the 13+- + case adversarial CQL suite end-to-end and assert each returns 403 + `confluence_search_rejected` with the specific reason. For `/execute`, assert rejection + of write methods, denied verbs (`restrictions`, `permissions`, `space.admin`, `users`, + `attachments`) — including bare, URL-encoded, and nested-position variants per risk R14; + plus a route-vs-execute anti-bypass test (risk R2) asserting no narrow-route path family + (`pages/{id}`, `spaces/{id}/pages`, `rest/api/search`) can be invoked via `/execute` to + bypass narrow-route policy. Route-enumeration regression test iterates `app.url_map` + for `/api/v1/confluence/*` and asserts each view function has + `__egg_requires_private_mode__ == True`. 404-envelope end-to-end for read routes. + `confluence_upstream_403` audit category surfaces for upstream 403s with structured body + including `reason: bot_account_lacks_read_access` (risk R15). `list_spaces` filtering + end-to-end including the case-mismatch test (risk R13). `redact_response` end-to-end + (mocked upstream payload with `accountId` / `emailAddress` returns `` to the + sandbox). `used_fallback` flag observable on inline-comment route AND + `confluence_v1_fallback` audit entry recorded. HTTP 413 + `confluence_response_too_large` returned when mocked upstream exceeds the 5 MiB cap + (risk R7). Descendants route default `depth=1` / `limit=25` exercised when omitted + (risk R8); caller-supplied values pass through verbatim. Audit-log assertions: every + event includes the expected fields per route. + acceptance: |- + Tests pass; every acceptance criterion in Phase 2 has at least one covering test case; + route enumeration catches any future Confluence route missing the decorator. + role: tester + files: + - gateway/tests/test_confluence_routes.py + - id: TASK-4-6 + description: |- + Add `tests/sandbox/test_confluence_wrapper.py` (alongside `tests/sandbox/test_jira_wrapper.py`). + Subprocess-invoke `sandbox/scripts/confluence` against a local mock gateway + (httpretty / responses / a fixture Flask app). Assert request body, path, and + `Authorization` header for each of the eight verbs; assert JSON on stdout on success + and non-zero exit on upstream 4xx/5xx; happy + failure path per verb; `--include-replies` + and `--depth` toggles reach the request body. + acceptance: |- + Tests pass; each verb has at least a happy-path and a failure-path case. + role: tester + files: + - tests/sandbox/test_confluence_wrapper.py + - id: TASK-4-7 + description: |- + Edit `gateway/tests/test_allowed_domains.py` to extend the `bad_substr` parametrize list + with `wiki.atlassian.net` and `confluence.atlassian.com` so the regression coverage + explicitly names Confluence-shaped hostnames (in addition to the existing `atlassian.net` / + `atlassian.com` / `api.atlassian.com` / `jira.atlassian.com` block). Add a docstring + sentence noting the test covers Confluence by extension so a future + `grep -i confluence gateway/tests/` surfaces it. + acceptance: |- + Tests pass; the new substrings are in the parametrize list; the existing block-list + invariant still holds for all six substrings. + role: tester + files: + - gateway/tests/test_allowed_domains.py + - id: 5 + name: |- + Config scaffolding + k8s + goal: |- + Add the operator-facing `confluence:` allowlist section, scaffold the shared `ATLASSIAN_*` credential block and drop the unused `CONFLUENCE_SPACE_KEYS` placeholder, and confirm the existing k8s `secrets.env` mount delivers the new keys. + tasks: + - id: TASK-5-1 + description: |- + Edit `config/context-filters.yaml` to add a `confluence:` section after the existing + `jira:` section. Authoritative key is `spaces` — a list of Atlassian Confluence space + keys allowed for read access (case-sensitive). Default empty list (fail-closed). Include + a heredoc comment explaining the semantics, the case-sensitivity, and the link to + `gateway/confluence_search.py` for the CQL extractor rules — mirror the prose style of + the existing `jira:` section. + acceptance: |- + Gateway starts cleanly with the default empty list; + `confluence_policy.allowed_spaces()` returns an empty set; every Confluence call rejected + until operator populates the list. + role: coder + files: + - config/context-filters.yaml + - id: TASK-5-2 + description: |- + Edit `config/secrets.template.env` to add a new `# Atlassian (shared)` block above the + existing Jira / Confluence blocks containing `ATLASSIAN_BASE_URL`, `ATLASSIAN_USERNAME`, + `ATLASSIAN_API_TOKEN` (the shared triple per decision F1). Keep the existing `JIRA_*` + and `CONFLUENCE_*` blocks intact for back-compat but DROP `CONFLUENCE_SPACE_KEYS` (line + 99 today) — supplanted by `config/context-filters.yaml :: confluence.spaces` per + decision H1. Replace the dropped key with a single comment line pointing operators at + the YAML. Update the `CONFLUENCE_BASE_URL` comment to mention that the `/wiki` suffix + is still required when this key is set explicitly (the loader auto-appends `/wiki` only + when deriving from `ATLASSIAN_BASE_URL`). + acceptance: |- + `config/secrets.template.env` no longer contains `CONFLUENCE_SPACE_KEYS`; the new + `ATLASSIAN_*` block is present with documentation; the `CONFLUENCE_BASE_URL` comment + references the `/wiki` suffix. + role: coder + files: + - config/secrets.template.env + - id: TASK-5-3 + description: |- + Edit `k8s/base/gateway-deployment.yaml` — add an inline comment listing + `ATLASSIAN_BASE_URL` / `ATLASSIAN_USERNAME` / `ATLASSIAN_API_TOKEN` (and the existing + `CONFLUENCE_*` keys for back-compat) alongside the existing GitHub / Anthropic / Jira + credential keys (comment-only change; the existing `secrets.env` mount already delivers + them). + acceptance: |- + `kubectl apply --dry-run=client -f k8s/base/gateway-deployment.yaml` succeeds; diff is + comment-only. + role: coder + files: + - k8s/base/gateway-deployment.yaml + - id: 6 + name: |- + Documentation + goal: |- + Make the Confluence wrapper discoverable and connect it to the architecture docs the analysis called out. + tasks: + - id: TASK-6-1 + description: |- + Update `docs/architecture/network-isolation.md` — add `/api/v1/confluence/*` to the + gateway endpoint table with a "private-mode only; fails closed in public mode" note, + and restate in the egress-policy section that `*.atlassian.net` is not in the Squid + allowlist (Confluence traffic flows through the gateway alongside Jira). + acceptance: |- + Endpoint table entry present; Squid statement updated to mention Confluence; doc renders + cleanly. + role: documenter + files: + - docs/architecture/network-isolation.md + - id: TASK-6-2 + description: |- + Update `docs/architecture/credential-injection.md` — extend the Atlassian section + (added by #1556) with a Confluence row describing the shared `ATLASSIAN_*` triple + (with `CONFLUENCE_*` back-compat fall-back) loaded via `gateway/confluence_credentials.py`, + per-request Basic auth header, and the reload hook. Document the credential precedence + (`ATLASSIAN_*` > `CONFLUENCE_*` per key) and the base-URL `/wiki` derivation. Emphasise + creds never reach the sandbox. + acceptance: |- + Atlassian/Confluence row present; precedence + base-URL derivation documented; cross-links + to `confluence_credentials.py` and the new `/api/v1/confluence/*` endpoints. + role: documenter + files: + - docs/architecture/credential-injection.md + - id: TASK-6-3 + description: |- + Update `sandbox/agent-config/rules/environment.md` — add a `confluence` wrapper entry + alongside `jira` and `gh`, including the eight verbs, a one-line example + (`confluence page get 12345`, `confluence search 'space = ENG AND text ~ "RFC"'`), the + body-format default note (`storage`), a reminder that Confluence is private-mode-only, + and a note that no per-pipeline env var is set for Confluence (decision 13) — agents + pass `pageId` / `spaceKey` directly in each call. + acceptance: |- + Wrapper entry present and consistent with the `jira` entry style. + role: documenter + files: + - sandbox/agent-config/rules/environment.md + - id: TASK-6-4 + description: |- + Add `docs/reference/confluence-wrapper.md`. Mirrors `docs/reference/jira-wrapper.md`. + Cover: endpoint surface (eight `POST /api/v1/confluence/*` routes); request/response + shapes including the `not_found` envelope, redaction of `accountId` / `emailAddress` / + `_links.webui`, and the v1 inline-comment fallback's `used_fallback` flag plus the + `confluence_v1_fallback` audit shape (architect Q3); error cases (403 sub-events + `confluence_space_denied` vs `confluence_upstream_403` with + `bot_account_lacks_read_access` reason vs `confluence_search_rejected` vs + `confluence_execute_denied`; HTTP 413 `confluence_response_too_large` on >5 MiB + payloads); space-allowlist semantics; default `body-format=storage` and per-call + overrides; bot-vs-human access caveat as a known limitation (Q9 / architect Q1 — + diagnostic command deferred); prompt-injection caveat for page bodies (risk R16); + Atlassian rate-limit runbook including the March 2026 points-based enforcement (risk + R10); descendants default `depth=1` / `limit=25` when omitted (risk R8); permanent + denylist (attachments, restrictions, permissions, space.admin, users, DELETE/PUT/PATCH); + future-verb extension points (`page/create`, `page/update`, `comment/create`); a + `Last reviewed against Atlassian docs: ` footer (risk R9). Cross-link from the + two architecture docs above. Edit `docs/index.md` to add a lookup-table row alongside + the Jira reference. + acceptance: |- + File exists and covers the listed sections; linked from `network-isolation.md`, + `credential-injection.md`, and `docs/index.md`. + role: documenter + files: + - docs/reference/confluence-wrapper.md + - docs/index.md +``` From 594fd2b5f2804c3917b5dbac8ffc1521236d1a5a Mon Sep 17 00:00:00 2001 From: egg Date: Mon, 27 Apr 2026 00:08:38 +0000 Subject: [PATCH 08/26] plan: address reviewer_plan NACK (#1931) Blocking fixes: - Add TASK-1-5 to update gateway/jira_credentials.py for the same ATLASSIAN_*-preferred / JIRA_* fall-back precedence (risk R11). Add TASK-4-1b extending test_jira_credentials.py with the six-combination matrix. Update PR description and pre-merge note so the shared-credential migration story is portable (operators can drop the legacy JIRA_* block once ATLASSIAN_* is fully populated without silently breaking Jira). - Specify v1-also-404 fall-through behaviour for inline-comment fallback in TASK-1-2: v1 200-empty -> {results: [], used_fallback}; v1 404 -> standard not_found envelope with used_fallback=true. Non-blocking fixes: - Replace "decision E5" with "decision-5" (8 sites). - Move boot-time policy log to confluence_policy.py; client logs only body-format default; credentials log precedence (risk R12). - Add `attempt: 1|2` to confluence_upstream_rate_limited audit shape. - Two-sided spaceId<->spaceKey LRU cache populated by both list_spaces and get_page so /space/pages cold-start avoids double round-trip. - Mention architect Q4 in TASK-1-1 acceptance. - Replace PRIVATE_MODE env-var references with private-mode session language (private mode is g.session_mode, not a process env var). - Note TASK-4-7 must verify existing test name at implement time. - Note show-metrics.md is intentionally untouched. - Add Atlassian rate-limit pool-sharing note to TASK-6-4. - TASK-2-9 audit-event shape mirrors whatever the existing Jira reload emits. Co-Authored-By: Claude Opus 4.7 --- .egg-state/drafts/1931-plan.md | 129 ++++++++++++++++++++++++++------- 1 file changed, 102 insertions(+), 27 deletions(-) diff --git a/.egg-state/drafts/1931-plan.md b/.egg-state/drafts/1931-plan.md index 10b132dde5..ad51d3dadf 100644 --- a/.egg-state/drafts/1931-plan.md +++ b/.egg-state/drafts/1931-plan.md @@ -71,7 +71,7 @@ extractor. No HTTP handlers yet. Each piece is independently testable. - Raise typed `ConfluenceCredentialsUnavailable` when any of the three resolved values is missing/blank; route layer translates to HTTP 503. - Expose `reload_confluence_credentials()` for the hot-reload hook (Task 2-9). -**Acceptance**: Unit test (4-1) points `EGG_SECRETS_PATH` at a tmp file containing `ATLASSIAN_*` and asserts the header string + base URL with `/wiki` appended; same test with `CONFLUENCE_*`-only secrets uses the verbatim URL; a third case with both prefers `ATLASSIAN_*` per-key and falls back to `CONFLUENCE_*` for any missing one; touching the tmp file invalidates the cache on the next call; missing values raise the typed exception; `reload_confluence_credentials()` clears the cache immediately. +**Acceptance**: Unit test (4-1) points `EGG_SECRETS_PATH` at a tmp file containing `ATLASSIAN_*` and asserts the header string + base URL with `/wiki` appended; same test with `CONFLUENCE_*`-only secrets uses the verbatim URL; a third case with both prefers `ATLASSIAN_*` per-key and falls back to `CONFLUENCE_*` for any missing one; touching the tmp file invalidates the cache on the next call; missing values raise the typed exception; `reload_confluence_credentials()` clears the cache immediately. **Per architect Q4**: no shared `atlassian_credentials.py` helper is extracted in v1; both `confluence_credentials.py` and `jira_credentials.py` (after Task 1-5) duplicate the loader skeleton for review clarity. Track shared extraction as a follow-up backlog item. **Files**: - `gateway/confluence_credentials.py` (new) @@ -84,7 +84,7 @@ extractor. No HTTP handlers yet. Each piece is independently testable. - `get_page(page_id, body_format=("storage",), expand=None)` → `GET /wiki/api/v2/pages/{id}` with `body-format=storage` by default. Caller may override `body_format` to a list/tuple containing any of `("storage", "atlas_doc_format", "view", "export_view")`. Comma-joined into the v2 query string. - `get_page_descendants(page_id, depth=None, limit=None, cursor=None)` → `GET /wiki/api/v2/pages/{id}/descendants`. Pass `depth`, `limit`, `cursor` through verbatim (Q6). - `get_page_footer_comments(page_id, body_format=("storage",), include_replies=False)` → `GET /wiki/api/v2/pages/{id}/footer-comments` (decision D1). When `include_replies=True`, follow up with `GET /wiki/api/v2/footer-comments?page-id={id}&depth=all` and merge the nested replies into the response under a normalized envelope `{"results": [...], "_replies": {...}}`. - - `get_page_inline_comments(page_id, body_format=("storage",))` → `GET /wiki/api/v2/pages/{id}/inline-comments`. **v2 → v1 fallback**: if v2 returns 404, retry transparently against `GET /wiki/rest/api/content/{id}/child/comment?location=inline&expand=body.view` (the known v2 inline-comment 404 bug per the analysis). Return the v1 response normalized into a `{"results": [...]}` envelope. + - `get_page_inline_comments(page_id, body_format=("storage",))` → `GET /wiki/api/v2/pages/{id}/inline-comments`. **v2 → v1 fallback**: if v2 returns 404, retry transparently against `GET /wiki/rest/api/content/{id}/child/comment?location=inline&expand=body.view` (the known v2 inline-comment 404 bug per the analysis). **Fall-through behaviour**: if v1 returns **200 with an empty `results` list**, return the v1 payload normalized into `{"results": [], "used_fallback": true}` (distinguishes "v2 bug + page exists with no inline comments" from "page actually not found"). If v1 also returns **404**, the page genuinely does not exist — return the standard not-found envelope `{"status": "not_found", "id": "...", "upstream_status": 404, "used_fallback": true}` matching every other read method. If v1 returns 200 with comments, return the v1 payload normalized into `{"results": [...], "used_fallback": true}`. - `list_spaces(allowed_spaces, limit=None, cursor=None)` → `GET /wiki/api/v2/spaces`. **Filter the response so only spaces whose `key` is in `allowed_spaces` are returned (decision 11)**. The cursor `next` is preserved if any allowlisted spaces were filtered out so callers can paginate. - `get_space_pages(space_id, limit=None, cursor=None, body_format=("storage",))` → `GET /wiki/api/v2/spaces/{space-id}/pages`. (Note: v2 uses numeric `space-id`; the route layer maps a `spaceKey` from the request body to the numeric id by calling `list_spaces` first if the agent supplied a key.) - `search_cql(cql, limit=None, cursor=None)` → `GET /wiki/rest/api/search?cql=...&limit=...&cursor=...`. v1-only — there is no v2 CQL endpoint. @@ -101,11 +101,11 @@ extractor. No HTTP handlers yet. Each piece is independently testable. - `^rest/api/search$` - `^rest/api/content/\d+/child/comment$` *(v1 fallback for inline comments — same endpoint family v1)* - `CONFLUENCE_DENIED_VERBS`: explicit frozenset — `"restrictions"`, `"permissions"`, `"space.admin"`, `"users"`, `"attachments"` (decision 12), plus HTTP `"DELETE"`, `"PUT"`, `"PATCH"`. `validate_confluence_api_path` returns `(False, reason)` whenever the path contains any denied verb or the method is not GET. This is the permanent "out of scope ever" fence. -- **429 handling (Q2)**: `_request(...)` retries **once** on HTTP 429, sleeping `min(int(response.headers.get("Retry-After", "1")), 30)` seconds. Retry is GET-only. After the second 429, pass it through verbatim. Emit a structured `audit_log("confluence_upstream_rate_limited", ..., details={"retry_after": ..., "path": ...})` on both 429s. Identical to the Jira client's stance. +- **429 handling (Q2, risk R10)**: `_request(...)` retries **once** on HTTP 429, sleeping `min(int(response.headers.get("Retry-After", "1")), 30)` seconds. Retry is GET-only. After the second 429, pass it through verbatim. Emit a structured `audit_log("confluence_upstream_rate_limited", ..., details={"retry_after": ..., "path": ..., "attempt": 1|2})` on both 429s — the `attempt` field lets operators see whether the retry succeeded. Identical to the Jira client's stance. - **404 envelope**: for `get_page`, `get_page_descendants`, `get_page_footer_comments`, `get_page_inline_comments`, and `get_space_pages`, on upstream 404 the client returns `{"status": "not_found", "id": "...", "upstream_status": 404}` instead of raising. `search_cql` and `execute_raw` still raise `ConfluenceUpstreamError` for 404. - **403 envelope (Q7, risk R15)**: on upstream 403 from any read method, the client raises `ConfluenceUpstreamForbidden(status_code=403, body=...)`. Route handlers translate this to HTTP 403 with audit event `confluence_upstream_403` (distinct from generic `confluence_upstream_error`) and a structured response body `{"status": "forbidden", "reason": "bot_account_lacks_read_access", "pageId"|"spaceKey": "..."}` so the agent can act on the precise denial cause without ambiguity. - **v1-fallback audit (architect Q3)**: every transparent v1 fallback (inline-comment 404, footer-comment nested-reply gap) emits a `confluence_v1_fallback` audit entry with `{endpoint: "inline_comments"|"footer_comments_nested", v2_status: , page_id: ...}` so operators can monitor whether Atlassian has fixed the v2 bugs and we can retire the fallback later. -- **Boot-time observability (risk R12)**: at module import, the credential / policy / client managers log a single INFO line each summarising the loaded state — credential precedence resolved, allowlist size, and `body-format=storage` default. Schema-mismatch reads (e.g., `confluence: {spaces: "ENG"}` instead of a list) emit ERROR-level audit entries so operators see the misconfiguration in the gateway logs immediately rather than discovering it via failed agent calls. +- **Boot-time observability (risk R12)**: ownership split — `gateway/confluence_credentials.py` logs a single INFO line at first credential load summarising the credential precedence resolved (`ATLASSIAN_*` vs `CONFLUENCE_*` per key); `gateway/confluence_policy.py` logs a single INFO line at first `allowed_spaces()` call with the allowlist size; the client logs the `body-format=storage` default at first invocation. Schema-mismatch reads (e.g., `confluence: {spaces: "ENG"}` instead of a list) in the policy module emit ERROR-level audit entries so operators see the misconfiguration in the gateway logs immediately rather than discovering it via failed agent calls. - Other upstream 4xx/5xx raise `ConfluenceUpstreamError(status_code=..., body=...)` for the route layer to translate. - **Response redaction (decision 10)**: `redact_response(payload)` walks the JSON response and strips: - `accountId` keys at any depth (replace with `""`). @@ -116,7 +116,7 @@ extractor. No HTTP handlers yet. Each piece is independently testable. **Files**: - `gateway/confluence_client.py` (new — class + validation helpers + redaction) -**Acceptance** (paired with Task 4-2): URL/header/body construction per method, default `body-format=storage` (decision E5 tweak), `body_format` override accepted, `validate_confluence_api_path` positive (every family above) + negative (`restrictions`, `permissions`, `space.admin`, `users`, `attachments`, DELETE, PUT, PATCH, `..`, duplicate slashes, non-ASCII), 429 single retry honouring `Retry-After`, second 429 surfaces error, 404 envelope on read methods, 403 raises `ConfluenceUpstreamForbidden`, v1 inline-comment fallback fires on v2 404, `list_spaces` filtering excludes non-allowlisted entries, `redact_response` strips the three default keys recursively. +**Acceptance** (paired with Task 4-2): URL/header/body construction per method, default `body-format=storage` (decision-5 tweak), `body_format` override accepted, `validate_confluence_api_path` positive (every family above) + negative (`restrictions`, `permissions`, `space.admin`, `users`, `attachments`, DELETE, PUT, PATCH, `..`, duplicate slashes, non-ASCII), 429 single retry honouring `Retry-After`, second 429 surfaces error, 404 envelope on read methods, 403 raises `ConfluenceUpstreamForbidden`, v1 inline-comment fallback fires on v2 404, `list_spaces` filtering excludes non-allowlisted entries, `redact_response` strips the three default keys recursively. ### Task 1-3 — Space-allowlist loader (`gateway/confluence_policy.py`) @@ -162,6 +162,18 @@ extractor. No HTTP handlers yet. Each piece is independently testable. **Acceptance** (paired with Task 4-4): all positive cases pass, all rejection cases above return `(None, reason)`. Mirrors the Jira `extract_search_projects` test grid one-for-one. +### Task 1-5 — Update `gateway/jira_credentials.py` to honour `ATLASSIAN_*` precedence (risk R11) + +- Edit `gateway/jira_credentials.py::JiraCredentialsManager._load_credentials()` (today reads only `JIRA_BASE_URL` / `JIRA_USERNAME` / `JIRA_API_TOKEN` — verified at lines 134-137) so that for each of the three keys the loader prefers `ATLASSIAN_*` and falls back to `JIRA_*` per-key. Same precedence and per-key independence as Task 1-1 for Confluence. +- No base-URL derivation needed for Jira — the Jira API lives at the bare Atlassian origin (no `/wiki` suffix), so `ATLASSIAN_BASE_URL` is used verbatim when `JIRA_BASE_URL` is unset. +- Update the module docstring to name the precedence and the back-compat invariant. Keep the existing `JiraCredentialsUnavailable` typed exception unchanged. +- This task is what makes the "shared Atlassian credential" promise (decision F1) actually portable — without it, the migration narrative in the PR description is incorrect (operators who delete the legacy `JIRA_*` keys after copying values to `ATLASSIAN_*` would silently break Jira). + +**Acceptance** (paired with Task 4-1b): unit test additions in `gateway/tests/test_jira_credentials.py` cover the same six-combination matrix as Confluence — `ATLASSIAN_*` only resolves correctly, `JIRA_*` only resolves correctly, mixed per-key fall-back works, missing-everywhere raises `JiraCredentialsUnavailable`. Existing test cases still pass (back-compat preserved). + +**Files**: +- `gateway/jira_credentials.py` (edit) + --- ## Phase 2 — Gateway routes @@ -188,9 +200,12 @@ so it tunes in one place. **Page → space resolution caching (architect Q2)**: the post-fetch space- allowlist check needs the page's `spaceKey`. To avoid double-fetching, the -client caches the `spaceId → spaceKey` mapping for 60 s in-process. The +client maintains a single bidirectional `spaceId ↔ spaceKey` LRU cache +(60 s TTL) that is populated by both `list_spaces` and `get_page`. The comment routes (2-3, 2-4) re-use that cache so they do not refetch the -page just to verify the space. +page just to verify the space; `/space/pages` (Task 2-5) re-uses the same +cache so cold-start `spaceKey → spaceId` lookups don't always cost a +double round-trip. ### Task 2-1 — `POST /api/v1/confluence/page/get` @@ -257,7 +272,7 @@ page just to verify the space. ### Task 2-9 — Hot-reload wiring in `gateway/gateway.py::_reload_all_config()` - Extend the existing `_reload_all_config()` helper (invoked by `POST /api/v1/config/reload`) to also call `reload_confluence_credentials()` and `reload_confluence_policy()` alongside the existing Jira reloads. -- Audit a single structured entry covering both reloads (`confluence_config_reloaded`). +- Audit shape: **mirror whatever the existing Jira reload emits** — coder confirms at implement time whether Jira emits one combined event or two separate ones (credentials + policy) and matches that shape. Default if Jira emits one event: `confluence_config_reloaded` covering both. If Jira emits two: `confluence_credentials_reloaded` + `confluence_policy_reloaded`. Consistency with the existing Jira pattern is the priority over either shape in isolation. **Acceptance (2-1 through 2-9, paired with 4-5)**: Every route returns 403 in public mode (route-enumeration regression test in 4-5); 403 on disallowed spaces / paths; 200 on happy paths with mocked upstream; structured audit records on every outcome (including the new `confluence_upstream_403` and `confluence_search_rejected` events). `POST /api/v1/config/reload` picks up secrets.env and context-filters.yaml changes without a gateway restart. Manual smoke via `curl`: allowlisted page in private mode → 200; public mode → 403; `execute` with `method=DELETE` or path containing `restrictions` → 403; `space/list` returns only allowlisted spaces. @@ -314,10 +329,20 @@ assertion comment so a future grep for "confluence" finds it. **Files**: - `gateway/tests/test_confluence_credentials.py` (new) +### Task 4-1b — Extend `gateway/tests/test_jira_credentials.py` for shared-credential precedence (risk R11) + +- Add the six-combination matrix to the existing `gateway/tests/test_jira_credentials.py`: `ATLASSIAN_*` only → resolves to Atlassian values; `JIRA_*` only → existing back-compat path still works; mixed per-key (`ATLASSIAN_USERNAME` + `JIRA_BASE_URL` + `JIRA_API_TOKEN`, etc.) → resolves correctly per key; missing all six keys → `JiraCredentialsUnavailable`. +- Existing tests for `JIRA_*`-only behaviour remain green (the loader change is additive). + +**Role**: tester. + +**Files**: +- `gateway/tests/test_jira_credentials.py` (edit — add cases) + ### Task 4-2 — `gateway/tests/test_confluence_client.py` - Each method builds the correct URL, headers, body — mocked via `respx` / `httpx.MockTransport`. -- **Default `body-format=storage`** on `get_page` / `get_space_pages` / comment methods (decision E5 tweak); override accepted. +- **Default `body-format=storage`** on `get_page` / `get_space_pages` / comment methods (decision-5 tweak); override accepted. - `validate_confluence_api_path`: positive cases for every allowed family in Task 1-2; negative cases (`restrictions`, `permissions`, `space.admin`, `users`, `attachments`, DELETE, PUT, PATCH, `..`, duplicate slashes, non-ASCII, random 404 paths, `pages/abc/...` non-numeric). - Pagination: `cursor` round-trip across `get_page_descendants`, `get_space_pages`, `list_spaces`, `search_cql`. - **429 retry**: first 429 with `Retry-After: 1` → retried once; second 429 → surfaces `ConfluenceUpstreamError(429)`; write verbs do NOT retry. Audit entries (monkeypatched) observed both 429s. @@ -406,7 +431,7 @@ assertion comment so a future grep for "confluence" finds it. ### Task 4-7 — Network-policy sanity test extension (`gateway/tests/test_allowed_domains.py`) -- The existing `test_atlassian_domains_absent` test already asserts `atlassian.net` / `atlassian.com` / `api.atlassian.com` / `jira.atlassian.com` are absent from `gateway/allowed_domains.txt`. **No new substring additions are required** — Confluence shares the `*.atlassian.net` exclusion. +- The existing test in `gateway/tests/test_allowed_domains.py` (verified above as `test_atlassian_domains_absent`) already asserts `atlassian.net` / `atlassian.com` / `api.atlassian.com` / `jira.atlassian.com` are absent from `gateway/allowed_domains.txt`. **Coder must confirm the test name at implement time** (it has been verified at plan time but may shift in subsequent commits) and either extend the parametrize list or describe by location if it has been renamed. - Add a docstring-level assertion comment to the existing test naming Confluence (so a future `grep -i confluence gateway/tests/` surfaces it) **plus** a small parametrized case that explicitly asserts `wiki.atlassian.net` and `confluence.atlassian.com` are not present (defence in depth even though they aren't real Atlassian Cloud hostnames; the cost is one parametrize entry). **Role**: tester. @@ -504,6 +529,7 @@ documents the analysis called out. - Add a `confluence` wrapper entry alongside `jira` and `gh`, with the eight verbs, a one-line example (`confluence page get 12345`, `confluence search 'space = ENG AND text ~ "RFC"'`), the body-format default note (`storage`), and a reminder that Confluence is private-mode-only. - Note that **no per-pipeline env var** is set for Confluence (decision 13) — agents pass `pageId` / `spaceKey` directly in each call. +- **Intentionally untouched**: `sandbox/agent-config/commands/show-metrics.md` references the legacy `~/context-sync/confluence/` syncer cache; that cache is independent of the new gateway wrapper, so we do not edit `show-metrics.md`. A one-line note in the wrapper section (`environment.md`) clarifies the two surfaces are unrelated. **Files**: - `sandbox/agent-config/rules/environment.md` (edit) @@ -520,7 +546,7 @@ documents the analysis called out. - Permanent denylist (attachments, restrictions, permissions, space.admin, users, DELETE/PUT/PATCH). - Future-verb extension points (`page/create`, `page/update`, `comment/create`). - **Prompt-injection caveat (risk R16)** — page bodies and ADF content are untrusted input and may carry instructions targeting the agent; the docs section explicitly warns reviewers and instructs callers to treat Confluence content as data, not directives. - - **Atlassian rate-limit runbook (risk R10)** — Atlassian Cloud's points-based throttling (March 2026 enforcement) details: the wrapper retries once on 429 honouring `Retry-After`; persistent throttling surfaces as `confluence_upstream_rate_limited` audit events; operator should provision a dedicated bot to avoid contention with human Confluence usage on the same Atlassian tenant. + - **Atlassian rate-limit runbook (risk R10)** — Atlassian Cloud's points-based throttling (March 2026 enforcement) details: the wrapper retries once on 429 honouring `Retry-After`; persistent throttling surfaces as `confluence_upstream_rate_limited` audit events with `attempt: 1|2`; operator should provision a dedicated bot to avoid contention with human Confluence usage on the same Atlassian tenant. **Pool-sharing note**: per decision-9, the same bot account owns Jira read scope, so the points-based quota is **pooled across `/api/v1/jira/*` and `/api/v1/confluence/*` traffic**. Two unrelated pipelines reading from Jira and Confluence simultaneously can throttle each other; operators seeing routine throttling on one service should expect it on the other and may want to provision a dedicated Confluence-only bot in a follow-up. - **Last-reviewed-date footer (risk R9)** — page footer carries `Last reviewed against Atlassian docs: ` so reviewers can spot stale endpoint-pinning. - Cross-link from the two architecture docs above and from `docs/index.md` (lookup-table row alongside the Jira reference). @@ -539,11 +565,13 @@ documents the analysis called out. 1-2 ─┼──► 2-1, 2-2, 2-3, 2-4, 2-5, 2-6, 2-7, 2-8, 2-9 ──► 4-5 1-3 ─┤ 1-4 ─┘ +1-5 (independent — same-PR Jira loader update for shared ATLASSIAN_* precedence) 1-1 ───► 4-1 1-2 ───► 4-2 1-3 ───► 4-3 1-4 ───► 4-4 +1-5 ───► 4-1b 2-* ───► 4-5 3-1 ───► 4-6 @@ -576,8 +604,8 @@ documents the analysis called out. 1. Copy `config/secrets.template.env` → `~/.config/egg/secrets.env` with real `ATLASSIAN_BASE_URL`, `ATLASSIAN_USERNAME`, `ATLASSIAN_API_TOKEN` (or the `CONFLUENCE_*` equivalents). Confirm the bot has read access to the spaces you intend to allowlist. 2. Add at least one space key under `confluence.spaces` in `config/context-filters.yaml`. -3. Start the gateway locally in **private mode** (`PRIVATE_MODE=1`); issue `curl -H "Authorization: Bearer " -d '{"pageId":""}' /api/v1/confluence/page/get` and assert you get the page JSON with `body.storage` populated and `accountId` / `emailAddress` redacted. -4. Repeat with `PRIVATE_MODE` unset / public mode and confirm 403 with `"endpoint requires private network mode"`. +3. Start the gateway locally and create a session in **private mode** (e.g., `--private` on the session-creation tool); issue `curl -H "Authorization: Bearer " -d '{"pageId":""}' /api/v1/confluence/page/get` and assert you get the page JSON with `body.storage` populated and `accountId` / `emailAddress` redacted. +4. Repeat with a public-mode session token (one created without `--private`) and confirm 403 with `"endpoint requires private network mode"`. (Note: private mode is a session-level attribute set by `@require_session_auth`, not a process-level env var.) 5. Call `/api/v1/confluence/execute` with `method=DELETE` or `path=api/v2/pages/123/restrictions` and confirm 403. 6. Call `/api/v1/confluence/search` with CQL `space = ENG OR space = SEC` (SEC not allowlisted) and confirm 403 `confluence_search_rejected`. 7. Call `/api/v1/confluence/page/get` with a non-existent pageId in an allowlisted space and confirm HTTP 200 with `{"status":"not_found",...}`. @@ -596,7 +624,7 @@ documents the analysis called out. - Operator confirms the Atlassian bot account already used for Jira (per #1556) has read scope on the Confluence spaces being allowlisted. Atlassian's per-space permission UI lives under "Space settings → Permissions" — the bot needs at least "View" on every space in `confluence.spaces`. - Operator decides which Confluence spaces to allowlist and edits `config/context-filters.yaml :: confluence.spaces` before enabling the feature in production. Leaving the list empty is a valid "install but don't use" state — every call will 403 until populated. - Operator confirms `*.atlassian.net` is **not** in the Squid domain allowlist (`gateway/allowed_domains.txt`). The existing `test_atlassian_domains_absent` regression test enforces this; the parametrize-extension in Task 4-7 keeps Confluence-shaped hostnames in scope. -- If migrating off independent `JIRA_*` + `CONFLUENCE_*` triples to the shared `ATLASSIAN_*` triple, operators may copy the same value into all three triples during the cutover and remove the legacy keys later — the loader prefers `ATLASSIAN_*` per key. +- If migrating off independent `JIRA_*` + `CONFLUENCE_*` triples to the shared `ATLASSIAN_*` triple, operators may copy the same value into all three triples during the cutover and remove the legacy keys later — both `gateway/jira_credentials.py` (updated by Task 1-5) and `gateway/confluence_credentials.py` prefer `ATLASSIAN_*` per key, so removing the legacy `JIRA_*` / `CONFLUENCE_*` blocks once the `ATLASSIAN_*` triple is fully populated is safe. **Post-merge**: @@ -630,7 +658,12 @@ pr: (Atlassian API-token loader with mtime refresh, prefers a shared `ATLASSIAN_*` triple over per-service `CONFLUENCE_*` / back-compat `JIRA_*`, derives Confluence base URL by appending - `/wiki` when only `ATLASSIAN_BASE_URL` is set); + `/wiki` when only `ATLASSIAN_BASE_URL` is set); a small edit to + `gateway/jira_credentials.py` so the same precedence applies to + the Jira loader (`ATLASSIAN_*` preferred per key with `JIRA_*` + fall-back) — this makes the shared-credential promise in + decision F1 portable so operators can drop the legacy `JIRA_*` + block after migration without breaking Jira; `gateway/confluence_client.py` (`ConfluenceClient` class backed by httpx, v2-first hybrid with v1 fallback for the known v2 inline-comment 404 bug and v2 footer-comment nested-reply gap, @@ -744,7 +777,7 @@ pr: - Operator confirms the Atlassian bot account already used for Jira has read scope on every space being allowlisted (Atlassian: Space settings → Permissions → at least View for the bot user). - Operator decides which Confluence spaces to allowlist and edits config/context-filters.yaml :: confluence.spaces before enabling the feature in production. Empty list is valid; keeps feature installed-but-inert. - Operator confirms *.atlassian.net is NOT in the Squid domain allowlist (gateway/allowed_domains.txt). The extended test in Task 4-7 enforces this for Confluence-named hostnames. - - If migrating off independent JIRA_* / CONFLUENCE_* triples to the shared ATLASSIAN_* triple, copy the same value into all three triples during the cutover; the loader prefers ATLASSIAN_* per key. + - If migrating off independent JIRA_* / CONFLUENCE_* triples to the shared ATLASSIAN_* triple, copy the same value into all three triples during the cutover; both gateway/jira_credentials.py (updated by Task 1-5) and gateway/confluence_credentials.py prefer ATLASSIAN_* per key, so removing the legacy blocks once ATLASSIAN_* is fully populated is safe. Post-merge: - Roll the gateway pod so it picks up the new secrets.env values and the updated context-filters.yaml, or POST /api/v1/config/reload for a hot-reload. - Execute the manual verification steps from the test plan against the live gateway. @@ -775,7 +808,10 @@ phases: Unit test (4-1) loads creds from a tmp `secrets.env` and asserts the base64 Basic header; covers `ATLASSIAN_*`-only with `/wiki` derivation, `CONFLUENCE_*`-only with verbatim URL, and mixed per-key precedence; mtime invalidation triggers reload; missing values raise - `ConfluenceCredentialsUnavailable`; `reload_confluence_credentials()` clears the cache. + `ConfluenceCredentialsUnavailable`; `reload_confluence_credentials()` clears the cache. Per + architect Q4, no shared `atlassian_credentials.py` helper is extracted in v1; both modules + duplicate the loader skeleton for review clarity. Track shared extraction as a follow-up + backlog item. role: coder files: - gateway/confluence_credentials.py @@ -789,7 +825,7 @@ phases: `get_page(page_id, body_format=("storage",), expand=None)` → `GET /wiki/api/v2/pages/{id}`; `get_page_descendants(page_id, depth=None, limit=None, cursor=None)` → `GET /wiki/api/v2/pages/{id}/descendants` (depth passed verbatim per Q6); `get_page_footer_comments(page_id, body_format=("storage",), include_replies=False)` → `GET /wiki/api/v2/pages/{id}/footer-comments`, with v1-fallback merge from `GET /wiki/api/v2/footer-comments?page-id=...&depth=all` when `include_replies=True`; - `get_page_inline_comments(page_id, body_format=("storage",))` → `GET /wiki/api/v2/pages/{id}/inline-comments`, with transparent v1 fallback to `GET /wiki/rest/api/content/{id}/child/comment?location=inline&expand=body.view` on v2 404 (decision D1) and a `used_fallback` flag in the normalized envelope; + `get_page_inline_comments(page_id, body_format=("storage",))` → `GET /wiki/api/v2/pages/{id}/inline-comments`, with transparent v1 fallback to `GET /wiki/rest/api/content/{id}/child/comment?location=inline&expand=body.view` on v2 404 (decision D1) and a `used_fallback` flag in the normalized envelope; if v1 returns 200-empty, return `{"results": [], "used_fallback": true}`; if v1 also returns 404, return the standard `{"status": "not_found", ..., "used_fallback": true}` envelope (so the route can distinguish "v2 bug + page exists with no comments" from "page actually not found"); `list_spaces(allowed_spaces, limit=None, cursor=None)` → `GET /wiki/api/v2/spaces` filtered to `allowed_spaces` per decision 11; `get_space_pages(space_id, limit=None, cursor=None, body_format=("storage",))` → `GET /wiki/api/v2/spaces/{space-id}/pages`; `search_cql(cql, limit=None, cursor=None)` → `GET /wiki/rest/api/search` (v1-only); @@ -805,10 +841,11 @@ phases: `restrictions`, `permissions`, `space.admin`, `users`, `attachments` plus HTTP `DELETE` / `PUT` / `PATCH` — return `(False, reason)` on any match. - 429 handling (Q2): `_request` retries once on HTTP 429 sleeping `min(int(Retry-After header, + 429 handling (Q2, risk R10): `_request` retries once on HTTP 429 sleeping `min(int(Retry-After header, default 1), 30)` seconds; retry is GET-only; writes never retry. After the second 429 pass through verbatim. Audit `confluence_upstream_rate_limited` on both 429s including the - `Retry-After` value and path. + `Retry-After` value, path, and `attempt: 1|2` field so operators can tell whether the retry + succeeded. 404 envelope: `get_page` / `get_page_descendants` / `get_page_footer_comments` / `get_page_inline_comments` / `get_space_pages` on upstream 404 return @@ -837,16 +874,19 @@ phases: `CONFLUENCE_RESPONSE_MAX_BYTES = 5 * 1024 * 1024` so route handlers can refuse oversized responses with HTTP 413 `confluence_response_too_large`. - Boot-time observability (risk R12): at module import, emit a single INFO log line with - credential precedence resolved, allowlist size (looked up via `confluence_policy`), and - the `body-format=storage` default. Schema-mismatch reads emit ERROR-level audit entries - so operators see misconfiguration immediately. + Boot-time observability (risk R12 — split across modules): `confluence_credentials.py` + logs the resolved credential precedence at first load; `confluence_policy.py` logs the + allowlist size at first `allowed_spaces()` call and emits ERROR audit entries on schema + mismatch (e.g. `confluence: {spaces: "ENG"}`); the client logs the `body-format=storage` + default at first invocation. - Page → space resolution caching (architect Q2): cache `spaceId → spaceKey` mappings for - 60 s in-process so the comment routes do not re-fetch the page just to verify the space. + Page → space resolution caching (architect Q2): single bidirectional `spaceId ↔ spaceKey` + LRU cache (60 s TTL) populated by both `list_spaces` and `get_page` so the comment routes + do not re-fetch the page just to verify the space and `/space/pages` cold-start lookups + do not always cost a double round-trip. acceptance: |- Unit tests in Task 4-2 assert URL/header/body per method, default `body-format=storage` - (decision E5 tweak) plus override, positive + negative `validate_confluence_api_path` cases + (decision-5 tweak) plus override, positive + negative `validate_confluence_api_path` cases (including `restrictions`, `permissions`, `space.admin`, `users`, `attachments`, DELETE, PUT, PATCH, `..`, duplicate slashes, non-ASCII, non-numeric pageId), pagination cursor round-trip, 429 single retry honouring `Retry-After`, writes never retry, 404 envelope on @@ -900,6 +940,28 @@ phases: role: coder files: - gateway/confluence_search.py + - id: TASK-1-5 + description: |- + Edit `gateway/jira_credentials.py::JiraCredentialsManager._load_credentials()` (today + reads only `JIRA_BASE_URL` / `JIRA_USERNAME` / `JIRA_API_TOKEN` at lines 134-137) so that + for each of the three keys the loader prefers `ATLASSIAN_*` and falls back to `JIRA_*` + per-key — same precedence and per-key independence as Task 1-1 for Confluence. No + base-URL derivation needed for Jira (the Jira API lives at the bare Atlassian origin with + no `/wiki` suffix), so `ATLASSIAN_BASE_URL` is used verbatim when `JIRA_BASE_URL` is + unset. Update the module docstring to name the precedence and the back-compat invariant. + Keep the existing `JiraCredentialsUnavailable` typed exception unchanged. + + This is what makes the "shared Atlassian credential" promise (decision F1) actually + portable per risk R11 — without it, an operator who follows the documented pre-merge path + and removes the legacy `JIRA_*` keys after copying values to `ATLASSIAN_*` would silently + break Jira (gateway returns 503 on every `/api/v1/jira/*` call). + acceptance: |- + Existing `gateway/tests/test_jira_credentials.py` tests pass unchanged (back-compat + preserved); the new six-combination matrix added in Task 4-1b passes (`ATLASSIAN_*` only, + `JIRA_*` only, mixed per-key, missing-everywhere → `JiraCredentialsUnavailable`). + role: coder + files: + - gateway/jira_credentials.py - id: 2 name: |- Gateway routes @@ -1099,6 +1161,19 @@ phases: role: tester files: - gateway/tests/test_confluence_credentials.py + - id: TASK-4-1b + description: |- + Edit `gateway/tests/test_jira_credentials.py` to add the six-combination shared-credential + matrix (risk R11) covering Task 1-5: `ATLASSIAN_*` only resolves correctly; `JIRA_*` only + (existing back-compat) still works; mixed per-key (`ATLASSIAN_USERNAME` + `JIRA_BASE_URL` + + `JIRA_API_TOKEN`, etc.) resolves correctly per key; missing all six keys raises + `JiraCredentialsUnavailable`. Existing tests for `JIRA_*`-only behaviour remain green. + acceptance: |- + Tests pass; new precedence cases hit the updated branches in `gateway/jira_credentials.py`; + existing assertions still hold (no regression in back-compat behaviour). + role: tester + files: + - gateway/tests/test_jira_credentials.py - id: TASK-4-2 description: |- Add `gateway/tests/test_confluence_client.py`. Cover URL/header/body construction per From 9e8b405112e74d4c1b95c2be22da0dba0986075d Mon Sep 17 00:00:00 2001 From: egg-orchestrator Date: Mon, 27 Apr 2026 00:10:20 +0000 Subject: [PATCH 09/26] Persist statefiles after plan phase --- .egg-state/brc-history/1931-plan.json | 2015 ++++++++++++++++++++ .egg-state/brc-history/1931-plan.md | 2444 +++++++++++++++++++++++++ .egg-state/contracts/issue-1931.json | 12 + 3 files changed, 4471 insertions(+) create mode 100644 .egg-state/brc-history/1931-plan.json create mode 100644 .egg-state/brc-history/1931-plan.md diff --git a/.egg-state/brc-history/1931-plan.json b/.egg-state/brc-history/1931-plan.json new file mode 100644 index 0000000000..d58c303b81 --- /dev/null +++ b/.egg-state/brc-history/1931-plan.json @@ -0,0 +1,2015 @@ +[ + { + "id": "8b9ff94c-60fe-49", + "pipeline_id": "issue-1931", + "from_role": "overseer", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "Overseer (ec29a51a-293) online. Cycle 1 parsed. Refine phase confirmed complete (consensus_reached=true). Analyzing plan phase readiness and HITL blocking status.", + "metadata": { + "state": "WORKING" + }, + "timestamp": "2026-04-26T23:43:31.588236+00:00", + "phase": "plan" + }, + { + "id": "2124f752-4cd1-4b", + "pipeline_id": "issue-1931", + "from_role": "overseer", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "Plan phase agent launch watch. HITL resolved 23:42:46Z. Running_agents still empty at +67s. Waiting for architect/risk_analyst/task_planner containers to appear (threshold 180s).", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-04-26T23:42:46Z" + }, + "timestamp": "2026-04-26T23:44:17.858832+00:00", + "phase": "plan" + }, + { + "id": "d561a7c3-cc8d-48", + "pipeline_id": "issue-1931", + "from_role": "reviewer_plan", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_PROPOSE", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-04-26T23:45:28.729802+00:00" + }, + "timestamp": "2026-04-26T23:45:28.817972+00:00", + "phase": "plan" + }, + { + "id": "b30b2fd2-0ec1-41", + "pipeline_id": "issue-1931", + "from_role": "reviewer_plan", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_PROPOSE", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-04-26T23:45:28.729802+00:00" + }, + "timestamp": "2026-04-26T23:46:29.007535+00:00", + "phase": "plan" + }, + { + "id": "c0d7cbe4-edcf-4a", + "pipeline_id": "issue-1931", + "from_role": "reviewer_plan", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_PROPOSE", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-04-26T23:45:28.729802+00:00" + }, + "timestamp": "2026-04-26T23:47:29.403487+00:00", + "phase": "plan" + }, + { + "id": "91daed0b-9329-46", + "pipeline_id": "issue-1931", + "from_role": "reviewer_plan", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_PROPOSE", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-04-26T23:45:28.729802+00:00" + }, + "timestamp": "2026-04-26T23:48:29.693017+00:00", + "phase": "plan" + }, + { + "id": "03bddbcd-bc67-4a", + "pipeline_id": "issue-1931", + "from_role": "overseer", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "Cycle 5. reviewer_plan alive (60s cadence). risk_analyst: appeared at ~23:45:36, no heartbeats yet, 135s elapsed \u2014 watching for 180s threshold. architect/task_planner not yet visible.", + "metadata": { + "state": "WORKING" + }, + "timestamp": "2026-04-26T23:48:53.676474+00:00", + "phase": "plan" + }, + { + "id": "85002524-df02-4a", + "pipeline_id": "issue-1931", + "from_role": "reviewer_plan", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_PROPOSE", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-04-26T23:45:28.729802+00:00" + }, + "timestamp": "2026-04-26T23:49:29.899970+00:00", + "phase": "plan" + }, + { + "id": "46433538-bdcc-49", + "pipeline_id": "issue-1931", + "from_role": "reviewer_plan", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_PROPOSE", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-04-26T23:45:28.729802+00:00" + }, + "timestamp": "2026-04-26T23:50:30.148297+00:00", + "phase": "plan" + }, + { + "id": "68f6e7f1-145e-42", + "pipeline_id": "issue-1931", + "from_role": "architect", + "to_role": "all", + "message_type": "CONSENSUS_PROPOSE", + "subject": "Proposal from architect", + "body": "Architecture analysis for issue #1931 (Confluence gateway v1, read-only). Captures the structural copy of #1556's Jira wrapper across four new gateway modules (confluence_client.py, confluence_credentials.py, confluence_policy.py, confluence_search.py), one Confluence route block in gateway.py, a sandbox/scripts/confluence wrapper, two config files (context-filters.yaml + secrets.template.env), and four doc files (confluence-wrapper.md plus three updates). Records the architectural consequences of all 14 HITL decisions resolved in refine: TD1 Jira-style verb-noun URL paths (decision-1); TD2 per-verb v2-first / v1-fallback hybrid (decision-2 + decision-4); TD3 conservative CQL static-scope extractor (decision-3); TD4 body-format=storage default with override (decision-5 HITL narrowing); TD5 shared ATLASSIAN_* credential triple with JIRA_* / CONFLUENCE_* fallback (decision-6 + decision-9); TD6 @require_private_mode per route + enumeration regression (decision-7); TD7 confluence.spaces in context-filters.yaml (decision-8); TD8 accountId / emailAddress / _links.webui redaction (decision-10); TD9 space-list filtering (decision-11); TD10 attachments permanent denylist (decision-12); TD11 no EGG_CONFLUENCE_* env vars (decision-13); TD12 GET-only /execute passthrough (decision-14); TD13 distinct confluence_upstream_403 audit category (feedback Q7); TD14 verbatim depth pass-through (feedback Q6); TD15 current-version-only on getConfluencePage (feedback Q8); TD16 no resolve-by-url verb (feedback Q5). Documents per-route validation (page allowlist verified against the page response's spaceId/spaceKey), 404 envelope, v1-fallback paths for footer / inline comments, and the data flow for both happy-path and rejection paths. Lists 14 alternatives considered and rejected with reasons. Proposes a 6-workstream implementation order (creds + policy + CQL extractor \u2192 client \u2192 routes \u2192 sandbox wrapper \u2192 docs) for the task_planner to refine into tasks. Surfaces 4 plan-phase open questions (bot-vs-human caveat in docs, double-fetch vs LRU cache for comment routes, fallback-event payload, shared-credential refactor deferral). No code authored \u2014 analysis only, written to .egg-state/agent-outputs/1931-architect-output.json. Tests run: JSON validation only (no production code touched).", + "metadata": { + "payload": { + "summary": "Architecture analysis for issue #1931 (Confluence gateway v1, read-only). Captures the structural copy of #1556's Jira wrapper across four new gateway modules (confluence_client.py, confluence_credentials.py, confluence_policy.py, confluence_search.py), one Confluence route block in gateway.py, a sandbox/scripts/confluence wrapper, two config files (context-filters.yaml + secrets.template.env), and four doc files (confluence-wrapper.md plus three updates). Records the architectural consequences of all 14 HITL decisions resolved in refine: TD1 Jira-style verb-noun URL paths (decision-1); TD2 per-verb v2-first / v1-fallback hybrid (decision-2 + decision-4); TD3 conservative CQL static-scope extractor (decision-3); TD4 body-format=storage default with override (decision-5 HITL narrowing); TD5 shared ATLASSIAN_* credential triple with JIRA_* / CONFLUENCE_* fallback (decision-6 + decision-9); TD6 @require_private_mode per route + enumeration regression (decision-7); TD7 confluence.spaces in context-filters.yaml (decision-8); TD8 accountId / emailAddress / _links.webui redaction (decision-10); TD9 space-list filtering (decision-11); TD10 attachments permanent denylist (decision-12); TD11 no EGG_CONFLUENCE_* env vars (decision-13); TD12 GET-only /execute passthrough (decision-14); TD13 distinct confluence_upstream_403 audit category (feedback Q7); TD14 verbatim depth pass-through (feedback Q6); TD15 current-version-only on getConfluencePage (feedback Q8); TD16 no resolve-by-url verb (feedback Q5). Documents per-route validation (page allowlist verified against the page response's spaceId/spaceKey), 404 envelope, v1-fallback paths for footer / inline comments, and the data flow for both happy-path and rejection paths. Lists 14 alternatives considered and rejected with reasons. Proposes a 6-workstream implementation order (creds + policy + CQL extractor \u2192 client \u2192 routes \u2192 sandbox wrapper \u2192 docs) for the task_planner to refine into tasks. Surfaces 4 plan-phase open questions (bot-vs-human caveat in docs, double-fetch vs LRU cache for comment routes, fallback-event payload, shared-credential refactor deferral). No code authored \u2014 analysis only, written to .egg-state/agent-outputs/1931-architect-output.json. Tests run: JSON validation only (no production code touched).", + "attestation": {}, + "artifacts": [ + ".egg-state/agent-outputs/1931-architect-output.json" + ], + "risk_considered": "", + "commit_sha": "256521c4b5c59341e9366cb87c08cfebc8a853f9", + "files_changed": [ + ".egg-state/agent-outputs/1931-architect-output.json" + ], + "tests_run": [ + "python3 -c 'import json; json.load(open(...))'" + ], + "tasks_satisfied": [] + }, + "version": 1, + "commit_sha": "256521c4b5c59341e9366cb87c08cfebc8a853f9" + }, + "timestamp": "2026-04-26T23:51:01.769042+00:00", + "phase": "plan" + }, + { + "id": "5ca29dca-bc40-4f", + "pipeline_id": "issue-1931", + "from_role": "reviewer_plan", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "wait_loop exited", + "metadata": { + "state": "WORKING" + }, + "timestamp": "2026-04-26T23:51:03.255028+00:00", + "phase": "plan" + }, + { + "id": "c514135c-620a-45", + "pipeline_id": "issue-1931", + "from_role": "architect", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_ACK,CONSENSUS_NACK,CONSENSUS_RE_REVIEW,CONSENSUS_CONFIRMED,OVERSEER_ALERT", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-04-26T23:51:09.440487+00:00" + }, + "timestamp": "2026-04-26T23:51:09.505033+00:00", + "phase": "plan" + }, + { + "id": "55ad8763-c5b4-4f", + "pipeline_id": "issue-1931", + "from_role": "architect", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "wait_loop exited", + "metadata": { + "state": "WORKING" + }, + "timestamp": "2026-04-26T23:51:09.592804+00:00", + "phase": "plan" + }, + { + "id": "88d1cbb3-d6fb-44", + "pipeline_id": "issue-1931", + "from_role": "architect", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_ACK,CONSENSUS_NACK,CONSENSUS_RE_REVIEW,OVERSEER_ALERT", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-04-26T23:51:12.779966+00:00" + }, + "timestamp": "2026-04-26T23:51:14.142654+00:00", + "phase": "plan" + }, + { + "id": "0cdec22a-94e6-4a", + "pipeline_id": "issue-1931", + "from_role": "risk_analyst", + "to_role": "all", + "message_type": "CONSENSUS_PROPOSE", + "subject": "Proposal from risk_analyst", + "body": "Plan-phase risk assessment for #1931 (Confluence gateway read-only v1). 18 risks identified across security (6), performance (3), compatibility (2), operational (3), data-privacy (1), external-dependency (1), test-coverage (1), and future-write (1). Severity: 1 high (CQL extractor adversarial coverage), 7 medium, 10 low. Three risks flagged for human review at implement time: R1 CQL-extractor parity (CQL grammar differs from JQL \u2014 text ~ contains, space.category() functions, etc.), R14 attachments / restrictions / permissions permanent-denylist enforcement across both narrow routes and /execute, R15 bot-account effective-access asymmetry (per refine feedback Q9). External research covered Atlassian April-2026 v1-deprecation status (endpoint-specific, CQL search has no v2 successor), the March-2026 points-based rate-limit rollout, and confirmed no public CQL-injection CVE. Output includes a four-level rollback plan (config-only \u2192 route-disable \u2192 credential-revoke \u2192 full-revert) and an 18-item implement-phase checklist that maps each risk to a reviewer-verifiable mitigation. Aligns with the architect's analysis (R3 covers v2 inline-404 fallback / Q3, R11 covers shared-creds module / Q4, R15 covers bot-vs-human caveat / Q1). No production code touched; output is a single JSON state file under .egg-state/agent-outputs/.", + "metadata": { + "payload": { + "summary": "Plan-phase risk assessment for #1931 (Confluence gateway read-only v1). 18 risks identified across security (6), performance (3), compatibility (2), operational (3), data-privacy (1), external-dependency (1), test-coverage (1), and future-write (1). Severity: 1 high (CQL extractor adversarial coverage), 7 medium, 10 low. Three risks flagged for human review at implement time: R1 CQL-extractor parity (CQL grammar differs from JQL \u2014 text ~ contains, space.category() functions, etc.), R14 attachments / restrictions / permissions permanent-denylist enforcement across both narrow routes and /execute, R15 bot-account effective-access asymmetry (per refine feedback Q9). External research covered Atlassian April-2026 v1-deprecation status (endpoint-specific, CQL search has no v2 successor), the March-2026 points-based rate-limit rollout, and confirmed no public CQL-injection CVE. Output includes a four-level rollback plan (config-only \u2192 route-disable \u2192 credential-revoke \u2192 full-revert) and an 18-item implement-phase checklist that maps each risk to a reviewer-verifiable mitigation. Aligns with the architect's analysis (R3 covers v2 inline-404 fallback / Q3, R11 covers shared-creds module / Q4, R15 covers bot-vs-human caveat / Q1). No production code touched; output is a single JSON state file under .egg-state/agent-outputs/.", + "attestation": {}, + "artifacts": [ + ".egg-state/agent-outputs/1931-risk_analyst-output.json" + ], + "risk_considered": "CQL-extractor adversarial coverage (R1) is the single high-severity risk; mitigation is a deny-on-ambiguity parser mirroring jira_search.py with adversarial fixtures targeting CQL-specific grammar (text-contains, OR widening, CQL functions, mixed-script keys). Permanent denylist for attachments / restrictions / permissions / space.admin / users / DELETE / PUT / PATCH is enforced at the path validator layer with test variants for case, URL-encoding, and nested positions. /execute escape hatch is GET-only, regex-allowlisted, and explicitly anti-tested to confirm narrow-route path families cannot be reached through it. private-mode gate enforced via @require_private_mode decorator + route-enumeration regression test. Squid allowlist already excludes *.atlassian.net (covered by existing test_allowed_domains.py regression). PII redaction (accountId, emailAddress, _links.webui) walks ADF mention nodes recursively. Body-format default is storage-only per HITL decision-5 to avoid token-budget blow-up; hard payload-size cap recommended at the route layer. v1 dependency on CQL search is documented with a 'last reviewed against Atlassian docs' marker; deprecation is endpoint-specific and not imminent. Shared Atlassian credential block coupling (decision-6) means a botched token rotation breaks both Jira and Confluence simultaneously \u2014 mitigated by mtime-based reload and a diagnostics endpoint. context-filters.yaml fail-closed empty default mitigated by a boot-time log line announcing the loaded allowlist size. Test coverage gap risk addressed by requiring each test_confluence_*.py file to have at least one fixture justified as Confluence-original (not a JQL\u2192CQL transliteration). Four-level rollback plan covers config-only, route-disable, credential-revoke, and full-revert.", + "commit_sha": "4151ef6b35de33b17551f5c6dbde9e45e277e88c", + "files_changed": [ + ".egg-state/agent-outputs/1931-risk_analyst-output.json" + ], + "tests_run": [ + "json.load validation of .egg-state/agent-outputs/1931-risk_analyst-output.json" + ], + "tasks_satisfied": [] + }, + "version": 1, + "commit_sha": "4151ef6b35de33b17551f5c6dbde9e45e277e88c" + }, + "timestamp": "2026-04-26T23:52:13.464518+00:00", + "phase": "plan" + }, + { + "id": "8891ecd1-5a2a-4e", + "pipeline_id": "issue-1931", + "from_role": "architect", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_ACK,CONSENSUS_NACK,CONSENSUS_RE_REVIEW,OVERSEER_ALERT", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-04-26T23:51:12.779966+00:00" + }, + "timestamp": "2026-04-26T23:52:14.226064+00:00", + "phase": "plan" + }, + { + "id": "050145ac-e61f-4d", + "pipeline_id": "issue-1931", + "from_role": "risk_analyst", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_ACK,CONSENSUS_NACK,CONSENSUS_RE_REVIEW,OVERSEER_ALERT", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-04-26T23:52:27.443103+00:00" + }, + "timestamp": "2026-04-26T23:52:27.519750+00:00", + "phase": "plan" + }, + { + "id": "7aa52f73-486f-4b", + "pipeline_id": "issue-1931", + "from_role": "architect", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_ACK,CONSENSUS_NACK,CONSENSUS_RE_REVIEW,OVERSEER_ALERT", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-04-26T23:51:12.779966+00:00" + }, + "timestamp": "2026-04-26T23:53:14.440735+00:00", + "phase": "plan" + }, + { + "id": "591ca84e-611d-4d", + "pipeline_id": "issue-1931", + "from_role": "risk_analyst", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_ACK,CONSENSUS_NACK,CONSENSUS_RE_REVIEW,OVERSEER_ALERT", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-04-26T23:52:27.443103+00:00" + }, + "timestamp": "2026-04-26T23:53:27.929929+00:00", + "phase": "plan" + }, + { + "id": "8db40599-fce1-43", + "pipeline_id": "issue-1931", + "from_role": "reviewer_plan", + "to_role": "architect", + "message_type": "CONSENSUS_ACK", + "subject": "ACK from reviewer_plan for architect", + "body": "\nReviewed `.egg-state/agent-outputs/1931-architect-output.json` end-to-end against the analysis (`1931-analysis.md`), the resolved HITL decisions (decision-1 through decision-14 + feedback Q1\u2013Q10), and the Jira sister files (`gateway/jira_client.py` 567 LoC, `jira_search.py` 250 LoC, `jira_credentials.py` 213 LoC, `jira_policy.py` 253 LoC, `sandbox/scripts/jira` 358 LoC, `docs/reference/jira-wrapper.md` 173 LoC, `config/context-filters.yaml`).\n\n**Alignment with analysis + HITL** \u2014 all 14 multiple-choice decisions are correctly threaded into the architecture: A1 (Jira-shape paths) \u2192 TD1, B1 (v2-first hybrid) \u2192 TD2, C1 (static CQL extractor) \u2192 TD3, D1 (v2 + v1 fallback) \u2192 TD2, E (storage default with override) \u2192 TD4 *correctly noting the HITL resolution narrowed the analysis-recommended dual default*, F1 (shared ATLASSIAN_*) \u2192 TD5, G1 (per-route mode gate) \u2192 TD6, H1 (`confluence.spaces` in context-filters.yaml) \u2192 TD7, decision-9 \u2192 TD5, decision-10 \u2192 TD8, decision-11 \u2192 TD9, decision-12 \u2192 TD10, decision-13 \u2192 TD11, decision-14 \u2192 TD12. All 10 free-form feedback answers are wired in (Q1 \u2192 fail-closed empty allowlist, Q2 \u2192 reuse Jira's 429 retry, Q5 \u2192 no resolve-by-url, Q6 \u2192 depth passthrough TD14, Q7 \u2192 distinct `confluence_upstream_403` audit TD13, Q8 \u2192 current-version-only TD15, Q9 \u2192 bot-vs-human caveat in wrapper docs, Q10 \u2192 Jira-style subcommands only).\n\n**Architecture coherence** \u2014 the module breakdown mirrors the Jira split (client / credentials / policy / search) with size estimates (550-650 / 230-260 / 240-270 / 260-290) that are believable against the Jira reference (567/213/253/250). Workstream sequencing (WS1+WS3 parallel, WS2 after WS1, WS4 after WS1/2/3, WS5/WS6 after WS4) is correct: the client depends on credentials, routes depend on all three, sandbox-wrapper smoke tests depend on routes. Permanent denylist `CONFLUENCE_WRITE_VERBS_DENIED = {restrictions, permissions, space.admin, users, attachments, DELETE, PUT, PATCH}` correctly bakes in decision-12 and the future-write-readiness invariant. Audit shape (`confluence_*` events + distinct `confluence_upstream_403` + `confluence_v1_fallback`) is consistent with the Jira convention. The `_redact()` helper at the client layer is the right placement (pre-route) \u2014 agents cannot accidentally bypass redaction by going through `/execute`.\n\n**404 envelope discipline** \u2014 get_page / list_pages_in_space / get_page_descendants / get_footer_comments / get_inline_comments return `{status: not_found, ...}`; CQL search and execute_raw raise `ConfluenceUpstreamError`. Mirrors Jira (`get_ticket`/`get_comments` envelope vs `search`/`execute_raw` raise). \u2713\n\n**Route-enumeration regression** \u2014 `test_confluence_routes.py` walks `app.url_map` for `/api/v1/confluence/*` and asserts `__egg_requires_private_mode__ = True` on every view. Identical to `test_jira_routes.py::test_every_jira_route_has_private_mode_marker`. \u2713\n\n**Open questions deferred to plan phase** (Q1\u2013Q4 in `open_questions_for_plan_phase`) are reasonable scoping calls \u2014 the bot-vs-human docs paragraph (Q1) and per-page double-fetch trade-off (Q2) belong in the task plan, not the architecture, and the v1_fallback audit-field detail (Q3) and the shared-credential refactor (Q4) are correctly deferred to a follow-up.\n\n### Non-blocking\n- **`include_replies` flag for footer-comments** (route block: `POST /api/v1/confluence/page/footer-comments`) \u2014 the architect promotes this to a request-body parameter rather than the analysis's \"fallback when v2 misses replies\" auto-trigger. This is fine but means callers must opt in; consider documenting in `docs/reference/confluence-wrapper.md` that omitting `include_replies` will silently miss nested replies even when v2 returns the buggy shape. Suggested fix: add a paragraph in the wrapper reference's \"v1 fallback\" section calling this out explicitly so the planner can write a corresponding doc task.\n- **Search pagination naming** (`ConfluenceClient.search_cql(cql, limit, cursor)`) \u2014 Atlassian's v1 search uses `start` (numeric offset) and returns `_links.next`; v2 uses cursor. Exposing it as a `cursor` string at the wrapper layer is fine but warrants a one-line note in the architecture that the gateway extracts/encodes the v1 `start=N` from `_links.next` so callers don't have to know they're talking to v1. Suggested fix: have `task_planner` add a sub-task \"translate v1 search pagination into a single opaque `next_page_token` string\" under WS2.\n- **Response envelope normalisation** \u2014 the architect says \"Wrapper methods normalise both response shapes to a single envelope so /api/v1/confluence/* responses are version-agnostic for the agent\" but doesn't fully specify the envelope. Acceptable at architecture level; flagging so the task_planner explicitly carves out a \"define and document the unified response envelope\" task in WS2 rather than leaving it as implicit work.\n- **`/execute` path-validator regex** \u2014 `validate_confluence_api_path` is named but the accepted-path families aren't enumerated (Jira's analogue accepts `^issue/[A-Z][A-Z0-9_]*-\\d+$` etc.). Confluence's path families differ from Jira (numeric IDs, both `/wiki/api/v2/...` and `/wiki/rest/api/...` prefixes). Suggested fix: have `task_planner` make \"enumerate `/execute` accepted path patterns\" an explicit sub-task in WS4 with a recommended starting set: `pages/(/.*)?`, `spaces/(/.*)?`, `search` (under v1), and the v1-fallback comment paths.\n- **`_links.webui` redaction** \u2014 the architect strips webui \"when it points at user-profile URLs\" but Atlassian uses `_links.webui` for both pages (`/wiki/spaces/KEY/pages/ID/...`) and people (`/wiki/people/`). The task `_redact()` needs a path-pattern check, not a blanket strip. Suggested fix: have `task_planner` make the redaction predicate a tested helper (e.g., `_is_user_profile_webui(path)`) with a tabulated test matrix in `test_confluence_client.py`.\n- **Space ID \u2194 key resolution** \u2014 `space/pages` requires an extra upstream call to map numeric `space_id` \u2192 `space.key` for allowlist verification. Worth surfacing as a task-level decision (accept double-call in v1 vs. cache key\u2194id map at gateway start). The architect raises Q2 for `pageId \u2192 spaceKey` but not the symmetric case. Suggested fix: have `risk_analyst` cover this latency-amplification risk and `task_planner` include \"decide space ID/key resolution strategy\" as a WS4 task.\n- **Existing references to `confluence` in sandbox** \u2014 `sandbox/agent-config/commands/show-metrics.md:12` references `~/context-sync/confluence/` and line 36 mentions `confluence/ENG`. These are unrelated (the legacy context-sync cache, not the gateway). Worth a one-line audit task to confirm the show-metrics doc is out of scope for #1931 so the implementer doesn't accidentally rewrite it.\n- **Migration semantics in `secrets.template.env`** \u2014 TD5's \"remove `CONFLUENCE_SPACE_KEYS`\" is correct (decision-8 moved it to YAML), but live deployments may have `CONFLUENCE_SPACE_KEYS` populated. Suggested fix: have `task_planner` include a \"migration-note doc paragraph\" sub-task under WS6 telling operators that any populated `CONFLUENCE_SPACE_KEYS` is now ignored and they must migrate to `confluence.spaces:` in `config/context-filters.yaml`.\n", + "metadata": { + "payload": { + "artifact_references": [ + ".egg-state/agent-outputs/1931-architect-output.json", + ".egg-state/drafts/1931-analysis.md", + ".egg-state/contracts/issue-1931.json", + "gateway/jira_client.py", + "gateway/jira_search.py", + "gateway/jira_credentials.py", + "gateway/jira_policy.py", + "gateway/mode_gate.py", + "sandbox/scripts/jira", + "config/context-filters.yaml" + ], + "reason": "\nReviewed `.egg-state/agent-outputs/1931-architect-output.json` end-to-end against the analysis (`1931-analysis.md`), the resolved HITL decisions (decision-1 through decision-14 + feedback Q1\u2013Q10), and the Jira sister files (`gateway/jira_client.py` 567 LoC, `jira_search.py` 250 LoC, `jira_credentials.py` 213 LoC, `jira_policy.py` 253 LoC, `sandbox/scripts/jira` 358 LoC, `docs/reference/jira-wrapper.md` 173 LoC, `config/context-filters.yaml`).\n\n**Alignment with analysis + HITL** \u2014 all 14 multiple-choice decisions are correctly threaded into the architecture: A1 (Jira-shape paths) \u2192 TD1, B1 (v2-first hybrid) \u2192 TD2, C1 (static CQL extractor) \u2192 TD3, D1 (v2 + v1 fallback) \u2192 TD2, E (storage default with override) \u2192 TD4 *correctly noting the HITL resolution narrowed the analysis-recommended dual default*, F1 (shared ATLASSIAN_*) \u2192 TD5, G1 (per-route mode gate) \u2192 TD6, H1 (`confluence.spaces` in context-filters.yaml) \u2192 TD7, decision-9 \u2192 TD5, decision-10 \u2192 TD8, decision-11 \u2192 TD9, decision-12 \u2192 TD10, decision-13 \u2192 TD11, decision-14 \u2192 TD12. All 10 free-form feedback answers are wired in (Q1 \u2192 fail-closed empty allowlist, Q2 \u2192 reuse Jira's 429 retry, Q5 \u2192 no resolve-by-url, Q6 \u2192 depth passthrough TD14, Q7 \u2192 distinct `confluence_upstream_403` audit TD13, Q8 \u2192 current-version-only TD15, Q9 \u2192 bot-vs-human caveat in wrapper docs, Q10 \u2192 Jira-style subcommands only).\n\n**Architecture coherence** \u2014 the module breakdown mirrors the Jira split (client / credentials / policy / search) with size estimates (550-650 / 230-260 / 240-270 / 260-290) that are believable against the Jira reference (567/213/253/250). Workstream sequencing (WS1+WS3 parallel, WS2 after WS1, WS4 after WS1/2/3, WS5/WS6 after WS4) is correct: the client depends on credentials, routes depend on all three, sandbox-wrapper smoke tests depend on routes. Permanent denylist `CONFLUENCE_WRITE_VERBS_DENIED = {restrictions, permissions, space.admin, users, attachments, DELETE, PUT, PATCH}` correctly bakes in decision-12 and the future-write-readiness invariant. Audit shape (`confluence_*` events + distinct `confluence_upstream_403` + `confluence_v1_fallback`) is consistent with the Jira convention. The `_redact()` helper at the client layer is the right placement (pre-route) \u2014 agents cannot accidentally bypass redaction by going through `/execute`.\n\n**404 envelope discipline** \u2014 get_page / list_pages_in_space / get_page_descendants / get_footer_comments / get_inline_comments return `{status: not_found, ...}`; CQL search and execute_raw raise `ConfluenceUpstreamError`. Mirrors Jira (`get_ticket`/`get_comments` envelope vs `search`/`execute_raw` raise). \u2713\n\n**Route-enumeration regression** \u2014 `test_confluence_routes.py` walks `app.url_map` for `/api/v1/confluence/*` and asserts `__egg_requires_private_mode__ = True` on every view. Identical to `test_jira_routes.py::test_every_jira_route_has_private_mode_marker`. \u2713\n\n**Open questions deferred to plan phase** (Q1\u2013Q4 in `open_questions_for_plan_phase`) are reasonable scoping calls \u2014 the bot-vs-human docs paragraph (Q1) and per-page double-fetch trade-off (Q2) belong in the task plan, not the architecture, and the v1_fallback audit-field detail (Q3) and the shared-credential refactor (Q4) are correctly deferred to a follow-up.\n\n### Non-blocking\n- **`include_replies` flag for footer-comments** (route block: `POST /api/v1/confluence/page/footer-comments`) \u2014 the architect promotes this to a request-body parameter rather than the analysis's \"fallback when v2 misses replies\" auto-trigger. This is fine but means callers must opt in; consider documenting in `docs/reference/confluence-wrapper.md` that omitting `include_replies` will silently miss nested replies even when v2 returns the buggy shape. Suggested fix: add a paragraph in the wrapper reference's \"v1 fallback\" section calling this out explicitly so the planner can write a corresponding doc task.\n- **Search pagination naming** (`ConfluenceClient.search_cql(cql, limit, cursor)`) \u2014 Atlassian's v1 search uses `start` (numeric offset) and returns `_links.next`; v2 uses cursor. Exposing it as a `cursor` string at the wrapper layer is fine but warrants a one-line note in the architecture that the gateway extracts/encodes the v1 `start=N` from `_links.next` so callers don't have to know they're talking to v1. Suggested fix: have `task_planner` add a sub-task \"translate v1 search pagination into a single opaque `next_page_token` string\" under WS2.\n- **Response envelope normalisation** \u2014 the architect says \"Wrapper methods normalise both response shapes to a single envelope so /api/v1/confluence/* responses are version-agnostic for the agent\" but doesn't fully specify the envelope. Acceptable at architecture level; flagging so the task_planner explicitly carves out a \"define and document the unified response envelope\" task in WS2 rather than leaving it as implicit work.\n- **`/execute` path-validator regex** \u2014 `validate_confluence_api_path` is named but the accepted-path families aren't enumerated (Jira's analogue accepts `^issue/[A-Z][A-Z0-9_]*-\\d+$` etc.). Confluence's path families differ from Jira (numeric IDs, both `/wiki/api/v2/...` and `/wiki/rest/api/...` prefixes). Suggested fix: have `task_planner` make \"enumerate `/execute` accepted path patterns\" an explicit sub-task in WS4 with a recommended starting set: `pages/(/.*)?`, `spaces/(/.*)?`, `search` (under v1), and the v1-fallback comment paths.\n- **`_links.webui` redaction** \u2014 the architect strips webui \"when it points at user-profile URLs\" but Atlassian uses `_links.webui` for both pages (`/wiki/spaces/KEY/pages/ID/...`) and people (`/wiki/people/`). The task `_redact()` needs a path-pattern check, not a blanket strip. Suggested fix: have `task_planner` make the redaction predicate a tested helper (e.g., `_is_user_profile_webui(path)`) with a tabulated test matrix in `test_confluence_client.py`.\n- **Space ID \u2194 key resolution** \u2014 `space/pages` requires an extra upstream call to map numeric `space_id` \u2192 `space.key` for allowlist verification. Worth surfacing as a task-level decision (accept double-call in v1 vs. cache key\u2194id map at gateway start). The architect raises Q2 for `pageId \u2192 spaceKey` but not the symmetric case. Suggested fix: have `risk_analyst` cover this latency-amplification risk and `task_planner` include \"decide space ID/key resolution strategy\" as a WS4 task.\n- **Existing references to `confluence` in sandbox** \u2014 `sandbox/agent-config/commands/show-metrics.md:12` references `~/context-sync/confluence/` and line 36 mentions `confluence/ENG`. These are unrelated (the legacy context-sync cache, not the gateway). Worth a one-line audit task to confirm the show-metrics doc is out of scope for #1931 so the implementer doesn't accidentally rewrite it.\n- **Migration semantics in `secrets.template.env`** \u2014 TD5's \"remove `CONFLUENCE_SPACE_KEYS`\" is correct (decision-8 moved it to YAML), but live deployments may have `CONFLUENCE_SPACE_KEYS` populated. Suggested fix: have `task_planner` include a \"migration-note doc paragraph\" sub-task under WS6 telling operators that any populated `CONFLUENCE_SPACE_KEYS` is now ignored and they must migrate to `confluence.spaces:` in `config/context-filters.yaml`.\n" + }, + "version": 1 + }, + "timestamp": "2026-04-26T23:53:34.235934+00:00", + "phase": "plan" + }, + { + "id": "666dce7c-96d1-4e", + "pipeline_id": "issue-1931", + "from_role": "architect", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "wait_loop exited", + "metadata": { + "state": "WORKING" + }, + "timestamp": "2026-04-26T23:53:34.319230+00:00", + "phase": "plan" + }, + { + "id": "0a7ace19-1bd6-44", + "pipeline_id": "issue-1931", + "from_role": "reviewer_plan", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_PROPOSE,CONSENSUS_RE_REVIEW,CONSENSUS_CONFIRMED", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-04-26T23:53:40.179778+00:00" + }, + "timestamp": "2026-04-26T23:53:40.274650+00:00", + "phase": "plan" + }, + { + "id": "7c1c739c-79d9-46", + "pipeline_id": "issue-1931", + "from_role": "architect", + "to_role": "all", + "message_type": "CONSENSUS_CONFIRMED", + "subject": "Confirmed by architect (pending_acks)", + "body": "Agent architect cannot confirm: producers ['task_planner'] have never proposed (proposal_version == 0). All producers must propose before any agent can confirm consensus.", + "metadata": { + "pending_acks": true + }, + "timestamp": "2026-04-26T23:53:45.212352+00:00", + "phase": "plan" + }, + { + "id": "bcc19efe-50e4-4d", + "pipeline_id": "issue-1931", + "from_role": "reviewer_plan", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "wait_loop exited", + "metadata": { + "state": "WORKING" + }, + "timestamp": "2026-04-26T23:53:45.382126+00:00", + "phase": "plan" + }, + { + "id": "26f1f7a3-f2e2-48", + "pipeline_id": "issue-1931", + "from_role": "reviewer_plan", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_PROPOSE,CONSENSUS_RE_REVIEW,CONSENSUS_CONFIRMED", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-04-26T23:53:50.219548+00:00" + }, + "timestamp": "2026-04-26T23:53:50.309680+00:00", + "phase": "plan" + }, + { + "id": "a0d028fc-7c68-4a", + "pipeline_id": "issue-1931", + "from_role": "architect", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_ON_ROLE", + "body": "ACKed by reviewer_plan; awaiting task_planner proposal before confirm", + "metadata": { + "state": "WAITING_ON_ROLE", + "waiting_on": "task_planner" + }, + "timestamp": "2026-04-26T23:53:51.573148+00:00", + "phase": "plan" + }, + { + "id": "09314431-df41-49", + "pipeline_id": "issue-1931", + "from_role": "architect", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_PROPOSE,CONSENSUS_RE_REVIEW,CONSENSUS_NACK,CONSENSUS_CONFIRMED,OVERSEER_ALERT", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-04-26T23:53:51.584828+00:00" + }, + "timestamp": "2026-04-26T23:53:51.664317+00:00", + "phase": "plan" + }, + { + "id": "f805dce9-cb0a-47", + "pipeline_id": "issue-1931", + "from_role": "architect", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "wait_loop exited", + "metadata": { + "state": "WORKING" + }, + "timestamp": "2026-04-26T23:53:51.846241+00:00", + "phase": "plan" + }, + { + "id": "39e445fb-2d2f-4c", + "pipeline_id": "issue-1931", + "from_role": "architect", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_PROPOSE,CONSENSUS_RE_REVIEW,CONSENSUS_NACK,OVERSEER_ALERT", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-04-26T23:53:54.994915+00:00" + }, + "timestamp": "2026-04-26T23:53:55.071332+00:00", + "phase": "plan" + }, + { + "id": "85c9fdf8-2524-44", + "pipeline_id": "issue-1931", + "from_role": "risk_analyst", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_ACK,CONSENSUS_NACK,CONSENSUS_RE_REVIEW,OVERSEER_ALERT", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-04-26T23:52:27.443103+00:00" + }, + "timestamp": "2026-04-26T23:54:28.073796+00:00", + "phase": "plan" + }, + { + "id": "e73fc84c-cc8d-4d", + "pipeline_id": "issue-1931", + "from_role": "reviewer_plan", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_PROPOSE,CONSENSUS_RE_REVIEW,CONSENSUS_CONFIRMED", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-04-26T23:53:50.219548+00:00" + }, + "timestamp": "2026-04-26T23:54:50.396451+00:00", + "phase": "plan" + }, + { + "id": "a7c9399e-bb32-4d", + "pipeline_id": "issue-1931", + "from_role": "architect", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_PROPOSE,CONSENSUS_RE_REVIEW,CONSENSUS_NACK,OVERSEER_ALERT", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-04-26T23:53:54.994915+00:00" + }, + "timestamp": "2026-04-26T23:54:55.241624+00:00", + "phase": "plan" + }, + { + "id": "23c61c1e-86b2-41", + "pipeline_id": "issue-1931", + "from_role": "orchestrator", + "to_role": "risk_analyst", + "message_type": "OVERSEER_ALERT", + "subject": "BRC confirmation timeout \u2014 call mcp__brc__confirm", + "body": "You are PROPOSED and fully ACKed but have not confirmed in 180s. Call `mcp__brc__confirm` now. If it returns `status='pending_acks'`, read `message` for the guard reason and wait on the prerequisite events instead: `CONSENSUS_PROPOSE` if a producer hasn't proposed (`zero_proposal_producers`), `CONSENSUS_ACK` / `CONSENSUS_RE_REVIEW` if a reviewer's ACK is stale or unresolved. Then retry confirm.", + "metadata": { + "alert_type": "brc_confirmation_timeout", + "elapsed_seconds": 180, + "source": "health_monitor" + }, + "timestamp": "2026-04-26T23:55:23.175081+00:00", + "phase": "plan" + }, + { + "id": "ddda38a2-747f-42", + "pipeline_id": "issue-1931", + "from_role": "risk_analyst", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "wait_loop exited", + "metadata": { + "state": "WORKING" + }, + "timestamp": "2026-04-26T23:55:23.402943+00:00", + "phase": "plan" + }, + { + "id": "e14bf09e-7c21-47", + "pipeline_id": "issue-1931", + "from_role": "risk_analyst", + "to_role": "all", + "message_type": "CONSENSUS_CONFIRMED", + "subject": "Confirmed by risk_analyst (pending_acks)", + "body": "Agent risk_analyst cannot confirm: producers ['task_planner'] have never proposed (proposal_version == 0). All producers must propose before any agent can confirm consensus.", + "metadata": { + "pending_acks": true + }, + "timestamp": "2026-04-26T23:55:26.671450+00:00", + "phase": "plan" + }, + { + "id": "5b1ffa87-9359-45", + "pipeline_id": "issue-1931", + "from_role": "reviewer_plan", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "wait_loop exited", + "metadata": { + "state": "WORKING" + }, + "timestamp": "2026-04-26T23:55:26.755652+00:00", + "phase": "plan" + }, + { + "id": "5974c015-007a-4b", + "pipeline_id": "issue-1931", + "from_role": "risk_analyst", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_PROPOSE,CONSENSUS_ACK,CONSENSUS_NACK,CONSENSUS_RE_REVIEW,CONSENSUS_CONFIRMED,OVERSEER_ALERT from=task_planner", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-04-26T23:55:31.349035+00:00" + }, + "timestamp": "2026-04-26T23:55:31.439264+00:00", + "phase": "plan" + }, + { + "id": "3074f5ff-867c-49", + "pipeline_id": "issue-1931", + "from_role": "risk_analyst", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "wait_loop exited", + "metadata": { + "state": "WORKING" + }, + "timestamp": "2026-04-26T23:55:31.623217+00:00", + "phase": "plan" + }, + { + "id": "d78d7a36-d032-48", + "pipeline_id": "issue-1931", + "from_role": "risk_analyst", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_PROPOSE,CONSENSUS_ACK,CONSENSUS_NACK,CONSENSUS_RE_REVIEW,OVERSEER_ALERT", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-04-26T23:55:35.320204+00:00" + }, + "timestamp": "2026-04-26T23:55:35.488229+00:00", + "phase": "plan" + }, + { + "id": "2f2b6104-bb01-44", + "pipeline_id": "issue-1931", + "from_role": "architect", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_PROPOSE,CONSENSUS_RE_REVIEW,CONSENSUS_NACK,OVERSEER_ALERT", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-04-26T23:53:54.994915+00:00" + }, + "timestamp": "2026-04-26T23:55:55.549507+00:00", + "phase": "plan" + }, + { + "id": "47e1e8b7-8045-4e", + "pipeline_id": "issue-1931", + "from_role": "risk_analyst", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_PROPOSE,CONSENSUS_ACK,CONSENSUS_NACK,CONSENSUS_RE_REVIEW,OVERSEER_ALERT", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-04-26T23:55:35.320204+00:00" + }, + "timestamp": "2026-04-26T23:56:35.779487+00:00", + "phase": "plan" + }, + { + "id": "ffe396ce-53c1-4e", + "pipeline_id": "issue-1931", + "from_role": "orchestrator", + "to_role": "architect", + "message_type": "OVERSEER_ALERT", + "subject": "BRC confirmation timeout \u2014 call mcp__brc__confirm", + "body": "You are PROPOSED and fully ACKed but have not confirmed in 181s. Call `mcp__brc__confirm` now. If it returns `status='pending_acks'`, read `message` for the guard reason and wait on the prerequisite events instead: `CONSENSUS_PROPOSE` if a producer hasn't proposed (`zero_proposal_producers`), `CONSENSUS_ACK` / `CONSENSUS_RE_REVIEW` if a reviewer's ACK is stale or unresolved. Then retry confirm.", + "metadata": { + "alert_type": "brc_confirmation_timeout", + "elapsed_seconds": 181, + "source": "health_monitor" + }, + "timestamp": "2026-04-26T23:56:54.163430+00:00", + "phase": "plan" + }, + { + "id": "d8a0f97a-3954-44", + "pipeline_id": "issue-1931", + "from_role": "architect", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "wait_loop exited", + "metadata": { + "state": "WORKING" + }, + "timestamp": "2026-04-26T23:56:54.441330+00:00", + "phase": "plan" + }, + { + "id": "0e8930e8-7b7a-4a", + "pipeline_id": "issue-1931", + "from_role": "architect", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_PROPOSE,CONSENSUS_RE_REVIEW,CONSENSUS_NACK,OVERSEER_ALERT", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-04-26T23:57:00.206525+00:00" + }, + "timestamp": "2026-04-26T23:57:00.283284+00:00", + "phase": "plan" + }, + { + "id": "2383f2be-fa42-42", + "pipeline_id": "issue-1931", + "from_role": "risk_analyst", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_PROPOSE,CONSENSUS_ACK,CONSENSUS_NACK,CONSENSUS_RE_REVIEW,OVERSEER_ALERT", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-04-26T23:55:35.320204+00:00" + }, + "timestamp": "2026-04-26T23:57:35.890992+00:00", + "phase": "plan" + }, + { + "id": "9ab232fb-b646-41", + "pipeline_id": "issue-1931", + "from_role": "reviewer_plan", + "to_role": "risk_analyst", + "message_type": "CONSENSUS_ACK", + "subject": "ACK from reviewer_plan for risk_analyst", + "body": "\nReviewed `.egg-state/agent-outputs/1931-risk_analyst-output.json` against the analysis, architect output, and the Jira sister patterns it references (`gateway/jira_client.py:120-236` for `validate_jira_api_path`/`JIRA_API_ALLOWED_PATHS`; `gateway/allowed_domains.txt:31-36` confirming `*.atlassian.net` exclusion).\n\n**Coverage assessment** \u2014 18 risks across security (6), performance (3), compatibility (2), operational (3), data-privacy (1), external-dependency (1), test-coverage (1), future-compat (1) is a thorough inventory. One HIGH severity (R1 CQL extractor), seven MEDIUM (R2/R3/R4/R5/R6/R7/R10/R17), ten LOW. Three flagged for human review (R1 CQL adversarial coverage, R14 attachments denylist, R15 bot-vs-human access caveat) \u2014 all genuinely warrant security or operational eyes at implement time.\n\n**Mitigation quality** \u2014 every risk has actionable, verifiable mitigations. R1's adversarial test-suite list (empty CQL, quoted keys, OR at depth, CQL functions, mixed-script, `text ~` widening, `space != BAD` negation, URL-encoded operators, leading whitespace/BOM) covers concrete CQL grammar features absent from JQL \u2014 this directly addresses my biggest worry about R17 (test-port copy-paste). R2's anti-bypass test (every narrow route's path family must be rejected by `/execute`) is the right shape \u2014 it inverts the contract so a future widening of `/execute` cannot silently shadow a narrow route. R6's recursive-walker requirement explicitly covering ADF mention nodes (`type: 'mention'`, `attrs.id`) is essential \u2014 a top-level-only redactor would miss every page body's inline @-mentions. R14's variant matrix (case, URL-encoded, nested) maps directly onto the future-writes invariant that decision-12 demands.\n\n**Implement-phase checklist** maps each risk to a verifiable gate. Reviewer-friendly format. Global rollback plan (config-only \u2192 route-disable \u2192 credential-revoke \u2192 full-revert) gives operators graduated levers.\n\n**External research** \u2014 the cited sources (community.developer.atlassian.com threads on the v2 inline-404 bug and footer-comment nested-reply gap, the Apr 2026 Confluence Cloud changelog, the v1-deprecation timeline thread) are the right primary sources for R3 / R9 / R10. Findings are dated and specific (Apr 14 2026 internal API, Aug 5 2026 Convert content body, March 2026 points-based rate limiting rollout). Good due diligence.\n\n**No HIGH-severity unmitigated risk** \u2014 the only HIGH-severity entry (R1) has comprehensive mitigations. Acceptable.\n\n### Non-blocking\n- **R8 (descendants depth) conflicts with architect TD14** \u2014 the risk analyst recommends `default depth=1` if caller omits, while the architect's TD14 (citing HITL feedback Q6) says \"depth passes through verbatim with no gateway-imposed cap\". The HITL answer is ambiguous on whether \"verbatim passthrough\" means \"no default added\" or \"no upper cap\". The two readings produce different request shapes when the caller omits `depth`. Suggested fix: have `task_planner` resolve this explicitly \u2014 recommended reading is \"no cap (don't constrain caller's explicit depth) but apply a sensible default of 1 when caller omits it\" because Atlassian's unbounded default is both expensive and almost never what an agent actually wants.\n- **R11's `EGG_ATLASSIAN_SHARED_CREDS=0` feature flag and `POST /api/v1/atlassian/diagnostics` endpoint** are net-new surface not present in the architect's plan. The flag is plausibly over-engineering for v1 (the architect's `ATLASSIAN_*` \u2192 `JIRA_*`/`CONFLUENCE_*` fall-back chain already gives operators an in-place rollback path). The diagnostics endpoint *is* a useful addition (would also satisfy R12's \"boot-time announcement\" need and R15's \"bot effective-access verification\" need), but if added it must be `@require_session_auth + @require_private_mode` gated and audit-logged like every other route. Suggested fix: have `task_planner` either (a) drop the feature flag and rely on the fall-back chain, or (b) carve the diagnostics endpoint into its own task with explicit decorator + redaction requirements (no echoing token bytes).\n- **R7's `body_truncated: true` response flag** is net-new response envelope shape. Reasonable defensive design but should be coordinated with the architect's overall envelope definition. Suggested fix: have `task_planner` make \"define unified Confluence response envelope (success / not_found / forbidden / body_truncated)\" an explicit deliverable in WS2.\n- **R15's structured forbidden envelope `{status: 'forbidden', upstream_status: 403, reason: 'bot_account_lacks_read_access'}`** is a useful agent-facing distinction beyond the architect's plain `confluence_upstream_403` audit. Suggested fix: have `task_planner` add a sub-task to translate upstream 403 into the structured envelope (so agents can tell \"denied by gateway allowlist\" from \"denied by Atlassian permission\" without parsing audit logs).\n- **R8's net-new audit field `descendant_count`, R11's `body_bytes`, R13's `confluence_spaces_filtered`** \u2014 multiple new audit categories beyond what the architect listed. Suggested fix: `task_planner` should consolidate all proposed audit fields into one \"audit-log schema\" task so the eventual implementation has a single source of truth for confluence_* event names and field sets.\n- **R6's `displayName` redaction status** \u2014 the analyst writes \"verify decision-10 didn't include it\". Decision-10's resolution is settled and excludes `displayName` (only `accountId` / `emailAddress` / `_links.webui` are stripped). The \"verify\" phrasing implies uncertainty that is no longer present. Cosmetic only; does not affect implementation correctness.\n- **R3's \"Distinguish v2 inline-404 from a real not-found by checking the response body shape\"** \u2014 Atlassian's v2 bug response shape is not formally documented in the linked thread; relying on a body-shape heuristic is fragile. A safer mitigation is \"always retry once on 404 against v1; if v1 also returns 404, surface as not_found; emit `confluence_v1_fallback` audit on every retry\". The double-call cost is bounded (one extra HTTP round-trip per inline-comments call) and avoids the false-positive of misclassifying a deleted page as the v2 bug. Suggested fix: have `task_planner` adopt the always-retry-on-404 pattern for v2 inline-comments and document the cost in the wrapper reference.\n- **R16 (ADF prompt injection) severity rating** \u2014 rated low/low/low. Defensible since v1's stance is documentation + agent-side hardening, but worth flagging that a Confluence page authored by a contractor with edit access *is* a realistic prompt-injection vector for any refine-phase pipeline pulling linked Confluence pages. Suggested fix: ensure `docs/architecture/network-isolation.md` AND `sandbox/agent-config/rules/security.md` both name Confluence as an untrusted input source, not just the wrapper reference.\n- **R5's existing `test_allowed_domains.py` regression** \u2014 verified `gateway/allowed_domains.txt:31-36` does carry the `*.atlassian.net / *.atlassian.com / api.atlassian.com are intentionally NOT here` comment. The risk analyst's \"verify the existing test\" mitigation is satisfied for the Jira case; the Confluence ride-along claim is correct. Non-blocking; just confirming.\n", + "metadata": { + "payload": { + "artifact_references": [ + ".egg-state/agent-outputs/1931-risk_analyst-output.json", + ".egg-state/agent-outputs/1931-architect-output.json", + ".egg-state/drafts/1931-analysis.md", + "gateway/allowed_domains.txt", + "gateway/jira_client.py" + ], + "reason": "\nReviewed `.egg-state/agent-outputs/1931-risk_analyst-output.json` against the analysis, architect output, and the Jira sister patterns it references (`gateway/jira_client.py:120-236` for `validate_jira_api_path`/`JIRA_API_ALLOWED_PATHS`; `gateway/allowed_domains.txt:31-36` confirming `*.atlassian.net` exclusion).\n\n**Coverage assessment** \u2014 18 risks across security (6), performance (3), compatibility (2), operational (3), data-privacy (1), external-dependency (1), test-coverage (1), future-compat (1) is a thorough inventory. One HIGH severity (R1 CQL extractor), seven MEDIUM (R2/R3/R4/R5/R6/R7/R10/R17), ten LOW. Three flagged for human review (R1 CQL adversarial coverage, R14 attachments denylist, R15 bot-vs-human access caveat) \u2014 all genuinely warrant security or operational eyes at implement time.\n\n**Mitigation quality** \u2014 every risk has actionable, verifiable mitigations. R1's adversarial test-suite list (empty CQL, quoted keys, OR at depth, CQL functions, mixed-script, `text ~` widening, `space != BAD` negation, URL-encoded operators, leading whitespace/BOM) covers concrete CQL grammar features absent from JQL \u2014 this directly addresses my biggest worry about R17 (test-port copy-paste). R2's anti-bypass test (every narrow route's path family must be rejected by `/execute`) is the right shape \u2014 it inverts the contract so a future widening of `/execute` cannot silently shadow a narrow route. R6's recursive-walker requirement explicitly covering ADF mention nodes (`type: 'mention'`, `attrs.id`) is essential \u2014 a top-level-only redactor would miss every page body's inline @-mentions. R14's variant matrix (case, URL-encoded, nested) maps directly onto the future-writes invariant that decision-12 demands.\n\n**Implement-phase checklist** maps each risk to a verifiable gate. Reviewer-friendly format. Global rollback plan (config-only \u2192 route-disable \u2192 credential-revoke \u2192 full-revert) gives operators graduated levers.\n\n**External research** \u2014 the cited sources (community.developer.atlassian.com threads on the v2 inline-404 bug and footer-comment nested-reply gap, the Apr 2026 Confluence Cloud changelog, the v1-deprecation timeline thread) are the right primary sources for R3 / R9 / R10. Findings are dated and specific (Apr 14 2026 internal API, Aug 5 2026 Convert content body, March 2026 points-based rate limiting rollout). Good due diligence.\n\n**No HIGH-severity unmitigated risk** \u2014 the only HIGH-severity entry (R1) has comprehensive mitigations. Acceptable.\n\n### Non-blocking\n- **R8 (descendants depth) conflicts with architect TD14** \u2014 the risk analyst recommends `default depth=1` if caller omits, while the architect's TD14 (citing HITL feedback Q6) says \"depth passes through verbatim with no gateway-imposed cap\". The HITL answer is ambiguous on whether \"verbatim passthrough\" means \"no default added\" or \"no upper cap\". The two readings produce different request shapes when the caller omits `depth`. Suggested fix: have `task_planner` resolve this explicitly \u2014 recommended reading is \"no cap (don't constrain caller's explicit depth) but apply a sensible default of 1 when caller omits it\" because Atlassian's unbounded default is both expensive and almost never what an agent actually wants.\n- **R11's `EGG_ATLASSIAN_SHARED_CREDS=0` feature flag and `POST /api/v1/atlassian/diagnostics` endpoint** are net-new surface not present in the architect's plan. The flag is plausibly over-engineering for v1 (the architect's `ATLASSIAN_*` \u2192 `JIRA_*`/`CONFLUENCE_*` fall-back chain already gives operators an in-place rollback path). The diagnostics endpoint *is* a useful addition (would also satisfy R12's \"boot-time announcement\" need and R15's \"bot effective-access verification\" need), but if added it must be `@require_session_auth + @require_private_mode` gated and audit-logged like every other route. Suggested fix: have `task_planner` either (a) drop the feature flag and rely on the fall-back chain, or (b) carve the diagnostics endpoint into its own task with explicit decorator + redaction requirements (no echoing token bytes).\n- **R7's `body_truncated: true` response flag** is net-new response envelope shape. Reasonable defensive design but should be coordinated with the architect's overall envelope definition. Suggested fix: have `task_planner` make \"define unified Confluence response envelope (success / not_found / forbidden / body_truncated)\" an explicit deliverable in WS2.\n- **R15's structured forbidden envelope `{status: 'forbidden', upstream_status: 403, reason: 'bot_account_lacks_read_access'}`** is a useful agent-facing distinction beyond the architect's plain `confluence_upstream_403` audit. Suggested fix: have `task_planner` add a sub-task to translate upstream 403 into the structured envelope (so agents can tell \"denied by gateway allowlist\" from \"denied by Atlassian permission\" without parsing audit logs).\n- **R8's net-new audit field `descendant_count`, R11's `body_bytes`, R13's `confluence_spaces_filtered`** \u2014 multiple new audit categories beyond what the architect listed. Suggested fix: `task_planner` should consolidate all proposed audit fields into one \"audit-log schema\" task so the eventual implementation has a single source of truth for confluence_* event names and field sets.\n- **R6's `displayName` redaction status** \u2014 the analyst writes \"verify decision-10 didn't include it\". Decision-10's resolution is settled and excludes `displayName` (only `accountId` / `emailAddress` / `_links.webui` are stripped). The \"verify\" phrasing implies uncertainty that is no longer present. Cosmetic only; does not affect implementation correctness.\n- **R3's \"Distinguish v2 inline-404 from a real not-found by checking the response body shape\"** \u2014 Atlassian's v2 bug response shape is not formally documented in the linked thread; relying on a body-shape heuristic is fragile. A safer mitigation is \"always retry once on 404 against v1; if v1 also returns 404, surface as not_found; emit `confluence_v1_fallback` audit on every retry\". The double-call cost is bounded (one extra HTTP round-trip per inline-comments call) and avoids the false-positive of misclassifying a deleted page as the v2 bug. Suggested fix: have `task_planner` adopt the always-retry-on-404 pattern for v2 inline-comments and document the cost in the wrapper reference.\n- **R16 (ADF prompt injection) severity rating** \u2014 rated low/low/low. Defensible since v1's stance is documentation + agent-side hardening, but worth flagging that a Confluence page authored by a contractor with edit access *is* a realistic prompt-injection vector for any refine-phase pipeline pulling linked Confluence pages. Suggested fix: ensure `docs/architecture/network-isolation.md` AND `sandbox/agent-config/rules/security.md` both name Confluence as an untrusted input source, not just the wrapper reference.\n- **R5's existing `test_allowed_domains.py` regression** \u2014 verified `gateway/allowed_domains.txt:31-36` does carry the `*.atlassian.net / *.atlassian.com / api.atlassian.com are intentionally NOT here` comment. The risk analyst's \"verify the existing test\" mitigation is satisfied for the Jira case; the Confluence ride-along claim is correct. Non-blocking; just confirming.\n" + }, + "version": 1 + }, + "timestamp": "2026-04-26T23:57:48.757598+00:00", + "phase": "plan" + }, + { + "id": "66101341-b8bf-43", + "pipeline_id": "issue-1931", + "from_role": "risk_analyst", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "wait_loop exited", + "metadata": { + "state": "WORKING" + }, + "timestamp": "2026-04-26T23:57:48.850621+00:00", + "phase": "plan" + }, + { + "id": "1e44635a-6535-4e", + "pipeline_id": "issue-1931", + "from_role": "reviewer_plan", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_PROPOSE,CONSENSUS_RE_REVIEW,CONSENSUS_CONFIRMED", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-04-26T23:57:53.410880+00:00" + }, + "timestamp": "2026-04-26T23:57:53.482985+00:00", + "phase": "plan" + }, + { + "id": "1bf00113-1a2f-4f", + "pipeline_id": "issue-1931", + "from_role": "risk_analyst", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_ON_ROLE", + "body": "Risk_analyst is fully ACKed by reviewer_plan; blocked on task_planner CONSENSUS_PROPOSE before risk_analyst can call confirm.", + "metadata": { + "state": "WAITING_ON_ROLE", + "waiting_on": "task_planner" + }, + "timestamp": "2026-04-26T23:57:59.995445+00:00", + "phase": "plan" + }, + { + "id": "456d1916-26af-4d", + "pipeline_id": "issue-1931", + "from_role": "architect", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_PROPOSE,CONSENSUS_RE_REVIEW,CONSENSUS_NACK,OVERSEER_ALERT", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-04-26T23:57:00.206525+00:00" + }, + "timestamp": "2026-04-26T23:58:00.411861+00:00", + "phase": "plan" + }, + { + "id": "d5128a62-392b-43", + "pipeline_id": "issue-1931", + "from_role": "risk_analyst", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_PROPOSE,CONSENSUS_ACK,CONSENSUS_NACK,CONSENSUS_RE_REVIEW,OVERSEER_ALERT", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-04-26T23:58:04.686009+00:00" + }, + "timestamp": "2026-04-26T23:58:04.735527+00:00", + "phase": "plan" + }, + { + "id": "afe10f57-6983-4e", + "pipeline_id": "issue-1931", + "from_role": "overseer", + "to_role": "all", + "message_type": "OVERSEER_ALERT", + "subject": "stuck-phase-transition [medium]", + "body": "task_planner (issue-1931) silent for 702s (~11.7 min) \u2014 exceeds 600s threshold; architect + risk_analyst blocked waiting for task_planner CONSENSUS_PROPOSE\n\nDetail:\nPipeline issue-1931, plan phase. task_planner entered WORKING state at ~23:45:36 UTC and has emitted zero self-initiated heartbeats in 702s. No CONSENSUS_PROPOSE from task_planner. By contrast: architect proposed at 23:51:01 (~330s), risk_analyst proposed at 23:52:13 (~397s). Both have been ACKed by reviewer_plan and are blocked waiting to confirm \u2014 BRC cannot complete until task_planner proposes. The platform detector has not independently flagged task_planner yet (calibration_only alerts only), but the overseer silent_agent threshold of 600s has been crossed.\n\nParallel to refine phase: refiner also went silent ~11.5 min before proposing, then self-healed 13s after the HIGH alert was broadcast. The same pattern may apply here \u2014 task_planner may be doing deep codebase analysis and task decomposition using architect's 6-workstream proposal + risk_analyst's 18-risk checklist.\n\nAll brc_confirmation_timeout alerts for architect (23:56:54) and risk_analyst (23:55:23) are expected artifacts of this wait condition \u2014 not independent failures.\n\nRecommended action: Check task_planner container logs to verify forward progress (tool calls, reads). If no meaningful progress in next 2 cycles (~4 min), consider restarting the task_planner container for pipeline issue-1931.\n\nRecommended action:\nMonitor for 2 more cycles (~4 min). If no task_planner CONSENSUS_PROPOSE by 00:01 UTC, inspect container logs and consider restart.", + "metadata": {}, + "timestamp": "2026-04-26T23:58:05.167009+00:00", + "phase": "plan" + }, + { + "id": "09e7067e-9466-49", + "pipeline_id": "issue-1931", + "from_role": "architect", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "wait_loop exited", + "metadata": { + "state": "WORKING" + }, + "timestamp": "2026-04-26T23:58:05.633517+00:00", + "phase": "plan" + }, + { + "id": "6b9ccee7-e344-44", + "pipeline_id": "issue-1931", + "from_role": "risk_analyst", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "wait_loop exited", + "metadata": { + "state": "WORKING" + }, + "timestamp": "2026-04-26T23:58:05.670382+00:00", + "phase": "plan" + }, + { + "id": "7cf3150b-4749-4e", + "pipeline_id": "issue-1931", + "from_role": "architect", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_PROPOSE,CONSENSUS_RE_REVIEW,CONSENSUS_NACK,OVERSEER_ALERT from=task_planner", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-04-26T23:58:09.903934+00:00" + }, + "timestamp": "2026-04-26T23:58:09.996570+00:00", + "phase": "plan" + }, + { + "id": "348e43ab-4ed6-4d", + "pipeline_id": "issue-1931", + "from_role": "risk_analyst", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_PROPOSE,CONSENSUS_ACK,CONSENSUS_NACK,CONSENSUS_RE_REVIEW,OVERSEER_ALERT", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-04-26T23:58:10.654583+00:00" + }, + "timestamp": "2026-04-26T23:58:10.732061+00:00", + "phase": "plan" + }, + { + "id": "b049adf9-a54b-40", + "pipeline_id": "issue-1931", + "from_role": "reviewer_plan", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_PROPOSE,CONSENSUS_RE_REVIEW,CONSENSUS_CONFIRMED", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-04-26T23:57:53.410880+00:00" + }, + "timestamp": "2026-04-26T23:58:53.606435+00:00", + "phase": "plan" + }, + { + "id": "62e94f31-6f21-4a", + "pipeline_id": "issue-1931", + "from_role": "task_planner", + "to_role": "all", + "message_type": "CONSENSUS_PROPOSE", + "subject": "Proposal from task_planner", + "body": "Plan for #1931 (Confluence gateway read-only v1) decomposes the architecture analysis into a single-PR plan with 6 phases and 28 tasks (4 foundation modules + 9 routes + 1 sandbox wrapper + 7 test suites + 3 config touches + 4 doc touches), mirroring the #1556 Jira gateway scaffolding line-for-line. Incorporates all 14 refine HITL decisions (verb-noun URL paths, v2-first hybrid with v1 fallbacks, conservative CQL extractor, body-format=storage default per the operator tweak, shared ATLASSIAN_* triple, per-route @require_private_mode + route-enumeration regression test, confluence.spaces allowlist in context-filters.yaml, permanent attachments denylist, no per-pipeline EGG_CONFLUENCE_*, GET-only /execute with regex allowlist, accountId/emailAddress/_links.webui redaction, getConfluenceSpaces filtered to allowlisted spaces, distinct confluence_upstream_403 audit category, depth verbatim per Q6, current-version-only per Q8, Jira-style subcommands only per Q10) plus the architect's per-verb endpoint pinning, page\u2192space resolution caching, no-shared-loader-refactor stance, and the risk analyst's R1-R18 mitigations (5 MiB CONFLUENCE_RESPONSE_MAX_BYTES payload cap, descendants depth=1/limit=25 default when omitted, bot_account_lacks_read_access reason in structured 403 body, confluence_v1_fallback audit with v2_status, ADF mention/nested redaction fixtures, attachments-denylist case/URL-encoding/nested-position test variants, route-vs-execute anti-bypass test, six-combination credential precedence matrix, case-sensitive list_spaces filter test, Confluence-original test fixture per file, prompt-injection caveat and Atlassian rate-limit runbook in docs/reference/confluence-wrapper.md). Test plan covers automated unit/route/wrapper suites with respx mocks plus 11 manual verification steps; manual pre-merge requires operator to populate confluence.spaces and confirm bot read access; post-merge rolls the gateway pod and notifies #1557. Plan satisfies tasks 1-1 through 6-4 and is structured as one PR with phase-aligned commits.", + "metadata": { + "payload": { + "summary": "Plan for #1931 (Confluence gateway read-only v1) decomposes the architecture analysis into a single-PR plan with 6 phases and 28 tasks (4 foundation modules + 9 routes + 1 sandbox wrapper + 7 test suites + 3 config touches + 4 doc touches), mirroring the #1556 Jira gateway scaffolding line-for-line. Incorporates all 14 refine HITL decisions (verb-noun URL paths, v2-first hybrid with v1 fallbacks, conservative CQL extractor, body-format=storage default per the operator tweak, shared ATLASSIAN_* triple, per-route @require_private_mode + route-enumeration regression test, confluence.spaces allowlist in context-filters.yaml, permanent attachments denylist, no per-pipeline EGG_CONFLUENCE_*, GET-only /execute with regex allowlist, accountId/emailAddress/_links.webui redaction, getConfluenceSpaces filtered to allowlisted spaces, distinct confluence_upstream_403 audit category, depth verbatim per Q6, current-version-only per Q8, Jira-style subcommands only per Q10) plus the architect's per-verb endpoint pinning, page\u2192space resolution caching, no-shared-loader-refactor stance, and the risk analyst's R1-R18 mitigations (5 MiB CONFLUENCE_RESPONSE_MAX_BYTES payload cap, descendants depth=1/limit=25 default when omitted, bot_account_lacks_read_access reason in structured 403 body, confluence_v1_fallback audit with v2_status, ADF mention/nested redaction fixtures, attachments-denylist case/URL-encoding/nested-position test variants, route-vs-execute anti-bypass test, six-combination credential precedence matrix, case-sensitive list_spaces filter test, Confluence-original test fixture per file, prompt-injection caveat and Atlassian rate-limit runbook in docs/reference/confluence-wrapper.md). Test plan covers automated unit/route/wrapper suites with respx mocks plus 11 manual verification steps; manual pre-merge requires operator to populate confluence.spaces and confirm bot read access; post-merge rolls the gateway pod and notifies #1557. Plan satisfies tasks 1-1 through 6-4 and is structured as one PR with phase-aligned commits.", + "attestation": {}, + "artifacts": [ + ".egg-state/drafts/1931-plan.md" + ], + "risk_considered": "CQL extractor adversarial coverage (R1, mirrored from jira_search.py with CQL grammar); /execute path-validator drift (R2, anti-bypass test); v2 comment endpoints inline-404 + missing-nested-replies (R3, transparent v1 fallback with confluence_v1_fallback audit); private-mode gate decorator drift (R4, route-enumeration regression test); Squid allowlist widening (R5, extended test_allowed_domains parametrize); PII leakage via accountId/emailAddress/_links.webui (R6, redact_response with ADF coverage); page body / ADF token blow-up (R7, 5 MiB payload cap); descendants runaway (R8, depth=1/limit=25 defaults); v1 deprecation timeline (R9, per-verb pinning + last-reviewed footer); Atlassian points-based rate-limit (R10, 429 retry + runbook); shared credential rotation coupling (R11, six-combination test matrix); fail-closed empty allowlist (R12, boot-time INFO log); list_spaces case-mismatch fuzzy match (R13, strict intersection test); attachments denylist bypass via case/encoding/nesting (R14, three-variant test); bot effective access drift (R15, structured 403 envelope with bot_account_lacks_read_access reason); ADF prompt injection (R16, docs caveat); JQL\u2192CQL transliteration over-fit (R17, Confluence-original fixture per test file); future-write boundary lock-in (R18, ALLOWED_METHODS=GET-only with PUT/DELETE/PATCH explicitly tested as rejected).", + "commit_sha": "1b0af9121845a568ae8e2ad11bbf1c3e95aca3db", + "files_changed": [ + ".egg-state/drafts/1931-plan.md" + ], + "tests_run": [], + "tasks_satisfied": [ + "TASK-1-1", + "TASK-1-2", + "TASK-1-3", + "TASK-1-4", + "TASK-2-1", + "TASK-2-2", + "TASK-2-3", + "TASK-2-4", + "TASK-2-5", + "TASK-2-6", + "TASK-2-7", + "TASK-2-8", + "TASK-2-9", + "TASK-3-1", + "TASK-4-1", + "TASK-4-2", + "TASK-4-3", + "TASK-4-4", + "TASK-4-5", + "TASK-4-6", + "TASK-4-7", + "TASK-5-1", + "TASK-5-2", + "TASK-5-3", + "TASK-6-1", + "TASK-6-2", + "TASK-6-3", + "TASK-6-4" + ] + }, + "version": 1, + "commit_sha": "1b0af9121845a568ae8e2ad11bbf1c3e95aca3db" + }, + "timestamp": "2026-04-26T23:59:07.737341+00:00", + "phase": "plan" + }, + { + "id": "e4f8b803-adeb-4b", + "pipeline_id": "issue-1931", + "from_role": "orchestrator", + "to_role": "risk_analyst", + "message_type": "STATUS", + "subject": "Ready to confirm \u2014 all confirm preconditions satisfied", + "body": "Your proposal (version 1) is ready to confirm \u2014 all blocking reviews are clear and global confirm preconditions are met. Run `egg-orch consensus confirmed` to confirm.", + "metadata": { + "ready_to_confirm": true, + "version": 1 + }, + "timestamp": "2026-04-26T23:59:07.737383+00:00", + "phase": "plan" + }, + { + "id": "ee3f0b48-9abc-48", + "pipeline_id": "issue-1931", + "from_role": "orchestrator", + "to_role": "architect", + "message_type": "STATUS", + "subject": "Ready to confirm \u2014 all confirm preconditions satisfied", + "body": "Your proposal (version 1) is ready to confirm \u2014 all blocking reviews are clear and global confirm preconditions are met. Run `egg-orch consensus confirmed` to confirm.", + "metadata": { + "ready_to_confirm": true, + "version": 1 + }, + "timestamp": "2026-04-26T23:59:07.737399+00:00", + "phase": "plan" + }, + { + "id": "65f6754f-8d34-4f", + "pipeline_id": "issue-1931", + "from_role": "risk_analyst", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "wait_loop exited", + "metadata": { + "state": "WORKING" + }, + "timestamp": "2026-04-26T23:59:08.077445+00:00", + "phase": "plan" + }, + { + "id": "f700ca41-18e4-4e", + "pipeline_id": "issue-1931", + "from_role": "architect", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "wait_loop exited", + "metadata": { + "state": "WORKING" + }, + "timestamp": "2026-04-26T23:59:08.078920+00:00", + "phase": "plan" + }, + { + "id": "2cdce297-6a4c-48", + "pipeline_id": "issue-1931", + "from_role": "reviewer_plan", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "wait_loop exited", + "metadata": { + "state": "WORKING" + }, + "timestamp": "2026-04-26T23:59:08.084338+00:00", + "phase": "plan" + }, + { + "id": "4a634f9d-2c60-44", + "pipeline_id": "issue-1931", + "from_role": "architect", + "to_role": "all", + "message_type": "CONSENSUS_CONFIRMED", + "subject": "Confirmed by architect", + "body": "", + "metadata": { + "consensus_reached": false + }, + "timestamp": "2026-04-26T23:59:11.674500+00:00", + "phase": "plan" + }, + { + "id": "bb9950ae-6e10-49", + "pipeline_id": "issue-1931", + "from_role": "risk_analyst", + "to_role": "all", + "message_type": "CONSENSUS_CONFIRMED", + "subject": "Confirmed by risk_analyst", + "body": "", + "metadata": { + "consensus_reached": false + }, + "timestamp": "2026-04-26T23:59:12.125344+00:00", + "phase": "plan" + }, + { + "id": "6892ecd5-ab63-44", + "pipeline_id": "issue-1931", + "from_role": "task_planner", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_ACK,CONSENSUS_NACK,CONSENSUS_RE_REVIEW,OVERSEER_ALERT", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-04-26T23:59:12.297766+00:00" + }, + "timestamp": "2026-04-26T23:59:12.402814+00:00", + "phase": "plan" + }, + { + "id": "c9adef4e-e22f-40", + "pipeline_id": "issue-1931", + "from_role": "architect", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_CONFIRMED,CONSENSUS_RE_REVIEW,OVERSEER_ALERT,CONSENSUS_NACK", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-04-26T23:59:16.347020+00:00" + }, + "timestamp": "2026-04-26T23:59:19.107567+00:00", + "phase": "plan" + }, + { + "id": "29fc5ad4-edbd-4c", + "pipeline_id": "issue-1931", + "from_role": "risk_analyst", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_CONFIRMED,CONSENSUS_RE_REVIEW,OVERSEER_ALERT", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-04-26T23:59:25.536825+00:00" + }, + "timestamp": "2026-04-26T23:59:25.576525+00:00", + "phase": "plan" + }, + { + "id": "951c4a63-e436-45", + "pipeline_id": "issue-1931", + "from_role": "task_planner", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_ACK,CONSENSUS_NACK,CONSENSUS_RE_REVIEW,OVERSEER_ALERT", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-04-26T23:59:12.297766+00:00" + }, + "timestamp": "2026-04-27T00:00:12.656147+00:00", + "phase": "plan" + }, + { + "id": "338fb851-9803-40", + "pipeline_id": "issue-1931", + "from_role": "architect", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_CONFIRMED,CONSENSUS_RE_REVIEW,OVERSEER_ALERT,CONSENSUS_NACK", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-04-26T23:59:16.347020+00:00" + }, + "timestamp": "2026-04-27T00:00:19.369700+00:00", + "phase": "plan" + }, + { + "id": "5263560f-c55c-45", + "pipeline_id": "issue-1931", + "from_role": "risk_analyst", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_CONFIRMED,CONSENSUS_RE_REVIEW,OVERSEER_ALERT", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-04-26T23:59:25.536825+00:00" + }, + "timestamp": "2026-04-27T00:00:25.742087+00:00", + "phase": "plan" + }, + { + "id": "da9bedd9-cb50-46", + "pipeline_id": "issue-1931", + "from_role": "task_planner", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_ACK,CONSENSUS_NACK,CONSENSUS_RE_REVIEW,OVERSEER_ALERT", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-04-26T23:59:12.297766+00:00" + }, + "timestamp": "2026-04-27T00:01:12.900520+00:00", + "phase": "plan" + }, + { + "id": "9bdc9688-cdd8-42", + "pipeline_id": "issue-1931", + "from_role": "architect", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_CONFIRMED,CONSENSUS_RE_REVIEW,OVERSEER_ALERT,CONSENSUS_NACK", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-04-26T23:59:16.347020+00:00" + }, + "timestamp": "2026-04-27T00:01:19.587195+00:00", + "phase": "plan" + }, + { + "id": "7e7a1089-b728-48", + "pipeline_id": "issue-1931", + "from_role": "risk_analyst", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_CONFIRMED,CONSENSUS_RE_REVIEW,OVERSEER_ALERT", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-04-26T23:59:25.536825+00:00" + }, + "timestamp": "2026-04-27T00:01:25.993951+00:00", + "phase": "plan" + }, + { + "id": "64d4abd2-defa-40", + "pipeline_id": "issue-1931", + "from_role": "task_planner", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_ACK,CONSENSUS_NACK,CONSENSUS_RE_REVIEW,OVERSEER_ALERT", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-04-26T23:59:12.297766+00:00" + }, + "timestamp": "2026-04-27T00:02:13.143505+00:00", + "phase": "plan" + }, + { + "id": "dd5a6314-b5fe-4f", + "pipeline_id": "issue-1931", + "from_role": "architect", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_CONFIRMED,CONSENSUS_RE_REVIEW,OVERSEER_ALERT,CONSENSUS_NACK", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-04-26T23:59:16.347020+00:00" + }, + "timestamp": "2026-04-27T00:02:19.907437+00:00", + "phase": "plan" + }, + { + "id": "137c13ef-8a79-43", + "pipeline_id": "issue-1931", + "from_role": "risk_analyst", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_CONFIRMED,CONSENSUS_RE_REVIEW,OVERSEER_ALERT", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-04-26T23:59:25.536825+00:00" + }, + "timestamp": "2026-04-27T00:02:26.259492+00:00", + "phase": "plan" + }, + { + "id": "26c53ac0-c5eb-44", + "pipeline_id": "issue-1931", + "from_role": "task_planner", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_ACK,CONSENSUS_NACK,CONSENSUS_RE_REVIEW,OVERSEER_ALERT", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-04-26T23:59:12.297766+00:00" + }, + "timestamp": "2026-04-27T00:03:13.397524+00:00", + "phase": "plan" + }, + { + "id": "951b9985-f331-45", + "pipeline_id": "issue-1931", + "from_role": "reviewer_plan", + "to_role": "task_planner", + "message_type": "CONSENSUS_NACK", + "subject": "NACK from reviewer_plan for task_planner", + "body": "\nReviewed `.egg-state/drafts/1931-plan.md` (1342 lines, 6 phases, 28 tasks) end-to-end against the analysis, the architect output, the risk_analyst output, and the existing Jira sister files (`gateway/jira_credentials.py:134-137`, `config/secrets.template.env:91-99`, `tests/sandbox/test_jira_wrapper.py`).\n\nThe plan is overall strong \u2014 task decomposition is granular and assigns roles (coder / tester / documenter); each task has explicit acceptance criteria that reference the covering test task; the dependency graph is correct (Phase 1 foundation \u2192 Phase 2 routes \u2192 Phase 3 sandbox wrapper \u2192 Phase 4 tests \u2192 Phase 5 config \u2192 Phase 6 docs); risk mitigations from R2/R6/R7/R8/R10/R11/R13/R14/R15/R16/R17 are threaded into specific tasks (5 MiB payload cap \u2192 Task 1-2 + 2-1 + 4-5; descendants `depth=1`/`limit=25` defaults \u2192 Task 2-2 + 4-5; ADF redaction recursion \u2192 Task 1-2 + 4-2; Confluence-original fixtures \u2192 Task 4-2; route-vs-execute anti-bypass \u2192 Task 4-5; structured `bot_account_lacks_read_access` envelope \u2192 Task 1-2 + 2-1 + 4-5). Adversarial CQL test grid (13+ cases) is reproduced exactly from Task 1-4 into Task 4-4 / 4-5. The PR description and test plan are publication-ready.\n\n### Blocking\n\n1. **Plan promises a migration story that the implementation as planned cannot deliver \u2014 `gateway/jira_credentials.py` is not modified.**\n\n - The plan's \"Manual Pre/Post-Merge Steps \u2192 Pre-merge\" section says (line 599): *\"If migrating off independent JIRA_* + CONFLUENCE_* triples to the shared ATLASSIAN_* triple, operators may copy the same value into all three triples during the cutover **and remove the legacy keys later** \u2014 the loader prefers ATLASSIAN_* per key.\"*\n - Task 5-2 says: *\"Keep the existing JIRA_* and CONFLUENCE_* blocks intact for back-compat (the loader prefers ATLASSIAN_* per key but falls back to either prefix).\"*\n - Both passages imply the gateway has a single shared loader (or two loaders that both honour `ATLASSIAN_*`), but Task 1-1 only writes precedence into the **new** `gateway/confluence_credentials.py` (`ATLASSIAN_*` \u2192 `CONFLUENCE_*`). The plan does **not** include any task to update `gateway/jira_credentials.py`.\n - Verified directly: `gateway/jira_credentials.py:134-137` today reads only `secrets[\"JIRA_BASE_URL\"]`, `secrets[\"JIRA_USERNAME\"]`, `secrets[\"JIRA_API_TOKEN\"]`. There is no fallback to `ATLASSIAN_*`.\n - Consequence: an operator who follows the documented pre-merge path and removes the legacy `JIRA_*` keys after copying values to `ATLASSIAN_*` will silently break Jira (gateway returns 503 on every `/api/v1/jira/*` call). The shared-credential premise of decision-6 is violated.\n - This is also what risk R11's mitigation explicitly demanded (`risk_analyst-output.json` R11: *\"gateway/jira_credentials.py \u2014 same fall-back chain (must be updated to read ATLASSIAN_* preferentially)\"*) and what the architect's TD5 `shared_credential_invariant` describes (*\"Both confluence_credentials.py and jira_credentials.py read the same secrets.env file. When ATLASSIAN_* is set, both use it\"*). The architect's `files_intentionally_unchanged` list contradictorily lists `jira_credentials.py` as unchanged \u2014 the plan inherited the architect's contradiction without resolving it.\n\n **Fix**: Add a new task under Phase 1 (e.g. TASK-1-5) to update `gateway/jira_credentials.py` to read `ATLASSIAN_BASE_URL` / `ATLASSIAN_USERNAME` / `ATLASSIAN_API_TOKEN` preferentially with `JIRA_*` per-key fallback (mirror the precedence shape used in TASK-1-1). Add a corresponding test addition in `gateway/tests/test_jira_credentials.py` covering the six combinations (ATLASSIAN-only, JIRA-only, mixed per-key) under TASK-4-1 or a new TASK-4-1b. Update the pre-merge note in the PR description (line 599 of the plan) to reflect that both modules now honour the precedence. Without this, decision-6's \"shared credential invariant\" cannot ship in this PR \u2014 the planner must either include the change or retract the migration narrative.\n\n2. **Inline-comment fallback fall-through behaviour is unspecified \u2014 Task 1-2 + 2-4 do not state what happens when v1 also returns 404.**\n\n - Task 1-2 says: *\"`get_page_inline_comments(page_id, body_format=(\"storage\",))` \u2192 `GET /wiki/api/v2/pages/{id}/inline-comments`. **v2 \u2192 v1 fallback**: if v2 returns 404, retry transparently against `GET /wiki/rest/api/content/{id}/child/comment?location=inline&expand=body.view` (the known v2 inline-comment 404 bug per the analysis). Return the v1 response normalized into a `{\"results\": [...]}` envelope.\"*\n - The plan never specifies what happens if v1 also returns 404 (i.e. the page genuinely doesn't exist or has no inline comments). Two plausible behaviours:\n - (a) Return the `{\"status\": \"not_found\", \"id\": \"...\", \"upstream_status\": 404}` envelope (matching every other read method).\n - (b) Return an empty `{\"results\": []}` envelope (since \"no comments\" and \"not found\" are different states and v1 distinguishes them).\n - Task 4-2 acceptance lists *\"v1 inline-comment fallback fires on v2 404 with `used_fallback` flag observable\"* but does not include a fixture for the v2-404 + v1-404 case. Without this, the implementer is left to make the call without a written contract, and the route layer's 404-envelope semantics (Task 2-4) cannot be tested deterministically.\n - This also intersects with risk R3 \u2014 the risk_analyst's mitigation suggested distinguishing v2 inline-404 (the bug) from a real not-found by inspecting the response body shape, which I flagged in my risk_analyst review as fragile. The \"always retry once on 404 and surface as not_found if v1 also 404s\" pattern is the correct simpler approach but the plan needs to say so.\n\n **Fix**: Edit Task 1-2 to specify: *\"If v2 returns 404 and the v1 retry also returns 404, return the standard `{\"status\": \"not_found\", \"id\": \"...\", \"upstream_status\": 404}` envelope. If v2 returns 404 and v1 returns 200 with an empty results list, return the v1 payload with `used_fallback=true` (distinguishes \"v2 bug + page exists with no comments\" from \"page actually not found\").\"* Add a corresponding fixture to TASK-4-2's acceptance grid covering the v2-404 + v1-404 case.\n\n### Non-blocking\n\n- **`E5 tweak` terminology mismatch.** Tasks 1-2 acceptance and 4-2 description refer to *\"decision E5 tweak\"*. The contract numbering is `decision-5` (analysis option labels were `E1`/`E2`/`E3`/`E4`, never `E5`). Cosmetic; will confuse anyone grepping the audit trail. **Fix**: replace `E5` with `decision-5` (or `option E1` if referencing the analysis label). Eight occurrences across the file.\n\n- **Boot-time policy-size INFO log is mis-placed.** Task 1-2 puts the boot-time observability logging inside `gateway/confluence_client.py` at module import (*\"the credential / policy / client managers log a single INFO line each summarising the loaded state\"*). The allowlist size lives in `confluence_policy.py`, not the client; the client at import time has no reason to know the policy size. Risk R12's mitigation expected the policy module to emit it. **Fix**: move the \"allowlist loaded: N keys\" log line to `gateway/confluence_policy.py`'s first `allowed_spaces()` call (or to `_reload_all_config()` in gateway.py at startup), and keep the client's log line scoped to credentials + body-format default.\n\n- **Shared rate-limit pool risk (R10) is undocumented in the wrapper reference.** The plan implements per-call 429 retry but doesn't surface that consolidating to a single Atlassian bot account (decision-9) means Jira and Confluence now share the points-based quota. Two unrelated pipelines reading from Jira and Confluence simultaneously can throttle each other. Task 6-4 (`docs/reference/confluence-wrapper.md`) mentions a rate-limit runbook but doesn't name the pool-sharing consequence. **Fix**: add a sentence to TASK-6-4: *\"Note: the bot account that owns Confluence access is the same principal as the Jira bot (per decision-9), so 429s from Atlassian's points-based quota are pooled across `/api/v1/jira/*` and `/api/v1/confluence/*` traffic. Operators seeing routine throttling on one service should expect it to manifest on the other and may want to provision a dedicated Confluence-only bot in a follow-up.\"*\n\n- **`spaceKey \u2192 spaceId` resolution caching is one-sided.** Task 2-1 introduces a 60s page\u2192space cache (architect Q2 mitigation), but Task 2-5 (`/space/pages`) needs the inverse mapping (`spaceKey \u2192 spaceId`) and gets it via `ConfluenceClient.list_spaces(...)` \u2014 one upstream call per request. The plan doesn't extend the cache to the spaceKey\u2194spaceId map, so cold-start `/space/pages` calls have a guaranteed double round-trip. **Fix**: have TASK-1-2 declare a single spaceId\u2194spaceKey LRU populated by both `list_spaces` and `get_page` so subsequent `/space/pages` calls reuse it; or explicitly accept the cost in TASK-2-5's acceptance criteria.\n\n- **Architect Q4 (shared credential extraction) is silently dropped.** The architect raised Q4 (whether to share credential-loading helpers between `confluence_credentials.py` and `jira_credentials.py`) and recommended deferring. The plan implements deferral implicitly but doesn't reference Q4 anywhere. **Fix**: add a one-line note in TASK-1-1 acceptance: *\"Per architect Q4, no shared `atlassian_credentials.py` helper is extracted in v1; both modules duplicate the loader skeleton for review clarity. Track shared extraction in a follow-up backlog item.\"*\n\n- **Manual verification step 4 talks about `PRIVATE_MODE` env var, but the gateway uses `g.session_mode == \"private\"` (set by `@require_session_auth`).** Step 4 of \"Test Strategy \u2192 Manual\" reads: *\"Repeat with `PRIVATE_MODE` unset / public mode and confirm 403 \u2026\"*. There is no `PRIVATE_MODE` env var; private mode is a session-level attribute. **Fix**: rephrase to *\"Repeat with a public-mode session token (or one created without `--private`) and confirm 403 with `endpoint requires private network mode`.\"*\n\n- **TASK-4-7 reuses an existing test name.** Plan says *\"the existing `test_atlassian_domains_absent` test\"*. I did not verify this name exists in `gateway/tests/test_allowed_domains.py`; if the actual test is named differently, the planner should either rename it or describe the test's behaviour by location rather than by name. Cheap to verify at implement time. **Fix**: have TASK-4-7's coder confirm the existing test name before extending it.\n\n- **No task to update `sandbox/agent-config/commands/show-metrics.md`** (lines 12 + 36) which currently references the legacy `~/context-sync/confluence/` cache. The plan correctly leaves this as out-of-scope (it's the legacy syncer cache, not the new gateway), but a reader landing in the file post-merge will be confused about which `confluence/` path is current. **Fix**: add a one-line task to TASK-6-3 noting that `show-metrics.md` is intentionally untouched because the syncer cache and the gateway wrapper are independent surfaces.\n\n- **R10 retry-cap audit field naming.** Task 1-2 says *\"Audit `confluence_upstream_rate_limited` on both 429s including the `Retry-After` value and path.\"* but doesn't specify the `attempt` field that R10's mitigation explicitly required (*\"Audit log records `confluence_upstream_rate_limited` with the Retry-After value and `attempt` index.\"*). Without `attempt: 1|2` operators can't tell from the log whether the retry succeeded. **Fix**: add `attempt: int (1 or 2)` to the audit shape in TASK-1-2.\n\n- **Task 2-9 audit event name `confluence_config_reloaded` is one event for both reloads.** That's reasonable but worth noting it loses the granularity the Jira reload presumably has (e.g., `jira_config_reloaded` may emit one event for credentials and one for policy). If the Jira pattern emits two events, copy that shape; if it emits one, this is consistent. Cheap to verify at implement time.\n", + "metadata": { + "payload": { + "reason": "\nReviewed `.egg-state/drafts/1931-plan.md` (1342 lines, 6 phases, 28 tasks) end-to-end against the analysis, the architect output, the risk_analyst output, and the existing Jira sister files (`gateway/jira_credentials.py:134-137`, `config/secrets.template.env:91-99`, `tests/sandbox/test_jira_wrapper.py`).\n\nThe plan is overall strong \u2014 task decomposition is granular and assigns roles (coder / tester / documenter); each task has explicit acceptance criteria that reference the covering test task; the dependency graph is correct (Phase 1 foundation \u2192 Phase 2 routes \u2192 Phase 3 sandbox wrapper \u2192 Phase 4 tests \u2192 Phase 5 config \u2192 Phase 6 docs); risk mitigations from R2/R6/R7/R8/R10/R11/R13/R14/R15/R16/R17 are threaded into specific tasks (5 MiB payload cap \u2192 Task 1-2 + 2-1 + 4-5; descendants `depth=1`/`limit=25` defaults \u2192 Task 2-2 + 4-5; ADF redaction recursion \u2192 Task 1-2 + 4-2; Confluence-original fixtures \u2192 Task 4-2; route-vs-execute anti-bypass \u2192 Task 4-5; structured `bot_account_lacks_read_access` envelope \u2192 Task 1-2 + 2-1 + 4-5). Adversarial CQL test grid (13+ cases) is reproduced exactly from Task 1-4 into Task 4-4 / 4-5. The PR description and test plan are publication-ready.\n\n### Blocking\n\n1. **Plan promises a migration story that the implementation as planned cannot deliver \u2014 `gateway/jira_credentials.py` is not modified.**\n\n - The plan's \"Manual Pre/Post-Merge Steps \u2192 Pre-merge\" section says (line 599): *\"If migrating off independent JIRA_* + CONFLUENCE_* triples to the shared ATLASSIAN_* triple, operators may copy the same value into all three triples during the cutover **and remove the legacy keys later** \u2014 the loader prefers ATLASSIAN_* per key.\"*\n - Task 5-2 says: *\"Keep the existing JIRA_* and CONFLUENCE_* blocks intact for back-compat (the loader prefers ATLASSIAN_* per key but falls back to either prefix).\"*\n - Both passages imply the gateway has a single shared loader (or two loaders that both honour `ATLASSIAN_*`), but Task 1-1 only writes precedence into the **new** `gateway/confluence_credentials.py` (`ATLASSIAN_*` \u2192 `CONFLUENCE_*`). The plan does **not** include any task to update `gateway/jira_credentials.py`.\n - Verified directly: `gateway/jira_credentials.py:134-137` today reads only `secrets[\"JIRA_BASE_URL\"]`, `secrets[\"JIRA_USERNAME\"]`, `secrets[\"JIRA_API_TOKEN\"]`. There is no fallback to `ATLASSIAN_*`.\n - Consequence: an operator who follows the documented pre-merge path and removes the legacy `JIRA_*` keys after copying values to `ATLASSIAN_*` will silently break Jira (gateway returns 503 on every `/api/v1/jira/*` call). The shared-credential premise of decision-6 is violated.\n - This is also what risk R11's mitigation explicitly demanded (`risk_analyst-output.json` R11: *\"gateway/jira_credentials.py \u2014 same fall-back chain (must be updated to read ATLASSIAN_* preferentially)\"*) and what the architect's TD5 `shared_credential_invariant` describes (*\"Both confluence_credentials.py and jira_credentials.py read the same secrets.env file. When ATLASSIAN_* is set, both use it\"*). The architect's `files_intentionally_unchanged` list contradictorily lists `jira_credentials.py` as unchanged \u2014 the plan inherited the architect's contradiction without resolving it.\n\n **Fix**: Add a new task under Phase 1 (e.g. TASK-1-5) to update `gateway/jira_credentials.py` to read `ATLASSIAN_BASE_URL` / `ATLASSIAN_USERNAME` / `ATLASSIAN_API_TOKEN` preferentially with `JIRA_*` per-key fallback (mirror the precedence shape used in TASK-1-1). Add a corresponding test addition in `gateway/tests/test_jira_credentials.py` covering the six combinations (ATLASSIAN-only, JIRA-only, mixed per-key) under TASK-4-1 or a new TASK-4-1b. Update the pre-merge note in the PR description (line 599 of the plan) to reflect that both modules now honour the precedence. Without this, decision-6's \"shared credential invariant\" cannot ship in this PR \u2014 the planner must either include the change or retract the migration narrative.\n\n2. **Inline-comment fallback fall-through behaviour is unspecified \u2014 Task 1-2 + 2-4 do not state what happens when v1 also returns 404.**\n\n - Task 1-2 says: *\"`get_page_inline_comments(page_id, body_format=(\"storage\",))` \u2192 `GET /wiki/api/v2/pages/{id}/inline-comments`. **v2 \u2192 v1 fallback**: if v2 returns 404, retry transparently against `GET /wiki/rest/api/content/{id}/child/comment?location=inline&expand=body.view` (the known v2 inline-comment 404 bug per the analysis). Return the v1 response normalized into a `{\"results\": [...]}` envelope.\"*\n - The plan never specifies what happens if v1 also returns 404 (i.e. the page genuinely doesn't exist or has no inline comments). Two plausible behaviours:\n - (a) Return the `{\"status\": \"not_found\", \"id\": \"...\", \"upstream_status\": 404}` envelope (matching every other read method).\n - (b) Return an empty `{\"results\": []}` envelope (since \"no comments\" and \"not found\" are different states and v1 distinguishes them).\n - Task 4-2 acceptance lists *\"v1 inline-comment fallback fires on v2 404 with `used_fallback` flag observable\"* but does not include a fixture for the v2-404 + v1-404 case. Without this, the implementer is left to make the call without a written contract, and the route layer's 404-envelope semantics (Task 2-4) cannot be tested deterministically.\n - This also intersects with risk R3 \u2014 the risk_analyst's mitigation suggested distinguishing v2 inline-404 (the bug) from a real not-found by inspecting the response body shape, which I flagged in my risk_analyst review as fragile. The \"always retry once on 404 and surface as not_found if v1 also 404s\" pattern is the correct simpler approach but the plan needs to say so.\n\n **Fix**: Edit Task 1-2 to specify: *\"If v2 returns 404 and the v1 retry also returns 404, return the standard `{\"status\": \"not_found\", \"id\": \"...\", \"upstream_status\": 404}` envelope. If v2 returns 404 and v1 returns 200 with an empty results list, return the v1 payload with `used_fallback=true` (distinguishes \"v2 bug + page exists with no comments\" from \"page actually not found\").\"* Add a corresponding fixture to TASK-4-2's acceptance grid covering the v2-404 + v1-404 case.\n\n### Non-blocking\n\n- **`E5 tweak` terminology mismatch.** Tasks 1-2 acceptance and 4-2 description refer to *\"decision E5 tweak\"*. The contract numbering is `decision-5` (analysis option labels were `E1`/`E2`/`E3`/`E4`, never `E5`). Cosmetic; will confuse anyone grepping the audit trail. **Fix**: replace `E5` with `decision-5` (or `option E1` if referencing the analysis label). Eight occurrences across the file.\n\n- **Boot-time policy-size INFO log is mis-placed.** Task 1-2 puts the boot-time observability logging inside `gateway/confluence_client.py` at module import (*\"the credential / policy / client managers log a single INFO line each summarising the loaded state\"*). The allowlist size lives in `confluence_policy.py`, not the client; the client at import time has no reason to know the policy size. Risk R12's mitigation expected the policy module to emit it. **Fix**: move the \"allowlist loaded: N keys\" log line to `gateway/confluence_policy.py`'s first `allowed_spaces()` call (or to `_reload_all_config()` in gateway.py at startup), and keep the client's log line scoped to credentials + body-format default.\n\n- **Shared rate-limit pool risk (R10) is undocumented in the wrapper reference.** The plan implements per-call 429 retry but doesn't surface that consolidating to a single Atlassian bot account (decision-9) means Jira and Confluence now share the points-based quota. Two unrelated pipelines reading from Jira and Confluence simultaneously can throttle each other. Task 6-4 (`docs/reference/confluence-wrapper.md`) mentions a rate-limit runbook but doesn't name the pool-sharing consequence. **Fix**: add a sentence to TASK-6-4: *\"Note: the bot account that owns Confluence access is the same principal as the Jira bot (per decision-9), so 429s from Atlassian's points-based quota are pooled across `/api/v1/jira/*` and `/api/v1/confluence/*` traffic. Operators seeing routine throttling on one service should expect it to manifest on the other and may want to provision a dedicated Confluence-only bot in a follow-up.\"*\n\n- **`spaceKey \u2192 spaceId` resolution caching is one-sided.** Task 2-1 introduces a 60s page\u2192space cache (architect Q2 mitigation), but Task 2-5 (`/space/pages`) needs the inverse mapping (`spaceKey \u2192 spaceId`) and gets it via `ConfluenceClient.list_spaces(...)` \u2014 one upstream call per request. The plan doesn't extend the cache to the spaceKey\u2194spaceId map, so cold-start `/space/pages` calls have a guaranteed double round-trip. **Fix**: have TASK-1-2 declare a single spaceId\u2194spaceKey LRU populated by both `list_spaces` and `get_page` so subsequent `/space/pages` calls reuse it; or explicitly accept the cost in TASK-2-5's acceptance criteria.\n\n- **Architect Q4 (shared credential extraction) is silently dropped.** The architect raised Q4 (whether to share credential-loading helpers between `confluence_credentials.py` and `jira_credentials.py`) and recommended deferring. The plan implements deferral implicitly but doesn't reference Q4 anywhere. **Fix**: add a one-line note in TASK-1-1 acceptance: *\"Per architect Q4, no shared `atlassian_credentials.py` helper is extracted in v1; both modules duplicate the loader skeleton for review clarity. Track shared extraction in a follow-up backlog item.\"*\n\n- **Manual verification step 4 talks about `PRIVATE_MODE` env var, but the gateway uses `g.session_mode == \"private\"` (set by `@require_session_auth`).** Step 4 of \"Test Strategy \u2192 Manual\" reads: *\"Repeat with `PRIVATE_MODE` unset / public mode and confirm 403 \u2026\"*. There is no `PRIVATE_MODE` env var; private mode is a session-level attribute. **Fix**: rephrase to *\"Repeat with a public-mode session token (or one created without `--private`) and confirm 403 with `endpoint requires private network mode`.\"*\n\n- **TASK-4-7 reuses an existing test name.** Plan says *\"the existing `test_atlassian_domains_absent` test\"*. I did not verify this name exists in `gateway/tests/test_allowed_domains.py`; if the actual test is named differently, the planner should either rename it or describe the test's behaviour by location rather than by name. Cheap to verify at implement time. **Fix**: have TASK-4-7's coder confirm the existing test name before extending it.\n\n- **No task to update `sandbox/agent-config/commands/show-metrics.md`** (lines 12 + 36) which currently references the legacy `~/context-sync/confluence/` cache. The plan correctly leaves this as out-of-scope (it's the legacy syncer cache, not the new gateway), but a reader landing in the file post-merge will be confused about which `confluence/` path is current. **Fix**: add a one-line task to TASK-6-3 noting that `show-metrics.md` is intentionally untouched because the syncer cache and the gateway wrapper are independent surfaces.\n\n- **R10 retry-cap audit field naming.** Task 1-2 says *\"Audit `confluence_upstream_rate_limited` on both 429s including the `Retry-After` value and path.\"* but doesn't specify the `attempt` field that R10's mitigation explicitly required (*\"Audit log records `confluence_upstream_rate_limited` with the Retry-After value and `attempt` index.\"*). Without `attempt: 1|2` operators can't tell from the log whether the retry succeeded. **Fix**: add `attempt: int (1 or 2)` to the audit shape in TASK-1-2.\n\n- **Task 2-9 audit event name `confluence_config_reloaded` is one event for both reloads.** That's reasonable but worth noting it loses the granularity the Jira reload presumably has (e.g., `jira_config_reloaded` may emit one event for credentials and one for policy). If the Jira pattern emits two events, copy that shape; if it emits one, this is consistent. Cheap to verify at implement time.\n", + "artifact_references": [ + ".egg-state/drafts/1931-plan.md", + ".egg-state/agent-outputs/1931-architect-output.json", + ".egg-state/agent-outputs/1931-risk_analyst-output.json", + ".egg-state/drafts/1931-analysis.md", + "gateway/jira_credentials.py", + "config/secrets.template.env", + "config/context-filters.yaml", + "tests/sandbox/test_jira_wrapper.py" + ] + }, + "reason": "\nReviewed `.egg-state/drafts/1931-plan.md` (1342 lines, 6 phases, 28 tasks) end-to-end against the analysis, the architect output, the risk_analyst output, and the existing Jira sister files (`gateway/jira_credentials.py:134-137`, `config/secrets.template.env:91-99`, `tests/sandbox/test_jira_wrapper.py`).\n\nThe plan is overall strong \u2014 task decomposition is granular and assigns roles (coder / tester / documenter); each task has explicit acceptance criteria that reference the covering test task; the dependency graph is correct (Phase 1 foundation \u2192 Phase 2 routes \u2192 Phase 3 sandbox wrapper \u2192 Phase 4 tests \u2192 Phase 5 config \u2192 Phase 6 docs); risk mitigations from R2/R6/R7/R8/R10/R11/R13/R14/R15/R16/R17 are threaded into specific tasks (5 MiB payload cap \u2192 Task 1-2 + 2-1 + 4-5; descendants `depth=1`/`limit=25` defaults \u2192 Task 2-2 + 4-5; ADF redaction recursion \u2192 Task 1-2 + 4-2; Confluence-original fixtures \u2192 Task 4-2; route-vs-execute anti-bypass \u2192 Task 4-5; structured `bot_account_lacks_read_access` envelope \u2192 Task 1-2 + 2-1 + 4-5). Adversarial CQL test grid (13+ cases) is reproduced exactly from Task 1-4 into Task 4-4 / 4-5. The PR description and test plan are publication-ready.\n\n### Blocking\n\n1. **Plan promises a migration story that the implementation as planned cannot deliver \u2014 `gateway/jira_credentials.py` is not modified.**\n\n - The plan's \"Manual Pre/Post-Merge Steps \u2192 Pre-merge\" section says (line 599): *\"If migrating off independent JIRA_* + CONFLUENCE_* triples to the shared ATLASSIAN_* triple, operators may copy the same value into all three triples during the cutover **and remove the legacy keys later** \u2014 the loader prefers ATLASSIAN_* per key.\"*\n - Task 5-2 says: *\"Keep the existing JIRA_* and CONFLUENCE_* blocks intact for back-compat (the loader prefers ATLASSIAN_* per key but falls back to either prefix).\"*\n - Both passages imply the gateway has a single shared loader (or two loaders that both honour `ATLASSIAN_*`), but Task 1-1 only writes precedence into the **new** `gateway/confluence_credentials.py` (`ATLASSIAN_*` \u2192 `CONFLUENCE_*`). The plan does **not** include any task to update `gateway/jira_credentials.py`.\n - Verified directly: `gateway/jira_credentials.py:134-137` today reads only `secrets[\"JIRA_BASE_URL\"]`, `secrets[\"JIRA_USERNAME\"]`, `secrets[\"JIRA_API_TOKEN\"]`. There is no fallback to `ATLASSIAN_*`.\n - Consequence: an operator who follows the documented pre-merge path and removes the legacy `JIRA_*` keys after copying values to `ATLASSIAN_*` will silently break Jira (gateway returns 503 on every `/api/v1/jira/*` call). The shared-credential premise of decision-6 is violated.\n - This is also what risk R11's mitigation explicitly demanded (`risk_analyst-output.json` R11: *\"gateway/jira_credentials.py \u2014 same fall-back chain (must be updated to read ATLASSIAN_* preferentially)\"*) and what the architect's TD5 `shared_credential_invariant` describes (*\"Both confluence_credentials.py and jira_credentials.py read the same secrets.env file. When ATLASSIAN_* is set, both use it\"*). The architect's `files_intentionally_unchanged` list contradictorily lists `jira_credentials.py` as unchanged \u2014 the plan inherited the architect's contradiction without resolving it.\n\n **Fix**: Add a new task under Phase 1 (e.g. TASK-1-5) to update `gateway/jira_credentials.py` to read `ATLASSIAN_BASE_URL` / `ATLASSIAN_USERNAME` / `ATLASSIAN_API_TOKEN` preferentially with `JIRA_*` per-key fallback (mirror the precedence shape used in TASK-1-1). Add a corresponding test addition in `gateway/tests/test_jira_credentials.py` covering the six combinations (ATLASSIAN-only, JIRA-only, mixed per-key) under TASK-4-1 or a new TASK-4-1b. Update the pre-merge note in the PR description (line 599 of the plan) to reflect that both modules now honour the precedence. Without this, decision-6's \"shared credential invariant\" cannot ship in this PR \u2014 the planner must either include the change or retract the migration narrative.\n\n2. **Inline-comment fallback fall-through behaviour is unspecified \u2014 Task 1-2 + 2-4 do not state what happens when v1 also returns 404.**\n\n - Task 1-2 says: *\"`get_page_inline_comments(page_id, body_format=(\"storage\",))` \u2192 `GET /wiki/api/v2/pages/{id}/inline-comments`. **v2 \u2192 v1 fallback**: if v2 returns 404, retry transparently against `GET /wiki/rest/api/content/{id}/child/comment?location=inline&expand=body.view` (the known v2 inline-comment 404 bug per the analysis). Return the v1 response normalized into a `{\"results\": [...]}` envelope.\"*\n - The plan never specifies what happens if v1 also returns 404 (i.e. the page genuinely doesn't exist or has no inline comments). Two plausible behaviours:\n - (a) Return the `{\"status\": \"not_found\", \"id\": \"...\", \"upstream_status\": 404}` envelope (matching every other read method).\n - (b) Return an empty `{\"results\": []}` envelope (since \"no comments\" and \"not found\" are different states and v1 distinguishes them).\n - Task 4-2 acceptance lists *\"v1 inline-comment fallback fires on v2 404 with `used_fallback` flag observable\"* but does not include a fixture for the v2-404 + v1-404 case. Without this, the implementer is left to make the call without a written contract, and the route layer's 404-envelope semantics (Task 2-4) cannot be tested deterministically.\n - This also intersects with risk R3 \u2014 the risk_analyst's mitigation suggested distinguishing v2 inline-404 (the bug) from a real not-found by inspecting the response body shape, which I flagged in my risk_analyst review as fragile. The \"always retry once on 404 and surface as not_found if v1 also 404s\" pattern is the correct simpler approach but the plan needs to say so.\n\n **Fix**: Edit Task 1-2 to specify: *\"If v2 returns 404 and the v1 retry also returns 404, return the standard `{\"status\": \"not_found\", \"id\": \"...\", \"upstream_status\": 404}` envelope. If v2 returns 404 and v1 returns 200 with an empty results list, return the v1 payload with `used_fallback=true` (distinguishes \"v2 bug + page exists with no comments\" from \"page actually not found\").\"* Add a corresponding fixture to TASK-4-2's acceptance grid covering the v2-404 + v1-404 case.\n\n### Non-blocking\n\n- **`E5 tweak` terminology mismatch.** Tasks 1-2 acceptance and 4-2 description refer to *\"decision E5 tweak\"*. The contract numbering is `decision-5` (analysis option labels were `E1`/`E2`/`E3`/`E4`, never `E5`). Cosmetic; will confuse anyone grepping the audit trail. **Fix**: replace `E5` with `decision-5` (or `option E1` if referencing the analysis label). Eight occurrences across the file.\n\n- **Boot-time policy-size INFO log is mis-placed.** Task 1-2 puts the boot-time observability logging inside `gateway/confluence_client.py` at module import (*\"the credential / policy / client managers log a single INFO line each summarising the loaded state\"*). The allowlist size lives in `confluence_policy.py`, not the client; the client at import time has no reason to know the policy size. Risk R12's mitigation expected the policy module to emit it. **Fix**: move the \"allowlist loaded: N keys\" log line to `gateway/confluence_policy.py`'s first `allowed_spaces()` call (or to `_reload_all_config()` in gateway.py at startup), and keep the client's log line scoped to credentials + body-format default.\n\n- **Shared rate-limit pool risk (R10) is undocumented in the wrapper reference.** The plan implements per-call 429 retry but doesn't surface that consolidating to a single Atlassian bot account (decision-9) means Jira and Confluence now share the points-based quota. Two unrelated pipelines reading from Jira and Confluence simultaneously can throttle each other. Task 6-4 (`docs/reference/confluence-wrapper.md`) mentions a rate-limit runbook but doesn't name the pool-sharing consequence. **Fix**: add a sentence to TASK-6-4: *\"Note: the bot account that owns Confluence access is the same principal as the Jira bot (per decision-9), so 429s from Atlassian's points-based quota are pooled across `/api/v1/jira/*` and `/api/v1/confluence/*` traffic. Operators seeing routine throttling on one service should expect it to manifest on the other and may want to provision a dedicated Confluence-only bot in a follow-up.\"*\n\n- **`spaceKey \u2192 spaceId` resolution caching is one-sided.** Task 2-1 introduces a 60s page\u2192space cache (architect Q2 mitigation), but Task 2-5 (`/space/pages`) needs the inverse mapping (`spaceKey \u2192 spaceId`) and gets it via `ConfluenceClient.list_spaces(...)` \u2014 one upstream call per request. The plan doesn't extend the cache to the spaceKey\u2194spaceId map, so cold-start `/space/pages` calls have a guaranteed double round-trip. **Fix**: have TASK-1-2 declare a single spaceId\u2194spaceKey LRU populated by both `list_spaces` and `get_page` so subsequent `/space/pages` calls reuse it; or explicitly accept the cost in TASK-2-5's acceptance criteria.\n\n- **Architect Q4 (shared credential extraction) is silently dropped.** The architect raised Q4 (whether to share credential-loading helpers between `confluence_credentials.py` and `jira_credentials.py`) and recommended deferring. The plan implements deferral implicitly but doesn't reference Q4 anywhere. **Fix**: add a one-line note in TASK-1-1 acceptance: *\"Per architect Q4, no shared `atlassian_credentials.py` helper is extracted in v1; both modules duplicate the loader skeleton for review clarity. Track shared extraction in a follow-up backlog item.\"*\n\n- **Manual verification step 4 talks about `PRIVATE_MODE` env var, but the gateway uses `g.session_mode == \"private\"` (set by `@require_session_auth`).** Step 4 of \"Test Strategy \u2192 Manual\" reads: *\"Repeat with `PRIVATE_MODE` unset / public mode and confirm 403 \u2026\"*. There is no `PRIVATE_MODE` env var; private mode is a session-level attribute. **Fix**: rephrase to *\"Repeat with a public-mode session token (or one created without `--private`) and confirm 403 with `endpoint requires private network mode`.\"*\n\n- **TASK-4-7 reuses an existing test name.** Plan says *\"the existing `test_atlassian_domains_absent` test\"*. I did not verify this name exists in `gateway/tests/test_allowed_domains.py`; if the actual test is named differently, the planner should either rename it or describe the test's behaviour by location rather than by name. Cheap to verify at implement time. **Fix**: have TASK-4-7's coder confirm the existing test name before extending it.\n\n- **No task to update `sandbox/agent-config/commands/show-metrics.md`** (lines 12 + 36) which currently references the legacy `~/context-sync/confluence/` cache. The plan correctly leaves this as out-of-scope (it's the legacy syncer cache, not the new gateway), but a reader landing in the file post-merge will be confused about which `confluence/` path is current. **Fix**: add a one-line task to TASK-6-3 noting that `show-metrics.md` is intentionally untouched because the syncer cache and the gateway wrapper are independent surfaces.\n\n- **R10 retry-cap audit field naming.** Task 1-2 says *\"Audit `confluence_upstream_rate_limited` on both 429s including the `Retry-After` value and path.\"* but doesn't specify the `attempt` field that R10's mitigation explicitly required (*\"Audit log records `confluence_upstream_rate_limited` with the Retry-After value and `attempt` index.\"*). Without `attempt: 1|2` operators can't tell from the log whether the retry succeeded. **Fix**: add `attempt: int (1 or 2)` to the audit shape in TASK-1-2.\n\n- **Task 2-9 audit event name `confluence_config_reloaded` is one event for both reloads.** That's reasonable but worth noting it loses the granularity the Jira reload presumably has (e.g., `jira_config_reloaded` may emit one event for credentials and one for policy). If the Jira pattern emits two events, copy that shape; if it emits one, this is consistent. Cheap to verify at implement time.\n", + "revision_count": 1 + }, + "timestamp": "2026-04-27T00:03:18.350954+00:00", + "phase": "plan" + }, + { + "id": "f8a83d6a-a482-49", + "pipeline_id": "issue-1931", + "from_role": "task_planner", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "wait_loop exited", + "metadata": { + "state": "WORKING" + }, + "timestamp": "2026-04-27T00:03:18.545983+00:00", + "phase": "plan" + }, + { + "id": "b7cfb652-7ec3-4f", + "pipeline_id": "issue-1931", + "from_role": "architect", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_CONFIRMED,CONSENSUS_RE_REVIEW,OVERSEER_ALERT,CONSENSUS_NACK", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-04-26T23:59:16.347020+00:00" + }, + "timestamp": "2026-04-27T00:03:20.219571+00:00", + "phase": "plan" + }, + { + "id": "c5669aa5-9c70-41", + "pipeline_id": "issue-1931", + "from_role": "reviewer_plan", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_PROPOSE,CONSENSUS_RE_REVIEW,CONSENSUS_CONFIRMED,OVERSEER_ALERT", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-04-27T00:03:25.968737+00:00" + }, + "timestamp": "2026-04-27T00:03:26.063758+00:00", + "phase": "plan" + }, + { + "id": "fddeb1f6-59c8-49", + "pipeline_id": "issue-1931", + "from_role": "reviewer_plan", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "wait_loop exited", + "metadata": { + "state": "WORKING" + }, + "timestamp": "2026-04-27T00:03:26.242531+00:00", + "phase": "plan" + }, + { + "id": "ad409015-e71b-49", + "pipeline_id": "issue-1931", + "from_role": "risk_analyst", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_CONFIRMED,CONSENSUS_RE_REVIEW,OVERSEER_ALERT", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-04-26T23:59:25.536825+00:00" + }, + "timestamp": "2026-04-27T00:03:26.500137+00:00", + "phase": "plan" + }, + { + "id": "a0a5db80-526f-42", + "pipeline_id": "issue-1931", + "from_role": "reviewer_plan", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_PROPOSE,CONSENSUS_RE_REVIEW,CONSENSUS_CONFIRMED,OVERSEER_ALERT", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-04-27T00:03:30.140522+00:00" + }, + "timestamp": "2026-04-27T00:03:30.221099+00:00", + "phase": "plan" + }, + { + "id": "1368cb4f-ed4a-4e", + "pipeline_id": "issue-1931", + "from_role": "architect", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_CONFIRMED,CONSENSUS_RE_REVIEW,OVERSEER_ALERT,CONSENSUS_NACK", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-04-26T23:59:16.347020+00:00" + }, + "timestamp": "2026-04-27T00:04:20.415701+00:00", + "phase": "plan" + }, + { + "id": "9c33fcbe-56fe-49", + "pipeline_id": "issue-1931", + "from_role": "risk_analyst", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_CONFIRMED,CONSENSUS_RE_REVIEW,OVERSEER_ALERT", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-04-26T23:59:25.536825+00:00" + }, + "timestamp": "2026-04-27T00:04:26.751676+00:00", + "phase": "plan" + }, + { + "id": "94511037-6d5e-43", + "pipeline_id": "issue-1931", + "from_role": "reviewer_plan", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_PROPOSE,CONSENSUS_RE_REVIEW,CONSENSUS_CONFIRMED,OVERSEER_ALERT", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-04-27T00:03:30.140522+00:00" + }, + "timestamp": "2026-04-27T00:04:30.340439+00:00", + "phase": "plan" + }, + { + "id": "1f42babf-b8fc-4e", + "pipeline_id": "issue-1931", + "from_role": "architect", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_CONFIRMED,CONSENSUS_RE_REVIEW,OVERSEER_ALERT,CONSENSUS_NACK", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-04-26T23:59:16.347020+00:00" + }, + "timestamp": "2026-04-27T00:05:20.671683+00:00", + "phase": "plan" + }, + { + "id": "57624ed8-86f6-47", + "pipeline_id": "issue-1931", + "from_role": "risk_analyst", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_CONFIRMED,CONSENSUS_RE_REVIEW,OVERSEER_ALERT", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-04-26T23:59:25.536825+00:00" + }, + "timestamp": "2026-04-27T00:05:26.963701+00:00", + "phase": "plan" + }, + { + "id": "12df28f2-705b-4e", + "pipeline_id": "issue-1931", + "from_role": "reviewer_plan", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_PROPOSE,CONSENSUS_RE_REVIEW,CONSENSUS_CONFIRMED,OVERSEER_ALERT", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-04-27T00:03:30.140522+00:00" + }, + "timestamp": "2026-04-27T00:05:30.550651+00:00", + "phase": "plan" + }, + { + "id": "a2f17346-5a00-49", + "pipeline_id": "issue-1931", + "from_role": "architect", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_CONFIRMED,CONSENSUS_RE_REVIEW,OVERSEER_ALERT,CONSENSUS_NACK", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-04-26T23:59:16.347020+00:00" + }, + "timestamp": "2026-04-27T00:06:20.959211+00:00", + "phase": "plan" + }, + { + "id": "75744ffe-74fc-4c", + "pipeline_id": "issue-1931", + "from_role": "risk_analyst", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_CONFIRMED,CONSENSUS_RE_REVIEW,OVERSEER_ALERT", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-04-26T23:59:25.536825+00:00" + }, + "timestamp": "2026-04-27T00:06:27.178418+00:00", + "phase": "plan" + }, + { + "id": "0b59683c-2383-41", + "pipeline_id": "issue-1931", + "from_role": "reviewer_plan", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_PROPOSE,CONSENSUS_RE_REVIEW,CONSENSUS_CONFIRMED,OVERSEER_ALERT", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-04-27T00:03:30.140522+00:00" + }, + "timestamp": "2026-04-27T00:06:30.750543+00:00", + "phase": "plan" + }, + { + "id": "f8cc1923-c2c2-45", + "pipeline_id": "issue-1931", + "from_role": "architect", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_CONFIRMED,CONSENSUS_RE_REVIEW,OVERSEER_ALERT,CONSENSUS_NACK", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-04-26T23:59:16.347020+00:00" + }, + "timestamp": "2026-04-27T00:07:21.201575+00:00", + "phase": "plan" + }, + { + "id": "2526f4fb-d678-49", + "pipeline_id": "issue-1931", + "from_role": "risk_analyst", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_CONFIRMED,CONSENSUS_RE_REVIEW,OVERSEER_ALERT", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-04-26T23:59:25.536825+00:00" + }, + "timestamp": "2026-04-27T00:07:27.406849+00:00", + "phase": "plan" + }, + { + "id": "8a3249e0-8c3b-4b", + "pipeline_id": "issue-1931", + "from_role": "reviewer_plan", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_PROPOSE,CONSENSUS_RE_REVIEW,CONSENSUS_CONFIRMED,OVERSEER_ALERT", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-04-27T00:03:30.140522+00:00" + }, + "timestamp": "2026-04-27T00:07:30.981905+00:00", + "phase": "plan" + }, + { + "id": "0b7f1477-1b76-4e", + "pipeline_id": "issue-1931", + "from_role": "architect", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_CONFIRMED,CONSENSUS_RE_REVIEW,OVERSEER_ALERT,CONSENSUS_NACK", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-04-26T23:59:16.347020+00:00" + }, + "timestamp": "2026-04-27T00:08:21.497183+00:00", + "phase": "plan" + }, + { + "id": "5e32cba1-0604-48", + "pipeline_id": "issue-1931", + "from_role": "risk_analyst", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_CONFIRMED,CONSENSUS_RE_REVIEW,OVERSEER_ALERT", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-04-26T23:59:25.536825+00:00" + }, + "timestamp": "2026-04-27T00:08:27.649671+00:00", + "phase": "plan" + }, + { + "id": "40805264-532f-46", + "pipeline_id": "issue-1931", + "from_role": "reviewer_plan", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_PROPOSE,CONSENSUS_RE_REVIEW,CONSENSUS_CONFIRMED,OVERSEER_ALERT", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-04-27T00:03:30.140522+00:00" + }, + "timestamp": "2026-04-27T00:08:31.238461+00:00", + "phase": "plan" + }, + { + "id": "a999e441-b46f-4b", + "pipeline_id": "issue-1931", + "from_role": "overseer", + "to_role": "all", + "message_type": "OVERSEER_ALERT", + "subject": "agent-heartbeat-stall [high]", + "body": "ESCALATION: task_planner stalled 261s post-ACK \u2014 wait_loop exited 00:03:18Z but no CONSENSUS_CONFIRMED, no heartbeats; advisor gate OPEN for issue-1931\n\nDetail:\nEscalating from medium (alert afe10f57) to HIGH. task_planner's wait_loop exited at 00:03:18Z (received CONSENSUS_ACK from reviewer_plan) and has been in producer_phase=WORKING with zero heartbeats for 261s (4:21). Normal post-ACK confirmation takes ~5-10s (architect and risk_analyst both confirmed within 3s of their ACKs). The three other active agents (architect, risk_analyst, reviewer_plan) are all alive with flawless 60s cadence \u2014 the stall is isolated to task_planner.\n\ntask_planner's post-ACK work may involve: (1) writing to .egg-state/drafts/1931-plan.md, (2) running JSON validation, (3) LLM call before confirm. However 261s far exceeds any reasonable post-ACK processing window.\n\nCascading effect: reviewer_plan cannot emit CONSENSUS_CONFIRMED until task_planner confirms; architect and risk_analyst have been waiting for CONFIRMED since 23:59:11Z (~8:28 elapsed). Plan phase completion is fully blocked on task_planner.\n\nThe overseer_nack_unresolved_seconds threshold (180s) has been exceeded.\n\nRecommended action: Inspect task_planner container logs for its activity since 00:03:18Z. If no forward progress (no tool calls, no LLM activity), restart the task_planner container. After restart, task_planner should re-enter BRC, and reviewer_plan already has the prior ACK state so the confirm round should complete quickly.\n\nRecommended action:\nInspect task_planner container logs immediately. If no activity since 00:03:18Z, restart container. Plan phase consensus is fully blocked.", + "metadata": {}, + "timestamp": "2026-04-27T00:08:31.851609+00:00", + "phase": "plan" + }, + { + "id": "96841eaa-47d6-44", + "pipeline_id": "issue-1931", + "from_role": "architect", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "wait_loop exited", + "metadata": { + "state": "WORKING" + }, + "timestamp": "2026-04-27T00:08:32.645682+00:00", + "phase": "plan" + }, + { + "id": "417a5471-0481-4d", + "pipeline_id": "issue-1931", + "from_role": "risk_analyst", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "wait_loop exited", + "metadata": { + "state": "WORKING" + }, + "timestamp": "2026-04-27T00:08:32.657752+00:00", + "phase": "plan" + }, + { + "id": "6351c1d2-a655-4f", + "pipeline_id": "issue-1931", + "from_role": "reviewer_plan", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "wait_loop exited", + "metadata": { + "state": "WORKING" + }, + "timestamp": "2026-04-27T00:08:32.664086+00:00", + "phase": "plan" + }, + { + "id": "a0a8272d-b4fc-4b", + "pipeline_id": "issue-1931", + "from_role": "risk_analyst", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_CONFIRMED,CONSENSUS_RE_REVIEW,OVERSEER_ALERT", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-04-27T00:08:36.620321+00:00" + }, + "timestamp": "2026-04-27T00:08:36.711610+00:00", + "phase": "plan" + }, + { + "id": "b7d19bf1-5714-44", + "pipeline_id": "issue-1931", + "from_role": "architect", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_CONFIRMED,CONSENSUS_RE_REVIEW,OVERSEER_ALERT,CONSENSUS_NACK", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-04-27T00:08:38.630974+00:00" + }, + "timestamp": "2026-04-27T00:08:38.840375+00:00", + "phase": "plan" + }, + { + "id": "a4086b8c-dc01-41", + "pipeline_id": "issue-1931", + "from_role": "reviewer_plan", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_ON_ROLE", + "body": "NACKed task_planner with two blocking issues (jira_credentials.py not modified for shared ATLASSIAN_* fall-back; v2-404 + v1-404 fall-through unspecified) and 8 non-blocking observations. Awaiting re-propose. ACKed architect + risk_analyst (both already confirmed).", + "metadata": { + "state": "WAITING_ON_ROLE", + "waiting_on": "task_planner" + }, + "timestamp": "2026-04-27T00:08:50.200084+00:00", + "phase": "plan" + }, + { + "id": "ed7f9781-5c36-45", + "pipeline_id": "issue-1931", + "from_role": "reviewer_plan", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_PROPOSE,CONSENSUS_RE_REVIEW,CONSENSUS_CONFIRMED,OVERSEER_ALERT", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-04-27T00:08:50.219667+00:00" + }, + "timestamp": "2026-04-27T00:08:50.292262+00:00", + "phase": "plan" + }, + { + "id": "b3178d1d-654b-41", + "pipeline_id": "issue-1931", + "from_role": "task_planner", + "to_role": "all", + "message_type": "CONSENSUS_PROPOSE", + "subject": "Proposal from task_planner", + "body": "Re-proposing plan for #1931 after addressing reviewer_plan NACK. Two blocking items resolved: (1) added TASK-1-5 to update gateway/jira_credentials.py with the same ATLASSIAN_*-preferred / JIRA_* fall-back precedence so the shared-credential promise (decision F1) is portable end-to-end \u2014 operators can drop the legacy JIRA_* block after migration without silently breaking Jira (risk R11 mitigation made concrete); paired with TASK-4-1b that extends gateway/tests/test_jira_credentials.py with the six-combination matrix; PR description and pre-merge note updated to reflect both loaders honour the precedence. (2) TASK-1-2 inline-comment fallback now specifies the v1-also-404 fall-through: v1 200 with empty results \u2192 {results: [], used_fallback=true} (page exists, no inline comments + v2 bug); v1 404 \u2192 standard {status: not_found, ..., used_fallback=true} envelope (page actually doesn't exist); v1 200 with comments \u2192 v1 payload normalized with used_fallback=true. Non-blocking fixes also applied: replaced \"decision E5\" with \"decision-5\" (8 sites), moved boot-time policy-size log from client to confluence_policy.py with credentials/policy/client each owning their own INFO line (risk R12), added `attempt: 1|2` field to confluence_upstream_rate_limited audit shape (risk R10), made the spaceId\u2194spaceKey LRU cache bidirectional and populated by both list_spaces and get_page so /space/pages cold-start avoids double round-trip (architect Q2), referenced architect Q4 in TASK-1-1 acceptance (no shared atlassian_credentials.py extraction in v1), replaced PRIVATE_MODE env-var references with private-mode session language, noted TASK-4-7 must verify existing test name at implement time, noted show-metrics.md intentionally untouched, added Atlassian rate-limit pool-sharing note to TASK-6-4 documentation, and made TASK-2-9 audit shape mirror the existing Jira reload (one or two events). Plan now totals 30 tasks across 6 phases.", + "metadata": { + "payload": { + "summary": "Re-proposing plan for #1931 after addressing reviewer_plan NACK. Two blocking items resolved: (1) added TASK-1-5 to update gateway/jira_credentials.py with the same ATLASSIAN_*-preferred / JIRA_* fall-back precedence so the shared-credential promise (decision F1) is portable end-to-end \u2014 operators can drop the legacy JIRA_* block after migration without silently breaking Jira (risk R11 mitigation made concrete); paired with TASK-4-1b that extends gateway/tests/test_jira_credentials.py with the six-combination matrix; PR description and pre-merge note updated to reflect both loaders honour the precedence. (2) TASK-1-2 inline-comment fallback now specifies the v1-also-404 fall-through: v1 200 with empty results \u2192 {results: [], used_fallback=true} (page exists, no inline comments + v2 bug); v1 404 \u2192 standard {status: not_found, ..., used_fallback=true} envelope (page actually doesn't exist); v1 200 with comments \u2192 v1 payload normalized with used_fallback=true. Non-blocking fixes also applied: replaced \"decision E5\" with \"decision-5\" (8 sites), moved boot-time policy-size log from client to confluence_policy.py with credentials/policy/client each owning their own INFO line (risk R12), added `attempt: 1|2` field to confluence_upstream_rate_limited audit shape (risk R10), made the spaceId\u2194spaceKey LRU cache bidirectional and populated by both list_spaces and get_page so /space/pages cold-start avoids double round-trip (architect Q2), referenced architect Q4 in TASK-1-1 acceptance (no shared atlassian_credentials.py extraction in v1), replaced PRIVATE_MODE env-var references with private-mode session language, noted TASK-4-7 must verify existing test name at implement time, noted show-metrics.md intentionally untouched, added Atlassian rate-limit pool-sharing note to TASK-6-4 documentation, and made TASK-2-9 audit shape mirror the existing Jira reload (one or two events). Plan now totals 30 tasks across 6 phases.", + "attestation": {}, + "artifacts": [ + ".egg-state/drafts/1931-plan.md" + ], + "risk_considered": "All 18 risks tracked in risk_analyst output; the previously-incomplete R11 mitigation (jira_credentials.py update) is now an explicit task (TASK-1-5 + TASK-4-1b). Inline-comment fallback fall-through (architect Q3 / R3) is now deterministic. Boot-time observability (R12) split correctly across modules. Rate-limit audit field (R10) includes attempt index. spaceId\u2194spaceKey cache (architect Q2) avoids double round-trip on /space/pages cold-start. All other reviewer non-blocking suggestions incorporated (E5 \u2192 decision-5, PRIVATE_MODE language, show-metrics.md note, pool-sharing note, audit-event-shape consistency).", + "commit_sha": "594fd2b5f2804c3917b5dbac8ffc1521236d1a5a", + "files_changed": [ + ".egg-state/drafts/1931-plan.md" + ], + "tests_run": [], + "tasks_satisfied": [ + "TASK-1-1", + "TASK-1-2", + "TASK-1-3", + "TASK-1-4", + "TASK-1-5", + "TASK-2-1", + "TASK-2-2", + "TASK-2-3", + "TASK-2-4", + "TASK-2-5", + "TASK-2-6", + "TASK-2-7", + "TASK-2-8", + "TASK-2-9", + "TASK-3-1", + "TASK-4-1", + "TASK-4-1b", + "TASK-4-2", + "TASK-4-3", + "TASK-4-4", + "TASK-4-5", + "TASK-4-6", + "TASK-4-7", + "TASK-5-1", + "TASK-5-2", + "TASK-5-3", + "TASK-6-1", + "TASK-6-2", + "TASK-6-3", + "TASK-6-4" + ] + }, + "version": 2, + "commit_sha": "594fd2b5f2804c3917b5dbac8ffc1521236d1a5a" + }, + "timestamp": "2026-04-27T00:09:04.971777+00:00", + "phase": "plan" + }, + { + "id": "7730de05-1ef6-4b", + "pipeline_id": "issue-1931", + "from_role": "reviewer_plan", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "wait_loop exited", + "metadata": { + "state": "WORKING" + }, + "timestamp": "2026-04-27T00:09:05.025684+00:00", + "phase": "plan" + }, + { + "id": "fa225074-99d5-41", + "pipeline_id": "issue-1931", + "from_role": "task_planner", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_ON_ROLE", + "body": "", + "metadata": { + "state": "WAITING_ON_ROLE", + "waiting_on": "reviewer_plan" + }, + "timestamp": "2026-04-27T00:09:11.088827+00:00", + "phase": "plan" + }, + { + "id": "e0215818-b344-45", + "pipeline_id": "issue-1931", + "from_role": "task_planner", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_ACK,CONSENSUS_NACK,CONSENSUS_RE_REVIEW,CONSENSUS_CONFIRMED,OVERSEER_ALERT", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-04-27T00:09:11.093172+00:00" + }, + "timestamp": "2026-04-27T00:09:11.150558+00:00", + "phase": "plan" + }, + { + "id": "12817e29-3b5d-44", + "pipeline_id": "issue-1931", + "from_role": "task_planner", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "wait_loop exited", + "metadata": { + "state": "WORKING" + }, + "timestamp": "2026-04-27T00:09:11.252573+00:00", + "phase": "plan" + }, + { + "id": "0a56f6d2-d715-4f", + "pipeline_id": "issue-1931", + "from_role": "task_planner", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_ACK,CONSENSUS_NACK,CONSENSUS_RE_REVIEW,OVERSEER_ALERT", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-04-27T00:09:14.613729+00:00" + }, + "timestamp": "2026-04-27T00:09:17.704409+00:00", + "phase": "plan" + }, + { + "id": "c2312add-a1b6-48", + "pipeline_id": "issue-1931", + "from_role": "risk_analyst", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_CONFIRMED,CONSENSUS_RE_REVIEW,OVERSEER_ALERT", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-04-27T00:08:36.620321+00:00" + }, + "timestamp": "2026-04-27T00:09:37.579916+00:00", + "phase": "plan" + }, + { + "id": "f36b9287-6566-4a", + "pipeline_id": "issue-1931", + "from_role": "architect", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_CONFIRMED,CONSENSUS_RE_REVIEW,OVERSEER_ALERT,CONSENSUS_NACK", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-04-27T00:08:38.630974+00:00" + }, + "timestamp": "2026-04-27T00:09:38.923859+00:00", + "phase": "plan" + }, + { + "id": "94b4df0f-fe91-41", + "pipeline_id": "issue-1931", + "from_role": "reviewer_plan", + "to_role": "task_planner", + "message_type": "CONSENSUS_ACK", + "subject": "ACK from reviewer_plan for task_planner", + "body": "\nRe-reviewed `.egg-state/drafts/1931-plan.md` at commit 594fd2b5f against my prior NACK (b3178d1d-654b-41 reply). Both blocking issues resolved and all eight non-blocking observations addressed.\n\n**Blocking #1 \u2014 `jira_credentials.py` shared-credential precedence \u2014 RESOLVED.**\n- New **TASK-1-5** (lines 50-60 + yaml-tasks 260-281) edits `gateway/jira_credentials.py::JiraCredentialsManager._load_credentials()` to prefer `ATLASSIAN_BASE_URL`/`ATLASSIAN_USERNAME`/`ATLASSIAN_API_TOKEN` per-key with `JIRA_*` per-key fallback. Correctly notes Jira does not need the `/wiki` suffix derivation (Jira API lives at bare Atlassian origin). Module docstring update + `JiraCredentialsUnavailable` preserved.\n- New **TASK-4-1b** (lines 93-101 + yaml-tasks 289-301) extends `gateway/tests/test_jira_credentials.py` with the same six-combination matrix as Confluence (`ATLASSIAN_*` only / `JIRA_*` only / mixed per-key / missing-everywhere \u2192 `JiraCredentialsUnavailable`); back-compat preserved.\n- Pre-merge note (line 167) and PR-description test plan (line 190) updated to reflect both loaders honour the precedence \u2014 operators can drop the legacy `JIRA_*` block after migration without breaking Jira. The migration narrative is now portable.\n- Dependency graph updated: `1-5 (independent \u2014 same-PR Jira loader update)` and `1-5 \u2500\u2500\u2500\u25ba 4-1b`.\n\n**Blocking #2 \u2014 inline-comment v2-404 + v1-404 fall-through \u2014 RESOLVED.**\n- TASK-1-2 (line 19, also yaml-tasks line 211) now specifies all three fall-through outcomes deterministically: (a) v1 200 with comments \u2192 v1 payload normalized with `used_fallback: true`; (b) v1 200 with **empty results** \u2192 `{\"results\": [], \"used_fallback\": true}` (page exists, has no inline comments + v2 bug fired); (c) v1 **404** \u2192 standard `{\"status\": \"not_found\", \"id\": \"...\", \"upstream_status\": 404, \"used_fallback\": true}` envelope (page genuinely doesn't exist). The route layer can now branch unambiguously and TASK-4-2 can write deterministic fixtures for all three.\n\n**Non-blocking observations \u2014 all addressed.**\n- `decision E5` \u2192 `decision-5` replaced at the eight sites I called out (TASK-1-2 acceptance + TASK-4-2 description + yaml-tasks both).\n- Boot-time observability (risk R12) split across modules: `confluence_credentials.py` logs precedence at first load; `confluence_policy.py` logs allowlist size at first `allowed_spaces()` and emits ERROR on schema mismatch; `confluence_client.py` logs body-format default at first invocation. Lines 33 + 234-241 of yaml-tasks.\n- 429 audit shape now includes `attempt: 1|2` field (line 28 + yaml-tasks line 224) so operators can see whether the retry succeeded.\n- `spaceId \u2194 spaceKey` cache is now bidirectional and populated by both `list_spaces` AND `get_page` (lines 70-77 + yaml-tasks 245-248), so `/space/pages` cold-start avoids double round-trip \u2014 addresses my \"spaceKey \u2192 spaceId resolution one-sided\" note.\n- Architect Q4 (no shared `atlassian_credentials.py` extraction in v1) is now explicit in TASK-1-1 acceptance (line 10 + yaml-tasks 199-202) \u2014 deferral is documented as a follow-up backlog item.\n- Manual verification step 3-4 (lines 157-158): `PRIVATE_MODE` env var language replaced with session-level `--private` language and a parenthetical clarifying it's a session attribute set by `@require_session_auth`.\n- TASK-4-7 (line 116) now says coder must confirm `test_atlassian_domains_absent` test name at implement time \u2014 captures the \"verify before extending\" caveat.\n- TASK-6-3 (line 124) explicitly notes `sandbox/agent-config/commands/show-metrics.md` is intentionally untouched (legacy syncer cache vs new gateway wrapper are independent).\n- TASK-6-4 wrapper-reference doc (line 133) now carries the Atlassian rate-limit pool-sharing paragraph: same bot account = pooled points-quota across `/api/v1/jira/*` and `/api/v1/confluence/*` traffic; operators seeing routine throttling on one service should expect it on the other.\n- TASK-2-9 audit shape (line 85) now says \"mirror whatever the existing Jira reload emits\" \u2014 coder picks one or two events at implement time to match Jira's pattern, with both fall-back names spelled out.\n\n**Final plan stats**: 6 phases, 30 tasks (was 28; added TASK-1-5 and TASK-4-1b), 1342 \u2192 1444 lines (+102 net). Task decomposition still granular; acceptance criteria still reference covering tests; dependency graph still correct (TASK-1-5 is independent within Phase 1 and pairs with TASK-4-1b in Phase 4 \u2014 no cross-phase dependency added).\n\nThe plan is now complete enough for implement-phase consumption. Architecture alignment \u2713, task decomposition \u2713, acceptance criteria \u2713, dependency ordering \u2713, risk mitigations threaded \u2713, test strategy \u2713, completeness \u2713.\n", + "metadata": { + "payload": { + "artifact_references": [ + ".egg-state/drafts/1931-plan.md" + ], + "reason": "\nRe-reviewed `.egg-state/drafts/1931-plan.md` at commit 594fd2b5f against my prior NACK (b3178d1d-654b-41 reply). Both blocking issues resolved and all eight non-blocking observations addressed.\n\n**Blocking #1 \u2014 `jira_credentials.py` shared-credential precedence \u2014 RESOLVED.**\n- New **TASK-1-5** (lines 50-60 + yaml-tasks 260-281) edits `gateway/jira_credentials.py::JiraCredentialsManager._load_credentials()` to prefer `ATLASSIAN_BASE_URL`/`ATLASSIAN_USERNAME`/`ATLASSIAN_API_TOKEN` per-key with `JIRA_*` per-key fallback. Correctly notes Jira does not need the `/wiki` suffix derivation (Jira API lives at bare Atlassian origin). Module docstring update + `JiraCredentialsUnavailable` preserved.\n- New **TASK-4-1b** (lines 93-101 + yaml-tasks 289-301) extends `gateway/tests/test_jira_credentials.py` with the same six-combination matrix as Confluence (`ATLASSIAN_*` only / `JIRA_*` only / mixed per-key / missing-everywhere \u2192 `JiraCredentialsUnavailable`); back-compat preserved.\n- Pre-merge note (line 167) and PR-description test plan (line 190) updated to reflect both loaders honour the precedence \u2014 operators can drop the legacy `JIRA_*` block after migration without breaking Jira. The migration narrative is now portable.\n- Dependency graph updated: `1-5 (independent \u2014 same-PR Jira loader update)` and `1-5 \u2500\u2500\u2500\u25ba 4-1b`.\n\n**Blocking #2 \u2014 inline-comment v2-404 + v1-404 fall-through \u2014 RESOLVED.**\n- TASK-1-2 (line 19, also yaml-tasks line 211) now specifies all three fall-through outcomes deterministically: (a) v1 200 with comments \u2192 v1 payload normalized with `used_fallback: true`; (b) v1 200 with **empty results** \u2192 `{\"results\": [], \"used_fallback\": true}` (page exists, has no inline comments + v2 bug fired); (c) v1 **404** \u2192 standard `{\"status\": \"not_found\", \"id\": \"...\", \"upstream_status\": 404, \"used_fallback\": true}` envelope (page genuinely doesn't exist). The route layer can now branch unambiguously and TASK-4-2 can write deterministic fixtures for all three.\n\n**Non-blocking observations \u2014 all addressed.**\n- `decision E5` \u2192 `decision-5` replaced at the eight sites I called out (TASK-1-2 acceptance + TASK-4-2 description + yaml-tasks both).\n- Boot-time observability (risk R12) split across modules: `confluence_credentials.py` logs precedence at first load; `confluence_policy.py` logs allowlist size at first `allowed_spaces()` and emits ERROR on schema mismatch; `confluence_client.py` logs body-format default at first invocation. Lines 33 + 234-241 of yaml-tasks.\n- 429 audit shape now includes `attempt: 1|2` field (line 28 + yaml-tasks line 224) so operators can see whether the retry succeeded.\n- `spaceId \u2194 spaceKey` cache is now bidirectional and populated by both `list_spaces` AND `get_page` (lines 70-77 + yaml-tasks 245-248), so `/space/pages` cold-start avoids double round-trip \u2014 addresses my \"spaceKey \u2192 spaceId resolution one-sided\" note.\n- Architect Q4 (no shared `atlassian_credentials.py` extraction in v1) is now explicit in TASK-1-1 acceptance (line 10 + yaml-tasks 199-202) \u2014 deferral is documented as a follow-up backlog item.\n- Manual verification step 3-4 (lines 157-158): `PRIVATE_MODE` env var language replaced with session-level `--private` language and a parenthetical clarifying it's a session attribute set by `@require_session_auth`.\n- TASK-4-7 (line 116) now says coder must confirm `test_atlassian_domains_absent` test name at implement time \u2014 captures the \"verify before extending\" caveat.\n- TASK-6-3 (line 124) explicitly notes `sandbox/agent-config/commands/show-metrics.md` is intentionally untouched (legacy syncer cache vs new gateway wrapper are independent).\n- TASK-6-4 wrapper-reference doc (line 133) now carries the Atlassian rate-limit pool-sharing paragraph: same bot account = pooled points-quota across `/api/v1/jira/*` and `/api/v1/confluence/*` traffic; operators seeing routine throttling on one service should expect it on the other.\n- TASK-2-9 audit shape (line 85) now says \"mirror whatever the existing Jira reload emits\" \u2014 coder picks one or two events at implement time to match Jira's pattern, with both fall-back names spelled out.\n\n**Final plan stats**: 6 phases, 30 tasks (was 28; added TASK-1-5 and TASK-4-1b), 1342 \u2192 1444 lines (+102 net). Task decomposition still granular; acceptance criteria still reference covering tests; dependency graph still correct (TASK-1-5 is independent within Phase 1 and pairs with TASK-4-1b in Phase 4 \u2014 no cross-phase dependency added).\n\nThe plan is now complete enough for implement-phase consumption. Architecture alignment \u2713, task decomposition \u2713, acceptance criteria \u2713, dependency ordering \u2713, risk mitigations threaded \u2713, test strategy \u2713, completeness \u2713.\n" + }, + "version": 2 + }, + "timestamp": "2026-04-27T00:10:10.594919+00:00", + "phase": "plan" + }, + { + "id": "734ef1e0-9313-40", + "pipeline_id": "issue-1931", + "from_role": "orchestrator", + "to_role": "task_planner", + "message_type": "STATUS", + "subject": "Ready to confirm \u2014 all confirm preconditions satisfied", + "body": "Your proposal (version 2) is ready to confirm \u2014 all blocking reviews are clear and global confirm preconditions are met. Run `egg-orch consensus confirmed` to confirm.", + "metadata": { + "ready_to_confirm": true, + "version": 2 + }, + "timestamp": "2026-04-27T00:10:10.594960+00:00", + "phase": "plan" + }, + { + "id": "6bfa9460-233d-45", + "pipeline_id": "issue-1931", + "from_role": "task_planner", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "wait_loop exited", + "metadata": { + "state": "WORKING" + }, + "timestamp": "2026-04-27T00:10:10.685268+00:00", + "phase": "plan" + }, + { + "id": "3f611e8f-7265-43", + "pipeline_id": "issue-1931", + "from_role": "task_planner", + "to_role": "all", + "message_type": "CONSENSUS_CONFIRMED", + "subject": "Confirmed by task_planner", + "body": "", + "metadata": { + "consensus_reached": false + }, + "timestamp": "2026-04-27T00:10:14.655907+00:00", + "phase": "plan" + }, + { + "id": "7b0cd3d1-5e56-48", + "pipeline_id": "issue-1931", + "from_role": "risk_analyst", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "wait_loop exited", + "metadata": { + "state": "WORKING" + }, + "timestamp": "2026-04-27T00:10:15.080386+00:00", + "phase": "plan" + }, + { + "id": "dd3c2dcc-d864-4e", + "pipeline_id": "issue-1931", + "from_role": "architect", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "wait_loop exited", + "metadata": { + "state": "WORKING" + }, + "timestamp": "2026-04-27T00:10:15.139193+00:00", + "phase": "plan" + }, + { + "id": "a57e3a99-fc9a-4d", + "pipeline_id": "issue-1931", + "from_role": "reviewer_plan", + "to_role": "all", + "message_type": "CONSENSUS_CONFIRMED", + "subject": "Confirmed by reviewer_plan", + "body": "", + "metadata": { + "consensus_reached": true + }, + "timestamp": "2026-04-27T00:10:16.846467+00:00", + "phase": "plan" + }, + { + "id": "f08ad9f7-e307-47", + "pipeline_id": "issue-1931", + "from_role": "risk_analyst", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_CONFIRMED,CONSENSUS_RE_REVIEW,OVERSEER_ALERT", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-04-27T00:10:19.516646+00:00" + }, + "timestamp": "2026-04-27T00:10:19.702800+00:00", + "phase": "plan" + }, + { + "id": "096d8af5-78be-41", + "pipeline_id": "issue-1931", + "from_role": "architect", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WAITING_FOR_EVENT", + "body": "wait_loop blocked on CONSENSUS_CONFIRMED,CONSENSUS_RE_REVIEW,OVERSEER_ALERT,CONSENSUS_NACK", + "metadata": { + "state": "WAITING_FOR_EVENT", + "since": "2026-04-27T00:10:19.864421+00:00" + }, + "timestamp": "2026-04-27T00:10:20.362659+00:00", + "phase": "plan" + }, + { + "id": "f12902e5-d444-4d", + "pipeline_id": "issue-1931", + "from_role": "risk_analyst", + "to_role": "all", + "message_type": "HEARTBEAT", + "subject": "heartbeat: WORKING", + "body": "wait_loop exited", + "metadata": { + "state": "WORKING" + }, + "timestamp": "2026-04-27T00:10:20.436055+00:00", + "phase": "plan" + } +] \ No newline at end of file diff --git a/.egg-state/brc-history/1931-plan.md b/.egg-state/brc-history/1931-plan.md new file mode 100644 index 0000000000..52195a450c --- /dev/null +++ b/.egg-state/brc-history/1931-plan.md @@ -0,0 +1,2444 @@ +# BRC Consensus History — plan phase + +Generated: 2026-04-27T00:10:20Z +Pipeline: issue-1931 + +### [2026-04-26T23:43:31Z] overseer (HEARTBEAT): heartbeat: WORKING + +Overseer (ec29a51a-293) online. Cycle 1 parsed. Refine phase confirmed complete (consensus_reached=true). Analyzing plan phase readiness and HITL blocking status. + +````yaml +id: 8b9ff94c-60fe-49 +phase: plan +metadata: + state: WORKING +```` + +### [2026-04-26T23:44:17Z] overseer (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +Plan phase agent launch watch. HITL resolved 23:42:46Z. Running_agents still empty at +67s. Waiting for architect/risk_analyst/task_planner containers to appear (threshold 180s). + +````yaml +id: 2124f752-4cd1-4b +phase: plan +metadata: + state: WAITING_FOR_EVENT + since: '2026-04-26T23:42:46Z' +```` + +### [2026-04-26T23:45:28Z] reviewer_plan (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_PROPOSE + +````yaml +id: d561a7c3-cc8d-48 +phase: plan +metadata: + state: WAITING_FOR_EVENT + since: '2026-04-26T23:45:28.729802+00:00' +```` + +### [2026-04-26T23:46:29Z] reviewer_plan (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_PROPOSE + +````yaml +id: b30b2fd2-0ec1-41 +phase: plan +metadata: + state: WAITING_FOR_EVENT + since: '2026-04-26T23:45:28.729802+00:00' +```` + +### [2026-04-26T23:47:29Z] reviewer_plan (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_PROPOSE + +````yaml +id: c0d7cbe4-edcf-4a +phase: plan +metadata: + state: WAITING_FOR_EVENT + since: '2026-04-26T23:45:28.729802+00:00' +```` + +### [2026-04-26T23:48:29Z] reviewer_plan (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_PROPOSE + +````yaml +id: 91daed0b-9329-46 +phase: plan +metadata: + state: WAITING_FOR_EVENT + since: '2026-04-26T23:45:28.729802+00:00' +```` + +### [2026-04-26T23:48:53Z] overseer (HEARTBEAT): heartbeat: WORKING + +Cycle 5. reviewer_plan alive (60s cadence). risk_analyst: appeared at ~23:45:36, no heartbeats yet, 135s elapsed — watching for 180s threshold. architect/task_planner not yet visible. + +````yaml +id: 03bddbcd-bc67-4a +phase: plan +metadata: + state: WORKING +```` + +### [2026-04-26T23:49:29Z] reviewer_plan (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_PROPOSE + +````yaml +id: 85002524-df02-4a +phase: plan +metadata: + state: WAITING_FOR_EVENT + since: '2026-04-26T23:45:28.729802+00:00' +```` + +### [2026-04-26T23:50:30Z] reviewer_plan (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_PROPOSE + +````yaml +id: 46433538-bdcc-49 +phase: plan +metadata: + state: WAITING_FOR_EVENT + since: '2026-04-26T23:45:28.729802+00:00' +```` + +### [2026-04-26T23:51:01Z] architect (CONSENSUS_PROPOSE): Proposal from architect + +Architecture analysis for issue #1931 (Confluence gateway v1, read-only). Captures the structural copy of #1556's Jira wrapper across four new gateway modules (confluence_client.py, confluence_credentials.py, confluence_policy.py, confluence_search.py), one Confluence route block in gateway.py, a sandbox/scripts/confluence wrapper, two config files (context-filters.yaml + secrets.template.env), and four doc files (confluence-wrapper.md plus three updates). Records the architectural consequences of all 14 HITL decisions resolved in refine: TD1 Jira-style verb-noun URL paths (decision-1); TD2 per-verb v2-first / v1-fallback hybrid (decision-2 + decision-4); TD3 conservative CQL static-scope extractor (decision-3); TD4 body-format=storage default with override (decision-5 HITL narrowing); TD5 shared ATLASSIAN_* credential triple with JIRA_* / CONFLUENCE_* fallback (decision-6 + decision-9); TD6 @require_private_mode per route + enumeration regression (decision-7); TD7 confluence.spaces in context-filters.yaml (decision-8); TD8 accountId / emailAddress / _links.webui redaction (decision-10); TD9 space-list filtering (decision-11); TD10 attachments permanent denylist (decision-12); TD11 no EGG_CONFLUENCE_* env vars (decision-13); TD12 GET-only /execute passthrough (decision-14); TD13 distinct confluence_upstream_403 audit category (feedback Q7); TD14 verbatim depth pass-through (feedback Q6); TD15 current-version-only on getConfluencePage (feedback Q8); TD16 no resolve-by-url verb (feedback Q5). Documents per-route validation (page allowlist verified against the page response's spaceId/spaceKey), 404 envelope, v1-fallback paths for footer / inline comments, and the data flow for both happy-path and rejection paths. Lists 14 alternatives considered and rejected with reasons. Proposes a 6-workstream implementation order (creds + policy + CQL extractor → client → routes → sandbox wrapper → docs) for the task_planner to refine into tasks. Surfaces 4 plan-phase open questions (bot-vs-human caveat in docs, double-fetch vs LRU cache for comment routes, fallback-event payload, shared-credential refactor deferral). No code authored — analysis only, written to .egg-state/agent-outputs/1931-architect-output.json. Tests run: JSON validation only (no production code touched). + +````yaml +id: 68f6e7f1-145e-42 +phase: plan +metadata: + payload: + summary: "Architecture analysis for issue #1931 (Confluence gateway v1, read-only).\ + \ Captures the structural copy of #1556's Jira wrapper across four new gateway\ + \ modules (confluence_client.py, confluence_credentials.py, confluence_policy.py,\ + \ confluence_search.py), one Confluence route block in gateway.py, a sandbox/scripts/confluence\ + \ wrapper, two config files (context-filters.yaml + secrets.template.env), and\ + \ four doc files (confluence-wrapper.md plus three updates). Records the architectural\ + \ consequences of all 14 HITL decisions resolved in refine: TD1 Jira-style verb-noun\ + \ URL paths (decision-1); TD2 per-verb v2-first / v1-fallback hybrid (decision-2\ + \ + decision-4); TD3 conservative CQL static-scope extractor (decision-3); TD4\ + \ body-format=storage default with override (decision-5 HITL narrowing); TD5\ + \ shared ATLASSIAN_* credential triple with JIRA_* / CONFLUENCE_* fallback (decision-6\ + \ + decision-9); TD6 @require_private_mode per route + enumeration regression\ + \ (decision-7); TD7 confluence.spaces in context-filters.yaml (decision-8);\ + \ TD8 accountId / emailAddress / _links.webui redaction (decision-10); TD9 space-list\ + \ filtering (decision-11); TD10 attachments permanent denylist (decision-12);\ + \ TD11 no EGG_CONFLUENCE_* env vars (decision-13); TD12 GET-only /execute passthrough\ + \ (decision-14); TD13 distinct confluence_upstream_403 audit category (feedback\ + \ Q7); TD14 verbatim depth pass-through (feedback Q6); TD15 current-version-only\ + \ on getConfluencePage (feedback Q8); TD16 no resolve-by-url verb (feedback\ + \ Q5). Documents per-route validation (page allowlist verified against the page\ + \ response's spaceId/spaceKey), 404 envelope, v1-fallback paths for footer /\ + \ inline comments, and the data flow for both happy-path and rejection paths.\ + \ Lists 14 alternatives considered and rejected with reasons. Proposes a 6-workstream\ + \ implementation order (creds + policy + CQL extractor \u2192 client \u2192\ + \ routes \u2192 sandbox wrapper \u2192 docs) for the task_planner to refine\ + \ into tasks. Surfaces 4 plan-phase open questions (bot-vs-human caveat in docs,\ + \ double-fetch vs LRU cache for comment routes, fallback-event payload, shared-credential\ + \ refactor deferral). No code authored \u2014 analysis only, written to .egg-state/agent-outputs/1931-architect-output.json.\ + \ Tests run: JSON validation only (no production code touched)." + attestation: {} + artifacts: + - .egg-state/agent-outputs/1931-architect-output.json + risk_considered: '' + commit_sha: 256521c4b5c59341e9366cb87c08cfebc8a853f9 + files_changed: + - .egg-state/agent-outputs/1931-architect-output.json + tests_run: + - python3 -c 'import json; json.load(open(...))' + tasks_satisfied: [] + version: 1 + commit_sha: 256521c4b5c59341e9366cb87c08cfebc8a853f9 +```` + +### [2026-04-26T23:51:03Z] reviewer_plan (HEARTBEAT): heartbeat: WORKING + +wait_loop exited + +````yaml +id: 5ca29dca-bc40-4f +phase: plan +metadata: + state: WORKING +```` + +### [2026-04-26T23:51:09Z] architect (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_ACK,CONSENSUS_NACK,CONSENSUS_RE_REVIEW,CONSENSUS_CONFIRMED,OVERSEER_ALERT + +````yaml +id: c514135c-620a-45 +phase: plan +metadata: + state: WAITING_FOR_EVENT + since: '2026-04-26T23:51:09.440487+00:00' +```` + +### [2026-04-26T23:51:09Z] architect (HEARTBEAT): heartbeat: WORKING + +wait_loop exited + +````yaml +id: 55ad8763-c5b4-4f +phase: plan +metadata: + state: WORKING +```` + +### [2026-04-26T23:51:14Z] architect (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_ACK,CONSENSUS_NACK,CONSENSUS_RE_REVIEW,OVERSEER_ALERT + +````yaml +id: 88d1cbb3-d6fb-44 +phase: plan +metadata: + state: WAITING_FOR_EVENT + since: '2026-04-26T23:51:12.779966+00:00' +```` + +### [2026-04-26T23:52:13Z] risk_analyst (CONSENSUS_PROPOSE): Proposal from risk_analyst + +Plan-phase risk assessment for #1931 (Confluence gateway read-only v1). 18 risks identified across security (6), performance (3), compatibility (2), operational (3), data-privacy (1), external-dependency (1), test-coverage (1), and future-write (1). Severity: 1 high (CQL extractor adversarial coverage), 7 medium, 10 low. Three risks flagged for human review at implement time: R1 CQL-extractor parity (CQL grammar differs from JQL — text ~ contains, space.category() functions, etc.), R14 attachments / restrictions / permissions permanent-denylist enforcement across both narrow routes and /execute, R15 bot-account effective-access asymmetry (per refine feedback Q9). External research covered Atlassian April-2026 v1-deprecation status (endpoint-specific, CQL search has no v2 successor), the March-2026 points-based rate-limit rollout, and confirmed no public CQL-injection CVE. Output includes a four-level rollback plan (config-only → route-disable → credential-revoke → full-revert) and an 18-item implement-phase checklist that maps each risk to a reviewer-verifiable mitigation. Aligns with the architect's analysis (R3 covers v2 inline-404 fallback / Q3, R11 covers shared-creds module / Q4, R15 covers bot-vs-human caveat / Q1). No production code touched; output is a single JSON state file under .egg-state/agent-outputs/. + +````yaml +id: 0cdec22a-94e6-4a +phase: plan +metadata: + payload: + summary: "Plan-phase risk assessment for #1931 (Confluence gateway read-only v1).\ + \ 18 risks identified across security (6), performance (3), compatibility (2),\ + \ operational (3), data-privacy (1), external-dependency (1), test-coverage\ + \ (1), and future-write (1). Severity: 1 high (CQL extractor adversarial coverage),\ + \ 7 medium, 10 low. Three risks flagged for human review at implement time:\ + \ R1 CQL-extractor parity (CQL grammar differs from JQL \u2014 text ~ contains,\ + \ space.category() functions, etc.), R14 attachments / restrictions / permissions\ + \ permanent-denylist enforcement across both narrow routes and /execute, R15\ + \ bot-account effective-access asymmetry (per refine feedback Q9). External\ + \ research covered Atlassian April-2026 v1-deprecation status (endpoint-specific,\ + \ CQL search has no v2 successor), the March-2026 points-based rate-limit rollout,\ + \ and confirmed no public CQL-injection CVE. Output includes a four-level rollback\ + \ plan (config-only \u2192 route-disable \u2192 credential-revoke \u2192 full-revert)\ + \ and an 18-item implement-phase checklist that maps each risk to a reviewer-verifiable\ + \ mitigation. Aligns with the architect's analysis (R3 covers v2 inline-404\ + \ fallback / Q3, R11 covers shared-creds module / Q4, R15 covers bot-vs-human\ + \ caveat / Q1). No production code touched; output is a single JSON state file\ + \ under .egg-state/agent-outputs/." + attestation: {} + artifacts: + - .egg-state/agent-outputs/1931-risk_analyst-output.json + risk_considered: "CQL-extractor adversarial coverage (R1) is the single high-severity\ + \ risk; mitigation is a deny-on-ambiguity parser mirroring jira_search.py with\ + \ adversarial fixtures targeting CQL-specific grammar (text-contains, OR widening,\ + \ CQL functions, mixed-script keys). Permanent denylist for attachments / restrictions\ + \ / permissions / space.admin / users / DELETE / PUT / PATCH is enforced at\ + \ the path validator layer with test variants for case, URL-encoding, and nested\ + \ positions. /execute escape hatch is GET-only, regex-allowlisted, and explicitly\ + \ anti-tested to confirm narrow-route path families cannot be reached through\ + \ it. private-mode gate enforced via @require_private_mode decorator + route-enumeration\ + \ regression test. Squid allowlist already excludes *.atlassian.net (covered\ + \ by existing test_allowed_domains.py regression). PII redaction (accountId,\ + \ emailAddress, _links.webui) walks ADF mention nodes recursively. Body-format\ + \ default is storage-only per HITL decision-5 to avoid token-budget blow-up;\ + \ hard payload-size cap recommended at the route layer. v1 dependency on CQL\ + \ search is documented with a 'last reviewed against Atlassian docs' marker;\ + \ deprecation is endpoint-specific and not imminent. Shared Atlassian credential\ + \ block coupling (decision-6) means a botched token rotation breaks both Jira\ + \ and Confluence simultaneously \u2014 mitigated by mtime-based reload and a\ + \ diagnostics endpoint. context-filters.yaml fail-closed empty default mitigated\ + \ by a boot-time log line announcing the loaded allowlist size. Test coverage\ + \ gap risk addressed by requiring each test_confluence_*.py file to have at\ + \ least one fixture justified as Confluence-original (not a JQL\u2192CQL transliteration).\ + \ Four-level rollback plan covers config-only, route-disable, credential-revoke,\ + \ and full-revert." + commit_sha: 4151ef6b35de33b17551f5c6dbde9e45e277e88c + files_changed: + - .egg-state/agent-outputs/1931-risk_analyst-output.json + tests_run: + - json.load validation of .egg-state/agent-outputs/1931-risk_analyst-output.json + tasks_satisfied: [] + version: 1 + commit_sha: 4151ef6b35de33b17551f5c6dbde9e45e277e88c +```` + +### [2026-04-26T23:52:14Z] architect (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_ACK,CONSENSUS_NACK,CONSENSUS_RE_REVIEW,OVERSEER_ALERT + +````yaml +id: 8891ecd1-5a2a-4e +phase: plan +metadata: + state: WAITING_FOR_EVENT + since: '2026-04-26T23:51:12.779966+00:00' +```` + +### [2026-04-26T23:52:27Z] risk_analyst (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_ACK,CONSENSUS_NACK,CONSENSUS_RE_REVIEW,OVERSEER_ALERT + +````yaml +id: 050145ac-e61f-4d +phase: plan +metadata: + state: WAITING_FOR_EVENT + since: '2026-04-26T23:52:27.443103+00:00' +```` + +### [2026-04-26T23:53:14Z] architect (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_ACK,CONSENSUS_NACK,CONSENSUS_RE_REVIEW,OVERSEER_ALERT + +````yaml +id: 7aa52f73-486f-4b +phase: plan +metadata: + state: WAITING_FOR_EVENT + since: '2026-04-26T23:51:12.779966+00:00' +```` + +### [2026-04-26T23:53:27Z] risk_analyst (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_ACK,CONSENSUS_NACK,CONSENSUS_RE_REVIEW,OVERSEER_ALERT + +````yaml +id: 591ca84e-611d-4d +phase: plan +metadata: + state: WAITING_FOR_EVENT + since: '2026-04-26T23:52:27.443103+00:00' +```` + +### [2026-04-26T23:53:34Z] reviewer_plan → architect (CONSENSUS_ACK): ACK from reviewer_plan for architect + + +Reviewed `.egg-state/agent-outputs/1931-architect-output.json` end-to-end against the analysis (`1931-analysis.md`), the resolved HITL decisions (decision-1 through decision-14 + feedback Q1–Q10), and the Jira sister files (`gateway/jira_client.py` 567 LoC, `jira_search.py` 250 LoC, `jira_credentials.py` 213 LoC, `jira_policy.py` 253 LoC, `sandbox/scripts/jira` 358 LoC, `docs/reference/jira-wrapper.md` 173 LoC, `config/context-filters.yaml`). + +**Alignment with analysis + HITL** — all 14 multiple-choice decisions are correctly threaded into the architecture: A1 (Jira-shape paths) → TD1, B1 (v2-first hybrid) → TD2, C1 (static CQL extractor) → TD3, D1 (v2 + v1 fallback) → TD2, E (storage default with override) → TD4 *correctly noting the HITL resolution narrowed the analysis-recommended dual default*, F1 (shared ATLASSIAN_*) → TD5, G1 (per-route mode gate) → TD6, H1 (`confluence.spaces` in context-filters.yaml) → TD7, decision-9 → TD5, decision-10 → TD8, decision-11 → TD9, decision-12 → TD10, decision-13 → TD11, decision-14 → TD12. All 10 free-form feedback answers are wired in (Q1 → fail-closed empty allowlist, Q2 → reuse Jira's 429 retry, Q5 → no resolve-by-url, Q6 → depth passthrough TD14, Q7 → distinct `confluence_upstream_403` audit TD13, Q8 → current-version-only TD15, Q9 → bot-vs-human caveat in wrapper docs, Q10 → Jira-style subcommands only). + +**Architecture coherence** — the module breakdown mirrors the Jira split (client / credentials / policy / search) with size estimates (550-650 / 230-260 / 240-270 / 260-290) that are believable against the Jira reference (567/213/253/250). Workstream sequencing (WS1+WS3 parallel, WS2 after WS1, WS4 after WS1/2/3, WS5/WS6 after WS4) is correct: the client depends on credentials, routes depend on all three, sandbox-wrapper smoke tests depend on routes. Permanent denylist `CONFLUENCE_WRITE_VERBS_DENIED = {restrictions, permissions, space.admin, users, attachments, DELETE, PUT, PATCH}` correctly bakes in decision-12 and the future-write-readiness invariant. Audit shape (`confluence_*` events + distinct `confluence_upstream_403` + `confluence_v1_fallback`) is consistent with the Jira convention. The `_redact()` helper at the client layer is the right placement (pre-route) — agents cannot accidentally bypass redaction by going through `/execute`. + +**404 envelope discipline** — get_page / list_pages_in_space / get_page_descendants / get_footer_comments / get_inline_comments return `{status: not_found, ...}`; CQL search and execute_raw raise `ConfluenceUpstreamError`. Mirrors Jira (`get_ticket`/`get_comments` envelope vs `search`/`execute_raw` raise). ✓ + +**Route-enumeration regression** — `test_confluence_routes.py` walks `app.url_map` for `/api/v1/confluence/*` and asserts `__egg_requires_private_mode__ = True` on every view. Identical to `test_jira_routes.py::test_every_jira_route_has_private_mode_marker`. ✓ + +**Open questions deferred to plan phase** (Q1–Q4 in `open_questions_for_plan_phase`) are reasonable scoping calls — the bot-vs-human docs paragraph (Q1) and per-page double-fetch trade-off (Q2) belong in the task plan, not the architecture, and the v1_fallback audit-field detail (Q3) and the shared-credential refactor (Q4) are correctly deferred to a follow-up. + +### Non-blocking +- **`include_replies` flag for footer-comments** (route block: `POST /api/v1/confluence/page/footer-comments`) — the architect promotes this to a request-body parameter rather than the analysis's "fallback when v2 misses replies" auto-trigger. This is fine but means callers must opt in; consider documenting in `docs/reference/confluence-wrapper.md` that omitting `include_replies` will silently miss nested replies even when v2 returns the buggy shape. Suggested fix: add a paragraph in the wrapper reference's "v1 fallback" section calling this out explicitly so the planner can write a corresponding doc task. +- **Search pagination naming** (`ConfluenceClient.search_cql(cql, limit, cursor)`) — Atlassian's v1 search uses `start` (numeric offset) and returns `_links.next`; v2 uses cursor. Exposing it as a `cursor` string at the wrapper layer is fine but warrants a one-line note in the architecture that the gateway extracts/encodes the v1 `start=N` from `_links.next` so callers don't have to know they're talking to v1. Suggested fix: have `task_planner` add a sub-task "translate v1 search pagination into a single opaque `next_page_token` string" under WS2. +- **Response envelope normalisation** — the architect says "Wrapper methods normalise both response shapes to a single envelope so /api/v1/confluence/* responses are version-agnostic for the agent" but doesn't fully specify the envelope. Acceptable at architecture level; flagging so the task_planner explicitly carves out a "define and document the unified response envelope" task in WS2 rather than leaving it as implicit work. +- **`/execute` path-validator regex** — `validate_confluence_api_path` is named but the accepted-path families aren't enumerated (Jira's analogue accepts `^issue/[A-Z][A-Z0-9_]*-\d+$` etc.). Confluence's path families differ from Jira (numeric IDs, both `/wiki/api/v2/...` and `/wiki/rest/api/...` prefixes). Suggested fix: have `task_planner` make "enumerate `/execute` accepted path patterns" an explicit sub-task in WS4 with a recommended starting set: `pages/(/.*)?`, `spaces/(/.*)?`, `search` (under v1), and the v1-fallback comment paths. +- **`_links.webui` redaction** — the architect strips webui "when it points at user-profile URLs" but Atlassian uses `_links.webui` for both pages (`/wiki/spaces/KEY/pages/ID/...`) and people (`/wiki/people/`). The task `_redact()` needs a path-pattern check, not a blanket strip. Suggested fix: have `task_planner` make the redaction predicate a tested helper (e.g., `_is_user_profile_webui(path)`) with a tabulated test matrix in `test_confluence_client.py`. +- **Space ID ↔ key resolution** — `space/pages` requires an extra upstream call to map numeric `space_id` → `space.key` for allowlist verification. Worth surfacing as a task-level decision (accept double-call in v1 vs. cache key↔id map at gateway start). The architect raises Q2 for `pageId → spaceKey` but not the symmetric case. Suggested fix: have `risk_analyst` cover this latency-amplification risk and `task_planner` include "decide space ID/key resolution strategy" as a WS4 task. +- **Existing references to `confluence` in sandbox** — `sandbox/agent-config/commands/show-metrics.md:12` references `~/context-sync/confluence/` and line 36 mentions `confluence/ENG`. These are unrelated (the legacy context-sync cache, not the gateway). Worth a one-line audit task to confirm the show-metrics doc is out of scope for #1931 so the implementer doesn't accidentally rewrite it. +- **Migration semantics in `secrets.template.env`** — TD5's "remove `CONFLUENCE_SPACE_KEYS`" is correct (decision-8 moved it to YAML), but live deployments may have `CONFLUENCE_SPACE_KEYS` populated. Suggested fix: have `task_planner` include a "migration-note doc paragraph" sub-task under WS6 telling operators that any populated `CONFLUENCE_SPACE_KEYS` is now ignored and they must migrate to `confluence.spaces:` in `config/context-filters.yaml`. + + +````yaml +id: 8db40599-fce1-43 +phase: plan +metadata: + payload: + artifact_references: + - .egg-state/agent-outputs/1931-architect-output.json + - .egg-state/drafts/1931-analysis.md + - .egg-state/contracts/issue-1931.json + - gateway/jira_client.py + - gateway/jira_search.py + - gateway/jira_credentials.py + - gateway/jira_policy.py + - gateway/mode_gate.py + - sandbox/scripts/jira + - config/context-filters.yaml + reason: "\nReviewed `.egg-state/agent-outputs/1931-architect-output.json` end-to-end\ + \ against the analysis (`1931-analysis.md`), the resolved HITL decisions (decision-1\ + \ through decision-14 + feedback Q1\u2013Q10), and the Jira sister files (`gateway/jira_client.py`\ + \ 567 LoC, `jira_search.py` 250 LoC, `jira_credentials.py` 213 LoC, `jira_policy.py`\ + \ 253 LoC, `sandbox/scripts/jira` 358 LoC, `docs/reference/jira-wrapper.md`\ + \ 173 LoC, `config/context-filters.yaml`).\n\n**Alignment with analysis + HITL**\ + \ \u2014 all 14 multiple-choice decisions are correctly threaded into the architecture:\ + \ A1 (Jira-shape paths) \u2192 TD1, B1 (v2-first hybrid) \u2192 TD2, C1 (static\ + \ CQL extractor) \u2192 TD3, D1 (v2 + v1 fallback) \u2192 TD2, E (storage default\ + \ with override) \u2192 TD4 *correctly noting the HITL resolution narrowed the\ + \ analysis-recommended dual default*, F1 (shared ATLASSIAN_*) \u2192 TD5, G1\ + \ (per-route mode gate) \u2192 TD6, H1 (`confluence.spaces` in context-filters.yaml)\ + \ \u2192 TD7, decision-9 \u2192 TD5, decision-10 \u2192 TD8, decision-11 \u2192\ + \ TD9, decision-12 \u2192 TD10, decision-13 \u2192 TD11, decision-14 \u2192\ + \ TD12. All 10 free-form feedback answers are wired in (Q1 \u2192 fail-closed\ + \ empty allowlist, Q2 \u2192 reuse Jira's 429 retry, Q5 \u2192 no resolve-by-url,\ + \ Q6 \u2192 depth passthrough TD14, Q7 \u2192 distinct `confluence_upstream_403`\ + \ audit TD13, Q8 \u2192 current-version-only TD15, Q9 \u2192 bot-vs-human caveat\ + \ in wrapper docs, Q10 \u2192 Jira-style subcommands only).\n\n**Architecture\ + \ coherence** \u2014 the module breakdown mirrors the Jira split (client / credentials\ + \ / policy / search) with size estimates (550-650 / 230-260 / 240-270 / 260-290)\ + \ that are believable against the Jira reference (567/213/253/250). Workstream\ + \ sequencing (WS1+WS3 parallel, WS2 after WS1, WS4 after WS1/2/3, WS5/WS6 after\ + \ WS4) is correct: the client depends on credentials, routes depend on all three,\ + \ sandbox-wrapper smoke tests depend on routes. Permanent denylist `CONFLUENCE_WRITE_VERBS_DENIED\ + \ = {restrictions, permissions, space.admin, users, attachments, DELETE, PUT,\ + \ PATCH}` correctly bakes in decision-12 and the future-write-readiness invariant.\ + \ Audit shape (`confluence_*` events + distinct `confluence_upstream_403` +\ + \ `confluence_v1_fallback`) is consistent with the Jira convention. The `_redact()`\ + \ helper at the client layer is the right placement (pre-route) \u2014 agents\ + \ cannot accidentally bypass redaction by going through `/execute`.\n\n**404\ + \ envelope discipline** \u2014 get_page / list_pages_in_space / get_page_descendants\ + \ / get_footer_comments / get_inline_comments return `{status: not_found, ...}`;\ + \ CQL search and execute_raw raise `ConfluenceUpstreamError`. Mirrors Jira (`get_ticket`/`get_comments`\ + \ envelope vs `search`/`execute_raw` raise). \u2713\n\n**Route-enumeration regression**\ + \ \u2014 `test_confluence_routes.py` walks `app.url_map` for `/api/v1/confluence/*`\ + \ and asserts `__egg_requires_private_mode__ = True` on every view. Identical\ + \ to `test_jira_routes.py::test_every_jira_route_has_private_mode_marker`. \u2713\ + \n\n**Open questions deferred to plan phase** (Q1\u2013Q4 in `open_questions_for_plan_phase`)\ + \ are reasonable scoping calls \u2014 the bot-vs-human docs paragraph (Q1) and\ + \ per-page double-fetch trade-off (Q2) belong in the task plan, not the architecture,\ + \ and the v1_fallback audit-field detail (Q3) and the shared-credential refactor\ + \ (Q4) are correctly deferred to a follow-up.\n\n### Non-blocking\n- **`include_replies`\ + \ flag for footer-comments** (route block: `POST /api/v1/confluence/page/footer-comments`)\ + \ \u2014 the architect promotes this to a request-body parameter rather than\ + \ the analysis's \"fallback when v2 misses replies\" auto-trigger. This is fine\ + \ but means callers must opt in; consider documenting in `docs/reference/confluence-wrapper.md`\ + \ that omitting `include_replies` will silently miss nested replies even when\ + \ v2 returns the buggy shape. Suggested fix: add a paragraph in the wrapper\ + \ reference's \"v1 fallback\" section calling this out explicitly so the planner\ + \ can write a corresponding doc task.\n- **Search pagination naming** (`ConfluenceClient.search_cql(cql,\ + \ limit, cursor)`) \u2014 Atlassian's v1 search uses `start` (numeric offset)\ + \ and returns `_links.next`; v2 uses cursor. Exposing it as a `cursor` string\ + \ at the wrapper layer is fine but warrants a one-line note in the architecture\ + \ that the gateway extracts/encodes the v1 `start=N` from `_links.next` so callers\ + \ don't have to know they're talking to v1. Suggested fix: have `task_planner`\ + \ add a sub-task \"translate v1 search pagination into a single opaque `next_page_token`\ + \ string\" under WS2.\n- **Response envelope normalisation** \u2014 the architect\ + \ says \"Wrapper methods normalise both response shapes to a single envelope\ + \ so /api/v1/confluence/* responses are version-agnostic for the agent\" but\ + \ doesn't fully specify the envelope. Acceptable at architecture level; flagging\ + \ so the task_planner explicitly carves out a \"define and document the unified\ + \ response envelope\" task in WS2 rather than leaving it as implicit work.\n\ + - **`/execute` path-validator regex** \u2014 `validate_confluence_api_path`\ + \ is named but the accepted-path families aren't enumerated (Jira's analogue\ + \ accepts `^issue/[A-Z][A-Z0-9_]*-\\d+$` etc.). Confluence's path families differ\ + \ from Jira (numeric IDs, both `/wiki/api/v2/...` and `/wiki/rest/api/...` prefixes).\ + \ Suggested fix: have `task_planner` make \"enumerate `/execute` accepted path\ + \ patterns\" an explicit sub-task in WS4 with a recommended starting set: `pages/(/.*)?`,\ + \ `spaces/(/.*)?`, `search` (under v1), and the v1-fallback comment\ + \ paths.\n- **`_links.webui` redaction** \u2014 the architect strips webui \"\ + when it points at user-profile URLs\" but Atlassian uses `_links.webui` for\ + \ both pages (`/wiki/spaces/KEY/pages/ID/...`) and people (`/wiki/people/`).\ + \ The task `_redact()` needs a path-pattern check, not a blanket strip. Suggested\ + \ fix: have `task_planner` make the redaction predicate a tested helper (e.g.,\ + \ `_is_user_profile_webui(path)`) with a tabulated test matrix in `test_confluence_client.py`.\n\ + - **Space ID \u2194 key resolution** \u2014 `space/pages` requires an extra\ + \ upstream call to map numeric `space_id` \u2192 `space.key` for allowlist verification.\ + \ Worth surfacing as a task-level decision (accept double-call in v1 vs. cache\ + \ key\u2194id map at gateway start). The architect raises Q2 for `pageId \u2192\ + \ spaceKey` but not the symmetric case. Suggested fix: have `risk_analyst` cover\ + \ this latency-amplification risk and `task_planner` include \"decide space\ + \ ID/key resolution strategy\" as a WS4 task.\n- **Existing references to `confluence`\ + \ in sandbox** \u2014 `sandbox/agent-config/commands/show-metrics.md:12` references\ + \ `~/context-sync/confluence/` and line 36 mentions `confluence/ENG`. These\ + \ are unrelated (the legacy context-sync cache, not the gateway). Worth a one-line\ + \ audit task to confirm the show-metrics doc is out of scope for #1931 so the\ + \ implementer doesn't accidentally rewrite it.\n- **Migration semantics in `secrets.template.env`**\ + \ \u2014 TD5's \"remove `CONFLUENCE_SPACE_KEYS`\" is correct (decision-8 moved\ + \ it to YAML), but live deployments may have `CONFLUENCE_SPACE_KEYS` populated.\ + \ Suggested fix: have `task_planner` include a \"migration-note doc paragraph\"\ + \ sub-task under WS6 telling operators that any populated `CONFLUENCE_SPACE_KEYS`\ + \ is now ignored and they must migrate to `confluence.spaces:` in `config/context-filters.yaml`.\n" + version: 1 +```` + +### [2026-04-26T23:53:34Z] architect (HEARTBEAT): heartbeat: WORKING + +wait_loop exited + +````yaml +id: 666dce7c-96d1-4e +phase: plan +metadata: + state: WORKING +```` + +### [2026-04-26T23:53:40Z] reviewer_plan (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_PROPOSE,CONSENSUS_RE_REVIEW,CONSENSUS_CONFIRMED + +````yaml +id: 0a7ace19-1bd6-44 +phase: plan +metadata: + state: WAITING_FOR_EVENT + since: '2026-04-26T23:53:40.179778+00:00' +```` + +### [2026-04-26T23:53:45Z] architect (CONSENSUS_CONFIRMED): Confirmed by architect (pending_acks) + +Agent architect cannot confirm: producers ['task_planner'] have never proposed (proposal_version == 0). All producers must propose before any agent can confirm consensus. + +````yaml +id: 7c1c739c-79d9-46 +phase: plan +metadata: + pending_acks: true +```` + +### [2026-04-26T23:53:45Z] reviewer_plan (HEARTBEAT): heartbeat: WORKING + +wait_loop exited + +````yaml +id: bcc19efe-50e4-4d +phase: plan +metadata: + state: WORKING +```` + +### [2026-04-26T23:53:50Z] reviewer_plan (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_PROPOSE,CONSENSUS_RE_REVIEW,CONSENSUS_CONFIRMED + +````yaml +id: 26f1f7a3-f2e2-48 +phase: plan +metadata: + state: WAITING_FOR_EVENT + since: '2026-04-26T23:53:50.219548+00:00' +```` + +### [2026-04-26T23:53:51Z] architect (HEARTBEAT): heartbeat: WAITING_ON_ROLE + +ACKed by reviewer_plan; awaiting task_planner proposal before confirm + +````yaml +id: a0d028fc-7c68-4a +phase: plan +metadata: + state: WAITING_ON_ROLE + waiting_on: task_planner +```` + +### [2026-04-26T23:53:51Z] architect (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_PROPOSE,CONSENSUS_RE_REVIEW,CONSENSUS_NACK,CONSENSUS_CONFIRMED,OVERSEER_ALERT + +````yaml +id: 09314431-df41-49 +phase: plan +metadata: + state: WAITING_FOR_EVENT + since: '2026-04-26T23:53:51.584828+00:00' +```` + +### [2026-04-26T23:53:51Z] architect (HEARTBEAT): heartbeat: WORKING + +wait_loop exited + +````yaml +id: f805dce9-cb0a-47 +phase: plan +metadata: + state: WORKING +```` + +### [2026-04-26T23:53:55Z] architect (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_PROPOSE,CONSENSUS_RE_REVIEW,CONSENSUS_NACK,OVERSEER_ALERT + +````yaml +id: 39e445fb-2d2f-4c +phase: plan +metadata: + state: WAITING_FOR_EVENT + since: '2026-04-26T23:53:54.994915+00:00' +```` + +### [2026-04-26T23:54:28Z] risk_analyst (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_ACK,CONSENSUS_NACK,CONSENSUS_RE_REVIEW,OVERSEER_ALERT + +````yaml +id: 85c9fdf8-2524-44 +phase: plan +metadata: + state: WAITING_FOR_EVENT + since: '2026-04-26T23:52:27.443103+00:00' +```` + +### [2026-04-26T23:54:50Z] reviewer_plan (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_PROPOSE,CONSENSUS_RE_REVIEW,CONSENSUS_CONFIRMED + +````yaml +id: e73fc84c-cc8d-4d +phase: plan +metadata: + state: WAITING_FOR_EVENT + since: '2026-04-26T23:53:50.219548+00:00' +```` + +### [2026-04-26T23:54:55Z] architect (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_PROPOSE,CONSENSUS_RE_REVIEW,CONSENSUS_NACK,OVERSEER_ALERT + +````yaml +id: a7c9399e-bb32-4d +phase: plan +metadata: + state: WAITING_FOR_EVENT + since: '2026-04-26T23:53:54.994915+00:00' +```` + +### [2026-04-26T23:55:23Z] orchestrator → risk_analyst (OVERSEER_ALERT): BRC confirmation timeout — call mcp__brc__confirm + +You are PROPOSED and fully ACKed but have not confirmed in 180s. Call `mcp__brc__confirm` now. If it returns `status='pending_acks'`, read `message` for the guard reason and wait on the prerequisite events instead: `CONSENSUS_PROPOSE` if a producer hasn't proposed (`zero_proposal_producers`), `CONSENSUS_ACK` / `CONSENSUS_RE_REVIEW` if a reviewer's ACK is stale or unresolved. Then retry confirm. + +````yaml +id: 23c61c1e-86b2-41 +phase: plan +metadata: + alert_type: brc_confirmation_timeout + elapsed_seconds: 180 + source: health_monitor +```` + +### [2026-04-26T23:55:23Z] risk_analyst (HEARTBEAT): heartbeat: WORKING + +wait_loop exited + +````yaml +id: ddda38a2-747f-42 +phase: plan +metadata: + state: WORKING +```` + +### [2026-04-26T23:55:26Z] risk_analyst (CONSENSUS_CONFIRMED): Confirmed by risk_analyst (pending_acks) + +Agent risk_analyst cannot confirm: producers ['task_planner'] have never proposed (proposal_version == 0). All producers must propose before any agent can confirm consensus. + +````yaml +id: e14bf09e-7c21-47 +phase: plan +metadata: + pending_acks: true +```` + +### [2026-04-26T23:55:26Z] reviewer_plan (HEARTBEAT): heartbeat: WORKING + +wait_loop exited + +````yaml +id: 5b1ffa87-9359-45 +phase: plan +metadata: + state: WORKING +```` + +### [2026-04-26T23:55:31Z] risk_analyst (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_PROPOSE,CONSENSUS_ACK,CONSENSUS_NACK,CONSENSUS_RE_REVIEW,CONSENSUS_CONFIRMED,OVERSEER_ALERT from=task_planner + +````yaml +id: 5974c015-007a-4b +phase: plan +metadata: + state: WAITING_FOR_EVENT + since: '2026-04-26T23:55:31.349035+00:00' +```` + +### [2026-04-26T23:55:31Z] risk_analyst (HEARTBEAT): heartbeat: WORKING + +wait_loop exited + +````yaml +id: 3074f5ff-867c-49 +phase: plan +metadata: + state: WORKING +```` + +### [2026-04-26T23:55:35Z] risk_analyst (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_PROPOSE,CONSENSUS_ACK,CONSENSUS_NACK,CONSENSUS_RE_REVIEW,OVERSEER_ALERT + +````yaml +id: d78d7a36-d032-48 +phase: plan +metadata: + state: WAITING_FOR_EVENT + since: '2026-04-26T23:55:35.320204+00:00' +```` + +### [2026-04-26T23:55:55Z] architect (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_PROPOSE,CONSENSUS_RE_REVIEW,CONSENSUS_NACK,OVERSEER_ALERT + +````yaml +id: 2f2b6104-bb01-44 +phase: plan +metadata: + state: WAITING_FOR_EVENT + since: '2026-04-26T23:53:54.994915+00:00' +```` + +### [2026-04-26T23:56:35Z] risk_analyst (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_PROPOSE,CONSENSUS_ACK,CONSENSUS_NACK,CONSENSUS_RE_REVIEW,OVERSEER_ALERT + +````yaml +id: 47e1e8b7-8045-4e +phase: plan +metadata: + state: WAITING_FOR_EVENT + since: '2026-04-26T23:55:35.320204+00:00' +```` + +### [2026-04-26T23:56:54Z] orchestrator → architect (OVERSEER_ALERT): BRC confirmation timeout — call mcp__brc__confirm + +You are PROPOSED and fully ACKed but have not confirmed in 181s. Call `mcp__brc__confirm` now. If it returns `status='pending_acks'`, read `message` for the guard reason and wait on the prerequisite events instead: `CONSENSUS_PROPOSE` if a producer hasn't proposed (`zero_proposal_producers`), `CONSENSUS_ACK` / `CONSENSUS_RE_REVIEW` if a reviewer's ACK is stale or unresolved. Then retry confirm. + +````yaml +id: ffe396ce-53c1-4e +phase: plan +metadata: + alert_type: brc_confirmation_timeout + elapsed_seconds: 181 + source: health_monitor +```` + +### [2026-04-26T23:56:54Z] architect (HEARTBEAT): heartbeat: WORKING + +wait_loop exited + +````yaml +id: d8a0f97a-3954-44 +phase: plan +metadata: + state: WORKING +```` + +### [2026-04-26T23:57:00Z] architect (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_PROPOSE,CONSENSUS_RE_REVIEW,CONSENSUS_NACK,OVERSEER_ALERT + +````yaml +id: 0e8930e8-7b7a-4a +phase: plan +metadata: + state: WAITING_FOR_EVENT + since: '2026-04-26T23:57:00.206525+00:00' +```` + +### [2026-04-26T23:57:35Z] risk_analyst (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_PROPOSE,CONSENSUS_ACK,CONSENSUS_NACK,CONSENSUS_RE_REVIEW,OVERSEER_ALERT + +````yaml +id: 2383f2be-fa42-42 +phase: plan +metadata: + state: WAITING_FOR_EVENT + since: '2026-04-26T23:55:35.320204+00:00' +```` + +### [2026-04-26T23:57:48Z] reviewer_plan → risk_analyst (CONSENSUS_ACK): ACK from reviewer_plan for risk_analyst + + +Reviewed `.egg-state/agent-outputs/1931-risk_analyst-output.json` against the analysis, architect output, and the Jira sister patterns it references (`gateway/jira_client.py:120-236` for `validate_jira_api_path`/`JIRA_API_ALLOWED_PATHS`; `gateway/allowed_domains.txt:31-36` confirming `*.atlassian.net` exclusion). + +**Coverage assessment** — 18 risks across security (6), performance (3), compatibility (2), operational (3), data-privacy (1), external-dependency (1), test-coverage (1), future-compat (1) is a thorough inventory. One HIGH severity (R1 CQL extractor), seven MEDIUM (R2/R3/R4/R5/R6/R7/R10/R17), ten LOW. Three flagged for human review (R1 CQL adversarial coverage, R14 attachments denylist, R15 bot-vs-human access caveat) — all genuinely warrant security or operational eyes at implement time. + +**Mitigation quality** — every risk has actionable, verifiable mitigations. R1's adversarial test-suite list (empty CQL, quoted keys, OR at depth, CQL functions, mixed-script, `text ~` widening, `space != BAD` negation, URL-encoded operators, leading whitespace/BOM) covers concrete CQL grammar features absent from JQL — this directly addresses my biggest worry about R17 (test-port copy-paste). R2's anti-bypass test (every narrow route's path family must be rejected by `/execute`) is the right shape — it inverts the contract so a future widening of `/execute` cannot silently shadow a narrow route. R6's recursive-walker requirement explicitly covering ADF mention nodes (`type: 'mention'`, `attrs.id`) is essential — a top-level-only redactor would miss every page body's inline @-mentions. R14's variant matrix (case, URL-encoded, nested) maps directly onto the future-writes invariant that decision-12 demands. + +**Implement-phase checklist** maps each risk to a verifiable gate. Reviewer-friendly format. Global rollback plan (config-only → route-disable → credential-revoke → full-revert) gives operators graduated levers. + +**External research** — the cited sources (community.developer.atlassian.com threads on the v2 inline-404 bug and footer-comment nested-reply gap, the Apr 2026 Confluence Cloud changelog, the v1-deprecation timeline thread) are the right primary sources for R3 / R9 / R10. Findings are dated and specific (Apr 14 2026 internal API, Aug 5 2026 Convert content body, March 2026 points-based rate limiting rollout). Good due diligence. + +**No HIGH-severity unmitigated risk** — the only HIGH-severity entry (R1) has comprehensive mitigations. Acceptable. + +### Non-blocking +- **R8 (descendants depth) conflicts with architect TD14** — the risk analyst recommends `default depth=1` if caller omits, while the architect's TD14 (citing HITL feedback Q6) says "depth passes through verbatim with no gateway-imposed cap". The HITL answer is ambiguous on whether "verbatim passthrough" means "no default added" or "no upper cap". The two readings produce different request shapes when the caller omits `depth`. Suggested fix: have `task_planner` resolve this explicitly — recommended reading is "no cap (don't constrain caller's explicit depth) but apply a sensible default of 1 when caller omits it" because Atlassian's unbounded default is both expensive and almost never what an agent actually wants. +- **R11's `EGG_ATLASSIAN_SHARED_CREDS=0` feature flag and `POST /api/v1/atlassian/diagnostics` endpoint** are net-new surface not present in the architect's plan. The flag is plausibly over-engineering for v1 (the architect's `ATLASSIAN_*` → `JIRA_*`/`CONFLUENCE_*` fall-back chain already gives operators an in-place rollback path). The diagnostics endpoint *is* a useful addition (would also satisfy R12's "boot-time announcement" need and R15's "bot effective-access verification" need), but if added it must be `@require_session_auth + @require_private_mode` gated and audit-logged like every other route. Suggested fix: have `task_planner` either (a) drop the feature flag and rely on the fall-back chain, or (b) carve the diagnostics endpoint into its own task with explicit decorator + redaction requirements (no echoing token bytes). +- **R7's `body_truncated: true` response flag** is net-new response envelope shape. Reasonable defensive design but should be coordinated with the architect's overall envelope definition. Suggested fix: have `task_planner` make "define unified Confluence response envelope (success / not_found / forbidden / body_truncated)" an explicit deliverable in WS2. +- **R15's structured forbidden envelope `{status: 'forbidden', upstream_status: 403, reason: 'bot_account_lacks_read_access'}`** is a useful agent-facing distinction beyond the architect's plain `confluence_upstream_403` audit. Suggested fix: have `task_planner` add a sub-task to translate upstream 403 into the structured envelope (so agents can tell "denied by gateway allowlist" from "denied by Atlassian permission" without parsing audit logs). +- **R8's net-new audit field `descendant_count`, R11's `body_bytes`, R13's `confluence_spaces_filtered`** — multiple new audit categories beyond what the architect listed. Suggested fix: `task_planner` should consolidate all proposed audit fields into one "audit-log schema" task so the eventual implementation has a single source of truth for confluence_* event names and field sets. +- **R6's `displayName` redaction status** — the analyst writes "verify decision-10 didn't include it". Decision-10's resolution is settled and excludes `displayName` (only `accountId` / `emailAddress` / `_links.webui` are stripped). The "verify" phrasing implies uncertainty that is no longer present. Cosmetic only; does not affect implementation correctness. +- **R3's "Distinguish v2 inline-404 from a real not-found by checking the response body shape"** — Atlassian's v2 bug response shape is not formally documented in the linked thread; relying on a body-shape heuristic is fragile. A safer mitigation is "always retry once on 404 against v1; if v1 also returns 404, surface as not_found; emit `confluence_v1_fallback` audit on every retry". The double-call cost is bounded (one extra HTTP round-trip per inline-comments call) and avoids the false-positive of misclassifying a deleted page as the v2 bug. Suggested fix: have `task_planner` adopt the always-retry-on-404 pattern for v2 inline-comments and document the cost in the wrapper reference. +- **R16 (ADF prompt injection) severity rating** — rated low/low/low. Defensible since v1's stance is documentation + agent-side hardening, but worth flagging that a Confluence page authored by a contractor with edit access *is* a realistic prompt-injection vector for any refine-phase pipeline pulling linked Confluence pages. Suggested fix: ensure `docs/architecture/network-isolation.md` AND `sandbox/agent-config/rules/security.md` both name Confluence as an untrusted input source, not just the wrapper reference. +- **R5's existing `test_allowed_domains.py` regression** — verified `gateway/allowed_domains.txt:31-36` does carry the `*.atlassian.net / *.atlassian.com / api.atlassian.com are intentionally NOT here` comment. The risk analyst's "verify the existing test" mitigation is satisfied for the Jira case; the Confluence ride-along claim is correct. Non-blocking; just confirming. + + +````yaml +id: 9ab232fb-b646-41 +phase: plan +metadata: + payload: + artifact_references: + - .egg-state/agent-outputs/1931-risk_analyst-output.json + - .egg-state/agent-outputs/1931-architect-output.json + - .egg-state/drafts/1931-analysis.md + - gateway/allowed_domains.txt + - gateway/jira_client.py + reason: "\nReviewed `.egg-state/agent-outputs/1931-risk_analyst-output.json` against\ + \ the analysis, architect output, and the Jira sister patterns it references\ + \ (`gateway/jira_client.py:120-236` for `validate_jira_api_path`/`JIRA_API_ALLOWED_PATHS`;\ + \ `gateway/allowed_domains.txt:31-36` confirming `*.atlassian.net` exclusion).\n\ + \n**Coverage assessment** \u2014 18 risks across security (6), performance (3),\ + \ compatibility (2), operational (3), data-privacy (1), external-dependency\ + \ (1), test-coverage (1), future-compat (1) is a thorough inventory. One HIGH\ + \ severity (R1 CQL extractor), seven MEDIUM (R2/R3/R4/R5/R6/R7/R10/R17), ten\ + \ LOW. Three flagged for human review (R1 CQL adversarial coverage, R14 attachments\ + \ denylist, R15 bot-vs-human access caveat) \u2014 all genuinely warrant security\ + \ or operational eyes at implement time.\n\n**Mitigation quality** \u2014 every\ + \ risk has actionable, verifiable mitigations. R1's adversarial test-suite list\ + \ (empty CQL, quoted keys, OR at depth, CQL functions, mixed-script, `text ~`\ + \ widening, `space != BAD` negation, URL-encoded operators, leading whitespace/BOM)\ + \ covers concrete CQL grammar features absent from JQL \u2014 this directly\ + \ addresses my biggest worry about R17 (test-port copy-paste). R2's anti-bypass\ + \ test (every narrow route's path family must be rejected by `/execute`) is\ + \ the right shape \u2014 it inverts the contract so a future widening of `/execute`\ + \ cannot silently shadow a narrow route. R6's recursive-walker requirement explicitly\ + \ covering ADF mention nodes (`type: 'mention'`, `attrs.id`) is essential \u2014\ + \ a top-level-only redactor would miss every page body's inline @-mentions.\ + \ R14's variant matrix (case, URL-encoded, nested) maps directly onto the future-writes\ + \ invariant that decision-12 demands.\n\n**Implement-phase checklist** maps\ + \ each risk to a verifiable gate. Reviewer-friendly format. Global rollback\ + \ plan (config-only \u2192 route-disable \u2192 credential-revoke \u2192 full-revert)\ + \ gives operators graduated levers.\n\n**External research** \u2014 the cited\ + \ sources (community.developer.atlassian.com threads on the v2 inline-404 bug\ + \ and footer-comment nested-reply gap, the Apr 2026 Confluence Cloud changelog,\ + \ the v1-deprecation timeline thread) are the right primary sources for R3 /\ + \ R9 / R10. Findings are dated and specific (Apr 14 2026 internal API, Aug 5\ + \ 2026 Convert content body, March 2026 points-based rate limiting rollout).\ + \ Good due diligence.\n\n**No HIGH-severity unmitigated risk** \u2014 the only\ + \ HIGH-severity entry (R1) has comprehensive mitigations. Acceptable.\n\n###\ + \ Non-blocking\n- **R8 (descendants depth) conflicts with architect TD14** \u2014\ + \ the risk analyst recommends `default depth=1` if caller omits, while the architect's\ + \ TD14 (citing HITL feedback Q6) says \"depth passes through verbatim with no\ + \ gateway-imposed cap\". The HITL answer is ambiguous on whether \"verbatim\ + \ passthrough\" means \"no default added\" or \"no upper cap\". The two readings\ + \ produce different request shapes when the caller omits `depth`. Suggested\ + \ fix: have `task_planner` resolve this explicitly \u2014 recommended reading\ + \ is \"no cap (don't constrain caller's explicit depth) but apply a sensible\ + \ default of 1 when caller omits it\" because Atlassian's unbounded default\ + \ is both expensive and almost never what an agent actually wants.\n- **R11's\ + \ `EGG_ATLASSIAN_SHARED_CREDS=0` feature flag and `POST /api/v1/atlassian/diagnostics`\ + \ endpoint** are net-new surface not present in the architect's plan. The flag\ + \ is plausibly over-engineering for v1 (the architect's `ATLASSIAN_*` \u2192\ + \ `JIRA_*`/`CONFLUENCE_*` fall-back chain already gives operators an in-place\ + \ rollback path). The diagnostics endpoint *is* a useful addition (would also\ + \ satisfy R12's \"boot-time announcement\" need and R15's \"bot effective-access\ + \ verification\" need), but if added it must be `@require_session_auth + @require_private_mode`\ + \ gated and audit-logged like every other route. Suggested fix: have `task_planner`\ + \ either (a) drop the feature flag and rely on the fall-back chain, or (b) carve\ + \ the diagnostics endpoint into its own task with explicit decorator + redaction\ + \ requirements (no echoing token bytes).\n- **R7's `body_truncated: true` response\ + \ flag** is net-new response envelope shape. Reasonable defensive design but\ + \ should be coordinated with the architect's overall envelope definition. Suggested\ + \ fix: have `task_planner` make \"define unified Confluence response envelope\ + \ (success / not_found / forbidden / body_truncated)\" an explicit deliverable\ + \ in WS2.\n- **R15's structured forbidden envelope `{status: 'forbidden', upstream_status:\ + \ 403, reason: 'bot_account_lacks_read_access'}`** is a useful agent-facing\ + \ distinction beyond the architect's plain `confluence_upstream_403` audit.\ + \ Suggested fix: have `task_planner` add a sub-task to translate upstream 403\ + \ into the structured envelope (so agents can tell \"denied by gateway allowlist\"\ + \ from \"denied by Atlassian permission\" without parsing audit logs).\n- **R8's\ + \ net-new audit field `descendant_count`, R11's `body_bytes`, R13's `confluence_spaces_filtered`**\ + \ \u2014 multiple new audit categories beyond what the architect listed. Suggested\ + \ fix: `task_planner` should consolidate all proposed audit fields into one\ + \ \"audit-log schema\" task so the eventual implementation has a single source\ + \ of truth for confluence_* event names and field sets.\n- **R6's `displayName`\ + \ redaction status** \u2014 the analyst writes \"verify decision-10 didn't include\ + \ it\". Decision-10's resolution is settled and excludes `displayName` (only\ + \ `accountId` / `emailAddress` / `_links.webui` are stripped). The \"verify\"\ + \ phrasing implies uncertainty that is no longer present. Cosmetic only; does\ + \ not affect implementation correctness.\n- **R3's \"Distinguish v2 inline-404\ + \ from a real not-found by checking the response body shape\"** \u2014 Atlassian's\ + \ v2 bug response shape is not formally documented in the linked thread; relying\ + \ on a body-shape heuristic is fragile. A safer mitigation is \"always retry\ + \ once on 404 against v1; if v1 also returns 404, surface as not_found; emit\ + \ `confluence_v1_fallback` audit on every retry\". The double-call cost is bounded\ + \ (one extra HTTP round-trip per inline-comments call) and avoids the false-positive\ + \ of misclassifying a deleted page as the v2 bug. Suggested fix: have `task_planner`\ + \ adopt the always-retry-on-404 pattern for v2 inline-comments and document\ + \ the cost in the wrapper reference.\n- **R16 (ADF prompt injection) severity\ + \ rating** \u2014 rated low/low/low. Defensible since v1's stance is documentation\ + \ + agent-side hardening, but worth flagging that a Confluence page authored\ + \ by a contractor with edit access *is* a realistic prompt-injection vector\ + \ for any refine-phase pipeline pulling linked Confluence pages. Suggested fix:\ + \ ensure `docs/architecture/network-isolation.md` AND `sandbox/agent-config/rules/security.md`\ + \ both name Confluence as an untrusted input source, not just the wrapper reference.\n\ + - **R5's existing `test_allowed_domains.py` regression** \u2014 verified `gateway/allowed_domains.txt:31-36`\ + \ does carry the `*.atlassian.net / *.atlassian.com / api.atlassian.com are\ + \ intentionally NOT here` comment. The risk analyst's \"verify the existing\ + \ test\" mitigation is satisfied for the Jira case; the Confluence ride-along\ + \ claim is correct. Non-blocking; just confirming.\n" + version: 1 +```` + +### [2026-04-26T23:57:48Z] risk_analyst (HEARTBEAT): heartbeat: WORKING + +wait_loop exited + +````yaml +id: 66101341-b8bf-43 +phase: plan +metadata: + state: WORKING +```` + +### [2026-04-26T23:57:53Z] reviewer_plan (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_PROPOSE,CONSENSUS_RE_REVIEW,CONSENSUS_CONFIRMED + +````yaml +id: 1e44635a-6535-4e +phase: plan +metadata: + state: WAITING_FOR_EVENT + since: '2026-04-26T23:57:53.410880+00:00' +```` + +### [2026-04-26T23:57:59Z] risk_analyst (HEARTBEAT): heartbeat: WAITING_ON_ROLE + +Risk_analyst is fully ACKed by reviewer_plan; blocked on task_planner CONSENSUS_PROPOSE before risk_analyst can call confirm. + +````yaml +id: 1bf00113-1a2f-4f +phase: plan +metadata: + state: WAITING_ON_ROLE + waiting_on: task_planner +```` + +### [2026-04-26T23:58:00Z] architect (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_PROPOSE,CONSENSUS_RE_REVIEW,CONSENSUS_NACK,OVERSEER_ALERT + +````yaml +id: 456d1916-26af-4d +phase: plan +metadata: + state: WAITING_FOR_EVENT + since: '2026-04-26T23:57:00.206525+00:00' +```` + +### [2026-04-26T23:58:04Z] risk_analyst (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_PROPOSE,CONSENSUS_ACK,CONSENSUS_NACK,CONSENSUS_RE_REVIEW,OVERSEER_ALERT + +````yaml +id: d5128a62-392b-43 +phase: plan +metadata: + state: WAITING_FOR_EVENT + since: '2026-04-26T23:58:04.686009+00:00' +```` + +### [2026-04-26T23:58:05Z] overseer (OVERSEER_ALERT): stuck-phase-transition [medium] + +task_planner (issue-1931) silent for 702s (~11.7 min) — exceeds 600s threshold; architect + risk_analyst blocked waiting for task_planner CONSENSUS_PROPOSE + +Detail: +Pipeline issue-1931, plan phase. task_planner entered WORKING state at ~23:45:36 UTC and has emitted zero self-initiated heartbeats in 702s. No CONSENSUS_PROPOSE from task_planner. By contrast: architect proposed at 23:51:01 (~330s), risk_analyst proposed at 23:52:13 (~397s). Both have been ACKed by reviewer_plan and are blocked waiting to confirm — BRC cannot complete until task_planner proposes. The platform detector has not independently flagged task_planner yet (calibration_only alerts only), but the overseer silent_agent threshold of 600s has been crossed. + +Parallel to refine phase: refiner also went silent ~11.5 min before proposing, then self-healed 13s after the HIGH alert was broadcast. The same pattern may apply here — task_planner may be doing deep codebase analysis and task decomposition using architect's 6-workstream proposal + risk_analyst's 18-risk checklist. + +All brc_confirmation_timeout alerts for architect (23:56:54) and risk_analyst (23:55:23) are expected artifacts of this wait condition — not independent failures. + +Recommended action: Check task_planner container logs to verify forward progress (tool calls, reads). If no meaningful progress in next 2 cycles (~4 min), consider restarting the task_planner container for pipeline issue-1931. + +Recommended action: +Monitor for 2 more cycles (~4 min). If no task_planner CONSENSUS_PROPOSE by 00:01 UTC, inspect container logs and consider restart. + +````yaml +id: afe10f57-6983-4e +phase: plan +```` + +### [2026-04-26T23:58:05Z] architect (HEARTBEAT): heartbeat: WORKING + +wait_loop exited + +````yaml +id: 09e7067e-9466-49 +phase: plan +metadata: + state: WORKING +```` + +### [2026-04-26T23:58:05Z] risk_analyst (HEARTBEAT): heartbeat: WORKING + +wait_loop exited + +````yaml +id: 6b9ccee7-e344-44 +phase: plan +metadata: + state: WORKING +```` + +### [2026-04-26T23:58:09Z] architect (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_PROPOSE,CONSENSUS_RE_REVIEW,CONSENSUS_NACK,OVERSEER_ALERT from=task_planner + +````yaml +id: 7cf3150b-4749-4e +phase: plan +metadata: + state: WAITING_FOR_EVENT + since: '2026-04-26T23:58:09.903934+00:00' +```` + +### [2026-04-26T23:58:10Z] risk_analyst (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_PROPOSE,CONSENSUS_ACK,CONSENSUS_NACK,CONSENSUS_RE_REVIEW,OVERSEER_ALERT + +````yaml +id: 348e43ab-4ed6-4d +phase: plan +metadata: + state: WAITING_FOR_EVENT + since: '2026-04-26T23:58:10.654583+00:00' +```` + +### [2026-04-26T23:58:53Z] reviewer_plan (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_PROPOSE,CONSENSUS_RE_REVIEW,CONSENSUS_CONFIRMED + +````yaml +id: b049adf9-a54b-40 +phase: plan +metadata: + state: WAITING_FOR_EVENT + since: '2026-04-26T23:57:53.410880+00:00' +```` + +### [2026-04-26T23:59:07Z] task_planner (CONSENSUS_PROPOSE): Proposal from task_planner + +Plan for #1931 (Confluence gateway read-only v1) decomposes the architecture analysis into a single-PR plan with 6 phases and 28 tasks (4 foundation modules + 9 routes + 1 sandbox wrapper + 7 test suites + 3 config touches + 4 doc touches), mirroring the #1556 Jira gateway scaffolding line-for-line. Incorporates all 14 refine HITL decisions (verb-noun URL paths, v2-first hybrid with v1 fallbacks, conservative CQL extractor, body-format=storage default per the operator tweak, shared ATLASSIAN_* triple, per-route @require_private_mode + route-enumeration regression test, confluence.spaces allowlist in context-filters.yaml, permanent attachments denylist, no per-pipeline EGG_CONFLUENCE_*, GET-only /execute with regex allowlist, accountId/emailAddress/_links.webui redaction, getConfluenceSpaces filtered to allowlisted spaces, distinct confluence_upstream_403 audit category, depth verbatim per Q6, current-version-only per Q8, Jira-style subcommands only per Q10) plus the architect's per-verb endpoint pinning, page→space resolution caching, no-shared-loader-refactor stance, and the risk analyst's R1-R18 mitigations (5 MiB CONFLUENCE_RESPONSE_MAX_BYTES payload cap, descendants depth=1/limit=25 default when omitted, bot_account_lacks_read_access reason in structured 403 body, confluence_v1_fallback audit with v2_status, ADF mention/nested redaction fixtures, attachments-denylist case/URL-encoding/nested-position test variants, route-vs-execute anti-bypass test, six-combination credential precedence matrix, case-sensitive list_spaces filter test, Confluence-original test fixture per file, prompt-injection caveat and Atlassian rate-limit runbook in docs/reference/confluence-wrapper.md). Test plan covers automated unit/route/wrapper suites with respx mocks plus 11 manual verification steps; manual pre-merge requires operator to populate confluence.spaces and confirm bot read access; post-merge rolls the gateway pod and notifies #1557. Plan satisfies tasks 1-1 through 6-4 and is structured as one PR with phase-aligned commits. + +````yaml +id: 62e94f31-6f21-4a +phase: plan +metadata: + payload: + summary: "Plan for #1931 (Confluence gateway read-only v1) decomposes the architecture\ + \ analysis into a single-PR plan with 6 phases and 28 tasks (4 foundation modules\ + \ + 9 routes + 1 sandbox wrapper + 7 test suites + 3 config touches + 4 doc\ + \ touches), mirroring the #1556 Jira gateway scaffolding line-for-line. Incorporates\ + \ all 14 refine HITL decisions (verb-noun URL paths, v2-first hybrid with v1\ + \ fallbacks, conservative CQL extractor, body-format=storage default per the\ + \ operator tweak, shared ATLASSIAN_* triple, per-route @require_private_mode\ + \ + route-enumeration regression test, confluence.spaces allowlist in context-filters.yaml,\ + \ permanent attachments denylist, no per-pipeline EGG_CONFLUENCE_*, GET-only\ + \ /execute with regex allowlist, accountId/emailAddress/_links.webui redaction,\ + \ getConfluenceSpaces filtered to allowlisted spaces, distinct confluence_upstream_403\ + \ audit category, depth verbatim per Q6, current-version-only per Q8, Jira-style\ + \ subcommands only per Q10) plus the architect's per-verb endpoint pinning,\ + \ page\u2192space resolution caching, no-shared-loader-refactor stance, and\ + \ the risk analyst's R1-R18 mitigations (5 MiB CONFLUENCE_RESPONSE_MAX_BYTES\ + \ payload cap, descendants depth=1/limit=25 default when omitted, bot_account_lacks_read_access\ + \ reason in structured 403 body, confluence_v1_fallback audit with v2_status,\ + \ ADF mention/nested redaction fixtures, attachments-denylist case/URL-encoding/nested-position\ + \ test variants, route-vs-execute anti-bypass test, six-combination credential\ + \ precedence matrix, case-sensitive list_spaces filter test, Confluence-original\ + \ test fixture per file, prompt-injection caveat and Atlassian rate-limit runbook\ + \ in docs/reference/confluence-wrapper.md). Test plan covers automated unit/route/wrapper\ + \ suites with respx mocks plus 11 manual verification steps; manual pre-merge\ + \ requires operator to populate confluence.spaces and confirm bot read access;\ + \ post-merge rolls the gateway pod and notifies #1557. Plan satisfies tasks\ + \ 1-1 through 6-4 and is structured as one PR with phase-aligned commits." + attestation: {} + artifacts: + - .egg-state/drafts/1931-plan.md + risk_considered: "CQL extractor adversarial coverage (R1, mirrored from jira_search.py\ + \ with CQL grammar); /execute path-validator drift (R2, anti-bypass test); v2\ + \ comment endpoints inline-404 + missing-nested-replies (R3, transparent v1\ + \ fallback with confluence_v1_fallback audit); private-mode gate decorator drift\ + \ (R4, route-enumeration regression test); Squid allowlist widening (R5, extended\ + \ test_allowed_domains parametrize); PII leakage via accountId/emailAddress/_links.webui\ + \ (R6, redact_response with ADF coverage); page body / ADF token blow-up (R7,\ + \ 5 MiB payload cap); descendants runaway (R8, depth=1/limit=25 defaults); v1\ + \ deprecation timeline (R9, per-verb pinning + last-reviewed footer); Atlassian\ + \ points-based rate-limit (R10, 429 retry + runbook); shared credential rotation\ + \ coupling (R11, six-combination test matrix); fail-closed empty allowlist (R12,\ + \ boot-time INFO log); list_spaces case-mismatch fuzzy match (R13, strict intersection\ + \ test); attachments denylist bypass via case/encoding/nesting (R14, three-variant\ + \ test); bot effective access drift (R15, structured 403 envelope with bot_account_lacks_read_access\ + \ reason); ADF prompt injection (R16, docs caveat); JQL\u2192CQL transliteration\ + \ over-fit (R17, Confluence-original fixture per test file); future-write boundary\ + \ lock-in (R18, ALLOWED_METHODS=GET-only with PUT/DELETE/PATCH explicitly tested\ + \ as rejected)." + commit_sha: 1b0af9121845a568ae8e2ad11bbf1c3e95aca3db + files_changed: + - .egg-state/drafts/1931-plan.md + tests_run: [] + tasks_satisfied: + - TASK-1-1 + - TASK-1-2 + - TASK-1-3 + - TASK-1-4 + - TASK-2-1 + - TASK-2-2 + - TASK-2-3 + - TASK-2-4 + - TASK-2-5 + - TASK-2-6 + - TASK-2-7 + - TASK-2-8 + - TASK-2-9 + - TASK-3-1 + - TASK-4-1 + - TASK-4-2 + - TASK-4-3 + - TASK-4-4 + - TASK-4-5 + - TASK-4-6 + - TASK-4-7 + - TASK-5-1 + - TASK-5-2 + - TASK-5-3 + - TASK-6-1 + - TASK-6-2 + - TASK-6-3 + - TASK-6-4 + version: 1 + commit_sha: 1b0af9121845a568ae8e2ad11bbf1c3e95aca3db +```` + +### [2026-04-26T23:59:07Z] orchestrator → risk_analyst (STATUS): Ready to confirm — all confirm preconditions satisfied + +Your proposal (version 1) is ready to confirm — all blocking reviews are clear and global confirm preconditions are met. Run `egg-orch consensus confirmed` to confirm. + +````yaml +id: e4f8b803-adeb-4b +phase: plan +metadata: + ready_to_confirm: true + version: 1 +```` + +### [2026-04-26T23:59:07Z] orchestrator → architect (STATUS): Ready to confirm — all confirm preconditions satisfied + +Your proposal (version 1) is ready to confirm — all blocking reviews are clear and global confirm preconditions are met. Run `egg-orch consensus confirmed` to confirm. + +````yaml +id: ee3f0b48-9abc-48 +phase: plan +metadata: + ready_to_confirm: true + version: 1 +```` + +### [2026-04-26T23:59:08Z] risk_analyst (HEARTBEAT): heartbeat: WORKING + +wait_loop exited + +````yaml +id: 65f6754f-8d34-4f +phase: plan +metadata: + state: WORKING +```` + +### [2026-04-26T23:59:08Z] architect (HEARTBEAT): heartbeat: WORKING + +wait_loop exited + +````yaml +id: f700ca41-18e4-4e +phase: plan +metadata: + state: WORKING +```` + +### [2026-04-26T23:59:08Z] reviewer_plan (HEARTBEAT): heartbeat: WORKING + +wait_loop exited + +````yaml +id: 2cdce297-6a4c-48 +phase: plan +metadata: + state: WORKING +```` + +### [2026-04-26T23:59:11Z] architect (CONSENSUS_CONFIRMED): Confirmed by architect + +````yaml +id: 4a634f9d-2c60-44 +phase: plan +metadata: + consensus_reached: false +```` + +### [2026-04-26T23:59:12Z] risk_analyst (CONSENSUS_CONFIRMED): Confirmed by risk_analyst + +````yaml +id: bb9950ae-6e10-49 +phase: plan +metadata: + consensus_reached: false +```` + +### [2026-04-26T23:59:12Z] task_planner (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_ACK,CONSENSUS_NACK,CONSENSUS_RE_REVIEW,OVERSEER_ALERT + +````yaml +id: 6892ecd5-ab63-44 +phase: plan +metadata: + state: WAITING_FOR_EVENT + since: '2026-04-26T23:59:12.297766+00:00' +```` + +### [2026-04-26T23:59:19Z] architect (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_CONFIRMED,CONSENSUS_RE_REVIEW,OVERSEER_ALERT,CONSENSUS_NACK + +````yaml +id: c9adef4e-e22f-40 +phase: plan +metadata: + state: WAITING_FOR_EVENT + since: '2026-04-26T23:59:16.347020+00:00' +```` + +### [2026-04-26T23:59:25Z] risk_analyst (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_CONFIRMED,CONSENSUS_RE_REVIEW,OVERSEER_ALERT + +````yaml +id: 29fc5ad4-edbd-4c +phase: plan +metadata: + state: WAITING_FOR_EVENT + since: '2026-04-26T23:59:25.536825+00:00' +```` + +### [2026-04-27T00:00:12Z] task_planner (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_ACK,CONSENSUS_NACK,CONSENSUS_RE_REVIEW,OVERSEER_ALERT + +````yaml +id: 951c4a63-e436-45 +phase: plan +metadata: + state: WAITING_FOR_EVENT + since: '2026-04-26T23:59:12.297766+00:00' +```` + +### [2026-04-27T00:00:19Z] architect (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_CONFIRMED,CONSENSUS_RE_REVIEW,OVERSEER_ALERT,CONSENSUS_NACK + +````yaml +id: 338fb851-9803-40 +phase: plan +metadata: + state: WAITING_FOR_EVENT + since: '2026-04-26T23:59:16.347020+00:00' +```` + +### [2026-04-27T00:00:25Z] risk_analyst (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_CONFIRMED,CONSENSUS_RE_REVIEW,OVERSEER_ALERT + +````yaml +id: 5263560f-c55c-45 +phase: plan +metadata: + state: WAITING_FOR_EVENT + since: '2026-04-26T23:59:25.536825+00:00' +```` + +### [2026-04-27T00:01:12Z] task_planner (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_ACK,CONSENSUS_NACK,CONSENSUS_RE_REVIEW,OVERSEER_ALERT + +````yaml +id: da9bedd9-cb50-46 +phase: plan +metadata: + state: WAITING_FOR_EVENT + since: '2026-04-26T23:59:12.297766+00:00' +```` + +### [2026-04-27T00:01:19Z] architect (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_CONFIRMED,CONSENSUS_RE_REVIEW,OVERSEER_ALERT,CONSENSUS_NACK + +````yaml +id: 9bdc9688-cdd8-42 +phase: plan +metadata: + state: WAITING_FOR_EVENT + since: '2026-04-26T23:59:16.347020+00:00' +```` + +### [2026-04-27T00:01:25Z] risk_analyst (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_CONFIRMED,CONSENSUS_RE_REVIEW,OVERSEER_ALERT + +````yaml +id: 7e7a1089-b728-48 +phase: plan +metadata: + state: WAITING_FOR_EVENT + since: '2026-04-26T23:59:25.536825+00:00' +```` + +### [2026-04-27T00:02:13Z] task_planner (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_ACK,CONSENSUS_NACK,CONSENSUS_RE_REVIEW,OVERSEER_ALERT + +````yaml +id: 64d4abd2-defa-40 +phase: plan +metadata: + state: WAITING_FOR_EVENT + since: '2026-04-26T23:59:12.297766+00:00' +```` + +### [2026-04-27T00:02:19Z] architect (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_CONFIRMED,CONSENSUS_RE_REVIEW,OVERSEER_ALERT,CONSENSUS_NACK + +````yaml +id: dd5a6314-b5fe-4f +phase: plan +metadata: + state: WAITING_FOR_EVENT + since: '2026-04-26T23:59:16.347020+00:00' +```` + +### [2026-04-27T00:02:26Z] risk_analyst (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_CONFIRMED,CONSENSUS_RE_REVIEW,OVERSEER_ALERT + +````yaml +id: 137c13ef-8a79-43 +phase: plan +metadata: + state: WAITING_FOR_EVENT + since: '2026-04-26T23:59:25.536825+00:00' +```` + +### [2026-04-27T00:03:13Z] task_planner (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_ACK,CONSENSUS_NACK,CONSENSUS_RE_REVIEW,OVERSEER_ALERT + +````yaml +id: 26c53ac0-c5eb-44 +phase: plan +metadata: + state: WAITING_FOR_EVENT + since: '2026-04-26T23:59:12.297766+00:00' +```` + +### [2026-04-27T00:03:18Z] reviewer_plan → task_planner (CONSENSUS_NACK): NACK from reviewer_plan for task_planner + + +Reviewed `.egg-state/drafts/1931-plan.md` (1342 lines, 6 phases, 28 tasks) end-to-end against the analysis, the architect output, the risk_analyst output, and the existing Jira sister files (`gateway/jira_credentials.py:134-137`, `config/secrets.template.env:91-99`, `tests/sandbox/test_jira_wrapper.py`). + +The plan is overall strong — task decomposition is granular and assigns roles (coder / tester / documenter); each task has explicit acceptance criteria that reference the covering test task; the dependency graph is correct (Phase 1 foundation → Phase 2 routes → Phase 3 sandbox wrapper → Phase 4 tests → Phase 5 config → Phase 6 docs); risk mitigations from R2/R6/R7/R8/R10/R11/R13/R14/R15/R16/R17 are threaded into specific tasks (5 MiB payload cap → Task 1-2 + 2-1 + 4-5; descendants `depth=1`/`limit=25` defaults → Task 2-2 + 4-5; ADF redaction recursion → Task 1-2 + 4-2; Confluence-original fixtures → Task 4-2; route-vs-execute anti-bypass → Task 4-5; structured `bot_account_lacks_read_access` envelope → Task 1-2 + 2-1 + 4-5). Adversarial CQL test grid (13+ cases) is reproduced exactly from Task 1-4 into Task 4-4 / 4-5. The PR description and test plan are publication-ready. + +### Blocking + +1. **Plan promises a migration story that the implementation as planned cannot deliver — `gateway/jira_credentials.py` is not modified.** + + - The plan's "Manual Pre/Post-Merge Steps → Pre-merge" section says (line 599): *"If migrating off independent JIRA_* + CONFLUENCE_* triples to the shared ATLASSIAN_* triple, operators may copy the same value into all three triples during the cutover **and remove the legacy keys later** — the loader prefers ATLASSIAN_* per key."* + - Task 5-2 says: *"Keep the existing JIRA_* and CONFLUENCE_* blocks intact for back-compat (the loader prefers ATLASSIAN_* per key but falls back to either prefix)."* + - Both passages imply the gateway has a single shared loader (or two loaders that both honour `ATLASSIAN_*`), but Task 1-1 only writes precedence into the **new** `gateway/confluence_credentials.py` (`ATLASSIAN_*` → `CONFLUENCE_*`). The plan does **not** include any task to update `gateway/jira_credentials.py`. + - Verified directly: `gateway/jira_credentials.py:134-137` today reads only `secrets["JIRA_BASE_URL"]`, `secrets["JIRA_USERNAME"]`, `secrets["JIRA_API_TOKEN"]`. There is no fallback to `ATLASSIAN_*`. + - Consequence: an operator who follows the documented pre-merge path and removes the legacy `JIRA_*` keys after copying values to `ATLASSIAN_*` will silently break Jira (gateway returns 503 on every `/api/v1/jira/*` call). The shared-credential premise of decision-6 is violated. + - This is also what risk R11's mitigation explicitly demanded (`risk_analyst-output.json` R11: *"gateway/jira_credentials.py — same fall-back chain (must be updated to read ATLASSIAN_* preferentially)"*) and what the architect's TD5 `shared_credential_invariant` describes (*"Both confluence_credentials.py and jira_credentials.py read the same secrets.env file. When ATLASSIAN_* is set, both use it"*). The architect's `files_intentionally_unchanged` list contradictorily lists `jira_credentials.py` as unchanged — the plan inherited the architect's contradiction without resolving it. + + **Fix**: Add a new task under Phase 1 (e.g. TASK-1-5) to update `gateway/jira_credentials.py` to read `ATLASSIAN_BASE_URL` / `ATLASSIAN_USERNAME` / `ATLASSIAN_API_TOKEN` preferentially with `JIRA_*` per-key fallback (mirror the precedence shape used in TASK-1-1). Add a corresponding test addition in `gateway/tests/test_jira_credentials.py` covering the six combinations (ATLASSIAN-only, JIRA-only, mixed per-key) under TASK-4-1 or a new TASK-4-1b. Update the pre-merge note in the PR description (line 599 of the plan) to reflect that both modules now honour the precedence. Without this, decision-6's "shared credential invariant" cannot ship in this PR — the planner must either include the change or retract the migration narrative. + +2. **Inline-comment fallback fall-through behaviour is unspecified — Task 1-2 + 2-4 do not state what happens when v1 also returns 404.** + + - Task 1-2 says: *"`get_page_inline_comments(page_id, body_format=("storage",))` → `GET /wiki/api/v2/pages/{id}/inline-comments`. **v2 → v1 fallback**: if v2 returns 404, retry transparently against `GET /wiki/rest/api/content/{id}/child/comment?location=inline&expand=body.view` (the known v2 inline-comment 404 bug per the analysis). Return the v1 response normalized into a `{"results": [...]}` envelope."* + - The plan never specifies what happens if v1 also returns 404 (i.e. the page genuinely doesn't exist or has no inline comments). Two plausible behaviours: + - (a) Return the `{"status": "not_found", "id": "...", "upstream_status": 404}` envelope (matching every other read method). + - (b) Return an empty `{"results": []}` envelope (since "no comments" and "not found" are different states and v1 distinguishes them). + - Task 4-2 acceptance lists *"v1 inline-comment fallback fires on v2 404 with `used_fallback` flag observable"* but does not include a fixture for the v2-404 + v1-404 case. Without this, the implementer is left to make the call without a written contract, and the route layer's 404-envelope semantics (Task 2-4) cannot be tested deterministically. + - This also intersects with risk R3 — the risk_analyst's mitigation suggested distinguishing v2 inline-404 (the bug) from a real not-found by inspecting the response body shape, which I flagged in my risk_analyst review as fragile. The "always retry once on 404 and surface as not_found if v1 also 404s" pattern is the correct simpler approach but the plan needs to say so. + + **Fix**: Edit Task 1-2 to specify: *"If v2 returns 404 and the v1 retry also returns 404, return the standard `{"status": "not_found", "id": "...", "upstream_status": 404}` envelope. If v2 returns 404 and v1 returns 200 with an empty results list, return the v1 payload with `used_fallback=true` (distinguishes "v2 bug + page exists with no comments" from "page actually not found")."* Add a corresponding fixture to TASK-4-2's acceptance grid covering the v2-404 + v1-404 case. + +### Non-blocking + +- **`E5 tweak` terminology mismatch.** Tasks 1-2 acceptance and 4-2 description refer to *"decision E5 tweak"*. The contract numbering is `decision-5` (analysis option labels were `E1`/`E2`/`E3`/`E4`, never `E5`). Cosmetic; will confuse anyone grepping the audit trail. **Fix**: replace `E5` with `decision-5` (or `option E1` if referencing the analysis label). Eight occurrences across the file. + +- **Boot-time policy-size INFO log is mis-placed.** Task 1-2 puts the boot-time observability logging inside `gateway/confluence_client.py` at module import (*"the credential / policy / client managers log a single INFO line each summarising the loaded state"*). The allowlist size lives in `confluence_policy.py`, not the client; the client at import time has no reason to know the policy size. Risk R12's mitigation expected the policy module to emit it. **Fix**: move the "allowlist loaded: N keys" log line to `gateway/confluence_policy.py`'s first `allowed_spaces()` call (or to `_reload_all_config()` in gateway.py at startup), and keep the client's log line scoped to credentials + body-format default. + +- **Shared rate-limit pool risk (R10) is undocumented in the wrapper reference.** The plan implements per-call 429 retry but doesn't surface that consolidating to a single Atlassian bot account (decision-9) means Jira and Confluence now share the points-based quota. Two unrelated pipelines reading from Jira and Confluence simultaneously can throttle each other. Task 6-4 (`docs/reference/confluence-wrapper.md`) mentions a rate-limit runbook but doesn't name the pool-sharing consequence. **Fix**: add a sentence to TASK-6-4: *"Note: the bot account that owns Confluence access is the same principal as the Jira bot (per decision-9), so 429s from Atlassian's points-based quota are pooled across `/api/v1/jira/*` and `/api/v1/confluence/*` traffic. Operators seeing routine throttling on one service should expect it to manifest on the other and may want to provision a dedicated Confluence-only bot in a follow-up."* + +- **`spaceKey → spaceId` resolution caching is one-sided.** Task 2-1 introduces a 60s page→space cache (architect Q2 mitigation), but Task 2-5 (`/space/pages`) needs the inverse mapping (`spaceKey → spaceId`) and gets it via `ConfluenceClient.list_spaces(...)` — one upstream call per request. The plan doesn't extend the cache to the spaceKey↔spaceId map, so cold-start `/space/pages` calls have a guaranteed double round-trip. **Fix**: have TASK-1-2 declare a single spaceId↔spaceKey LRU populated by both `list_spaces` and `get_page` so subsequent `/space/pages` calls reuse it; or explicitly accept the cost in TASK-2-5's acceptance criteria. + +- **Architect Q4 (shared credential extraction) is silently dropped.** The architect raised Q4 (whether to share credential-loading helpers between `confluence_credentials.py` and `jira_credentials.py`) and recommended deferring. The plan implements deferral implicitly but doesn't reference Q4 anywhere. **Fix**: add a one-line note in TASK-1-1 acceptance: *"Per architect Q4, no shared `atlassian_credentials.py` helper is extracted in v1; both modules duplicate the loader skeleton for review clarity. Track shared extraction in a follow-up backlog item."* + +- **Manual verification step 4 talks about `PRIVATE_MODE` env var, but the gateway uses `g.session_mode == "private"` (set by `@require_session_auth`).** Step 4 of "Test Strategy → Manual" reads: *"Repeat with `PRIVATE_MODE` unset / public mode and confirm 403 …"*. There is no `PRIVATE_MODE` env var; private mode is a session-level attribute. **Fix**: rephrase to *"Repeat with a public-mode session token (or one created without `--private`) and confirm 403 with `endpoint requires private network mode`."* + +- **TASK-4-7 reuses an existing test name.** Plan says *"the existing `test_atlassian_domains_absent` test"*. I did not verify this name exists in `gateway/tests/test_allowed_domains.py`; if the actual test is named differently, the planner should either rename it or describe the test's behaviour by location rather than by name. Cheap to verify at implement time. **Fix**: have TASK-4-7's coder confirm the existing test name before extending it. + +- **No task to update `sandbox/agent-config/commands/show-metrics.md`** (lines 12 + 36) which currently references the legacy `~/context-sync/confluence/` cache. The plan correctly leaves this as out-of-scope (it's the legacy syncer cache, not the new gateway), but a reader landing in the file post-merge will be confused about which `confluence/` path is current. **Fix**: add a one-line task to TASK-6-3 noting that `show-metrics.md` is intentionally untouched because the syncer cache and the gateway wrapper are independent surfaces. + +- **R10 retry-cap audit field naming.** Task 1-2 says *"Audit `confluence_upstream_rate_limited` on both 429s including the `Retry-After` value and path."* but doesn't specify the `attempt` field that R10's mitigation explicitly required (*"Audit log records `confluence_upstream_rate_limited` with the Retry-After value and `attempt` index."*). Without `attempt: 1|2` operators can't tell from the log whether the retry succeeded. **Fix**: add `attempt: int (1 or 2)` to the audit shape in TASK-1-2. + +- **Task 2-9 audit event name `confluence_config_reloaded` is one event for both reloads.** That's reasonable but worth noting it loses the granularity the Jira reload presumably has (e.g., `jira_config_reloaded` may emit one event for credentials and one for policy). If the Jira pattern emits two events, copy that shape; if it emits one, this is consistent. Cheap to verify at implement time. + + +````yaml +id: 951b9985-f331-45 +phase: plan +metadata: + payload: + reason: "\nReviewed `.egg-state/drafts/1931-plan.md` (1342 lines, 6 phases, 28\ + \ tasks) end-to-end against the analysis, the architect output, the risk_analyst\ + \ output, and the existing Jira sister files (`gateway/jira_credentials.py:134-137`,\ + \ `config/secrets.template.env:91-99`, `tests/sandbox/test_jira_wrapper.py`).\n\ + \nThe plan is overall strong \u2014 task decomposition is granular and assigns\ + \ roles (coder / tester / documenter); each task has explicit acceptance criteria\ + \ that reference the covering test task; the dependency graph is correct (Phase\ + \ 1 foundation \u2192 Phase 2 routes \u2192 Phase 3 sandbox wrapper \u2192 Phase\ + \ 4 tests \u2192 Phase 5 config \u2192 Phase 6 docs); risk mitigations from\ + \ R2/R6/R7/R8/R10/R11/R13/R14/R15/R16/R17 are threaded into specific tasks (5\ + \ MiB payload cap \u2192 Task 1-2 + 2-1 + 4-5; descendants `depth=1`/`limit=25`\ + \ defaults \u2192 Task 2-2 + 4-5; ADF redaction recursion \u2192 Task 1-2 +\ + \ 4-2; Confluence-original fixtures \u2192 Task 4-2; route-vs-execute anti-bypass\ + \ \u2192 Task 4-5; structured `bot_account_lacks_read_access` envelope \u2192\ + \ Task 1-2 + 2-1 + 4-5). Adversarial CQL test grid (13+ cases) is reproduced\ + \ exactly from Task 1-4 into Task 4-4 / 4-5. The PR description and test plan\ + \ are publication-ready.\n\n### Blocking\n\n1. **Plan promises a migration story\ + \ that the implementation as planned cannot deliver \u2014 `gateway/jira_credentials.py`\ + \ is not modified.**\n\n - The plan's \"Manual Pre/Post-Merge Steps \u2192\ + \ Pre-merge\" section says (line 599): *\"If migrating off independent JIRA_*\ + \ + CONFLUENCE_* triples to the shared ATLASSIAN_* triple, operators may copy\ + \ the same value into all three triples during the cutover **and remove the\ + \ legacy keys later** \u2014 the loader prefers ATLASSIAN_* per key.\"*\n \ + \ - Task 5-2 says: *\"Keep the existing JIRA_* and CONFLUENCE_* blocks intact\ + \ for back-compat (the loader prefers ATLASSIAN_* per key but falls back to\ + \ either prefix).\"*\n - Both passages imply the gateway has a single shared\ + \ loader (or two loaders that both honour `ATLASSIAN_*`), but Task 1-1 only\ + \ writes precedence into the **new** `gateway/confluence_credentials.py` (`ATLASSIAN_*`\ + \ \u2192 `CONFLUENCE_*`). The plan does **not** include any task to update `gateway/jira_credentials.py`.\n\ + \ - Verified directly: `gateway/jira_credentials.py:134-137` today reads only\ + \ `secrets[\"JIRA_BASE_URL\"]`, `secrets[\"JIRA_USERNAME\"]`, `secrets[\"JIRA_API_TOKEN\"\ + ]`. There is no fallback to `ATLASSIAN_*`.\n - Consequence: an operator who\ + \ follows the documented pre-merge path and removes the legacy `JIRA_*` keys\ + \ after copying values to `ATLASSIAN_*` will silently break Jira (gateway returns\ + \ 503 on every `/api/v1/jira/*` call). The shared-credential premise of decision-6\ + \ is violated.\n - This is also what risk R11's mitigation explicitly demanded\ + \ (`risk_analyst-output.json` R11: *\"gateway/jira_credentials.py \u2014 same\ + \ fall-back chain (must be updated to read ATLASSIAN_* preferentially)\"*) and\ + \ what the architect's TD5 `shared_credential_invariant` describes (*\"Both\ + \ confluence_credentials.py and jira_credentials.py read the same secrets.env\ + \ file. When ATLASSIAN_* is set, both use it\"*). The architect's `files_intentionally_unchanged`\ + \ list contradictorily lists `jira_credentials.py` as unchanged \u2014 the plan\ + \ inherited the architect's contradiction without resolving it.\n\n **Fix**:\ + \ Add a new task under Phase 1 (e.g. TASK-1-5) to update `gateway/jira_credentials.py`\ + \ to read `ATLASSIAN_BASE_URL` / `ATLASSIAN_USERNAME` / `ATLASSIAN_API_TOKEN`\ + \ preferentially with `JIRA_*` per-key fallback (mirror the precedence shape\ + \ used in TASK-1-1). Add a corresponding test addition in `gateway/tests/test_jira_credentials.py`\ + \ covering the six combinations (ATLASSIAN-only, JIRA-only, mixed per-key) under\ + \ TASK-4-1 or a new TASK-4-1b. Update the pre-merge note in the PR description\ + \ (line 599 of the plan) to reflect that both modules now honour the precedence.\ + \ Without this, decision-6's \"shared credential invariant\" cannot ship in\ + \ this PR \u2014 the planner must either include the change or retract the migration\ + \ narrative.\n\n2. **Inline-comment fallback fall-through behaviour is unspecified\ + \ \u2014 Task 1-2 + 2-4 do not state what happens when v1 also returns 404.**\n\ + \n - Task 1-2 says: *\"`get_page_inline_comments(page_id, body_format=(\"\ + storage\",))` \u2192 `GET /wiki/api/v2/pages/{id}/inline-comments`. **v2 \u2192\ + \ v1 fallback**: if v2 returns 404, retry transparently against `GET /wiki/rest/api/content/{id}/child/comment?location=inline&expand=body.view`\ + \ (the known v2 inline-comment 404 bug per the analysis). Return the v1 response\ + \ normalized into a `{\"results\": [...]}` envelope.\"*\n - The plan never\ + \ specifies what happens if v1 also returns 404 (i.e. the page genuinely doesn't\ + \ exist or has no inline comments). Two plausible behaviours:\n - (a) Return\ + \ the `{\"status\": \"not_found\", \"id\": \"...\", \"upstream_status\": 404}`\ + \ envelope (matching every other read method).\n - (b) Return an empty `{\"\ + results\": []}` envelope (since \"no comments\" and \"not found\" are different\ + \ states and v1 distinguishes them).\n - Task 4-2 acceptance lists *\"v1 inline-comment\ + \ fallback fires on v2 404 with `used_fallback` flag observable\"* but does\ + \ not include a fixture for the v2-404 + v1-404 case. Without this, the implementer\ + \ is left to make the call without a written contract, and the route layer's\ + \ 404-envelope semantics (Task 2-4) cannot be tested deterministically.\n \ + \ - This also intersects with risk R3 \u2014 the risk_analyst's mitigation suggested\ + \ distinguishing v2 inline-404 (the bug) from a real not-found by inspecting\ + \ the response body shape, which I flagged in my risk_analyst review as fragile.\ + \ The \"always retry once on 404 and surface as not_found if v1 also 404s\"\ + \ pattern is the correct simpler approach but the plan needs to say so.\n\n\ + \ **Fix**: Edit Task 1-2 to specify: *\"If v2 returns 404 and the v1 retry\ + \ also returns 404, return the standard `{\"status\": \"not_found\", \"id\"\ + : \"...\", \"upstream_status\": 404}` envelope. If v2 returns 404 and v1 returns\ + \ 200 with an empty results list, return the v1 payload with `used_fallback=true`\ + \ (distinguishes \"v2 bug + page exists with no comments\" from \"page actually\ + \ not found\").\"* Add a corresponding fixture to TASK-4-2's acceptance grid\ + \ covering the v2-404 + v1-404 case.\n\n### Non-blocking\n\n- **`E5 tweak` terminology\ + \ mismatch.** Tasks 1-2 acceptance and 4-2 description refer to *\"decision\ + \ E5 tweak\"*. The contract numbering is `decision-5` (analysis option labels\ + \ were `E1`/`E2`/`E3`/`E4`, never `E5`). Cosmetic; will confuse anyone grepping\ + \ the audit trail. **Fix**: replace `E5` with `decision-5` (or `option E1` if\ + \ referencing the analysis label). Eight occurrences across the file.\n\n- **Boot-time\ + \ policy-size INFO log is mis-placed.** Task 1-2 puts the boot-time observability\ + \ logging inside `gateway/confluence_client.py` at module import (*\"the credential\ + \ / policy / client managers log a single INFO line each summarising the loaded\ + \ state\"*). The allowlist size lives in `confluence_policy.py`, not the client;\ + \ the client at import time has no reason to know the policy size. Risk R12's\ + \ mitigation expected the policy module to emit it. **Fix**: move the \"allowlist\ + \ loaded: N keys\" log line to `gateway/confluence_policy.py`'s first `allowed_spaces()`\ + \ call (or to `_reload_all_config()` in gateway.py at startup), and keep the\ + \ client's log line scoped to credentials + body-format default.\n\n- **Shared\ + \ rate-limit pool risk (R10) is undocumented in the wrapper reference.** The\ + \ plan implements per-call 429 retry but doesn't surface that consolidating\ + \ to a single Atlassian bot account (decision-9) means Jira and Confluence now\ + \ share the points-based quota. Two unrelated pipelines reading from Jira and\ + \ Confluence simultaneously can throttle each other. Task 6-4 (`docs/reference/confluence-wrapper.md`)\ + \ mentions a rate-limit runbook but doesn't name the pool-sharing consequence.\ + \ **Fix**: add a sentence to TASK-6-4: *\"Note: the bot account that owns Confluence\ + \ access is the same principal as the Jira bot (per decision-9), so 429s from\ + \ Atlassian's points-based quota are pooled across `/api/v1/jira/*` and `/api/v1/confluence/*`\ + \ traffic. Operators seeing routine throttling on one service should expect\ + \ it to manifest on the other and may want to provision a dedicated Confluence-only\ + \ bot in a follow-up.\"*\n\n- **`spaceKey \u2192 spaceId` resolution caching\ + \ is one-sided.** Task 2-1 introduces a 60s page\u2192space cache (architect\ + \ Q2 mitigation), but Task 2-5 (`/space/pages`) needs the inverse mapping (`spaceKey\ + \ \u2192 spaceId`) and gets it via `ConfluenceClient.list_spaces(...)` \u2014\ + \ one upstream call per request. The plan doesn't extend the cache to the spaceKey\u2194\ + spaceId map, so cold-start `/space/pages` calls have a guaranteed double round-trip.\ + \ **Fix**: have TASK-1-2 declare a single spaceId\u2194spaceKey LRU populated\ + \ by both `list_spaces` and `get_page` so subsequent `/space/pages` calls reuse\ + \ it; or explicitly accept the cost in TASK-2-5's acceptance criteria.\n\n-\ + \ **Architect Q4 (shared credential extraction) is silently dropped.** The architect\ + \ raised Q4 (whether to share credential-loading helpers between `confluence_credentials.py`\ + \ and `jira_credentials.py`) and recommended deferring. The plan implements\ + \ deferral implicitly but doesn't reference Q4 anywhere. **Fix**: add a one-line\ + \ note in TASK-1-1 acceptance: *\"Per architect Q4, no shared `atlassian_credentials.py`\ + \ helper is extracted in v1; both modules duplicate the loader skeleton for\ + \ review clarity. Track shared extraction in a follow-up backlog item.\"*\n\n\ + - **Manual verification step 4 talks about `PRIVATE_MODE` env var, but the gateway\ + \ uses `g.session_mode == \"private\"` (set by `@require_session_auth`).** Step\ + \ 4 of \"Test Strategy \u2192 Manual\" reads: *\"Repeat with `PRIVATE_MODE`\ + \ unset / public mode and confirm 403 \u2026\"*. There is no `PRIVATE_MODE`\ + \ env var; private mode is a session-level attribute. **Fix**: rephrase to *\"\ + Repeat with a public-mode session token (or one created without `--private`)\ + \ and confirm 403 with `endpoint requires private network mode`.\"*\n\n- **TASK-4-7\ + \ reuses an existing test name.** Plan says *\"the existing `test_atlassian_domains_absent`\ + \ test\"*. I did not verify this name exists in `gateway/tests/test_allowed_domains.py`;\ + \ if the actual test is named differently, the planner should either rename\ + \ it or describe the test's behaviour by location rather than by name. Cheap\ + \ to verify at implement time. **Fix**: have TASK-4-7's coder confirm the existing\ + \ test name before extending it.\n\n- **No task to update `sandbox/agent-config/commands/show-metrics.md`**\ + \ (lines 12 + 36) which currently references the legacy `~/context-sync/confluence/`\ + \ cache. The plan correctly leaves this as out-of-scope (it's the legacy syncer\ + \ cache, not the new gateway), but a reader landing in the file post-merge will\ + \ be confused about which `confluence/` path is current. **Fix**: add a one-line\ + \ task to TASK-6-3 noting that `show-metrics.md` is intentionally untouched\ + \ because the syncer cache and the gateway wrapper are independent surfaces.\n\ + \n- **R10 retry-cap audit field naming.** Task 1-2 says *\"Audit `confluence_upstream_rate_limited`\ + \ on both 429s including the `Retry-After` value and path.\"* but doesn't specify\ + \ the `attempt` field that R10's mitigation explicitly required (*\"Audit log\ + \ records `confluence_upstream_rate_limited` with the Retry-After value and\ + \ `attempt` index.\"*). Without `attempt: 1|2` operators can't tell from the\ + \ log whether the retry succeeded. **Fix**: add `attempt: int (1 or 2)` to the\ + \ audit shape in TASK-1-2.\n\n- **Task 2-9 audit event name `confluence_config_reloaded`\ + \ is one event for both reloads.** That's reasonable but worth noting it loses\ + \ the granularity the Jira reload presumably has (e.g., `jira_config_reloaded`\ + \ may emit one event for credentials and one for policy). If the Jira pattern\ + \ emits two events, copy that shape; if it emits one, this is consistent. Cheap\ + \ to verify at implement time.\n" + artifact_references: + - .egg-state/drafts/1931-plan.md + - .egg-state/agent-outputs/1931-architect-output.json + - .egg-state/agent-outputs/1931-risk_analyst-output.json + - .egg-state/drafts/1931-analysis.md + - gateway/jira_credentials.py + - config/secrets.template.env + - config/context-filters.yaml + - tests/sandbox/test_jira_wrapper.py + reason: "\nReviewed `.egg-state/drafts/1931-plan.md` (1342 lines, 6 phases, 28 tasks)\ + \ end-to-end against the analysis, the architect output, the risk_analyst output,\ + \ and the existing Jira sister files (`gateway/jira_credentials.py:134-137`, `config/secrets.template.env:91-99`,\ + \ `tests/sandbox/test_jira_wrapper.py`).\n\nThe plan is overall strong \u2014\ + \ task decomposition is granular and assigns roles (coder / tester / documenter);\ + \ each task has explicit acceptance criteria that reference the covering test\ + \ task; the dependency graph is correct (Phase 1 foundation \u2192 Phase 2 routes\ + \ \u2192 Phase 3 sandbox wrapper \u2192 Phase 4 tests \u2192 Phase 5 config \u2192\ + \ Phase 6 docs); risk mitigations from R2/R6/R7/R8/R10/R11/R13/R14/R15/R16/R17\ + \ are threaded into specific tasks (5 MiB payload cap \u2192 Task 1-2 + 2-1 +\ + \ 4-5; descendants `depth=1`/`limit=25` defaults \u2192 Task 2-2 + 4-5; ADF redaction\ + \ recursion \u2192 Task 1-2 + 4-2; Confluence-original fixtures \u2192 Task 4-2;\ + \ route-vs-execute anti-bypass \u2192 Task 4-5; structured `bot_account_lacks_read_access`\ + \ envelope \u2192 Task 1-2 + 2-1 + 4-5). Adversarial CQL test grid (13+ cases)\ + \ is reproduced exactly from Task 1-4 into Task 4-4 / 4-5. The PR description\ + \ and test plan are publication-ready.\n\n### Blocking\n\n1. **Plan promises a\ + \ migration story that the implementation as planned cannot deliver \u2014 `gateway/jira_credentials.py`\ + \ is not modified.**\n\n - The plan's \"Manual Pre/Post-Merge Steps \u2192 Pre-merge\"\ + \ section says (line 599): *\"If migrating off independent JIRA_* + CONFLUENCE_*\ + \ triples to the shared ATLASSIAN_* triple, operators may copy the same value\ + \ into all three triples during the cutover **and remove the legacy keys later**\ + \ \u2014 the loader prefers ATLASSIAN_* per key.\"*\n - Task 5-2 says: *\"Keep\ + \ the existing JIRA_* and CONFLUENCE_* blocks intact for back-compat (the loader\ + \ prefers ATLASSIAN_* per key but falls back to either prefix).\"*\n - Both\ + \ passages imply the gateway has a single shared loader (or two loaders that both\ + \ honour `ATLASSIAN_*`), but Task 1-1 only writes precedence into the **new**\ + \ `gateway/confluence_credentials.py` (`ATLASSIAN_*` \u2192 `CONFLUENCE_*`). The\ + \ plan does **not** include any task to update `gateway/jira_credentials.py`.\n\ + \ - Verified directly: `gateway/jira_credentials.py:134-137` today reads only\ + \ `secrets[\"JIRA_BASE_URL\"]`, `secrets[\"JIRA_USERNAME\"]`, `secrets[\"JIRA_API_TOKEN\"\ + ]`. There is no fallback to `ATLASSIAN_*`.\n - Consequence: an operator who\ + \ follows the documented pre-merge path and removes the legacy `JIRA_*` keys after\ + \ copying values to `ATLASSIAN_*` will silently break Jira (gateway returns 503\ + \ on every `/api/v1/jira/*` call). The shared-credential premise of decision-6\ + \ is violated.\n - This is also what risk R11's mitigation explicitly demanded\ + \ (`risk_analyst-output.json` R11: *\"gateway/jira_credentials.py \u2014 same\ + \ fall-back chain (must be updated to read ATLASSIAN_* preferentially)\"*) and\ + \ what the architect's TD5 `shared_credential_invariant` describes (*\"Both confluence_credentials.py\ + \ and jira_credentials.py read the same secrets.env file. When ATLASSIAN_* is\ + \ set, both use it\"*). The architect's `files_intentionally_unchanged` list contradictorily\ + \ lists `jira_credentials.py` as unchanged \u2014 the plan inherited the architect's\ + \ contradiction without resolving it.\n\n **Fix**: Add a new task under Phase\ + \ 1 (e.g. TASK-1-5) to update `gateway/jira_credentials.py` to read `ATLASSIAN_BASE_URL`\ + \ / `ATLASSIAN_USERNAME` / `ATLASSIAN_API_TOKEN` preferentially with `JIRA_*`\ + \ per-key fallback (mirror the precedence shape used in TASK-1-1). Add a corresponding\ + \ test addition in `gateway/tests/test_jira_credentials.py` covering the six combinations\ + \ (ATLASSIAN-only, JIRA-only, mixed per-key) under TASK-4-1 or a new TASK-4-1b.\ + \ Update the pre-merge note in the PR description (line 599 of the plan) to reflect\ + \ that both modules now honour the precedence. Without this, decision-6's \"shared\ + \ credential invariant\" cannot ship in this PR \u2014 the planner must either\ + \ include the change or retract the migration narrative.\n\n2. **Inline-comment\ + \ fallback fall-through behaviour is unspecified \u2014 Task 1-2 + 2-4 do not\ + \ state what happens when v1 also returns 404.**\n\n - Task 1-2 says: *\"`get_page_inline_comments(page_id,\ + \ body_format=(\"storage\",))` \u2192 `GET /wiki/api/v2/pages/{id}/inline-comments`.\ + \ **v2 \u2192 v1 fallback**: if v2 returns 404, retry transparently against `GET\ + \ /wiki/rest/api/content/{id}/child/comment?location=inline&expand=body.view`\ + \ (the known v2 inline-comment 404 bug per the analysis). Return the v1 response\ + \ normalized into a `{\"results\": [...]}` envelope.\"*\n - The plan never specifies\ + \ what happens if v1 also returns 404 (i.e. the page genuinely doesn't exist or\ + \ has no inline comments). Two plausible behaviours:\n - (a) Return the `{\"\ + status\": \"not_found\", \"id\": \"...\", \"upstream_status\": 404}` envelope\ + \ (matching every other read method).\n - (b) Return an empty `{\"results\"\ + : []}` envelope (since \"no comments\" and \"not found\" are different states\ + \ and v1 distinguishes them).\n - Task 4-2 acceptance lists *\"v1 inline-comment\ + \ fallback fires on v2 404 with `used_fallback` flag observable\"* but does not\ + \ include a fixture for the v2-404 + v1-404 case. Without this, the implementer\ + \ is left to make the call without a written contract, and the route layer's 404-envelope\ + \ semantics (Task 2-4) cannot be tested deterministically.\n - This also intersects\ + \ with risk R3 \u2014 the risk_analyst's mitigation suggested distinguishing v2\ + \ inline-404 (the bug) from a real not-found by inspecting the response body shape,\ + \ which I flagged in my risk_analyst review as fragile. The \"always retry once\ + \ on 404 and surface as not_found if v1 also 404s\" pattern is the correct simpler\ + \ approach but the plan needs to say so.\n\n **Fix**: Edit Task 1-2 to specify:\ + \ *\"If v2 returns 404 and the v1 retry also returns 404, return the standard\ + \ `{\"status\": \"not_found\", \"id\": \"...\", \"upstream_status\": 404}` envelope.\ + \ If v2 returns 404 and v1 returns 200 with an empty results list, return the\ + \ v1 payload with `used_fallback=true` (distinguishes \"v2 bug + page exists with\ + \ no comments\" from \"page actually not found\").\"* Add a corresponding fixture\ + \ to TASK-4-2's acceptance grid covering the v2-404 + v1-404 case.\n\n### Non-blocking\n\ + \n- **`E5 tweak` terminology mismatch.** Tasks 1-2 acceptance and 4-2 description\ + \ refer to *\"decision E5 tweak\"*. The contract numbering is `decision-5` (analysis\ + \ option labels were `E1`/`E2`/`E3`/`E4`, never `E5`). Cosmetic; will confuse\ + \ anyone grepping the audit trail. **Fix**: replace `E5` with `decision-5` (or\ + \ `option E1` if referencing the analysis label). Eight occurrences across the\ + \ file.\n\n- **Boot-time policy-size INFO log is mis-placed.** Task 1-2 puts the\ + \ boot-time observability logging inside `gateway/confluence_client.py` at module\ + \ import (*\"the credential / policy / client managers log a single INFO line\ + \ each summarising the loaded state\"*). The allowlist size lives in `confluence_policy.py`,\ + \ not the client; the client at import time has no reason to know the policy size.\ + \ Risk R12's mitigation expected the policy module to emit it. **Fix**: move the\ + \ \"allowlist loaded: N keys\" log line to `gateway/confluence_policy.py`'s first\ + \ `allowed_spaces()` call (or to `_reload_all_config()` in gateway.py at startup),\ + \ and keep the client's log line scoped to credentials + body-format default.\n\ + \n- **Shared rate-limit pool risk (R10) is undocumented in the wrapper reference.**\ + \ The plan implements per-call 429 retry but doesn't surface that consolidating\ + \ to a single Atlassian bot account (decision-9) means Jira and Confluence now\ + \ share the points-based quota. Two unrelated pipelines reading from Jira and\ + \ Confluence simultaneously can throttle each other. Task 6-4 (`docs/reference/confluence-wrapper.md`)\ + \ mentions a rate-limit runbook but doesn't name the pool-sharing consequence.\ + \ **Fix**: add a sentence to TASK-6-4: *\"Note: the bot account that owns Confluence\ + \ access is the same principal as the Jira bot (per decision-9), so 429s from\ + \ Atlassian's points-based quota are pooled across `/api/v1/jira/*` and `/api/v1/confluence/*`\ + \ traffic. Operators seeing routine throttling on one service should expect it\ + \ to manifest on the other and may want to provision a dedicated Confluence-only\ + \ bot in a follow-up.\"*\n\n- **`spaceKey \u2192 spaceId` resolution caching is\ + \ one-sided.** Task 2-1 introduces a 60s page\u2192space cache (architect Q2 mitigation),\ + \ but Task 2-5 (`/space/pages`) needs the inverse mapping (`spaceKey \u2192 spaceId`)\ + \ and gets it via `ConfluenceClient.list_spaces(...)` \u2014 one upstream call\ + \ per request. The plan doesn't extend the cache to the spaceKey\u2194spaceId\ + \ map, so cold-start `/space/pages` calls have a guaranteed double round-trip.\ + \ **Fix**: have TASK-1-2 declare a single spaceId\u2194spaceKey LRU populated\ + \ by both `list_spaces` and `get_page` so subsequent `/space/pages` calls reuse\ + \ it; or explicitly accept the cost in TASK-2-5's acceptance criteria.\n\n- **Architect\ + \ Q4 (shared credential extraction) is silently dropped.** The architect raised\ + \ Q4 (whether to share credential-loading helpers between `confluence_credentials.py`\ + \ and `jira_credentials.py`) and recommended deferring. The plan implements deferral\ + \ implicitly but doesn't reference Q4 anywhere. **Fix**: add a one-line note in\ + \ TASK-1-1 acceptance: *\"Per architect Q4, no shared `atlassian_credentials.py`\ + \ helper is extracted in v1; both modules duplicate the loader skeleton for review\ + \ clarity. Track shared extraction in a follow-up backlog item.\"*\n\n- **Manual\ + \ verification step 4 talks about `PRIVATE_MODE` env var, but the gateway uses\ + \ `g.session_mode == \"private\"` (set by `@require_session_auth`).** Step 4 of\ + \ \"Test Strategy \u2192 Manual\" reads: *\"Repeat with `PRIVATE_MODE` unset /\ + \ public mode and confirm 403 \u2026\"*. There is no `PRIVATE_MODE` env var; private\ + \ mode is a session-level attribute. **Fix**: rephrase to *\"Repeat with a public-mode\ + \ session token (or one created without `--private`) and confirm 403 with `endpoint\ + \ requires private network mode`.\"*\n\n- **TASK-4-7 reuses an existing test name.**\ + \ Plan says *\"the existing `test_atlassian_domains_absent` test\"*. I did not\ + \ verify this name exists in `gateway/tests/test_allowed_domains.py`; if the actual\ + \ test is named differently, the planner should either rename it or describe the\ + \ test's behaviour by location rather than by name. Cheap to verify at implement\ + \ time. **Fix**: have TASK-4-7's coder confirm the existing test name before extending\ + \ it.\n\n- **No task to update `sandbox/agent-config/commands/show-metrics.md`**\ + \ (lines 12 + 36) which currently references the legacy `~/context-sync/confluence/`\ + \ cache. The plan correctly leaves this as out-of-scope (it's the legacy syncer\ + \ cache, not the new gateway), but a reader landing in the file post-merge will\ + \ be confused about which `confluence/` path is current. **Fix**: add a one-line\ + \ task to TASK-6-3 noting that `show-metrics.md` is intentionally untouched because\ + \ the syncer cache and the gateway wrapper are independent surfaces.\n\n- **R10\ + \ retry-cap audit field naming.** Task 1-2 says *\"Audit `confluence_upstream_rate_limited`\ + \ on both 429s including the `Retry-After` value and path.\"* but doesn't specify\ + \ the `attempt` field that R10's mitigation explicitly required (*\"Audit log\ + \ records `confluence_upstream_rate_limited` with the Retry-After value and `attempt`\ + \ index.\"*). Without `attempt: 1|2` operators can't tell from the log whether\ + \ the retry succeeded. **Fix**: add `attempt: int (1 or 2)` to the audit shape\ + \ in TASK-1-2.\n\n- **Task 2-9 audit event name `confluence_config_reloaded` is\ + \ one event for both reloads.** That's reasonable but worth noting it loses the\ + \ granularity the Jira reload presumably has (e.g., `jira_config_reloaded` may\ + \ emit one event for credentials and one for policy). If the Jira pattern emits\ + \ two events, copy that shape; if it emits one, this is consistent. Cheap to verify\ + \ at implement time.\n" + revision_count: 1 +```` + +### [2026-04-27T00:03:18Z] task_planner (HEARTBEAT): heartbeat: WORKING + +wait_loop exited + +````yaml +id: f8a83d6a-a482-49 +phase: plan +metadata: + state: WORKING +```` + +### [2026-04-27T00:03:20Z] architect (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_CONFIRMED,CONSENSUS_RE_REVIEW,OVERSEER_ALERT,CONSENSUS_NACK + +````yaml +id: b7cfb652-7ec3-4f +phase: plan +metadata: + state: WAITING_FOR_EVENT + since: '2026-04-26T23:59:16.347020+00:00' +```` + +### [2026-04-27T00:03:26Z] reviewer_plan (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_PROPOSE,CONSENSUS_RE_REVIEW,CONSENSUS_CONFIRMED,OVERSEER_ALERT + +````yaml +id: c5669aa5-9c70-41 +phase: plan +metadata: + state: WAITING_FOR_EVENT + since: '2026-04-27T00:03:25.968737+00:00' +```` + +### [2026-04-27T00:03:26Z] reviewer_plan (HEARTBEAT): heartbeat: WORKING + +wait_loop exited + +````yaml +id: fddeb1f6-59c8-49 +phase: plan +metadata: + state: WORKING +```` + +### [2026-04-27T00:03:26Z] risk_analyst (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_CONFIRMED,CONSENSUS_RE_REVIEW,OVERSEER_ALERT + +````yaml +id: ad409015-e71b-49 +phase: plan +metadata: + state: WAITING_FOR_EVENT + since: '2026-04-26T23:59:25.536825+00:00' +```` + +### [2026-04-27T00:03:30Z] reviewer_plan (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_PROPOSE,CONSENSUS_RE_REVIEW,CONSENSUS_CONFIRMED,OVERSEER_ALERT + +````yaml +id: a0a5db80-526f-42 +phase: plan +metadata: + state: WAITING_FOR_EVENT + since: '2026-04-27T00:03:30.140522+00:00' +```` + +### [2026-04-27T00:04:20Z] architect (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_CONFIRMED,CONSENSUS_RE_REVIEW,OVERSEER_ALERT,CONSENSUS_NACK + +````yaml +id: 1368cb4f-ed4a-4e +phase: plan +metadata: + state: WAITING_FOR_EVENT + since: '2026-04-26T23:59:16.347020+00:00' +```` + +### [2026-04-27T00:04:26Z] risk_analyst (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_CONFIRMED,CONSENSUS_RE_REVIEW,OVERSEER_ALERT + +````yaml +id: 9c33fcbe-56fe-49 +phase: plan +metadata: + state: WAITING_FOR_EVENT + since: '2026-04-26T23:59:25.536825+00:00' +```` + +### [2026-04-27T00:04:30Z] reviewer_plan (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_PROPOSE,CONSENSUS_RE_REVIEW,CONSENSUS_CONFIRMED,OVERSEER_ALERT + +````yaml +id: 94511037-6d5e-43 +phase: plan +metadata: + state: WAITING_FOR_EVENT + since: '2026-04-27T00:03:30.140522+00:00' +```` + +### [2026-04-27T00:05:20Z] architect (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_CONFIRMED,CONSENSUS_RE_REVIEW,OVERSEER_ALERT,CONSENSUS_NACK + +````yaml +id: 1f42babf-b8fc-4e +phase: plan +metadata: + state: WAITING_FOR_EVENT + since: '2026-04-26T23:59:16.347020+00:00' +```` + +### [2026-04-27T00:05:26Z] risk_analyst (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_CONFIRMED,CONSENSUS_RE_REVIEW,OVERSEER_ALERT + +````yaml +id: 57624ed8-86f6-47 +phase: plan +metadata: + state: WAITING_FOR_EVENT + since: '2026-04-26T23:59:25.536825+00:00' +```` + +### [2026-04-27T00:05:30Z] reviewer_plan (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_PROPOSE,CONSENSUS_RE_REVIEW,CONSENSUS_CONFIRMED,OVERSEER_ALERT + +````yaml +id: 12df28f2-705b-4e +phase: plan +metadata: + state: WAITING_FOR_EVENT + since: '2026-04-27T00:03:30.140522+00:00' +```` + +### [2026-04-27T00:06:20Z] architect (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_CONFIRMED,CONSENSUS_RE_REVIEW,OVERSEER_ALERT,CONSENSUS_NACK + +````yaml +id: a2f17346-5a00-49 +phase: plan +metadata: + state: WAITING_FOR_EVENT + since: '2026-04-26T23:59:16.347020+00:00' +```` + +### [2026-04-27T00:06:27Z] risk_analyst (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_CONFIRMED,CONSENSUS_RE_REVIEW,OVERSEER_ALERT + +````yaml +id: 75744ffe-74fc-4c +phase: plan +metadata: + state: WAITING_FOR_EVENT + since: '2026-04-26T23:59:25.536825+00:00' +```` + +### [2026-04-27T00:06:30Z] reviewer_plan (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_PROPOSE,CONSENSUS_RE_REVIEW,CONSENSUS_CONFIRMED,OVERSEER_ALERT + +````yaml +id: 0b59683c-2383-41 +phase: plan +metadata: + state: WAITING_FOR_EVENT + since: '2026-04-27T00:03:30.140522+00:00' +```` + +### [2026-04-27T00:07:21Z] architect (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_CONFIRMED,CONSENSUS_RE_REVIEW,OVERSEER_ALERT,CONSENSUS_NACK + +````yaml +id: f8cc1923-c2c2-45 +phase: plan +metadata: + state: WAITING_FOR_EVENT + since: '2026-04-26T23:59:16.347020+00:00' +```` + +### [2026-04-27T00:07:27Z] risk_analyst (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_CONFIRMED,CONSENSUS_RE_REVIEW,OVERSEER_ALERT + +````yaml +id: 2526f4fb-d678-49 +phase: plan +metadata: + state: WAITING_FOR_EVENT + since: '2026-04-26T23:59:25.536825+00:00' +```` + +### [2026-04-27T00:07:30Z] reviewer_plan (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_PROPOSE,CONSENSUS_RE_REVIEW,CONSENSUS_CONFIRMED,OVERSEER_ALERT + +````yaml +id: 8a3249e0-8c3b-4b +phase: plan +metadata: + state: WAITING_FOR_EVENT + since: '2026-04-27T00:03:30.140522+00:00' +```` + +### [2026-04-27T00:08:21Z] architect (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_CONFIRMED,CONSENSUS_RE_REVIEW,OVERSEER_ALERT,CONSENSUS_NACK + +````yaml +id: 0b7f1477-1b76-4e +phase: plan +metadata: + state: WAITING_FOR_EVENT + since: '2026-04-26T23:59:16.347020+00:00' +```` + +### [2026-04-27T00:08:27Z] risk_analyst (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_CONFIRMED,CONSENSUS_RE_REVIEW,OVERSEER_ALERT + +````yaml +id: 5e32cba1-0604-48 +phase: plan +metadata: + state: WAITING_FOR_EVENT + since: '2026-04-26T23:59:25.536825+00:00' +```` + +### [2026-04-27T00:08:31Z] reviewer_plan (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_PROPOSE,CONSENSUS_RE_REVIEW,CONSENSUS_CONFIRMED,OVERSEER_ALERT + +````yaml +id: 40805264-532f-46 +phase: plan +metadata: + state: WAITING_FOR_EVENT + since: '2026-04-27T00:03:30.140522+00:00' +```` + +### [2026-04-27T00:08:31Z] overseer (OVERSEER_ALERT): agent-heartbeat-stall [high] + +ESCALATION: task_planner stalled 261s post-ACK — wait_loop exited 00:03:18Z but no CONSENSUS_CONFIRMED, no heartbeats; advisor gate OPEN for issue-1931 + +Detail: +Escalating from medium (alert afe10f57) to HIGH. task_planner's wait_loop exited at 00:03:18Z (received CONSENSUS_ACK from reviewer_plan) and has been in producer_phase=WORKING with zero heartbeats for 261s (4:21). Normal post-ACK confirmation takes ~5-10s (architect and risk_analyst both confirmed within 3s of their ACKs). The three other active agents (architect, risk_analyst, reviewer_plan) are all alive with flawless 60s cadence — the stall is isolated to task_planner. + +task_planner's post-ACK work may involve: (1) writing to .egg-state/drafts/1931-plan.md, (2) running JSON validation, (3) LLM call before confirm. However 261s far exceeds any reasonable post-ACK processing window. + +Cascading effect: reviewer_plan cannot emit CONSENSUS_CONFIRMED until task_planner confirms; architect and risk_analyst have been waiting for CONFIRMED since 23:59:11Z (~8:28 elapsed). Plan phase completion is fully blocked on task_planner. + +The overseer_nack_unresolved_seconds threshold (180s) has been exceeded. + +Recommended action: Inspect task_planner container logs for its activity since 00:03:18Z. If no forward progress (no tool calls, no LLM activity), restart the task_planner container. After restart, task_planner should re-enter BRC, and reviewer_plan already has the prior ACK state so the confirm round should complete quickly. + +Recommended action: +Inspect task_planner container logs immediately. If no activity since 00:03:18Z, restart container. Plan phase consensus is fully blocked. + +````yaml +id: a999e441-b46f-4b +phase: plan +```` + +### [2026-04-27T00:08:32Z] architect (HEARTBEAT): heartbeat: WORKING + +wait_loop exited + +````yaml +id: 96841eaa-47d6-44 +phase: plan +metadata: + state: WORKING +```` + +### [2026-04-27T00:08:32Z] risk_analyst (HEARTBEAT): heartbeat: WORKING + +wait_loop exited + +````yaml +id: 417a5471-0481-4d +phase: plan +metadata: + state: WORKING +```` + +### [2026-04-27T00:08:32Z] reviewer_plan (HEARTBEAT): heartbeat: WORKING + +wait_loop exited + +````yaml +id: 6351c1d2-a655-4f +phase: plan +metadata: + state: WORKING +```` + +### [2026-04-27T00:08:36Z] risk_analyst (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_CONFIRMED,CONSENSUS_RE_REVIEW,OVERSEER_ALERT + +````yaml +id: a0a8272d-b4fc-4b +phase: plan +metadata: + state: WAITING_FOR_EVENT + since: '2026-04-27T00:08:36.620321+00:00' +```` + +### [2026-04-27T00:08:38Z] architect (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_CONFIRMED,CONSENSUS_RE_REVIEW,OVERSEER_ALERT,CONSENSUS_NACK + +````yaml +id: b7d19bf1-5714-44 +phase: plan +metadata: + state: WAITING_FOR_EVENT + since: '2026-04-27T00:08:38.630974+00:00' +```` + +### [2026-04-27T00:08:50Z] reviewer_plan (HEARTBEAT): heartbeat: WAITING_ON_ROLE + +NACKed task_planner with two blocking issues (jira_credentials.py not modified for shared ATLASSIAN_* fall-back; v2-404 + v1-404 fall-through unspecified) and 8 non-blocking observations. Awaiting re-propose. ACKed architect + risk_analyst (both already confirmed). + +````yaml +id: a4086b8c-dc01-41 +phase: plan +metadata: + state: WAITING_ON_ROLE + waiting_on: task_planner +```` + +### [2026-04-27T00:08:50Z] reviewer_plan (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_PROPOSE,CONSENSUS_RE_REVIEW,CONSENSUS_CONFIRMED,OVERSEER_ALERT + +````yaml +id: ed7f9781-5c36-45 +phase: plan +metadata: + state: WAITING_FOR_EVENT + since: '2026-04-27T00:08:50.219667+00:00' +```` + +### [2026-04-27T00:09:04Z] task_planner (CONSENSUS_PROPOSE): Proposal from task_planner + +Re-proposing plan for #1931 after addressing reviewer_plan NACK. Two blocking items resolved: (1) added TASK-1-5 to update gateway/jira_credentials.py with the same ATLASSIAN_*-preferred / JIRA_* fall-back precedence so the shared-credential promise (decision F1) is portable end-to-end — operators can drop the legacy JIRA_* block after migration without silently breaking Jira (risk R11 mitigation made concrete); paired with TASK-4-1b that extends gateway/tests/test_jira_credentials.py with the six-combination matrix; PR description and pre-merge note updated to reflect both loaders honour the precedence. (2) TASK-1-2 inline-comment fallback now specifies the v1-also-404 fall-through: v1 200 with empty results → {results: [], used_fallback=true} (page exists, no inline comments + v2 bug); v1 404 → standard {status: not_found, ..., used_fallback=true} envelope (page actually doesn't exist); v1 200 with comments → v1 payload normalized with used_fallback=true. Non-blocking fixes also applied: replaced "decision E5" with "decision-5" (8 sites), moved boot-time policy-size log from client to confluence_policy.py with credentials/policy/client each owning their own INFO line (risk R12), added `attempt: 1|2` field to confluence_upstream_rate_limited audit shape (risk R10), made the spaceId↔spaceKey LRU cache bidirectional and populated by both list_spaces and get_page so /space/pages cold-start avoids double round-trip (architect Q2), referenced architect Q4 in TASK-1-1 acceptance (no shared atlassian_credentials.py extraction in v1), replaced PRIVATE_MODE env-var references with private-mode session language, noted TASK-4-7 must verify existing test name at implement time, noted show-metrics.md intentionally untouched, added Atlassian rate-limit pool-sharing note to TASK-6-4 documentation, and made TASK-2-9 audit shape mirror the existing Jira reload (one or two events). Plan now totals 30 tasks across 6 phases. + +````yaml +id: b3178d1d-654b-41 +phase: plan +metadata: + payload: + summary: "Re-proposing plan for #1931 after addressing reviewer_plan NACK. Two\ + \ blocking items resolved: (1) added TASK-1-5 to update gateway/jira_credentials.py\ + \ with the same ATLASSIAN_*-preferred / JIRA_* fall-back precedence so the shared-credential\ + \ promise (decision F1) is portable end-to-end \u2014 operators can drop the\ + \ legacy JIRA_* block after migration without silently breaking Jira (risk R11\ + \ mitigation made concrete); paired with TASK-4-1b that extends gateway/tests/test_jira_credentials.py\ + \ with the six-combination matrix; PR description and pre-merge note updated\ + \ to reflect both loaders honour the precedence. (2) TASK-1-2 inline-comment\ + \ fallback now specifies the v1-also-404 fall-through: v1 200 with empty results\ + \ \u2192 {results: [], used_fallback=true} (page exists, no inline comments\ + \ + v2 bug); v1 404 \u2192 standard {status: not_found, ..., used_fallback=true}\ + \ envelope (page actually doesn't exist); v1 200 with comments \u2192 v1 payload\ + \ normalized with used_fallback=true. Non-blocking fixes also applied: replaced\ + \ \"decision E5\" with \"decision-5\" (8 sites), moved boot-time policy-size\ + \ log from client to confluence_policy.py with credentials/policy/client each\ + \ owning their own INFO line (risk R12), added `attempt: 1|2` field to confluence_upstream_rate_limited\ + \ audit shape (risk R10), made the spaceId\u2194spaceKey LRU cache bidirectional\ + \ and populated by both list_spaces and get_page so /space/pages cold-start\ + \ avoids double round-trip (architect Q2), referenced architect Q4 in TASK-1-1\ + \ acceptance (no shared atlassian_credentials.py extraction in v1), replaced\ + \ PRIVATE_MODE env-var references with private-mode session language, noted\ + \ TASK-4-7 must verify existing test name at implement time, noted show-metrics.md\ + \ intentionally untouched, added Atlassian rate-limit pool-sharing note to TASK-6-4\ + \ documentation, and made TASK-2-9 audit shape mirror the existing Jira reload\ + \ (one or two events). Plan now totals 30 tasks across 6 phases." + attestation: {} + artifacts: + - .egg-state/drafts/1931-plan.md + risk_considered: "All 18 risks tracked in risk_analyst output; the previously-incomplete\ + \ R11 mitigation (jira_credentials.py update) is now an explicit task (TASK-1-5\ + \ + TASK-4-1b). Inline-comment fallback fall-through (architect Q3 / R3) is\ + \ now deterministic. Boot-time observability (R12) split correctly across modules.\ + \ Rate-limit audit field (R10) includes attempt index. spaceId\u2194spaceKey\ + \ cache (architect Q2) avoids double round-trip on /space/pages cold-start.\ + \ All other reviewer non-blocking suggestions incorporated (E5 \u2192 decision-5,\ + \ PRIVATE_MODE language, show-metrics.md note, pool-sharing note, audit-event-shape\ + \ consistency)." + commit_sha: 594fd2b5f2804c3917b5dbac8ffc1521236d1a5a + files_changed: + - .egg-state/drafts/1931-plan.md + tests_run: [] + tasks_satisfied: + - TASK-1-1 + - TASK-1-2 + - TASK-1-3 + - TASK-1-4 + - TASK-1-5 + - TASK-2-1 + - TASK-2-2 + - TASK-2-3 + - TASK-2-4 + - TASK-2-5 + - TASK-2-6 + - TASK-2-7 + - TASK-2-8 + - TASK-2-9 + - TASK-3-1 + - TASK-4-1 + - TASK-4-1b + - TASK-4-2 + - TASK-4-3 + - TASK-4-4 + - TASK-4-5 + - TASK-4-6 + - TASK-4-7 + - TASK-5-1 + - TASK-5-2 + - TASK-5-3 + - TASK-6-1 + - TASK-6-2 + - TASK-6-3 + - TASK-6-4 + version: 2 + commit_sha: 594fd2b5f2804c3917b5dbac8ffc1521236d1a5a +```` + +### [2026-04-27T00:09:05Z] reviewer_plan (HEARTBEAT): heartbeat: WORKING + +wait_loop exited + +````yaml +id: 7730de05-1ef6-4b +phase: plan +metadata: + state: WORKING +```` + +### [2026-04-27T00:09:11Z] task_planner (HEARTBEAT): heartbeat: WAITING_ON_ROLE + +````yaml +id: fa225074-99d5-41 +phase: plan +metadata: + state: WAITING_ON_ROLE + waiting_on: reviewer_plan +```` + +### [2026-04-27T00:09:11Z] task_planner (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_ACK,CONSENSUS_NACK,CONSENSUS_RE_REVIEW,CONSENSUS_CONFIRMED,OVERSEER_ALERT + +````yaml +id: e0215818-b344-45 +phase: plan +metadata: + state: WAITING_FOR_EVENT + since: '2026-04-27T00:09:11.093172+00:00' +```` + +### [2026-04-27T00:09:11Z] task_planner (HEARTBEAT): heartbeat: WORKING + +wait_loop exited + +````yaml +id: 12817e29-3b5d-44 +phase: plan +metadata: + state: WORKING +```` + +### [2026-04-27T00:09:17Z] task_planner (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_ACK,CONSENSUS_NACK,CONSENSUS_RE_REVIEW,OVERSEER_ALERT + +````yaml +id: 0a56f6d2-d715-4f +phase: plan +metadata: + state: WAITING_FOR_EVENT + since: '2026-04-27T00:09:14.613729+00:00' +```` + +### [2026-04-27T00:09:37Z] risk_analyst (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_CONFIRMED,CONSENSUS_RE_REVIEW,OVERSEER_ALERT + +````yaml +id: c2312add-a1b6-48 +phase: plan +metadata: + state: WAITING_FOR_EVENT + since: '2026-04-27T00:08:36.620321+00:00' +```` + +### [2026-04-27T00:09:38Z] architect (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_CONFIRMED,CONSENSUS_RE_REVIEW,OVERSEER_ALERT,CONSENSUS_NACK + +````yaml +id: f36b9287-6566-4a +phase: plan +metadata: + state: WAITING_FOR_EVENT + since: '2026-04-27T00:08:38.630974+00:00' +```` + +### [2026-04-27T00:10:10Z] reviewer_plan → task_planner (CONSENSUS_ACK): ACK from reviewer_plan for task_planner + + +Re-reviewed `.egg-state/drafts/1931-plan.md` at commit 594fd2b5f against my prior NACK (b3178d1d-654b-41 reply). Both blocking issues resolved and all eight non-blocking observations addressed. + +**Blocking #1 — `jira_credentials.py` shared-credential precedence — RESOLVED.** +- New **TASK-1-5** (lines 50-60 + yaml-tasks 260-281) edits `gateway/jira_credentials.py::JiraCredentialsManager._load_credentials()` to prefer `ATLASSIAN_BASE_URL`/`ATLASSIAN_USERNAME`/`ATLASSIAN_API_TOKEN` per-key with `JIRA_*` per-key fallback. Correctly notes Jira does not need the `/wiki` suffix derivation (Jira API lives at bare Atlassian origin). Module docstring update + `JiraCredentialsUnavailable` preserved. +- New **TASK-4-1b** (lines 93-101 + yaml-tasks 289-301) extends `gateway/tests/test_jira_credentials.py` with the same six-combination matrix as Confluence (`ATLASSIAN_*` only / `JIRA_*` only / mixed per-key / missing-everywhere → `JiraCredentialsUnavailable`); back-compat preserved. +- Pre-merge note (line 167) and PR-description test plan (line 190) updated to reflect both loaders honour the precedence — operators can drop the legacy `JIRA_*` block after migration without breaking Jira. The migration narrative is now portable. +- Dependency graph updated: `1-5 (independent — same-PR Jira loader update)` and `1-5 ───► 4-1b`. + +**Blocking #2 — inline-comment v2-404 + v1-404 fall-through — RESOLVED.** +- TASK-1-2 (line 19, also yaml-tasks line 211) now specifies all three fall-through outcomes deterministically: (a) v1 200 with comments → v1 payload normalized with `used_fallback: true`; (b) v1 200 with **empty results** → `{"results": [], "used_fallback": true}` (page exists, has no inline comments + v2 bug fired); (c) v1 **404** → standard `{"status": "not_found", "id": "...", "upstream_status": 404, "used_fallback": true}` envelope (page genuinely doesn't exist). The route layer can now branch unambiguously and TASK-4-2 can write deterministic fixtures for all three. + +**Non-blocking observations — all addressed.** +- `decision E5` → `decision-5` replaced at the eight sites I called out (TASK-1-2 acceptance + TASK-4-2 description + yaml-tasks both). +- Boot-time observability (risk R12) split across modules: `confluence_credentials.py` logs precedence at first load; `confluence_policy.py` logs allowlist size at first `allowed_spaces()` and emits ERROR on schema mismatch; `confluence_client.py` logs body-format default at first invocation. Lines 33 + 234-241 of yaml-tasks. +- 429 audit shape now includes `attempt: 1|2` field (line 28 + yaml-tasks line 224) so operators can see whether the retry succeeded. +- `spaceId ↔ spaceKey` cache is now bidirectional and populated by both `list_spaces` AND `get_page` (lines 70-77 + yaml-tasks 245-248), so `/space/pages` cold-start avoids double round-trip — addresses my "spaceKey → spaceId resolution one-sided" note. +- Architect Q4 (no shared `atlassian_credentials.py` extraction in v1) is now explicit in TASK-1-1 acceptance (line 10 + yaml-tasks 199-202) — deferral is documented as a follow-up backlog item. +- Manual verification step 3-4 (lines 157-158): `PRIVATE_MODE` env var language replaced with session-level `--private` language and a parenthetical clarifying it's a session attribute set by `@require_session_auth`. +- TASK-4-7 (line 116) now says coder must confirm `test_atlassian_domains_absent` test name at implement time — captures the "verify before extending" caveat. +- TASK-6-3 (line 124) explicitly notes `sandbox/agent-config/commands/show-metrics.md` is intentionally untouched (legacy syncer cache vs new gateway wrapper are independent). +- TASK-6-4 wrapper-reference doc (line 133) now carries the Atlassian rate-limit pool-sharing paragraph: same bot account = pooled points-quota across `/api/v1/jira/*` and `/api/v1/confluence/*` traffic; operators seeing routine throttling on one service should expect it on the other. +- TASK-2-9 audit shape (line 85) now says "mirror whatever the existing Jira reload emits" — coder picks one or two events at implement time to match Jira's pattern, with both fall-back names spelled out. + +**Final plan stats**: 6 phases, 30 tasks (was 28; added TASK-1-5 and TASK-4-1b), 1342 → 1444 lines (+102 net). Task decomposition still granular; acceptance criteria still reference covering tests; dependency graph still correct (TASK-1-5 is independent within Phase 1 and pairs with TASK-4-1b in Phase 4 — no cross-phase dependency added). + +The plan is now complete enough for implement-phase consumption. Architecture alignment ✓, task decomposition ✓, acceptance criteria ✓, dependency ordering ✓, risk mitigations threaded ✓, test strategy ✓, completeness ✓. + + +````yaml +id: 94b4df0f-fe91-41 +phase: plan +metadata: + payload: + artifact_references: + - .egg-state/drafts/1931-plan.md + reason: "\nRe-reviewed `.egg-state/drafts/1931-plan.md` at commit 594fd2b5f against\ + \ my prior NACK (b3178d1d-654b-41 reply). Both blocking issues resolved and\ + \ all eight non-blocking observations addressed.\n\n**Blocking #1 \u2014 `jira_credentials.py`\ + \ shared-credential precedence \u2014 RESOLVED.**\n- New **TASK-1-5** (lines\ + \ 50-60 + yaml-tasks 260-281) edits `gateway/jira_credentials.py::JiraCredentialsManager._load_credentials()`\ + \ to prefer `ATLASSIAN_BASE_URL`/`ATLASSIAN_USERNAME`/`ATLASSIAN_API_TOKEN`\ + \ per-key with `JIRA_*` per-key fallback. Correctly notes Jira does not need\ + \ the `/wiki` suffix derivation (Jira API lives at bare Atlassian origin). Module\ + \ docstring update + `JiraCredentialsUnavailable` preserved.\n- New **TASK-4-1b**\ + \ (lines 93-101 + yaml-tasks 289-301) extends `gateway/tests/test_jira_credentials.py`\ + \ with the same six-combination matrix as Confluence (`ATLASSIAN_*` only / `JIRA_*`\ + \ only / mixed per-key / missing-everywhere \u2192 `JiraCredentialsUnavailable`);\ + \ back-compat preserved.\n- Pre-merge note (line 167) and PR-description test\ + \ plan (line 190) updated to reflect both loaders honour the precedence \u2014\ + \ operators can drop the legacy `JIRA_*` block after migration without breaking\ + \ Jira. The migration narrative is now portable.\n- Dependency graph updated:\ + \ `1-5 (independent \u2014 same-PR Jira loader update)` and `1-5 \u2500\u2500\ + \u2500\u25BA 4-1b`.\n\n**Blocking #2 \u2014 inline-comment v2-404 + v1-404 fall-through\ + \ \u2014 RESOLVED.**\n- TASK-1-2 (line 19, also yaml-tasks line 211) now specifies\ + \ all three fall-through outcomes deterministically: (a) v1 200 with comments\ + \ \u2192 v1 payload normalized with `used_fallback: true`; (b) v1 200 with **empty\ + \ results** \u2192 `{\"results\": [], \"used_fallback\": true}` (page exists,\ + \ has no inline comments + v2 bug fired); (c) v1 **404** \u2192 standard `{\"\ + status\": \"not_found\", \"id\": \"...\", \"upstream_status\": 404, \"used_fallback\"\ + : true}` envelope (page genuinely doesn't exist). The route layer can now branch\ + \ unambiguously and TASK-4-2 can write deterministic fixtures for all three.\n\ + \n**Non-blocking observations \u2014 all addressed.**\n- `decision E5` \u2192\ + \ `decision-5` replaced at the eight sites I called out (TASK-1-2 acceptance\ + \ + TASK-4-2 description + yaml-tasks both).\n- Boot-time observability (risk\ + \ R12) split across modules: `confluence_credentials.py` logs precedence at\ + \ first load; `confluence_policy.py` logs allowlist size at first `allowed_spaces()`\ + \ and emits ERROR on schema mismatch; `confluence_client.py` logs body-format\ + \ default at first invocation. Lines 33 + 234-241 of yaml-tasks.\n- 429 audit\ + \ shape now includes `attempt: 1|2` field (line 28 + yaml-tasks line 224) so\ + \ operators can see whether the retry succeeded.\n- `spaceId \u2194 spaceKey`\ + \ cache is now bidirectional and populated by both `list_spaces` AND `get_page`\ + \ (lines 70-77 + yaml-tasks 245-248), so `/space/pages` cold-start avoids double\ + \ round-trip \u2014 addresses my \"spaceKey \u2192 spaceId resolution one-sided\"\ + \ note.\n- Architect Q4 (no shared `atlassian_credentials.py` extraction in\ + \ v1) is now explicit in TASK-1-1 acceptance (line 10 + yaml-tasks 199-202)\ + \ \u2014 deferral is documented as a follow-up backlog item.\n- Manual verification\ + \ step 3-4 (lines 157-158): `PRIVATE_MODE` env var language replaced with session-level\ + \ `--private` language and a parenthetical clarifying it's a session attribute\ + \ set by `@require_session_auth`.\n- TASK-4-7 (line 116) now says coder must\ + \ confirm `test_atlassian_domains_absent` test name at implement time \u2014\ + \ captures the \"verify before extending\" caveat.\n- TASK-6-3 (line 124) explicitly\ + \ notes `sandbox/agent-config/commands/show-metrics.md` is intentionally untouched\ + \ (legacy syncer cache vs new gateway wrapper are independent).\n- TASK-6-4\ + \ wrapper-reference doc (line 133) now carries the Atlassian rate-limit pool-sharing\ + \ paragraph: same bot account = pooled points-quota across `/api/v1/jira/*`\ + \ and `/api/v1/confluence/*` traffic; operators seeing routine throttling on\ + \ one service should expect it on the other.\n- TASK-2-9 audit shape (line 85)\ + \ now says \"mirror whatever the existing Jira reload emits\" \u2014 coder picks\ + \ one or two events at implement time to match Jira's pattern, with both fall-back\ + \ names spelled out.\n\n**Final plan stats**: 6 phases, 30 tasks (was 28; added\ + \ TASK-1-5 and TASK-4-1b), 1342 \u2192 1444 lines (+102 net). Task decomposition\ + \ still granular; acceptance criteria still reference covering tests; dependency\ + \ graph still correct (TASK-1-5 is independent within Phase 1 and pairs with\ + \ TASK-4-1b in Phase 4 \u2014 no cross-phase dependency added).\n\nThe plan\ + \ is now complete enough for implement-phase consumption. Architecture alignment\ + \ \u2713, task decomposition \u2713, acceptance criteria \u2713, dependency\ + \ ordering \u2713, risk mitigations threaded \u2713, test strategy \u2713, completeness\ + \ \u2713.\n" + version: 2 +```` + +### [2026-04-27T00:10:10Z] orchestrator → task_planner (STATUS): Ready to confirm — all confirm preconditions satisfied + +Your proposal (version 2) is ready to confirm — all blocking reviews are clear and global confirm preconditions are met. Run `egg-orch consensus confirmed` to confirm. + +````yaml +id: 734ef1e0-9313-40 +phase: plan +metadata: + ready_to_confirm: true + version: 2 +```` + +### [2026-04-27T00:10:10Z] task_planner (HEARTBEAT): heartbeat: WORKING + +wait_loop exited + +````yaml +id: 6bfa9460-233d-45 +phase: plan +metadata: + state: WORKING +```` + +### [2026-04-27T00:10:14Z] task_planner (CONSENSUS_CONFIRMED): Confirmed by task_planner + +````yaml +id: 3f611e8f-7265-43 +phase: plan +metadata: + consensus_reached: false +```` + +### [2026-04-27T00:10:15Z] risk_analyst (HEARTBEAT): heartbeat: WORKING + +wait_loop exited + +````yaml +id: 7b0cd3d1-5e56-48 +phase: plan +metadata: + state: WORKING +```` + +### [2026-04-27T00:10:15Z] architect (HEARTBEAT): heartbeat: WORKING + +wait_loop exited + +````yaml +id: dd3c2dcc-d864-4e +phase: plan +metadata: + state: WORKING +```` + +### [2026-04-27T00:10:16Z] reviewer_plan (CONSENSUS_CONFIRMED): Confirmed by reviewer_plan + +````yaml +id: a57e3a99-fc9a-4d +phase: plan +metadata: + consensus_reached: true +```` + +### [2026-04-27T00:10:19Z] risk_analyst (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_CONFIRMED,CONSENSUS_RE_REVIEW,OVERSEER_ALERT + +````yaml +id: f08ad9f7-e307-47 +phase: plan +metadata: + state: WAITING_FOR_EVENT + since: '2026-04-27T00:10:19.516646+00:00' +```` + +### [2026-04-27T00:10:20Z] architect (HEARTBEAT): heartbeat: WAITING_FOR_EVENT + +wait_loop blocked on CONSENSUS_CONFIRMED,CONSENSUS_RE_REVIEW,OVERSEER_ALERT,CONSENSUS_NACK + +````yaml +id: 096d8af5-78be-41 +phase: plan +metadata: + state: WAITING_FOR_EVENT + since: '2026-04-27T00:10:19.864421+00:00' +```` + +### [2026-04-27T00:10:20Z] risk_analyst (HEARTBEAT): heartbeat: WORKING + +wait_loop exited + +````yaml +id: f12902e5-d444-4d +phase: plan +metadata: + state: WORKING +```` diff --git a/.egg-state/contracts/issue-1931.json b/.egg-state/contracts/issue-1931.json index 6d7d09cf0a..42c9af3da0 100644 --- a/.egg-state/contracts/issue-1931.json +++ b/.egg-state/contracts/issue-1931.json @@ -441,6 +441,18 @@ "resolved_by": "human", "resolved_at": "2026-04-26T23:40:09.289899Z", "debounce_until": null + }, + { + "id": "decision-15", + "question": "Open feedback request feedback-1", + "type": "hitl", + "phase": null, + "options": [], + "resolved": true, + "resolution": "{\"action\": \"submit_feedback\", \"answers\": {\"Q1\": \"Defer to operator at deploy time. Ship with confluence.spaces: [] (empty allowlist, fail-closed) in config/context-filters.yaml; operators populate before enabling Confluence routes. Do not hard-code spaces.\", \"Q2\": \"Reuse Jira's rate-limit defaults (single retry on 429 with min(Retry-After, 30); audit confluence_upstream_rate_limited on both attempts). No Confluence-specific tuning in v1; revisit if 429s observed in production.\", \"Q3\": \"None known; defaults only. Ship with accountId / emailAddress / _links.webui redaction. Add a follow-up ticket if a sensitive macro or page property is identified post-rollout.\", \"Q4\": \"Defer to the future-writes phase; do not pre-design idempotency in v1. Atlassian's create-comment endpoint is not naturally idempotent. Design the idempotency stance at the write-phase ticket alongside Jira's equivalent so the design-once choice stays consistent.\", \"Q5\": \"Do not add a page/resolve-by-url verb in v1. Agents that have a Confluence URL parse the pageId themselves and call /api/v1/confluence/page/get. Add resolve-by-url only if URL-parsing in the agent becomes a recurring pain point; not in v1 scope.\", \"Q6\": \"Pass Atlassian's depth parameter through verbatim with no gateway-imposed cap in v1. Add a depth ceiling if a runaway response is observed; not pre-emptively. Document the parameter in the wrapper reference.\", \"Q7\": \"Yes, separate confluence_upstream_403 audit category from generic upstream errors. Parallels the not_found vs error split. Helps operators distinguish space-allowlist denials, page-permission denials, and generic upstream errors at audit time.\", \"Q8\": \"Default to current-version only in v1. Do not expose a ?version= query parameter until a use case appears. Document the default in the wrapper reference so future expansion is unsurprising.\", \"Q9\": \"Operator concern; ship a diagnostic command in a follow-up rather than v1. v1 documents the bot-vs-human access caveat in the wrapper reference. Operators can manually verify the bot's space access at deploy time using the host-side mcp__confluence__* MCP under the bot's token.\", \"Q10\": \"Jira-style subcommands only (e.g., confluence page get, confluence search-cql). Mirrors the existing jira wrapper shape. Do not ship MCP-style aliases in v1; add only if a real porting pain point appears post-rollout.\"}}", + "resolved_by": "human", + "resolved_at": "2026-04-26T23:42:43.026940Z", + "debounce_until": null } ], "workflow_owner": null, From 1717c177f1fe62022f94bb8e9d1757a212d8da2b Mon Sep 17 00:00:00 2001 From: egg Date: Mon, 27 Apr 2026 00:19:23 +0000 Subject: [PATCH 10/26] docs(implement): add Confluence gateway wrapper docs (#1931) Phase 6 of the read-only Confluence gateway wrapper. New docs/reference/confluence-wrapper.md mirrors the Jira reference and covers the eight `/api/v1/confluence/*` routes, the conservative CQL extractor, the `not_found` envelope, the v1 inline-comment fallback, the response-redaction walker (accountId / emailAddress / user-profile _links.webui), the bot-vs-human access caveat, the prompt-injection caveat, the Atlassian rate-limit runbook (with shared-bot quota-pool note), and the future write-verb extension points. Architecture docs gain a Confluence row in the gateway endpoint table, a Confluence section in credential injection covering the shared `ATLASSIAN_*` precedence and `/wiki` base-URL derivation, and an extended Squid allowlist exclusion paragraph naming Confluence hostnames. The sandbox environment rules document the new `confluence` wrapper verbs and call out that no per-pipeline env var is exported (Confluence is reference material, not a unit of work). docs/index.md adds the wrapper to the reference lookup table. Co-Authored-By: Claude Opus 4.7 --- docs/architecture/credential-injection.md | 73 ++++- docs/architecture/network-isolation.md | 15 +- docs/index.md | 1 + docs/reference/confluence-wrapper.md | 334 ++++++++++++++++++++++ sandbox/agent-config/rules/environment.md | 43 +++ 5 files changed, 454 insertions(+), 12 deletions(-) create mode 100644 docs/reference/confluence-wrapper.md diff --git a/docs/architecture/credential-injection.md b/docs/architecture/credential-injection.md index 9a974e108f..30d719e44b 100644 --- a/docs/architecture/credential-injection.md +++ b/docs/architecture/credential-injection.md @@ -1,6 +1,6 @@ # Credential Injection -The gateway sidecar injects credentials at the proxy layer, ensuring the sandbox container has zero credential access. This covers GitHub (via git wrappers), Anthropic API (via `ANTHROPIC_BASE_URL`), and Atlassian/Jira (via the `/api/v1/jira/*` REST endpoints) credentials. +The gateway sidecar injects credentials at the proxy layer, ensuring the sandbox container has zero credential access. This covers GitHub (via git wrappers), Anthropic API (via `ANTHROPIC_BASE_URL`), and Atlassian/Jira/Confluence (via the `/api/v1/jira/*` and `/api/v1/confluence/*` REST endpoints) credentials. **Key properties:** - **Zero credential exposure**: Container never sees API keys, OAuth tokens, or GitHub tokens @@ -147,7 +147,8 @@ Gateway enforcement cannot be bypassed because: |------|--------|-----------------| | API Key | `ANTHROPIC_API_KEY` in secrets.env | `x-api-key: ` | | OAuth Token | `ANTHROPIC_OAUTH_TOKEN` in secrets.env | `Authorization: Bearer ` | -| Atlassian / Jira | `JIRA_BASE_URL` + `JIRA_USERNAME` + `JIRA_API_TOKEN` in secrets.env | `Authorization: Basic ` | +| Atlassian / Jira | `ATLASSIAN_*` (preferred) or `JIRA_*` triple in secrets.env | `Authorization: Basic ` | +| Atlassian / Confluence | `ATLASSIAN_*` (preferred) or `CONFLUENCE_*` triple in secrets.env | `Authorization: Basic ` | OAuth takes precedence over API key if both are configured. OAuth tokens may expire; the user runs `claude auth status` to generate a new token, and the gateway hot-reloads via mtime-based cache refresh. @@ -158,40 +159,90 @@ Sandboxed agents reach Jira exclusively through the gateway's `/api/v1/jira/*` R **Credential storage:** ```bash # ~/.config/egg/secrets.env -JIRA_BASE_URL="https://your-site.atlassian.net" -JIRA_USERNAME="bot@example.com" # Atlassian account email -JIRA_API_TOKEN="ATATT3x..." # Atlassian Cloud API token +# Shared Atlassian triple (preferred — also covers Confluence per-key with /wiki derivation) +ATLASSIAN_BASE_URL="https://your-site.atlassian.net" +ATLASSIAN_USERNAME="bot@example.com" +ATLASSIAN_API_TOKEN="ATATT3x..." + +# Legacy per-service blocks (back-compat fall-back per key) +JIRA_BASE_URL="https://your-site.atlassian.net" # used if ATLASSIAN_BASE_URL absent +JIRA_USERNAME="bot@example.com" # used if ATLASSIAN_USERNAME absent +JIRA_API_TOKEN="ATATT3x..." # used if ATLASSIAN_API_TOKEN absent ``` +**Credential precedence:** Per-key — for each of `BASE_URL`, `USERNAME`, `API_TOKEN`, the loader prefers the `ATLASSIAN_*` value and falls back to `JIRA_*`. The two name shapes can be mixed (e.g., `ATLASSIAN_USERNAME` + `JIRA_BASE_URL` is a valid combination — Atlassian accounts are tenant-wide). This makes the shared-credential migration safe: operators can copy values to `ATLASSIAN_*` and remove the legacy `JIRA_*` block once the shared triple is fully populated, without breaking Jira. + **Loader:** `gateway/jira_credentials.py` mirrors `gateway/anthropic_credentials.py` — mtime-based cache refresh of `~/.config/egg/secrets.env` (override with `EGG_SECRETS_PATH`). `get_jira_credentials()` returns a `JiraCredentials` dataclass with `base_url`, `username`, `api_token`, and a `basic_auth_header()` helper that emits the base64-encoded `Basic` header. Missing values raise `JiraCredentialsUnavailable`, which the route layer translates to HTTP 503. `reload_jira_credentials()` is wired into the gateway's `_reload_all_config()` hook, so `POST /api/v1/config/reload` picks up rotated tokens without a process restart. -**Zero-credential invariant:** The orchestrator's sandbox-launch env builder (`orchestrator/routes/pipelines.py`) is forbidden from exporting `JIRA_BASE_URL`, `JIRA_USERNAME`, or `JIRA_API_TOKEN` to the agent container. A regression test in `orchestrator/tests/test_start_pipeline.py` iterates the sandbox env and asserts those three keys are absent. The only Jira-related variables the agent sees are `EGG_JIRA_TICKET` (the ticket the pipeline is scoped to) and `EGG_JIRA_PROJECT` (optional, advisory) — neither is a credential. +**Zero-credential invariant:** The orchestrator's sandbox-launch env builder (`orchestrator/routes/pipelines.py`) is forbidden from exporting `JIRA_BASE_URL`, `JIRA_USERNAME`, `JIRA_API_TOKEN`, or any `ATLASSIAN_*` key to the agent container. A regression test in `orchestrator/tests/test_start_pipeline.py` iterates the sandbox env and asserts those keys are absent. The only Jira-related variables the agent sees are `EGG_JIRA_TICKET` (the ticket the pipeline is scoped to) and `EGG_JIRA_PROJECT` (optional, advisory) — neither is a credential. **Private-mode only + project allowlist:** Every `/api/v1/jira/*` route is decorated with `@require_private_mode` (`gateway/mode_gate.py`). In public mode, the decorator returns 403 and emits a `private_mode_required` audit entry **before** any credential is loaded or any upstream request is issued. After the mode gate, each route checks the extracted project key against the allowlist in `config/context-filters.yaml` (`jira.projects`) — see [Jira wrapper reference](../reference/jira-wrapper.md) for the full policy semantics. -**Squid allowlist excludes Atlassian domains:** `*.atlassian.net`, `*.atlassian.com`, `api.atlassian.com`, and `jira.atlassian.com` are intentionally **not** in `gateway/allowed_domains.txt`. All Jira traffic flows through the gateway REST endpoints, never through the Squid proxy, so the private-mode gate and the project allowlist cannot be bypassed by a direct `CONNECT` to Atlassian through the proxy. A regression test (`gateway/tests/test_allowed_domains.py`) enforces this invariant. +**Squid allowlist excludes Atlassian domains:** `*.atlassian.net`, `*.atlassian.com`, `api.atlassian.com`, `jira.atlassian.com`, `wiki.atlassian.net`, and `confluence.atlassian.com` are intentionally **not** in `gateway/allowed_domains.txt`. All Jira and Confluence traffic flows through the gateway REST endpoints, never through the Squid proxy, so the private-mode gate and the project / space allowlists cannot be bypassed by a direct `CONNECT` to Atlassian through the proxy. A regression test (`gateway/tests/test_allowed_domains.py`) enforces this invariant. + +### Atlassian / Confluence + +Sandboxed agents reach Confluence exclusively through the gateway's `/api/v1/confluence/*` REST endpoints. Atlassian credentials are held by the gateway and injected per-request; they never enter the sandbox. The Confluence wrapper shares the dedicated Atlassian bot account with the Jira wrapper (single principal owns both services' read scopes). + +**Credential storage:** +```bash +# ~/.config/egg/secrets.env +# Shared Atlassian triple (preferred) +ATLASSIAN_BASE_URL="https://your-site.atlassian.net" # NO trailing /wiki — loader appends it +ATLASSIAN_USERNAME="bot@example.com" +ATLASSIAN_API_TOKEN="ATATT3x..." + +# Legacy per-service block (back-compat fall-back per key) +CONFLUENCE_BASE_URL="https://your-site.atlassian.net/wiki" # /wiki required when set explicitly +CONFLUENCE_USERNAME="bot@example.com" +CONFLUENCE_API_TOKEN="ATATT3x..." +``` + +**Credential precedence:** Per-key — `ATLASSIAN_*` wins; missing keys fall back to `CONFLUENCE_*`. The two name shapes can be mixed at the per-key level. + +**Base-URL derivation.** Confluence lives under `/wiki` on Atlassian Cloud: + +- If `CONFLUENCE_BASE_URL` is set, the loader uses it verbatim. Operators have already added `/wiki`. +- If `CONFLUENCE_BASE_URL` is unset and `ATLASSIAN_BASE_URL` is set, the loader **derives** the Confluence base URL by appending `/wiki` to `ATLASSIAN_BASE_URL`. Jira's base URL is the bare Atlassian origin and uses `ATLASSIAN_BASE_URL` verbatim. + +**Loader:** `gateway/confluence_credentials.py` mirrors `gateway/jira_credentials.py` exactly (mtime-based cache refresh, thread-safe singleton, override via `EGG_SECRETS_PATH`). `get_confluence_credentials()` returns a `ConfluenceCredentials` dataclass with `base_url`, `username`, `api_token`, and a `basic_auth_header()` helper that emits the base64-encoded `Basic` header. Missing values raise `ConfluenceCredentialsUnavailable`, which the route layer translates to HTTP 503. `reload_confluence_credentials()` is wired into the gateway's `_reload_all_config()` hook so `POST /api/v1/config/reload` picks up rotated tokens without a process restart, alongside the Jira reload. + +**Why two loader files instead of one shared helper.** v1 deliberately duplicates the loader skeleton across `gateway/jira_credentials.py` and `gateway/confluence_credentials.py` for review clarity (architect Q4). Extracting a shared `atlassian_credentials.py` helper is tracked as a follow-up backlog item — the duplication makes the per-service precedence rules easier to audit at v1 review time. + +**Zero-credential invariant:** No `ATLASSIAN_*` or `CONFLUENCE_*` key is ever exported to the agent container. The orchestrator-side regression test that gates Jira keys is extended to cover both Atlassian and Confluence prefixes. + +**Private-mode only + space allowlist:** Every `/api/v1/confluence/*` route is decorated with `@require_private_mode`. A route-enumeration regression test (`gateway/tests/test_confluence_routes.py`) asserts every Confluence view function carries `__egg_requires_private_mode__ = True` so a future contributor cannot accidentally drop the gate. After the mode gate, each route checks the resolved space key against the allowlist in `config/context-filters.yaml` (`confluence.spaces`). The check runs **after** the upstream fetch for routes that take a `pageId` (the response carries the `spaceId`), and **before** the upstream fetch for routes that take a `spaceKey` directly. See [Confluence wrapper reference](../reference/confluence-wrapper.md) for the full policy semantics, the conservative CQL extractor, the v1 inline-comment fallback, and the response-redaction walker. + +**No per-pipeline `EGG_CONFLUENCE_*` env var.** Unlike Jira (`EGG_JIRA_TICKET`), Confluence has no orchestrator-exported observational env var (refine-phase decision 13 of #1931). Confluence is consulted as reference material from ticket/epic links, not as the pipeline's primary unit of work; audits recover `pageId` / `spaceKey` from each request body or response. ## Files | File | Purpose | |------|---------| -| `gateway/gateway.py` | Anthropic proxy endpoints, `/api/v1/jira/*` routes, credential injection, tool filtering, `_reload_all_config()` hot-reload hook | +| `gateway/gateway.py` | Anthropic proxy endpoints, `/api/v1/jira/*` and `/api/v1/confluence/*` routes, credential injection, tool filtering, `_reload_all_config()` hot-reload hook | | `gateway/anthropic_credentials.py` | Anthropic credential loading from secrets.env | -| `gateway/jira_credentials.py` | Atlassian credential loading from secrets.env (mtime refresh, basic-auth header helper) | +| `gateway/jira_credentials.py` | Atlassian credential loading from secrets.env for Jira (mtime refresh, basic-auth header helper, `ATLASSIAN_*` precedence) | +| `gateway/confluence_credentials.py` | Atlassian credential loading from secrets.env for Confluence (mtime refresh, `ATLASSIAN_*` precedence with `/wiki` derivation) | | `gateway/jira_client.py` | Jira REST client + `validate_jira_api_path` regex allowlist + 429 retry + 404 envelope | +| `gateway/confluence_client.py` | Confluence REST client + `validate_confluence_api_path` regex allowlist + 429 retry + 404 envelope + 403 escalation + v1 fallback for inline comments + response redaction | | `gateway/jira_policy.py` | Project allowlist loader for `config/context-filters.yaml` (`jira.projects`) | +| `gateway/confluence_policy.py` | Space allowlist loader for `config/context-filters.yaml` (`confluence.spaces`) | +| `gateway/jira_search.py` | JQL static project-scope extractor (deny-on-ambiguity) | +| `gateway/confluence_search.py` | CQL static space-scope extractor (deny-on-ambiguity) | | `gateway/mode_gate.py` | `@require_private_mode` decorator (fails closed in public mode, marks view for regression test) | | `gateway/session_manager.py` | `Session.jira_ticket` audit field (observational; project allowlist is the only hard boundary) | | `gateway/allowed_domains.txt` | Domain allowlist (api.anthropic.com and all Atlassian domains intentionally absent) | | `sandbox/entrypoint.py` | Set ANTHROPIC_BASE_URL, remove creds from env, set disallowedTools in private mode | | `sandbox/scripts/jira` | Sandbox CLI wrapper — POSTs to `/api/v1/jira/*` with `EGG_SESSION_TOKEN` | +| `sandbox/scripts/confluence` | Sandbox CLI wrapper — POSTs to `/api/v1/confluence/*` with `EGG_SESSION_TOKEN` | | `shared/egg_agent/client.py` | Pass `disallowed_tools` via SDK options for headless agents in private mode | -| `config/secrets.template.env` | Template for Anthropic and Atlassian credentials | -| `config/context-filters.yaml` | Operator-facing Jira project allowlist (`jira.projects:`) | +| `config/secrets.template.env` | Template for Anthropic and Atlassian credentials (shared `ATLASSIAN_*` block + legacy `JIRA_*` / `CONFLUENCE_*` blocks) | +| `config/context-filters.yaml` | Operator-facing Jira project allowlist (`jira.projects:`) and Confluence space allowlist (`confluence.spaces:`) | ## Related Documentation - [Git Isolation Architecture](git-isolation.md) — Worktree isolation via gateway - [Network Isolation](network-isolation.md) — Full network lockdown design - [Jira Wrapper Reference](../reference/jira-wrapper.md) — `/api/v1/jira/*` endpoint surface, JQL scope extractor, not-found envelope, future-verb extension points +- [Confluence Wrapper Reference](../reference/confluence-wrapper.md) — `/api/v1/confluence/*` endpoint surface, CQL scope extractor, v1 fallback for inline comments, response redaction, future-verb extension points - [Architecture Overview](README.md) — System design diff --git a/docs/architecture/network-isolation.md b/docs/architecture/network-isolation.md index 11ec0ab196..8c64c0d58c 100644 --- a/docs/architecture/network-isolation.md +++ b/docs/architecture/network-isolation.md @@ -104,6 +104,19 @@ The gateway exposes a controlled API for git/gh operations: All four routes compose `@require_session_auth` → `@require_private_mode` → project-allowlist check → fields/JQL validation → `JiraClient` call → structured audit log. In **public mode**, `@require_private_mode` short-circuits every call with a 403 and a `private_mode_required` audit entry **before** any upstream request is issued — no Atlassian traffic ever leaves the gateway in public mode. See [Jira wrapper reference](../reference/jira-wrapper.md). +**Confluence read endpoints (`/api/v1/confluence/*`) — private-mode only, fail closed in public mode:** + +- `POST /api/v1/confluence/page/get` — read a single page; default `body-format=storage` +- `POST /api/v1/confluence/page/descendants` — list pages under a page (depth-bounded by default) +- `POST /api/v1/confluence/page/footer-comments` — read footer comments; optional v1 nested-reply merge +- `POST /api/v1/confluence/page/inline-comments` — read inline comments with transparent v1 fallback on the known v2 404 bug +- `POST /api/v1/confluence/space/pages` — list pages in a space +- `POST /api/v1/confluence/space/list` — list spaces, response filtered to allowlisted spaces (agents cannot enumerate the full tenant set) +- `POST /api/v1/confluence/search` — CQL search via Atlassian's v1-only `/wiki/rest/api/search` with a conservative static space-scope extractor (deny-on-ambiguity) +- `POST /api/v1/confluence/execute` — GET-only passthrough, regex-allowlisted paths; permanently denied verbs are `restrictions`, `permissions`, `space.admin`, `users`, `attachments`, plus HTTP `DELETE` / `PUT` / `PATCH` + +All eight routes compose `@require_session_auth` → `@require_private_mode` → space-allowlist check → fields/CQL validation → `ConfluenceClient` call → response redaction → structured audit log. A route-enumeration regression test asserts every `/api/v1/confluence/*` view function carries `__egg_requires_private_mode__ = True` so newly-added routes cannot accidentally drop the gate. In **public mode**, `@require_private_mode` short-circuits every call with a 403 and a `private_mode_required` audit entry **before** any upstream request is issued — no Atlassian traffic ever leaves the gateway in public mode. See [Confluence wrapper reference](../reference/confluence-wrapper.md). + ### CLI Wrappers The egg container uses `git` and `gh` CLI wrappers that: @@ -321,7 +334,7 @@ The gateway maintains a strict allowlist of permitted domains: **Explicitly excluded:** `*.actions.githubusercontent.com`, `ghcr.io`, `*.github.io`, `copilot-*.githubusercontent.com`, **`*.atlassian.net` / `*.atlassian.com` / `api.atlassian.com` / `jira.atlassian.com`** -**Why Atlassian domains are excluded from the Squid allowlist:** all Jira traffic **must** flow through the gateway REST endpoints (`/api/v1/jira/*`). Adding `*.atlassian.net` to Squid would let a compromised sandbox reach Jira directly through the proxy, bypassing the private-mode gate, the project allowlist, the verb allowlist, and the audit log. A dedicated regression test (`gateway/tests/test_allowed_domains.py`) asserts that none of `atlassian.net`, `atlassian.com`, `api.atlassian.com`, or `jira.atlassian.com` appear in `gateway/allowed_domains.txt`. +**Why Atlassian domains are excluded from the Squid allowlist:** all Jira and Confluence traffic **must** flow through the gateway REST endpoints (`/api/v1/jira/*` and `/api/v1/confluence/*`). Adding `*.atlassian.net` to Squid would let a compromised sandbox reach Jira or Confluence directly through the proxy, bypassing the private-mode gate, the project / space allowlist, the verb allowlist, and the audit log. A dedicated regression test (`gateway/tests/test_allowed_domains.py`) asserts that none of `atlassian.net`, `atlassian.com`, `api.atlassian.com`, `jira.atlassian.com`, `wiki.atlassian.net`, or `confluence.atlassian.com` appear in `gateway/allowed_domains.txt`. Confluence-shaped hostnames (`wiki.atlassian.net`, `confluence.atlassian.com`) are listed defensively even though they aren't real Atlassian Cloud hostnames — the cost is one parametrize entry and the test surfaces in any future grep for "confluence". ### What Gets Blocked diff --git a/docs/index.md b/docs/index.md index 702560eca3..726a8d8a56 100644 --- a/docs/index.md +++ b/docs/index.md @@ -81,6 +81,7 @@ This index helps both humans and LLMs navigate the documentation efficiently. | [Agent MCP Tools](reference/agent-tools.md) | In-process SDK MCP tools sandbox agents call on the `tool_use` stream (30 verbs across 6 namespaces: `mcp__sdlc__*`, `mcp__brc__*`, `mcp__phase__*`, `mcp__progress__*`, `mcp__task__*`, `mcp__checkpoint__*`); on by default — set `EGG_MCP_TOOLS=false` to opt out | | [Agent Wait Patterns](reference/agent-wait-patterns.md) | Canonical `egg-orch message wait-loop` idiom for BRC STAY ALIVE, the five anti-patterns to avoid, the `egg-orch message wait` exit-code contract, the `HEARTBEAT` metadata schema, the `EGG_MESSAGE_POLL_MAX_WAIT` / `EGG_ORCH_WAITRESS_THREADS` env-var couplings, and §7 host-side `wait_for_status_change` for event-driven pipeline monitoring | | [Jira Wrapper](reference/jira-wrapper.md) | `/api/v1/jira/*` read-only gateway endpoints (ticket read, JQL search with static project-scope extraction, ticket comments, GET-only execute passthrough); private-mode only; project allowlist via `config/context-filters.yaml`; `not_found` envelope; future write-verb extension points | +| [Confluence Wrapper](reference/confluence-wrapper.md) | `/api/v1/confluence/*` read-only gateway endpoints (page read, descendants, footer/inline comments with v1 fallback, space list/pages, CQL search with static space-scope extraction, GET-only execute passthrough); private-mode only; space allowlist via `config/context-filters.yaml`; `not_found` envelope; response redaction (`accountId` / `emailAddress` / user-profile `_links.webui`); future write-verb extension points | | [Conditional ACK](reference/conditional-ack.md) | Reviewer verdict variant: ACK + `--pre-merge-condition "..."` attaches a merge-time human obligation (e.g. `git mv`) that surfaces in `egg-orch consensus status` and in a "Pre-merge Obligations" section on the auto-created PR body | ### SDLC Pipeline Templates diff --git a/docs/reference/confluence-wrapper.md b/docs/reference/confluence-wrapper.md new file mode 100644 index 0000000000..869214be96 --- /dev/null +++ b/docs/reference/confluence-wrapper.md @@ -0,0 +1,334 @@ +# Confluence Wrapper Reference + +> Last reviewed against Atlassian docs: 2026-04-27 + +> Gateway REST surface that gives sandboxed agents **read-only** access to Confluence. Mirrors the `/api/v1/jira/*` pattern from [#1556](https://github.com/jwbron/egg/issues/1556): Atlassian credentials live in the gateway, the sandbox posts session-authenticated JSON, and every call is funneled through a private-mode gate, a **space allowlist**, a verb allowlist, and structured audit logs. + +v1 is read-only — eight `POST /api/v1/confluence/*` routes covering page reads, descendants, footer/inline comments, space listings, CQL search, and a regex-allowlisted GET-only `/execute` escape hatch. Write verbs (`page/create`, `page/update`, `comment/create`) are scoped as a follow-up and drop in as three additional narrow routes under the same decorators and policy plumbing — no re-architecting. Attachments, restrictions, permissions, space-admin verbs, user enumeration, and `PUT` / `PATCH` / `DELETE` methods are **permanently out of scope** and are enforced at the path validator. + +The wrapper is a **v2-first hybrid** with transparent v1 fallbacks for two known v2 quirks: + +1. CQL search uses Atlassian's v1-only `/wiki/rest/api/search` endpoint — there is no v2 CQL search. +2. Inline-comments reads automatically retry against the v1 `child/comment?location=inline` endpoint when v2 returns 404 (the well-known v2 inline-comment routing bug). The route response carries a `used_fallback` flag so operators can monitor when the v1 path is exercised. + +## Endpoint surface + +All eight routes are `POST /api/v1/confluence/...`, require a session token via `@require_session_auth`, and are gated by `@require_private_mode` (from `gateway/mode_gate.py`). In public mode every route returns 403 `private_mode_required` **before** any upstream call — no Atlassian credential is loaded and no network egress happens. A route-enumeration regression test in `gateway/tests/test_confluence_routes.py` iterates `app.url_map` and asserts `__egg_requires_private_mode__ = True` on every `/api/v1/confluence/*` view function so newly-added routes cannot accidentally drop the gate. + +| Endpoint | Purpose | Upstream | +|----------|---------|----------| +| `POST /api/v1/confluence/page/get` | Read a single page; default `body-format=storage` | `GET /wiki/api/v2/pages/{id}` | +| `POST /api/v1/confluence/page/descendants` | List pages under a page (depth-bounded by default) | `GET /wiki/api/v2/pages/{id}/descendants` | +| `POST /api/v1/confluence/page/footer-comments` | Read footer comments; optional v1 nested-reply merge | `GET /wiki/api/v2/pages/{id}/footer-comments` (+ `/wiki/api/v2/footer-comments` for replies) | +| `POST /api/v1/confluence/page/inline-comments` | Read inline comments; transparent v1 fallback on v2 404 | `GET /wiki/api/v2/pages/{id}/inline-comments` (+ `/wiki/rest/api/content/{id}/child/comment` v1 fallback) | +| `POST /api/v1/confluence/space/pages` | List pages in a space | `GET /wiki/api/v2/spaces/{space-id}/pages` | +| `POST /api/v1/confluence/space/list` | List spaces (filtered to allowlist) | `GET /wiki/api/v2/spaces` | +| `POST /api/v1/confluence/search` | CQL search with conservative static space-scope extraction | `GET /wiki/rest/api/search` | +| `POST /api/v1/confluence/execute` | GET-only regex-allowlisted passthrough | `GET /wiki/api/v2/...` or `GET /wiki/rest/api/...` | + +### `POST /api/v1/confluence/page/get` + +**Request body:** +```json +{ + "pageId": "12345", + "bodyFormat": ["storage"], // optional; default ["storage"] + "expand": null // optional passthrough +} +``` + +**Validation:** +- `pageId` must match `^\d+$`. +- `bodyFormat` entries (when supplied) must each be one of `"storage"`, `"atlas_doc_format"`, `"view"`, `"export_view"`. Comma-joined into the v2 `body-format` query parameter. +- The space-allowlist check runs **after** the upstream fetch using the response's `spaceId` (resolved to `spaceKey` via the in-process bidirectional cache; see [Page → space resolution caching](#page--space-resolution-caching)). If the resolved `spaceKey` is not in `confluence.spaces`, the gateway returns HTTP 403 `confluence_space_denied` and **does not forward the response body** — allowlist denials never leak page bodies. + +**Response:** the Atlassian page JSON with `body.storage` populated by default, post-redaction (see [Response redaction](#response-redaction)). On upstream 404, see the [`not_found` envelope](#not_found-envelope). + +### `POST /api/v1/confluence/page/descendants` + +**Request body:** +```json +{ + "pageId": "12345", + "depth": null, // optional; default depth=1 when caller omits + "limit": null, // optional; default limit=25 when caller omits + "cursor": null +} +``` + +When the caller omits `depth` and `limit`, the route applies sensible defaults (`depth=1`, `limit=25`) to bound runaway responses on deeply nested space trees. Caller-supplied values are passed through verbatim — there is **no gateway-imposed cap** in v1. Same `pageId` shape validation and post-fetch space-allowlist check as `/page/get`. + +### `POST /api/v1/confluence/page/footer-comments` + +**Request body:** +```json +{ + "pageId": "12345", + "bodyFormat": ["storage"], + "includeReplies": false, // optional; when true, merges nested replies via v1 fallback + "limit": null, + "cursor": null +} +``` + +Calls `ConfluenceClient.get_page_footer_comments(...)`. When `includeReplies=true`, the client follows up with `GET /wiki/api/v2/footer-comments?page-id={id}&depth=all` and merges the nested replies into the response under a normalized envelope (`{"results": [...], "_replies": {...}}`). The v2 endpoint alone returns only top-level footer comments; the secondary call closes that gap. Same post-fetch space-allowlist check as `/page/get`. + +### `POST /api/v1/confluence/page/inline-comments` + +**Request body:** +```json +{ + "pageId": "12345", + "bodyFormat": ["storage"], + "limit": null, + "cursor": null +} +``` + +Calls `ConfluenceClient.get_page_inline_comments(...)`. The client targets `GET /wiki/api/v2/pages/{id}/inline-comments` first; on v2 404 it transparently retries the v1 endpoint `GET /wiki/rest/api/content/{id}/child/comment?location=inline&expand=body.view` (the well-known v2 inline-comment routing bug). The route response contains a `used_fallback` boolean so operators can see how often v1 is being exercised. The fallback distinguishes three cases: + +| v2 response | v1 response | Wrapper response | +|-------------|-------------|------------------| +| 200 | (not called) | v2 payload, `used_fallback=false` | +| 404 | 200 with comments | v1 payload normalized, `used_fallback=true` | +| 404 | 200 empty `results` | `{"results": [], "used_fallback": true}` (page exists, has no inline comments) | +| 404 | 404 | [`not_found` envelope](#not_found-envelope) with `used_fallback=true` (page genuinely missing) | + +Each fallback also emits a `confluence_v1_fallback` audit entry with `{endpoint, v2_status, page_id}` so operators can monitor whether Atlassian has fixed the v2 bug and we can retire the fallback later. Same post-fetch space-allowlist check as `/page/get`. + +### `POST /api/v1/confluence/space/pages` + +**Request body:** +```json +{ + "spaceKey": "ENG", + "bodyFormat": ["storage"], + "limit": null, + "cursor": null +} +``` + +`spaceKey` is validated against `^[a-zA-Z][a-zA-Z0-9_]*$` and the allowlist check runs **before** any upstream call (the agent supplied the key directly, no risk of bypass via response shape). The route then resolves `spaceKey → spaceId` via `ConfluenceClient.list_spaces(allowed_spaces=...)`; if no match, HTTP 404 `{"status": "not_found", "spaceKey": "..."}`. On match, calls `get_space_pages(space_id, ...)`. + +### `POST /api/v1/confluence/space/list` + +**Request body:** +```json +{ + "limit": null, + "cursor": null +} +``` + +Calls `ConfluenceClient.list_spaces(allowed_spaces=allowed_spaces(), ...)`. The response is **filtered to allowlisted spaces only** — agents cannot enumerate the full tenant space set. The Atlassian cursor is preserved if any allowlisted spaces were filtered out so callers can paginate. Audit entry: `confluence_space_list` with `spaces_returned: N` (count after filtering). + +### `POST /api/v1/confluence/search` + +**Request body:** +```json +{ + "cql": "space = ENG AND text ~ \"RFC\"", + "limit": null, // optional; clamped to 100, default 50 + "cursor": null +} +``` + +`limit` is clamped to **100** (default 50). CQL search uses Atlassian's v1-only `/wiki/rest/api/search` endpoint — there is no v2 CQL search. + +**Conservative static CQL space-scope extractor.** To keep the route safe even against adversarial CQL, the gateway does **not** pass arbitrary queries through. It statically extracts the space scope and **denies on ambiguity**. The CQL is accepted only if it matches one of: + +- `space = KEY` (case-sensitive `space` keyword) at top level, ANDed only. +- `space IN (K1, K2, ...)` at top level with every key in `confluence.spaces`, ANDed only. + +…optionally AND-combined at top level with arbitrary additional clauses (e.g., `space = ENG AND text ~ "rfc"`). + +Rejected (403 `confluence_search_rejected` with the matched reason): + +- No `space` clause at all. +- `space` under any `OR` — including `space = ENG OR space = SEC` and `space = ENG OR id = "12345"`. +- Case-variant keywords (`SPACE = ENG`). +- Quoted space keys (`space = "ENG"`) are rejected unconditionally — the static extractor requires bare keys, even when the quoted key decodes to an allowlisted space. Rationale: deny-on-ambiguity, mirrors Jira's stance for the same reason. +- CQL functions (`currentUser()`, `recentlyViewedContent()`, `now()`, etc.) inside the `space` operand. +- Bare `id =` / `content =` / `title ~` clauses without a `space` clause. +- Semicolons, CQL comments (`/* */`, `--`, `//`), or other injection patterns. +- Unicode homoglyph / mixed-script space keys (e.g., `ENG`). +- `IN` lists containing any non-allowlisted key. + +The extractor is the hard boundary — if it cannot prove the query is scoped to allowlisted spaces, the request is denied. The audit entry records `spaces_extracted` on acceptance and the rejection reason on denial. + +### `POST /api/v1/confluence/execute` + +**Request body:** +```json +{ + "method": "GET", + "path": "api/v2/pages/12345", + "query": { "body-format": "storage" }, + "body": null +} +``` + +Only `GET` is accepted. The `path` is validated against a hardened regex allowlist in `validate_confluence_api_path`: + +- Leading/trailing slashes are stripped, query strings are stripped, `..` segments are rejected, duplicate slashes are rejected, non-ASCII / non-normalized Unicode is rejected, URL-encoded smuggling (e.g., `%61ttachments`) is rejected. +- Allowed path families (GET-only): `^api/v2/pages/\d+$`, `^api/v2/pages/\d+/descendants$`, `^api/v2/pages/\d+/footer-comments$`, `^api/v2/pages/\d+/inline-comments$`, `^api/v2/footer-comments$`, `^api/v2/inline-comments$`, `^api/v2/spaces$`, `^api/v2/spaces/\d+/pages$`, `^rest/api/search$`, `^rest/api/content/\d+/child/comment$` (the v1 fallback for inline comments). +- Any path containing `restrictions`, `permissions`, `space.admin`, `users`, or `attachments` is rejected — these are the permanent "out of scope ever" verbs (decision 12). The `CONFLUENCE_DENIED_VERBS` frozenset checks for the term in any path position so `pages/123/attachments` is refused as well. + +For path families that target a specific resource (`pages/{id}`, `spaces/{id}/pages`), the post-fetch space-allowlist check runs once the upstream response arrives, identical to the narrow routes. For families that don't carry an obvious `spaceId` in the response (e.g., `api/v2/footer-comments?page-id=...`), the route requires a `spaceKey` query parameter and validates it up-front before issuing the upstream call. + +**Anti-bypass invariant.** `/execute` does **not** accept paths that the narrow routes already cover (`api/v2/pages/{id}`, `api/v2/spaces/{id}/pages`, `rest/api/search`); routing those through `/execute` is refused with the same `confluence_execute_denied` audit category. This prevents an attacker from bypassing narrow-route policy checks (e.g., the CQL extractor) by re-routing through `/execute`. A regression test asserts that every narrow-route path family fails the `/execute` validator. + +`/execute` is a pragmatic escape hatch for future read verbs not yet promoted to narrow routes. It is **not** a general passthrough — the regex allowlist plus the anti-bypass invariant is the fence. + +## `not_found` envelope + +The Atlassian v2 API returns 404 for missing pages/spaces with error-message JSON that varies across instances and rarely maps cleanly to a sandbox flow. To give agents a stable shape, the gateway **intercepts upstream 404 on the read methods** (`page/get`, `page/descendants`, `page/footer-comments`, `page/inline-comments`, `space/pages`) and returns HTTP 200 with: + +```json +{ + "status": "not_found", + "id": "12345", + "upstream_status": 404 +} +``` + +For the inline-comments route specifically, the envelope additionally carries `"used_fallback": true` when the v1 fallback also returned 404 (so callers can tell the page genuinely doesn't exist, not just "v2 bug + page exists"). + +`/search` and `/execute` do **not** use the envelope — their upstream 404 is a real API error (wrong path, deleted space, etc.) and is surfaced as `ConfluenceUpstreamError` and translated to the original upstream status by the route handler. + +## Response redaction + +Every successful response body is sanitised by `redact_response(payload)` in `gateway/confluence_client.py` before it leaves the gateway. The recursive walker: + +- Replaces every `accountId` value (at any depth) with `""`. +- Replaces every `emailAddress` value (at any depth) with `""`. +- Strips `_links.webui` user-profile URLs — any URL whose path begins with `/people/` or matches an Atlassian user-profile shape. **Page and space `_links.webui` URLs are preserved** — those are addressable resources the agent legitimately needs. + +The walker handles nested ADF mention nodes and `body.atlas_doc_format.content` trees, so the redaction holds for storage-format, ADF, and view-format bodies alike. + +If a tenant carries custom Confluence macros, page properties, or fields known to hold PII or secrets beyond the three default keys, file a follow-up to extend the redaction list — the v1 design ships defaults only (refine-phase Q3). + +## Error cases + +| HTTP | Condition | Audit event | +|------|-----------|-------------| +| 400 | Malformed `pageId` / `spaceKey`, invalid `bodyFormat`, missing required body field | `confluence__rejected` with reason | +| 401 | Session token invalid / missing | Standard gateway auth rejection | +| 403 | Public mode (private-mode gate) | `private_mode_required` | +| 403 | Resolved space not in `confluence.spaces` | `confluence_space_denied` | +| 403 | `/search` CQL fails the static scope extractor | `confluence_search_rejected` with the specific reason | +| 403 | `/execute` denied verb, non-GET method, path traversal, disallowed path family, duplicate slash, non-ASCII, narrow-route bypass attempt | `confluence_execute_denied` with reason | +| 403 | Atlassian returned 403 (bot lacks read access on the resource) | `confluence_upstream_403` (distinct from generic `confluence_upstream_error`) — body: `{"status": "forbidden", "reason": "bot_account_lacks_read_access", "pageId" \| "spaceKey": "..."}` | +| 413 | Response body exceeds `CONFLUENCE_RESPONSE_MAX_BYTES` (5 MiB) post-redaction | `confluence_response_too_large` | +| 503 | Atlassian credentials not configured (`ConfluenceCredentialsUnavailable`) | `confluence_credentials_unavailable` | +| *upstream* | Atlassian 4xx/5xx other than the 404 envelope paths and the 403 escalation | Upstream status passed through, `confluence_upstream_error` audit entry | + +**429 handling.** Atlassian rate-limit responses are retried exactly once, sleeping `min(Retry-After, 30)` seconds; retry is GET-only, write verbs never retry (future-proofing). Both the initial and retry 429 emit a `confluence_upstream_rate_limited` audit entry including the `Retry-After` value, the path, and an `attempt: 1|2` field so operators can tell whether the retry succeeded. After the second 429, the response is passed through verbatim. Identical semantics to the Jira client. + +**Why the 403 escalation has its own audit category.** Atlassian's permission model is per-page (and per-space), so a bot that has read access to a space can still hit 403 on a specific page whose hierarchy is restricted. Splitting `confluence_upstream_403` from generic `confluence_upstream_error` (refine-phase Q7) lets operators distinguish: + +- `confluence_space_denied` — gateway-side allowlist denial (operator-facing config issue). +- `confluence_upstream_403` — Atlassian-side permission denial (bot needs more access in Atlassian's UI). +- `confluence_upstream_error` — generic upstream error (Atlassian outage, malformed request, etc.). + +Every audit entry includes `session_mode`, `pipeline_id`, `agent_role`, and (where applicable) `pageId` / `spaceKey` so operators can reconcile Confluence calls with the pipeline they came from. **Per [refine-phase decision 13](#related-documentation), there is no per-pipeline `EGG_CONFLUENCE_*` env var** — the audit recovers `pageId` / `spaceKey` from each request body or response. Confluence is reference material, not a unit of work. + +## Space-allowlist semantics + +The allowlist lives in `config/context-filters.yaml` alongside the existing Jira section: + +```yaml +confluence: + # Atlassian Confluence space keys agents are allowed to read through + # the /api/v1/confluence/* endpoints. Space keys are case-sensitive. + spaces: ["ENG", "DOCS"] +``` + +- The authoritative key is `spaces` (parallel to Jira's `projects`). +- Default is an **empty list** — every Confluence call is rejected until an operator populates it. This is the "installed but inert" state for v1 rollout (refine-phase Q1). +- Fail-closed: if the file is missing, the `confluence:` section is absent, the `spaces:` key is missing, the YAML is malformed, or the value isn't a list, `allowed_spaces()` returns an empty set and no error is raised. Operators must see 403s on every Confluence call rather than a crashed gateway. Schema mismatches (e.g., `spaces: "ENG"` instead of a list) emit ERROR-level audit entries on first read so the misconfiguration is visible in the gateway logs immediately. +- Mixed-case keys are preserved verbatim. Atlassian space keys are conventionally uppercase but the API accepts mixed case; the allowlist intersects strictly case-sensitive. +- Reloaded on mtime change; `POST /api/v1/config/reload` calls `reload_confluence_policy()` and `reload_confluence_credentials()` as part of the existing `_reload_all_config()` hook. + +**Why `context-filters.yaml` and not a dedicated file?** Operators already edit this file for GitHub context filtering and Jira project allowlisting; keeping Confluence policy in the same place means one allowlist surface to review. The Confluence section is self-contained and does not interact with the GitHub or Jira sections. + +**No `EGG_CONFLUENCE_*` env vars.** Unlike Jira (where `EGG_JIRA_TICKET` is exported as the ticket the pipeline is scoped to), Confluence has **no per-pipeline env var** in v1 (refine-phase decision 13). Confluence is consulted as reference material from ticket/epic links, not as the pipeline's primary unit of work. Audits recover `pageId` / `spaceKey` from each request body or response. + +## Default `body-format=storage` + +Atlassian Confluence stores page bodies in three primary formats: + +- **`storage`** — Confluence's internal XHTML-like format. Compact, lossy on rich macros but readable as a string. +- **`atlas_doc_format`** (ADF) — JSON tree. Most structured; necessary for ADF-aware traversal. +- **`view`** — rendered HTML. Lossy on macros / layout but immediately consumable. +- **`export_view`** — rendered HTML for export. Larger payloads than `view`. + +The gateway's `ConfluenceClient.get_page` and the comment methods default to `body-format=storage` (refine-phase decision 5, operator tweak). Callers may override per-call by passing `bodyFormat: ["atlas_doc_format"]` (or any combination of the four), and the client comma-joins the entries into the v2 query. The default exists so the common case "just read me this page" works without additional ceremony and produces the smallest payload on the wire. + +## Page → space resolution caching + +The post-fetch space-allowlist check needs each page's `spaceKey`, which the v2 page response carries as a numeric `spaceId`. To avoid double-fetching (or refetching the page just to verify the space on the comment routes), the client maintains a single bidirectional `spaceId ↔ spaceKey` LRU cache with a 60-second TTL, populated by both `list_spaces` and `get_page`. The comment routes (`page/footer-comments`, `page/inline-comments`) reuse the cache so they do not refetch the page. `/space/pages` (`spaceKey → spaceId` cold start) reuses the cache so the cold-start lookup does not always cost a double round-trip. + +## Atlassian rate-limit runbook + +Atlassian Cloud uses a points-based throttling model (enforced site-wide as of March 2026). The wrapper retries once on 429 honouring `Retry-After`; persistent throttling surfaces as `confluence_upstream_rate_limited` audit events with `attempt: 1|2` fields. Operators seeing routine throttling should: + +1. Provision a dedicated Atlassian bot account (separate from human users) so points contention does not happen against interactive Confluence usage on the same tenant. +2. Note that the bot account is **shared with Jira read scope** (refine-phase decision 9), so the points-based quota is **pooled across `/api/v1/jira/*` and `/api/v1/confluence/*` traffic**. Two unrelated pipelines reading from Jira and Confluence simultaneously can throttle each other. +3. Consider provisioning a Confluence-only bot in a follow-up ticket if pooled-quota throttling becomes a recurring issue. The shared-bot decision optimises for credential simplicity in v1; splitting later is a per-tenant call. + +## Bot-vs-human access caveat + +The gateway authenticates as the dedicated Atlassian bot account. The host-side `mcp__confluence__*` MCP authenticates as the consenting human user. **There can be pages a human reads but the bot cannot** (or vice versa) when Atlassian's per-page or per-space permission scheme excludes the bot. Operators must verify the bot's effective access at deploy time before enabling the feature in production: + +- Confirm the bot has at least "View" permission on every space listed in `confluence.spaces` (Atlassian → Space settings → Permissions). +- For pages restricted by hierarchy, confirm the bot has read access on the restricted ancestor (Atlassian → Page restrictions UI). + +A diagnostic command that surfaces the bot's effective space / page access from inside the gateway is **deferred to a follow-up ticket** (refine-phase Q9) — v1 ships the documentation only. In the meantime, operators can manually verify by issuing test requests against `/api/v1/confluence/page/get` for canonical pages in each allowlisted space. Hits that 403 with `confluence_upstream_403` indicate the bot lacks permission; hits that 403 with `confluence_space_denied` indicate the gateway-side allowlist is incomplete. + +## Prompt-injection caveat + +**Confluence content is untrusted input.** Pages and comments are written by humans (and increasingly by bots), and may carry instructions that target a downstream agent. Reviewers and operators should treat anything returned by `/api/v1/confluence/*` as **data, not directives** — wrap it in clear delimiters when feeding it to a model, and never let an agent execute instructions read from a Confluence body without an additional explicit human-approved step. + +The wrapper does not attempt to sanitise content for prompt-injection; that's the consumer's responsibility. The gateway's contribution is to keep the surface narrow (space allowlist, verb allowlist, response redaction) so that the universe of attacker-controllable content is bounded and auditable. + +## Future-verb extension points + +The v1 shape is deliberately the base case for the follow-up write verbs (not in this ticket): + +- **`POST /api/v1/confluence/page/create`** — new narrow route. Adds `POST /wiki/api/v2/pages` to `validate_confluence_api_path` (POST-allowed list keyed to the same space-allowlist gate). `ConfluenceClient.create_page(space_id, title, body, body_format)` added; existing `_request` 429-retry does not retry writes (already enforced for future safety). +- **`POST /api/v1/confluence/page/update`** — new narrow route. Adds `PUT /wiki/api/v2/pages/{id}` (Atlassian's update endpoint is PUT). `ConfluenceClient.update_page(page_id, title, body, version, body_format)`. +- **`POST /api/v1/confluence/comment/create`** — new narrow route. Adds `POST /wiki/api/v2/footer-comments` and `POST /wiki/api/v2/inline-comments`. `ConfluenceClient.add_footer_comment(page_id, body)` / `add_inline_comment(page_id, body, anchor)`. + +All three land under the same `@require_session_auth` → `@require_private_mode` → space-allowlist chain. None of them extends the `/execute` passthrough — the regex allowlist there stays GET-only. The `CONFLUENCE_DENIED_VERBS` frozenset permanently refuses `restrictions`, `permissions`, `space.admin`, `users`, `attachments`, `DELETE`, `PUT`, and `PATCH` at the path validator (the v1 path validator's `PUT` denial is lifted only for the explicit `pages/{id}` write route, behind a new POST/PUT-allowlist). + +**Idempotency for `comment/create`** is **deferred to the future-writes phase** (refine-phase Q4). Atlassian's create-comment endpoint is not naturally idempotent. The gateway design will be made consistent across Jira and Confluence (Jira write verbs are also out of scope today) so the design-once decision is made once. + +**Deferred to v1.1 (explicit, not silently dropped):** + +- `page/resolve-by-url` — given a Confluence URL, return the canonical `pageId` and `spaceKey` (refine-phase Q5). v1 expects callers to parse `pageId` from URLs themselves. +- `?version=` parameter on `/page/get` — read historical revisions (refine-phase Q8). v1 returns current-version only. +- Per-verb rate-limit config under `confluence.rate_limits:` — v1 inherits Jira's defaults. +- `EGG_CONFLUENCE_ENABLED` kill-switch env var. +- Custom-macro / page-property PII redaction beyond the three default keys (refine-phase Q3). + +## Migration: shared `ATLASSIAN_*` credentials + +The Confluence wrapper credentials and the Jira wrapper credentials both prefer a shared **`ATLASSIAN_BASE_URL` / `ATLASSIAN_USERNAME` / `ATLASSIAN_API_TOKEN`** triple, with per-key fall-back to the legacy `JIRA_*` and `CONFLUENCE_*` blocks for back-compat (refine-phase decision F1). Per-key precedence means a value set under `ATLASSIAN_*` wins for that key; missing keys fall back to the per-service prefix. This is what makes "shared Atlassian credential" portable — operators can copy values from the legacy blocks to `ATLASSIAN_*` and remove the legacy blocks once the shared triple is fully populated, without breaking either service. + +Base-URL derivation: + +- If `CONFLUENCE_BASE_URL` is set, the loader uses it verbatim. Operators have already added the `/wiki` suffix. +- If `CONFLUENCE_BASE_URL` is unset and `ATLASSIAN_BASE_URL` is set, the loader **derives** the Confluence base URL by appending `/wiki` to `ATLASSIAN_BASE_URL`. (Jira's base URL is the bare Atlassian origin; Confluence lives under `/wiki`.) + +The Confluence loader (`gateway/confluence_credentials.py`) and the Jira loader (`gateway/jira_credentials.py`, updated as part of #1931 task 1-5) duplicate the loader skeleton in v1 — extracting a shared `atlassian_credentials.py` helper is tracked as a follow-up backlog item (architect Q4). + +## Related documentation + +- [Credential Injection — Atlassian / Confluence](../architecture/credential-injection.md#atlassian--confluence) — where credentials live, mtime refresh, zero-credential invariant +- [Network Isolation — Gateway REST API](../architecture/network-isolation.md#gateway-rest-api) — endpoint summary and Squid allowlist exclusion +- [Sandbox environment rules](../../sandbox/agent-config/rules/environment.md) — `confluence` wrapper verbs, no per-pipeline env var +- [Jira Wrapper Reference](jira-wrapper.md) — sister wrapper this design mirrors +- Gateway source: `gateway/confluence_credentials.py`, `gateway/confluence_client.py`, `gateway/confluence_policy.py`, `gateway/confluence_search.py`, `gateway/mode_gate.py` +- Sandbox wrapper: `sandbox/scripts/confluence` +- Config: `config/context-filters.yaml` (`confluence.spaces:`), `config/secrets.template.env` (`ATLASSIAN_*` shared block + legacy `CONFLUENCE_*` block) diff --git a/sandbox/agent-config/rules/environment.md b/sandbox/agent-config/rules/environment.md index d09e4c06cb..d6d31fbd8f 100644 --- a/sandbox/agent-config/rules/environment.md +++ b/sandbox/agent-config/rules/environment.md @@ -71,6 +71,49 @@ jira search 'project = ENG OR project = SEC' **Hard limits (always denied):** `transitions`, `worklog`, `attachments`, `watchers`, HTTP `DELETE` / `PUT` / `PATCH`, path traversal (`..`), duplicate slashes, non-ASCII keys. Non-GET `execute` calls return 403 regardless of the path. See [Jira Wrapper Reference](../../../docs/reference/jira-wrapper.md) for the full endpoint surface, JQL scope extractor rules, and the `not_found` response envelope. +### Confluence Wrapper (`confluence`) + +The `sandbox/scripts/confluence` wrapper is the only way for the sandbox to reach Confluence — it POSTs to the gateway's `/api/v1/confluence/*` routes with `Authorization: Bearer $EGG_SESSION_TOKEN` and Atlassian credentials never enter the sandbox. **Private network mode only**: in public mode every Confluence call returns 403 `private_mode_required` before any upstream request. + +| Verb | Gateway route | +|------|---------------| +| `confluence page get [--body-format storage,atlas_doc_format] [--expand ...]` | `POST /api/v1/confluence/page/get` | +| `confluence page descendants [--depth N] [--limit N] [--cursor TOK]` | `POST /api/v1/confluence/page/descendants` | +| `confluence page footer-comments [--include-replies] [--body-format ...] [--limit N] [--cursor TOK]` | `POST /api/v1/confluence/page/footer-comments` | +| `confluence page inline-comments [--body-format ...] [--limit N] [--cursor TOK]` | `POST /api/v1/confluence/page/inline-comments` | +| `confluence space pages [--body-format ...] [--limit N] [--cursor TOK]` | `POST /api/v1/confluence/space/pages` | +| `confluence space list [--limit N] [--cursor TOK]` | `POST /api/v1/confluence/space/list` | +| `confluence search '' [--limit N] [--cursor TOK]` | `POST /api/v1/confluence/search` | +| `confluence execute [--query k=v,...] [--body-file path]` | `POST /api/v1/confluence/execute` (GET-only) | + +**No per-pipeline env var.** Unlike Jira (`EGG_JIRA_TICKET`), Confluence has **no orchestrator-exported env var** — agents pass `pageId` / `spaceKey` directly in each call. Confluence is reference material, not a primary unit of work; the gateway recovers `pageId` / `spaceKey` from each request body or response for audit purposes. + +**Default body format.** Reads default to `body-format=storage` (smallest payload, Confluence's internal XHTML-like format). Pass `--body-format atlas_doc_format` for the ADF JSON tree, or `--body-format view` for rendered HTML. + +**Example:** +```bash +# Read a referenced page from a Jira ticket — pass the numeric pageId +confluence page get 12345 + +# Search within an allowlisted space (CQL must statically scope to allowlisted spaces) +confluence search 'space = ENG AND text ~ "RFC"' + +# List spaces visible to the agent (response is filtered to the allowlist) +confluence space list + +# Read inline comments — wrapper transparently retries against v1 if v2 returns 404 +confluence page inline-comments 12345 + +# This WILL be rejected with 403 confluence_search_rejected — the CQL scope extractor +# denies on ambiguity, so any `OR` clause containing `space` is refused even when +# every candidate is allowlisted. +confluence search 'space = ENG OR space = SEC' +``` + +**Hard limits (always denied):** `restrictions`, `permissions`, `space.admin`, `users`, `attachments`, HTTP `DELETE` / `PUT` / `PATCH`, path traversal (`..`), duplicate slashes, non-ASCII keys, URL-encoded smuggling of denied terms (e.g., `%61ttachments`), and any `/execute` path that a narrow route already covers (anti-bypass). Non-GET `execute` calls return 403 regardless of the path. See [Confluence Wrapper Reference](../../../docs/reference/confluence-wrapper.md) for the full endpoint surface, CQL scope extractor rules, the `not_found` response envelope, response redaction (`accountId` / `emailAddress` / user-profile `_links.webui`), and the `used_fallback` flag emitted when the v1 inline-comment fallback fires. + +> **Note on `~/context-sync/confluence/`.** The sandbox may also have a read-only `~/context-sync/confluence/` cache mounted (legacy syncer). That cache is independent of the new gateway wrapper — `confluence ...` calls always go through the gateway and never touch the syncer cache. + ## File System | Path | Purpose | From efbfd5e5d8073961573ba25d04221d854d2e2551 Mon Sep 17 00:00:00 2001 From: egg Date: Mon, 27 Apr 2026 00:33:49 +0000 Subject: [PATCH 11/26] gateway: Confluence foundation modules + ATLASSIAN_* credential precedence (#1931) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 of the Confluence read-only gateway support. Introduces the Confluence-specific building blocks the routes will compose: - gateway/confluence_credentials.py — ATLASSIAN_*/CONFLUENCE_* per-key precedence with /wiki base derivation, mtime-cached, thread-safe. - gateway/confluence_client.py — class-shaped v2-first client with v1 fallbacks for inline-comment 404 and footer-comment nested replies, redaction (accountId / emailAddress / user-profile webui links), payload-size cap (5 MiB), 429 single-retry, 403 envelope, space cache. - gateway/confluence_policy.py — confluence.spaces YAML allowlist loader with mtime cache and fail-closed semantics. - gateway/confluence_search.py — conservative CQL space=/space IN(...) extractor mirroring jira_search's deny-on-ambiguity stance. - gateway/jira_credentials.py — extended to honor ATLASSIAN_*/JIRA_* per-key precedence so the shared-credential migration is portable. Also adds confluence: section to config/context-filters.yaml (empty, fail-closed) and ATLASSIAN_* triple to config/secrets.template.env. Co-Authored-By: Claude Opus 4.7 --- config/context-filters.yaml | 18 + config/secrets.template.env | 30 +- gateway/confluence_client.py | 1070 +++++++++++++++++++++++++++++ gateway/confluence_credentials.py | 286 ++++++++ gateway/confluence_policy.py | 244 +++++++ gateway/confluence_search.py | 212 ++++++ gateway/jira_credentials.py | 77 ++- 7 files changed, 1915 insertions(+), 22 deletions(-) create mode 100644 gateway/confluence_client.py create mode 100644 gateway/confluence_credentials.py create mode 100644 gateway/confluence_policy.py create mode 100644 gateway/confluence_search.py diff --git a/config/context-filters.yaml b/config/context-filters.yaml index b2fd1d95d7..512ab67e0b 100644 --- a/config/context-filters.yaml +++ b/config/context-filters.yaml @@ -22,3 +22,21 @@ jira: # statically provable as scoped to the listed projects — see # gateway/jira_search.py for the exact acceptance rules. projects: [] + +confluence: + # Atlassian Confluence space keys agents are allowed to read through the + # /api/v1/confluence/* endpoints. Space keys typically match the shape: + # leading letter followed by letters / digits / underscore (e.g. ENG, + # DOCS, ONBOARDING). Case-sensitive. + # + # Example: + # spaces: ["ENG", "DOCS"] + # + # Any page whose space is not on this list returns HTTP 403 with + # `confluence_*_denied` in the gateway audit log. CQL searches must be + # statically provable as scoped to the listed spaces — see + # gateway/confluence_search.py for the exact acceptance rules. + # + # Leaving this list empty blocks every Confluence call until an operator + # populates it (fail-closed). + spaces: [] diff --git a/config/secrets.template.env b/config/secrets.template.env index cf31fd503f..b65d1a6eda 100644 --- a/config/secrets.template.env +++ b/config/secrets.template.env @@ -89,19 +89,39 @@ GATEWAY_BOT_BRANCH_PREFIX="" GATEWAY_TRUSTED_USERS="" # ============================================================================= -# Confluence Integration (Optional) +# Atlassian Cloud Integration (Jira + Confluence) — Recommended # ============================================================================= -# Get API token from https://id.atlassian.com/manage-profile/security/api-tokens +# Atlassian accounts are tenant-wide. A single bot account with read scopes +# on both Jira and Confluence covers the gateway's read-only wrappers. +# +# Get an API token from https://id.atlassian.com/manage-profile/security/api-tokens +# +# When set, ATLASSIAN_* takes precedence over JIRA_* / CONFLUENCE_* per key +# (the loader checks each key independently, so partial migrations work). +# Confluence Cloud lives at /wiki — the gateway appends +# /wiki automatically when ATLASSIAN_BASE_URL is set and CONFLUENCE_BASE_URL +# is unset. + +ATLASSIAN_BASE_URL="" # e.g., https://yourcompany.atlassian.net (no trailing slash) +ATLASSIAN_USERNAME="" # Atlassian account email +ATLASSIAN_API_TOKEN="" # Atlassian Cloud API token + +# ============================================================================= +# Confluence Integration (Optional, legacy per-service triple) +# ============================================================================= +# Back-compat fallback — used per-key when the matching ATLASSIAN_* is unset. +# Consumed by gateway/confluence_credentials.py. The space allowlist lives +# in config/context-filters.yaml under confluence.spaces. CONFLUENCE_BASE_URL="" # e.g., https://yourcompany.atlassian.net/wiki CONFLUENCE_USERNAME="" # Your email CONFLUENCE_API_TOKEN="" -CONFLUENCE_SPACE_KEYS="" # Comma-separated list of space keys to sync +CONFLUENCE_SPACE_KEYS="" # Legacy — now read from context-filters.yaml # ============================================================================= -# JIRA Integration (Optional) +# JIRA Integration (Optional, legacy per-service triple) # ============================================================================= -# Same API token as Confluence if using Atlassian Cloud. +# Back-compat fallback — used per-key when the matching ATLASSIAN_* is unset. # Consumed by gateway/jira_credentials.py — sandboxed agents reach Jira # through the gateway's /api/v1/jira/* endpoints; these credentials are # never exported to the sandbox. diff --git a/gateway/confluence_client.py b/gateway/confluence_client.py new file mode 100644 index 0000000000..5f9c59ac08 --- /dev/null +++ b/gateway/confluence_client.py @@ -0,0 +1,1070 @@ +""" +Confluence REST API client for the gateway sidecar. + +Provides a thin, read-only wrapper around the Atlassian Cloud Confluence +REST API. All traffic originates from the gateway (never from the sandbox) +and is authenticated with Basic auth using credentials loaded from +``gateway/confluence_credentials.py``. + +Per-verb endpoint pinning (decision B1): + +The wrapper is v2-first hybrid (refine decision #2): every public verb is +pinned to a specific Atlassian API version, and known v2 bugs (inline- +comment 404, footer-comment nested-reply gap) fall back transparently to +v1. Each fall-back emits a structured ``confluence_v1_fallback`` audit +entry so operators can monitor whether Atlassian has fixed the v2 bugs and +the fallback can be retired. + +Public surface (used by ``/api/v1/confluence/*`` routes in ``gateway.py``): + +- ``ConfluenceClient.get_page(page_id, body_format=("storage",), expand=None)`` + → ``GET /wiki/api/v2/pages/{id}`` +- ``ConfluenceClient.get_page_descendants(page_id, depth=None, limit=None, + cursor=None)`` → ``GET /wiki/api/v2/pages/{id}/descendants`` +- ``ConfluenceClient.get_page_footer_comments(page_id, body_format=("storage",), + include_replies=False, limit=None, cursor=None)`` → + ``GET /wiki/api/v2/pages/{id}/footer-comments`` (+ nested-reply pull when + requested) +- ``ConfluenceClient.get_page_inline_comments(page_id, body_format=("storage",), + limit=None, cursor=None)`` → ``GET /wiki/api/v2/pages/{id}/inline-comments`` + with v1 fallback on 404 +- ``ConfluenceClient.list_spaces(allowed_spaces, limit=None, cursor=None)`` + → ``GET /wiki/api/v2/spaces`` filtered to ``allowed_spaces`` +- ``ConfluenceClient.get_space_pages(space_id, limit=None, cursor=None, + body_format=("storage",))`` → ``GET /wiki/api/v2/spaces/{space-id}/pages`` +- ``ConfluenceClient.search_cql(cql, limit=None, cursor=None)`` → + ``GET /wiki/rest/api/search`` (v1-only — there is no v2 CQL endpoint) +- ``ConfluenceClient.execute_raw(method, path, query=None, body=None)`` → + passthrough used by ``/api/v1/confluence/execute`` for read-only paths. + +Path safety: + +- ``validate_confluence_api_path(path, method)`` enforces a regex allowlist + of the read-only REST paths permitted by v1. Write verbs and path + fragments in ``CONFLUENCE_DENIED_VERBS`` (``restrictions``, + ``permissions``, ``space.admin``, ``users``, ``attachments``) are rejected + unconditionally. + +429 handling (Q2, risk R10): + +- GET requests retry at most once on HTTP 429, honoring ``Retry-After`` up + to 30s. Both attempts emit ``confluence_upstream_rate_limited`` audit + entries. + +404 envelope (architect D8): + +- ``get_page``, ``get_page_descendants``, ``get_page_footer_comments``, + ``get_page_inline_comments``, and ``get_space_pages`` translate upstream + 404 into a structured ``{"status": "not_found", "id": ..., + "upstream_status": 404}`` dict. ``search_cql`` and ``execute_raw`` still + raise ``ConfluenceUpstreamError`` for 404. + +403 envelope (Q7, risk R15): + +- All read methods raise ``ConfluenceUpstreamForbidden`` on upstream 403 + so the route layer can audit it as ``confluence_upstream_403`` (distinct + from generic upstream errors). + +Response redaction (decision 10): + +- ``redact_response`` walks every JSON response and strips ``accountId``, + ``emailAddress``, and user-profile ``_links.webui`` URLs before returning + to the route layer. Page / space ``_links.webui`` URLs are preserved + because they are addressable by the agent. + +Payload-size cap (risk R7): + +- Responses larger than ``CONFLUENCE_RESPONSE_MAX_BYTES`` (5 MiB) raise + ``ConfluenceResponseTooLarge`` so the route layer can return HTTP 413. +""" + +from __future__ import annotations + +import json +import re +import sys +import threading +import time +from collections import OrderedDict +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +import httpx + +# Add shared directory to path for egg_logging +_shared_path = Path(__file__).parent.parent / "shared" +if _shared_path.exists() and str(_shared_path) not in sys.path: + sys.path.insert(0, str(_shared_path)) + +from egg_logging import get_logger + +try: + from .confluence_credentials import ( + ConfluenceCredentials, + ConfluenceCredentialsUnavailable, + get_confluence_credentials, + ) +except ImportError: + from confluence_credentials import ( # type: ignore[no-redef, import-untyped] + ConfluenceCredentials, + ConfluenceCredentialsUnavailable, + get_confluence_credentials, + ) + +logger = get_logger("gateway.confluence-client") + + +# ----------------------------------------------------------------------------- +# Constants & validation helpers +# ----------------------------------------------------------------------------- + +# Allowed HTTP methods for Confluence REST calls in v1. Read-only fence. +ALLOWED_METHODS: frozenset[str] = frozenset({"GET"}) + +# Path / verb segments and HTTP methods permanently out of scope for the +# Confluence wrapper. See decisions 12 (attachments) and the broader read- +# only stance of v1. Even if a future maintainer widens ALLOWED_METHODS, +# the gateway will still refuse these. +CONFLUENCE_DENIED_VERBS: frozenset[str] = frozenset( + { + # Path-segment denylist. + "restrictions", + "permissions", + "space.admin", + "users", + "attachments", + # HTTP-method denylist (also enforced by ALLOWED_METHODS). + "DELETE", + "PUT", + "PATCH", + "POST", + } +) + +# Allowed body-format tokens passed to the v2 ``body-format=`` query param. +# Everything else is rejected at validation time. +ALLOWED_BODY_FORMATS: frozenset[str] = frozenset( + {"storage", "atlas_doc_format", "view", "export_view"} +) + +# Default body format (decision-5 tweak): single body shape on the wire by +# default to keep payloads small; planners that need ADF tree traversal +# pass the override per-call. +DEFAULT_BODY_FORMAT: tuple[str, ...] = ("storage",) + + +# Regex allowlist for /api/v1/confluence/execute. GET only; intentionally +# narrow. Confluence Cloud v2 reads live under ``api/v2/...``, search and +# the v1 fallback live under ``rest/api/...``. +_PAGE_ID = r"\d+" +_SPACE_ID = r"\d+" + +CONFLUENCE_API_ALLOWED_PATHS: list[re.Pattern[str]] = [ + re.compile(rf"^api/v2/pages/{_PAGE_ID}$"), + re.compile(rf"^api/v2/pages/{_PAGE_ID}/descendants$"), + re.compile(rf"^api/v2/pages/{_PAGE_ID}/footer-comments$"), + re.compile(rf"^api/v2/pages/{_PAGE_ID}/inline-comments$"), + re.compile(r"^api/v2/footer-comments$"), + re.compile(r"^api/v2/inline-comments$"), + re.compile(r"^api/v2/spaces$"), + re.compile(rf"^api/v2/spaces/{_SPACE_ID}/pages$"), + re.compile(r"^rest/api/search$"), + # v1 fallback for inline / footer comments (decision D1). + re.compile(rf"^rest/api/content/{_PAGE_ID}/child/comment$"), +] + +# CQL search has a 200-result hard upper bound at Atlassian. We clamp at +# 100 to match Jira's defaults and keep transcripts predictable. +DEFAULT_LIMIT: int = 25 +HARD_MAX_LIMIT: int = 100 + +# Hard payload cap on responses returned to the sandbox (risk R7). 5 MiB. +CONFLUENCE_RESPONSE_MAX_BYTES: int = 5 * 1024 * 1024 + +# 429 retry policy. +_RETRY_AFTER_CAP_SECONDS: int = 30 +_DEFAULT_RETRY_AFTER_SECONDS: int = 1 + +# Single-request timeout for upstream Confluence calls. +_DEFAULT_TIMEOUT_SECONDS: float = 30.0 + +# spaceId ↔ spaceKey LRU cache (architect Q2). Populated by list_spaces and +# get_page; consumed by routes that need to translate one to the other +# without double-fetching. +_SPACE_CACHE_TTL_SECONDS: float = 60.0 +_SPACE_CACHE_MAX_ENTRIES: int = 256 + + +class ConfluenceUpstreamError(RuntimeError): + """Raised when Atlassian returns a non-2xx response that isn't a 404 on + the endpoints where 404 is modelled as a ``not_found`` envelope. + """ + + def __init__(self, status_code: int, body: Any, path: str): + super().__init__(f"Confluence upstream returned {status_code} for {path}") + self.status_code = status_code + self.body = body + self.path = path + + +class ConfluenceUpstreamForbidden(RuntimeError): + """Raised when Atlassian returns HTTP 403 for a read endpoint. + + The route layer translates this to a ``confluence_upstream_403`` audit + event so operators can distinguish bot-account permission denials from + space-allowlist denials and other upstream errors (Q7, risk R15). + """ + + def __init__(self, status_code: int, body: Any, path: str): + super().__init__(f"Confluence upstream returned 403 for {path}") + self.status_code = status_code + self.body = body + self.path = path + + +class ConfluenceResponseTooLarge(RuntimeError): + """Raised when an upstream response exceeds ``CONFLUENCE_RESPONSE_MAX_BYTES``. + + The route layer translates this to HTTP 413 so the agent can request a + narrower scope (different bodyFormat, smaller limit, etc.). + """ + + def __init__(self, size_bytes: int, path: str): + super().__init__(f"Confluence response too large: {size_bytes} bytes from {path}") + self.size_bytes = size_bytes + self.path = path + + +def validate_confluence_api_path(path: str, method: str) -> tuple[bool, str]: + """Validate a Confluence REST API path + method against the allowlist. + + Normalizes the path (strips leading/trailing slashes, drops query string, + rejects ``..`` segments, rejects duplicate slashes, rejects non-ASCII + characters) before checking the regex allowlist. + + Args: + path: REST path relative to the Confluence base + (e.g. ``api/v2/pages/12345`` or ``rest/api/search``). + method: HTTP method (``GET`` is the only one allowed in v1). + + Returns: + ``(True, "")`` if allowed; ``(False, reason)`` otherwise. + """ + method_upper = (method or "").upper() + if method_upper not in ALLOWED_METHODS: + return False, f"HTTP method '{method_upper}' not allowed for Confluence" + + if method_upper in CONFLUENCE_DENIED_VERBS: + return False, f"HTTP method '{method_upper}' is permanently denied" + + if not isinstance(path, str) or not path: + return False, "path is required" + + # Reject non-ASCII / unicode (homoglyph guard). + try: + path.encode("ascii") + except UnicodeEncodeError: + return False, "path contains non-ASCII characters" + + # Strip query string before any other normalisation. + path_no_query = path.split("?", 1)[0].split("#", 1)[0] + + # Reject path traversal and duplicate slashes (catch ``//foo`` BEFORE + # stripping leading/trailing slashes). + if ".." in path_no_query.split("/"): + return False, "path contains '..' segment" + if "//" in path_no_query: + return False, "path contains duplicate slashes" + stripped = path_no_query.strip("/") + if not stripped: + return False, "path is empty after normalisation" + + for segment in stripped.split("/"): + if segment in CONFLUENCE_DENIED_VERBS: + return False, f"path segment '{segment}' is permanently denied" + + for pattern in CONFLUENCE_API_ALLOWED_PATHS: + if pattern.fullmatch(stripped): + return True, "" + + return False, f"path '{stripped}' not in allowlist" + + +def _validate_body_format(body_format: Any) -> list[str]: + """Validate and normalise a body-format list. + + Returns a list of validated tokens (empty if ``body_format`` is None). + Raises ``ValueError`` on invalid input. + """ + if body_format is None: + return [] + if isinstance(body_format, str): + # Accept a single string for ergonomic callers. + body_format = [body_format] + if not isinstance(body_format, (list, tuple)): + raise ValueError("body_format must be a string or list of strings") + cleaned: list[str] = [] + for entry in body_format: + if not isinstance(entry, str): + raise ValueError("body_format entries must be strings") + if entry not in ALLOWED_BODY_FORMATS: + raise ValueError( + f"invalid body_format: {entry!r} (allowed: " + f"{sorted(ALLOWED_BODY_FORMATS)})" + ) + cleaned.append(entry) + return cleaned + + +def _validate_page_id(page_id: Any) -> str: + """Validate that page_id is a numeric string. Raises ValueError on miss.""" + if not isinstance(page_id, str) or not page_id: + raise ValueError("page_id must be a non-empty string") + if not page_id.isdigit(): + raise ValueError(f"page_id must be numeric, got: {page_id!r}") + return page_id + + +def _validate_space_id(space_id: Any) -> str: + """Validate that space_id is a numeric string. Raises ValueError on miss.""" + if not isinstance(space_id, str) or not space_id: + raise ValueError("space_id must be a non-empty string") + if not space_id.isdigit(): + raise ValueError(f"space_id must be numeric, got: {space_id!r}") + return space_id + + +# ----------------------------------------------------------------------------- +# Response redaction +# ----------------------------------------------------------------------------- + +# Canonical sentinel substituted in for redacted user-identifying fields. +_REDACTED_VALUE: str = "" + +# Keys to scrub at any depth in any JSON response body. +_REDACTED_KEYS: frozenset[str] = frozenset({"accountId", "emailAddress"}) + +# user-profile ``_links.webui`` URL detector. Confluence uses +# ``/wiki/people/`` for user profiles; we redact those but leave +# space / page ``webui`` URLs alone (they're addressable by the agent). +_USER_PROFILE_WEBUI_RE = re.compile(r"(^|/)(?:wiki/)?people/") + + +def _is_user_profile_link(value: Any) -> bool: + """Return True if a ``_links.webui`` value points at a user profile.""" + if not isinstance(value, str) or not value: + return False + return _USER_PROFILE_WEBUI_RE.search(value) is not None + + +def redact_response(payload: Any) -> Any: + """Walk ``payload`` and strip user-identifying fields in-place. + + - ``accountId`` / ``emailAddress`` keys at any depth → ``""`` + - ``_links.webui`` URLs that look like user profile links → + ``""`` + - Page / space ``_links.webui`` URLs are preserved. + + The walker mutates dicts in place and returns the same object (for + callers that want to chain the call). Lists are walked recursively. + """ + if isinstance(payload, dict): + for key, value in list(payload.items()): + if key in _REDACTED_KEYS: + payload[key] = _REDACTED_VALUE + continue + if key == "_links" and isinstance(value, dict): + webui = value.get("webui") + if _is_user_profile_link(webui): + value["webui"] = _REDACTED_VALUE + # Walk into the rest of _links so nested user-profile + # references inside ``self`` etc. still get scrubbed. + redact_response(value) + continue + redact_response(value) + elif isinstance(payload, list): + for item in payload: + redact_response(item) + return payload + + +# ----------------------------------------------------------------------------- +# Space cache (spaceId ↔ spaceKey, 60s TTL) — architect Q2 +# ----------------------------------------------------------------------------- + + +@dataclass +class _SpaceCacheEntry: + space_id: str + space_key: str + expires_at: float + + +class _SpaceCache: + """Tiny LRU-with-TTL cache mapping space_id ↔ space_key. + + Populated by ``list_spaces`` and ``get_page``; consumed by route helpers + that need to translate one to the other without re-fetching. + """ + + def __init__( + self, + *, + ttl_seconds: float = _SPACE_CACHE_TTL_SECONDS, + max_entries: int = _SPACE_CACHE_MAX_ENTRIES, + ): + self._ttl = ttl_seconds + self._max = max_entries + self._lock = threading.Lock() + self._by_id: OrderedDict[str, _SpaceCacheEntry] = OrderedDict() + self._by_key: OrderedDict[str, _SpaceCacheEntry] = OrderedDict() + + def put(self, space_id: str, space_key: str) -> None: + if not space_id or not space_key: + return + entry = _SpaceCacheEntry( + space_id=str(space_id), + space_key=str(space_key), + expires_at=time.time() + self._ttl, + ) + with self._lock: + self._by_id[entry.space_id] = entry + self._by_id.move_to_end(entry.space_id) + self._by_key[entry.space_key] = entry + self._by_key.move_to_end(entry.space_key) + self._evict() + + def key_for_id(self, space_id: str) -> str | None: + with self._lock: + entry = self._by_id.get(space_id) + if entry is None: + return None + if entry.expires_at < time.time(): + self._drop(entry) + return None + self._by_id.move_to_end(space_id) + return entry.space_key + + def id_for_key(self, space_key: str) -> str | None: + with self._lock: + entry = self._by_key.get(space_key) + if entry is None: + return None + if entry.expires_at < time.time(): + self._drop(entry) + return None + self._by_key.move_to_end(space_key) + return entry.space_id + + def clear(self) -> None: + with self._lock: + self._by_id.clear() + self._by_key.clear() + + def _evict(self) -> None: + while len(self._by_id) > self._max: + _, evicted = self._by_id.popitem(last=False) + self._by_key.pop(evicted.space_key, None) + + def _drop(self, entry: _SpaceCacheEntry) -> None: + self._by_id.pop(entry.space_id, None) + self._by_key.pop(entry.space_key, None) + + +# ----------------------------------------------------------------------------- +# Client +# ----------------------------------------------------------------------------- + + +@dataclass +class ConfluenceClient: + """Thin REST-API wrapper around Atlassian Cloud Confluence. + + The client is class-shaped so v1.1 multi-site support is a single-file + drop-in: wire a second instance with its own ``creds_provider`` / + ``http_client`` and the route layer can pick between them without + refactoring the read paths. + """ + + creds_provider: Any = get_confluence_credentials + http_client: httpx.Client | None = None + timeout_seconds: float = _DEFAULT_TIMEOUT_SECONDS + space_cache: _SpaceCache = field(default_factory=_SpaceCache) + _logged_default_body_format: bool = field(default=False, init=False, repr=False) + + def _client(self) -> httpx.Client: + if self.http_client is None: + self.http_client = httpx.Client(timeout=self.timeout_seconds) + return self.http_client + + def _build_url(self, creds: ConfluenceCredentials, path: str) -> str: + """Compose the full Atlassian Confluence URL for a relative path. + + ``path`` is relative to the Confluence base — the base already + includes ``/wiki`` (see ``confluence_credentials.py``), so we just + append the API segment. + """ + return f"{creds.base_url}/{path.lstrip('/')}" + + def _request( + self, + method: str, + path: str, + query: dict[str, Any] | None = None, + body: dict[str, Any] | None = None, + ) -> httpx.Response: + """Issue a single REST call with Basic auth + one 429-retry. + + Retry is GET-only. Both attempts emit a structured + ``confluence_upstream_rate_limited`` audit entry so operators see + whether the retry succeeded. + """ + creds = self.creds_provider() + headers = { + "Authorization": creds.basic_auth_header(), + "Accept": "application/json", + } + if body is not None: + headers["Content-Type"] = "application/json" + + url = self._build_url(creds, path) + client = self._client() + retryable = method.upper() == "GET" + + response: httpx.Response | None = None + for attempt in (0, 1): + response = client.request( + method=method, + url=url, + params=query, + json=body, + headers=headers, + ) + if response.status_code != 429: + return response + + retry_after = _parse_retry_after(response.headers.get("Retry-After")) + _audit_rate_limited(path=path, attempt=attempt, retry_after=retry_after) + + if attempt == 1 or not retryable: + return response + time.sleep(retry_after) + + # Defensive — loop always returns above. + assert response is not None + return response # pragma: no cover + + # -- Public verbs --------------------------------------------------------- + + def get_page( + self, + page_id: str, + body_format: Any = None, + expand: Any = None, + ) -> dict[str, Any]: + """Fetch a single Confluence page (v2-first). + + Returns the parsed (and redacted) JSON body on 2xx, or a 404 envelope + when the page does not exist. Default ``body-format=storage``; + callers may override to any subset of ``ALLOWED_BODY_FORMATS``. + """ + page_id = _validate_page_id(page_id) + formats = _validate_body_format(body_format) or list(DEFAULT_BODY_FORMAT) + self._log_default_body_format(formats) + + query: dict[str, Any] = {"body-format": ",".join(formats)} + if expand is not None: + if isinstance(expand, (list, tuple)): + query["expand"] = ",".join(str(v) for v in expand) + elif isinstance(expand, str): + query["expand"] = expand + else: + raise ValueError("expand must be a string or list of strings") + + path = f"api/v2/pages/{page_id}" + response = self._request("GET", path, query=query) + if response.status_code == 404: + return _not_found_envelope(page_id) + if response.status_code == 403: + raise ConfluenceUpstreamForbidden( + 403, _safe_response_body(response), path + ) + _raise_for_status(response, path) + body_json = _safe_json(response, path) + + # Populate the space cache opportunistically — get_page returns + # ``spaceId`` at the top level in v2. + space_id = body_json.get("spaceId") + if isinstance(space_id, (str, int)): + # The space key isn't on the page response; we only cache the id + # mapping here when we can pair it with a key (handled by + # list_spaces). Skip silently if we don't have a key. + pass + + return _finalize_response(body_json, path) + + def get_page_descendants( + self, + page_id: str, + depth: Any = None, + limit: Any = None, + cursor: str | None = None, + ) -> dict[str, Any]: + """Fetch the descendants of a Confluence page (v2).""" + page_id = _validate_page_id(page_id) + query: dict[str, Any] = {} + if depth is not None: + query["depth"] = depth + if limit is not None: + query["limit"] = limit + if cursor: + query["cursor"] = cursor + + path = f"api/v2/pages/{page_id}/descendants" + response = self._request("GET", path, query=query or None) + if response.status_code == 404: + return _not_found_envelope(page_id) + if response.status_code == 403: + raise ConfluenceUpstreamForbidden( + 403, _safe_response_body(response), path + ) + _raise_for_status(response, path) + return _finalize_response(_safe_json(response, path), path) + + def get_page_footer_comments( + self, + page_id: str, + body_format: Any = None, + include_replies: bool = False, + limit: Any = None, + cursor: str | None = None, + ) -> dict[str, Any]: + """Fetch footer comments on a Confluence page. + + Per decision D1, when ``include_replies`` is set the wrapper makes + a second call to the v1 ``/wiki/api/v2/footer-comments`` endpoint + with ``page-id={page_id}&depth=all`` to fill in nested replies the + v2 page-scoped endpoint omits. + """ + page_id = _validate_page_id(page_id) + formats = _validate_body_format(body_format) or list(DEFAULT_BODY_FORMAT) + query: dict[str, Any] = {"body-format": ",".join(formats)} + if limit is not None: + query["limit"] = limit + if cursor: + query["cursor"] = cursor + + path = f"api/v2/pages/{page_id}/footer-comments" + response = self._request("GET", path, query=query) + if response.status_code == 404: + return _not_found_envelope(page_id) + if response.status_code == 403: + raise ConfluenceUpstreamForbidden( + 403, _safe_response_body(response), path + ) + _raise_for_status(response, path) + primary = _safe_json(response, path) + + if include_replies: + replies_path = "api/v2/footer-comments" + replies_query: dict[str, Any] = { + "page-id": page_id, + "depth": "all", + "body-format": ",".join(formats), + } + replies_response = self._request("GET", replies_path, query=replies_query) + # On 2xx, fold replies into the primary envelope under a + # normalized key. On non-2xx for the replies side-call, we + # *don't* fail the primary read — log and emit the v1-fallback + # audit so operators see the gap. + if 200 <= replies_response.status_code < 300: + primary["_replies"] = _safe_json(replies_response, replies_path) + _audit_v1_fallback( + endpoint="footer_comments_nested", + v2_status=replies_response.status_code, + page_id=page_id, + ) + else: + logger.warning( + "Footer-comment nested-reply fetch failed", + page_id=page_id, + status=replies_response.status_code, + ) + + return _finalize_response(primary, path) + + def get_page_inline_comments( + self, + page_id: str, + body_format: Any = None, + limit: Any = None, + cursor: str | None = None, + ) -> dict[str, Any]: + """Fetch inline comments on a Confluence page. + + v2-first with v1 fallback (decision D1). When the v2 endpoint + returns 404 — Atlassian's known inline-comment bug — the wrapper + retries against the v1 endpoint + ``rest/api/content/{page_id}/child/comment?location=inline&expand=body.view``. + """ + page_id = _validate_page_id(page_id) + formats = _validate_body_format(body_format) or list(DEFAULT_BODY_FORMAT) + query: dict[str, Any] = {"body-format": ",".join(formats)} + if limit is not None: + query["limit"] = limit + if cursor: + query["cursor"] = cursor + + path = f"api/v2/pages/{page_id}/inline-comments" + response = self._request("GET", path, query=query) + if response.status_code == 403: + raise ConfluenceUpstreamForbidden( + 403, _safe_response_body(response), path + ) + if response.status_code == 404: + # v1 fallback (decision D1). + v1_path = f"rest/api/content/{page_id}/child/comment" + v1_query = {"location": "inline", "expand": "body.view"} + v1_response = self._request("GET", v1_path, query=v1_query) + _audit_v1_fallback( + endpoint="inline_comments", + v2_status=404, + page_id=page_id, + ) + if v1_response.status_code == 404: + envelope = _not_found_envelope(page_id) + envelope["used_fallback"] = True + return envelope + if v1_response.status_code == 403: + raise ConfluenceUpstreamForbidden( + 403, _safe_response_body(v1_response), v1_path + ) + _raise_for_status(v1_response, v1_path) + v1_body = _safe_json(v1_response, v1_path) + v1_body["used_fallback"] = True + return _finalize_response(v1_body, v1_path) + + _raise_for_status(response, path) + return _finalize_response(_safe_json(response, path), path) + + def list_spaces( + self, + allowed_spaces: frozenset[str], + limit: Any = None, + cursor: str | None = None, + ) -> dict[str, Any]: + """List Confluence spaces, filtered to ``allowed_spaces``. + + Per decision 11, agents cannot enumerate the full tenant space set + — the response only contains spaces whose ``key`` is in the + operator's allowlist. The cursor is preserved (minus filtered + entries) so callers can paginate. + """ + query: dict[str, Any] = {} + if limit is not None: + query["limit"] = limit + if cursor: + query["cursor"] = cursor + + path = "api/v2/spaces" + response = self._request("GET", path, query=query or None) + if response.status_code == 403: + raise ConfluenceUpstreamForbidden( + 403, _safe_response_body(response), path + ) + _raise_for_status(response, path) + body_json = _safe_json(response, path) + + results = body_json.get("results") + if isinstance(results, list): + kept: list[Any] = [] + for entry in results: + if not isinstance(entry, dict): + continue + key = entry.get("key") + space_id = entry.get("id") + if isinstance(space_id, (str, int)) and isinstance(key, str): + self.space_cache.put(str(space_id), key) + if isinstance(key, str) and key in allowed_spaces: + kept.append(entry) + body_json["results"] = kept + + return _finalize_response(body_json, path) + + def get_space_pages( + self, + space_id: str, + limit: Any = None, + cursor: str | None = None, + body_format: Any = None, + ) -> dict[str, Any]: + """List pages in a Confluence space (by numeric space id, v2).""" + space_id = _validate_space_id(space_id) + formats = _validate_body_format(body_format) or list(DEFAULT_BODY_FORMAT) + query: dict[str, Any] = {"body-format": ",".join(formats)} + if limit is not None: + query["limit"] = limit + if cursor: + query["cursor"] = cursor + + path = f"api/v2/spaces/{space_id}/pages" + response = self._request("GET", path, query=query) + if response.status_code == 404: + return _not_found_envelope(space_id) + if response.status_code == 403: + raise ConfluenceUpstreamForbidden( + 403, _safe_response_body(response), path + ) + _raise_for_status(response, path) + return _finalize_response(_safe_json(response, path), path) + + def search_cql( + self, + cql: str, + limit: Any = None, + cursor: str | None = None, + ) -> dict[str, Any]: + """Run a CQL query (v1-only — there is no v2 CQL endpoint).""" + if not isinstance(cql, str) or not cql.strip(): + raise ValueError("cql is required") + query: dict[str, Any] = {"cql": cql} + if limit is not None: + query["limit"] = limit + if cursor: + query["cursor"] = cursor + + path = "rest/api/search" + response = self._request("GET", path, query=query) + if response.status_code == 403: + raise ConfluenceUpstreamForbidden( + 403, _safe_response_body(response), path + ) + _raise_for_status(response, path) + return _finalize_response(_safe_json(response, path), path) + + def execute_raw( + self, + method: str, + path: str, + query: dict[str, Any] | None = None, + body: dict[str, Any] | None = None, + ) -> dict[str, Any]: + """Pass-through for the ``/api/v1/confluence/execute`` route. + + Callers must have already validated ``path``/``method`` via + ``validate_confluence_api_path``. Raises ``ConfluenceUpstreamError`` + on any non-2xx status. + """ + response = self._request(method, path, query=query, body=body) + if response.status_code == 403: + raise ConfluenceUpstreamForbidden( + 403, _safe_response_body(response), path + ) + _raise_for_status(response, path) + return _finalize_response(_safe_json(response, path), path) + + def _log_default_body_format(self, formats: list[str]) -> None: + if self._logged_default_body_format: + return + if formats == list(DEFAULT_BODY_FORMAT): + logger.info( + "Confluence default body-format active", + body_format=",".join(formats), + ) + self._logged_default_body_format = True + + +# ----------------------------------------------------------------------------- +# Helpers & module-level singleton +# ----------------------------------------------------------------------------- + + +def _not_found_envelope(identifier: str) -> dict[str, Any]: + """Canonical ``not_found`` envelope used by read endpoints.""" + return {"status": "not_found", "id": identifier, "upstream_status": 404} + + +def _raise_for_status(response: httpx.Response, path: str) -> None: + """Raise ``ConfluenceUpstreamError`` if the response is not a 2xx.""" + if 200 <= response.status_code < 300: + return + body: Any + try: + body = response.json() + except Exception: + body = response.text + raise ConfluenceUpstreamError(response.status_code, body, path) + + +def _safe_response_body(response: httpx.Response) -> Any: + """Best-effort JSON-or-text body for upstream-error envelopes.""" + try: + return response.json() + except Exception: + return response.text + + +def _safe_json(response: httpx.Response, path: str) -> dict[str, Any]: + """Parse a 2xx JSON response, wrapping non-dict shapes for callers.""" + try: + data = response.json() + except Exception as exc: # pragma: no cover — Atlassian always returns JSON + raise ConfluenceUpstreamError(response.status_code, response.text, path) from exc + if not isinstance(data, dict): + return {"data": data} + return data + + +def _finalize_response(body: dict[str, Any], path: str) -> dict[str, Any]: + """Apply redaction + payload-size cap before returning to the route layer.""" + redact_response(body) + # Size check — JSON-serialise once. We'd rather allocate the bytes here + # than ship an oversized payload to the sandbox. + try: + size = len(json.dumps(body, ensure_ascii=False).encode("utf-8")) + except Exception: # pragma: no cover — defensive + size = 0 + if size > CONFLUENCE_RESPONSE_MAX_BYTES: + raise ConfluenceResponseTooLarge(size, path) + return body + + +def _parse_retry_after(value: str | None) -> int: + """Parse a ``Retry-After`` header value to an integer number of seconds.""" + if value is None: + return _DEFAULT_RETRY_AFTER_SECONDS + try: + parsed = int(str(value).strip()) + except (TypeError, ValueError): + return _DEFAULT_RETRY_AFTER_SECONDS + if parsed <= 0: + return _DEFAULT_RETRY_AFTER_SECONDS + return min(parsed, _RETRY_AFTER_CAP_SECONDS) + + +def _audit_rate_limited(*, path: str, attempt: int, retry_after: int) -> None: + """Emit a ``confluence_upstream_rate_limited`` audit entry, if possible.""" + try: + from flask import has_request_context + except ImportError: # pragma: no cover + + def has_request_context() -> bool: + return False + + try: + from .gateway import audit_log + except ImportError: + try: + from gateway import audit_log # type: ignore[no-redef, attr-defined] + except ImportError: + audit_log = None # type: ignore[assignment] + + if audit_log is not None and has_request_context(): + try: + audit_log( + "confluence_upstream_rate_limited", + "confluence_request", + success=False, + details={ + "path": path, + "attempt": attempt, + "retry_after": retry_after, + }, + ) + except Exception: # pragma: no cover - defensive + logger.exception("audit_log failed in confluence _request") + return + return + + logger.warning( + "Confluence upstream 429", + path=path, + attempt=attempt, + retry_after=retry_after, + ) + + +def _audit_v1_fallback(*, endpoint: str, v2_status: int, page_id: str) -> None: + """Emit a ``confluence_v1_fallback`` audit entry.""" + try: + from flask import has_request_context + except ImportError: # pragma: no cover + + def has_request_context() -> bool: + return False + + try: + from .gateway import audit_log + except ImportError: + try: + from gateway import audit_log # type: ignore[no-redef, attr-defined] + except ImportError: + audit_log = None # type: ignore[assignment] + + if audit_log is not None and has_request_context(): + try: + audit_log( + "confluence_v1_fallback", + "confluence_request", + success=True, + details={ + "endpoint": endpoint, + "v2_status": v2_status, + "page_id": page_id, + }, + ) + except Exception: # pragma: no cover - defensive + logger.exception("audit_log failed in confluence v1 fallback") + return + return + + logger.info( + "Confluence v1 fallback exercised", + endpoint=endpoint, + v2_status=v2_status, + page_id=page_id, + ) + + +# Module-level singleton — mirrors ``jira_client.get_jira_client``. +_confluence_client: ConfluenceClient | None = None +_confluence_client_lock = threading.Lock() + + +def get_confluence_client() -> ConfluenceClient: + """Return the process-wide ``ConfluenceClient`` singleton.""" + global _confluence_client + with _confluence_client_lock: + if _confluence_client is None: + _confluence_client = ConfluenceClient() + return _confluence_client + + +def reset_confluence_client() -> None: + """Drop the module-level singleton (test helper).""" + global _confluence_client + with _confluence_client_lock: + _confluence_client = None + + +__all__ = [ + "ALLOWED_BODY_FORMATS", + "ALLOWED_METHODS", + "CONFLUENCE_API_ALLOWED_PATHS", + "CONFLUENCE_DENIED_VERBS", + "CONFLUENCE_RESPONSE_MAX_BYTES", + "ConfluenceClient", + "ConfluenceCredentials", + "ConfluenceCredentialsUnavailable", + "ConfluenceResponseTooLarge", + "ConfluenceUpstreamError", + "ConfluenceUpstreamForbidden", + "DEFAULT_BODY_FORMAT", + "DEFAULT_LIMIT", + "HARD_MAX_LIMIT", + "get_confluence_client", + "redact_response", + "reset_confluence_client", + "validate_confluence_api_path", +] diff --git a/gateway/confluence_credentials.py b/gateway/confluence_credentials.py new file mode 100644 index 0000000000..59459e200e --- /dev/null +++ b/gateway/confluence_credentials.py @@ -0,0 +1,286 @@ +""" +Confluence Credentials Manager for Gateway Sidecar. + +Manages Atlassian Confluence API credentials for gateway-side credential +injection. Credentials are read from ``~/.config/egg/secrets.env`` on the +host (or the path pointed to by ``EGG_SECRETS_PATH``), mirroring the pattern +used by ``jira_credentials.py`` — mtime-based cache refresh, thread-safe +access, never exported to the sandbox. + +Credential precedence (decision F1): + +For each of base URL, username, and API token, the loader checks +``ATLASSIAN_*`` first and falls back to ``CONFLUENCE_*`` per-key. This lets +operators run a single shared Atlassian principal that covers both Jira and +Confluence reads while preserving back-compatibility with deployments that +still set the legacy per-service triple. + +Per-key precedence — i.e. ``ATLASSIAN_USERNAME`` + ``CONFLUENCE_BASE_URL`` ++ ``CONFLUENCE_API_TOKEN`` is a valid combination — because Atlassian +accounts are tenant-wide and operators may stage the migration in steps. + +Base-URL derivation: + +- If ``CONFLUENCE_BASE_URL`` is set, it is used verbatim (operators have + already added the ``/wiki`` suffix). +- Otherwise, if ``ATLASSIAN_BASE_URL`` is set, the loader appends ``/wiki`` + because Confluence Cloud lives at ``/wiki/...`` while Jira lives + at the bare origin. + +When any of the three resolved values is missing/blank, the loader raises +``ConfluenceCredentialsUnavailable``; the route layer translates that to +HTTP 503. +""" + +from __future__ import annotations + +import base64 +import os +import sys +import threading +from dataclasses import dataclass +from pathlib import Path + +# Add shared directory to path for egg_logging +_shared_path = Path(__file__).parent.parent / "shared" +if _shared_path.exists() and str(_shared_path) not in sys.path: + sys.path.insert(0, str(_shared_path)) +from egg_logging import get_logger + +# Import parse_env_file from the sibling credentials module so we share the +# exact same parsing rules (comments, quoting, empty lines). The module is +# loaded via either a relative import (production, package form) or a flat +# import (tests / standalone container mode), matching how gateway.py does it. +try: + from .anthropic_credentials import parse_env_file +except ImportError: + from anthropic_credentials import parse_env_file # type: ignore[no-redef] + +logger = get_logger("gateway.confluence-credentials") + +# Default secrets path - can be overridden via environment variable. +SECRETS_PATH = Path( + os.environ.get("EGG_SECRETS_PATH", Path.home() / ".config" / "egg" / "secrets.env") +) + + +class ConfluenceCredentialsUnavailable(RuntimeError): + """Raised when Confluence credentials cannot be loaded. + + Route handlers should translate this to an HTTP 503 response. + """ + + +@dataclass(frozen=True) +class ConfluenceCredentials: + """Container for Atlassian Cloud Basic-auth credentials for Confluence. + + ``base_url`` is the Confluence root — Atlassian Cloud lives at + ``https://.atlassian.net/wiki``. The client appends the REST + path (``/api/v2/...`` or ``/rest/api/...``) at request time. No + trailing slash. + """ + + base_url: str + username: str + api_token: str + + def basic_auth_header(self) -> str: + """Return the value of an ``Authorization: Basic ...`` header. + + Atlassian Basic auth is ``base64(email:api_token)``. + """ + raw = f"{self.username}:{self.api_token}".encode() + encoded = base64.b64encode(raw).decode("ascii") + return f"Basic {encoded}" + + +class ConfluenceCredentialsManager: + """Thread-safe, mtime-caching loader for Confluence credentials. + + Mirrors ``JiraCredentialsManager`` so reload semantics are identical + — the cache is invalidated whenever the secrets file's ``st_mtime`` + changes, and concurrent readers never observe a torn credential. + """ + + def __init__(self, secrets_path: Path | None = None): + self._secrets_path = secrets_path or SECRETS_PATH + self._credentials: ConfluenceCredentials | None = None + self._cached_mtime: float = 0 + self._lock = threading.Lock() + self._logged_first_load: bool = False + + def get_credentials(self) -> ConfluenceCredentials: + """Return currently-loaded credentials, raising if unavailable. + + Checks the file's mtime first; reloads if it has changed (or if the + file has disappeared). Raises ``ConfluenceCredentialsUnavailable`` + when any of the three required values resolves to blank/missing. + """ + try: + current_mtime = self._secrets_path.stat().st_mtime + except OSError: + with self._lock: + self._credentials = None + self._cached_mtime = 0 + raise ConfluenceCredentialsUnavailable( + f"Secrets file not found: {self._secrets_path}" + ) from None + + with self._lock: + if current_mtime != self._cached_mtime: + self._load_credentials() + self._cached_mtime = current_mtime + creds = self._credentials + + if creds is None: + raise ConfluenceCredentialsUnavailable( + "Confluence credentials missing — set ATLASSIAN_BASE_URL " + "(or CONFLUENCE_BASE_URL), ATLASSIAN_USERNAME (or " + "CONFLUENCE_USERNAME), ATLASSIAN_API_TOKEN (or " + f"CONFLUENCE_API_TOKEN) in {self._secrets_path}" + ) + return creds + + def _load_credentials(self) -> None: + """Load credentials from secrets.env file (called under lock).""" + if not self._secrets_path.exists(): + logger.warning("Secrets file not found", path=str(self._secrets_path)) + self._credentials = None + return + + secrets = parse_env_file(self._secrets_path) + + # Per-key ATLASSIAN_* → CONFLUENCE_* fallback (decision F1). + atlassian_base = (secrets.get("ATLASSIAN_BASE_URL") or "").strip().rstrip("/") + confluence_base = (secrets.get("CONFLUENCE_BASE_URL") or "").strip().rstrip("/") + username = ( + (secrets.get("ATLASSIAN_USERNAME") or "").strip() + or (secrets.get("CONFLUENCE_USERNAME") or "").strip() + ) + api_token = ( + (secrets.get("ATLASSIAN_API_TOKEN") or "").strip() + or (secrets.get("CONFLUENCE_API_TOKEN") or "").strip() + ) + + # Base URL derivation — CONFLUENCE_BASE_URL wins when set (operators + # have already added /wiki). Otherwise derive from ATLASSIAN_BASE_URL + # by appending /wiki. + base_source: str + if confluence_base: + base_url = confluence_base + base_source = "CONFLUENCE_BASE_URL" + elif atlassian_base: + base_url = f"{atlassian_base}/wiki" + base_source = "ATLASSIAN_BASE_URL+/wiki" + else: + base_url = "" + base_source = "" + + if not (base_url and username and api_token): + missing = [ + name + for name, value in ( + ("BASE_URL (ATLASSIAN_BASE_URL or CONFLUENCE_BASE_URL)", base_url), + ("USERNAME (ATLASSIAN_USERNAME or CONFLUENCE_USERNAME)", username), + ("API_TOKEN (ATLASSIAN_API_TOKEN or CONFLUENCE_API_TOKEN)", api_token), + ) + if not value + ] + logger.warning( + "Confluence credentials incomplete", + path=str(self._secrets_path), + missing=missing, + ) + self._credentials = None + return + + username_source = ( + "ATLASSIAN_USERNAME" + if (secrets.get("ATLASSIAN_USERNAME") or "").strip() + else "CONFLUENCE_USERNAME" + ) + token_source = ( + "ATLASSIAN_API_TOKEN" + if (secrets.get("ATLASSIAN_API_TOKEN") or "").strip() + else "CONFLUENCE_API_TOKEN" + ) + + self._credentials = ConfluenceCredentials( + base_url=base_url, + username=username, + api_token=api_token, + ) + if not self._logged_first_load: + # Boot-time observability (risk R12) — log the resolved precedence + # ONCE so operators can see which env-var triple won without + # spamming every request. + logger.info( + "Confluence credentials loaded", + base_url=base_url, + base_source=base_source, + username_source=username_source, + token_source=token_source, + username=username, + token_prefix=api_token[:4] + "...", + ) + self._logged_first_load = True + + def reload(self) -> None: + """Force a reload on the next ``get_credentials()`` call.""" + with self._lock: + self._cached_mtime = 0 + self._credentials = None + self._logged_first_load = False + + +# Global singleton — resolved lazily so that tests can reset it. +_credentials_manager: ConfluenceCredentialsManager | None = None +_credentials_manager_lock = threading.Lock() + + +def get_confluence_credentials_manager() -> ConfluenceCredentialsManager: + """Get or create the process-wide Confluence credentials manager.""" + global _credentials_manager + with _credentials_manager_lock: + if _credentials_manager is None: + _credentials_manager = ConfluenceCredentialsManager() + return _credentials_manager + + +def get_confluence_credentials() -> ConfluenceCredentials: + """Return the current Confluence credentials. + + Raises ``ConfluenceCredentialsUnavailable`` when any of the three values + is missing. Routes call this per-request — the mtime check keeps the + overhead to a single ``stat()`` syscall on the hot path. + """ + return get_confluence_credentials_manager().get_credentials() + + +def reload_confluence_credentials() -> None: + """Clear the credentials cache so the next call re-reads from disk. + + Invoked by the gateway's ``_reload_all_config()`` hook (triggered by + ``POST /api/v1/config/reload`` and SIGHUP) so operators can rotate + Atlassian tokens without restarting the gateway. + """ + get_confluence_credentials_manager().reload() + + +def reset_confluence_credentials_manager() -> None: + """Drop the module-level singleton (test helper).""" + global _credentials_manager + with _credentials_manager_lock: + _credentials_manager = None + + +__all__ = [ + "ConfluenceCredentials", + "ConfluenceCredentialsManager", + "ConfluenceCredentialsUnavailable", + "get_confluence_credentials", + "get_confluence_credentials_manager", + "reload_confluence_credentials", + "reset_confluence_credentials_manager", +] diff --git a/gateway/confluence_policy.py b/gateway/confluence_policy.py new file mode 100644 index 0000000000..cdc08d3085 --- /dev/null +++ b/gateway/confluence_policy.py @@ -0,0 +1,244 @@ +""" +Confluence space-allowlist loader. + +Reads the ``confluence:`` section of ``config/context-filters.yaml`` (or +whatever ``EGG_CONTEXT_FILTERS_PATH`` points at) and exposes helpers the +Confluence routes compose: + +- ``allowed_spaces()`` — current ``frozenset[str]`` of allowlisted space keys. +- ``is_space_allowed(key)`` — simple membership test. +- ``reload_confluence_policy()`` — force a re-read from disk on the next call. + +Expected YAML shape:: + + confluence: + spaces: ["ENG", "DOCS"] # Atlassian space keys allowed for read access + +Fail-closed semantics: + +- Missing file → empty set (no space allowed). +- Missing ``confluence:`` section → empty set. +- Missing ``spaces:`` key → empty set. +- Malformed YAML → empty set, and the parse error is logged once per load + cycle (not re-raised — a bad config file must not crash the gateway). + +Cache invalidation mirrors ``jira_policy.py``: an ``st_mtime`` check fires +on every access, and ``reload_confluence_policy()`` forces a clear so +``POST /api/v1/config/reload`` picks up operator edits immediately. +""" + +from __future__ import annotations + +import os +import re +import sys +import threading +from pathlib import Path + +# Add shared directory to path for egg_logging +_shared_path = Path(__file__).parent.parent / "shared" +if _shared_path.exists() and str(_shared_path) not in sys.path: + sys.path.insert(0, str(_shared_path)) + +from egg_logging import get_logger + +try: + import yaml +except ImportError as _exc: # pragma: no cover — yaml is a hard dependency + raise RuntimeError("PyYAML is required by gateway.confluence_policy") from _exc + +logger = get_logger("gateway.confluence-policy") + + +# Default path to the context-filters YAML file. The gateway runs from the +# repository root (or from the in-container /app path, where the config dir +# is mirrored under /app/config). Operators can override via env var. +_DEFAULT_CONFIG_PATH = Path( + os.environ.get( + "EGG_CONTEXT_FILTERS_PATH", + str(Path(__file__).parent.parent / "config" / "context-filters.yaml"), + ) +) + +# Atlassian space keys are conventionally uppercase but the API accepts mixed +# case. Anchor on a leading letter and accept letters/digits/underscore. +_SPACE_KEY_RE = re.compile(r"^[a-zA-Z][a-zA-Z0-9_]*$") + + +class ConfluencePolicy: + """Thread-safe, mtime-caching loader for the Confluence space allowlist.""" + + def __init__(self, config_path: Path | None = None): + self._config_path = config_path or _DEFAULT_CONFIG_PATH + self._spaces: frozenset[str] = frozenset() + self._cached_mtime: float = 0 + self._lock = threading.Lock() + self._loaded: bool = False + self._logged_first_load: bool = False + + def allowed_spaces(self) -> frozenset[str]: + """Return the current allowlist, reloading if the file changed.""" + try: + current_mtime = self._config_path.stat().st_mtime + except OSError: + with self._lock: + if self._spaces: + logger.warning( + "context-filters.yaml disappeared — clearing allowlist", + path=str(self._config_path), + ) + self._spaces = frozenset() + self._cached_mtime = 0 + self._loaded = True + return self._spaces + + with self._lock: + if (not self._loaded) or current_mtime != self._cached_mtime: + self._load() + self._cached_mtime = current_mtime + self._loaded = True + return self._spaces + + def is_space_allowed(self, space_key: str) -> bool: + """Return True iff ``space_key`` is in the allowlist.""" + if not space_key: + return False + return space_key in self.allowed_spaces() + + def reload(self) -> None: + """Force the next ``allowed_spaces()`` to re-read from disk.""" + with self._lock: + self._cached_mtime = 0 + self._loaded = False + self._spaces = frozenset() + self._logged_first_load = False + + def _load(self) -> None: + """Load the allowlist from YAML (called under the lock).""" + try: + raw = self._config_path.read_text() + except OSError as exc: + logger.warning( + "Failed to read context-filters.yaml", + path=str(self._config_path), + error=str(exc), + ) + self._spaces = frozenset() + return + + try: + parsed = yaml.safe_load(raw) or {} + except yaml.YAMLError as exc: + logger.error( + "Malformed context-filters.yaml — failing closed", + path=str(self._config_path), + error=str(exc), + ) + self._spaces = frozenset() + return + + if not isinstance(parsed, dict): + logger.error( + "context-filters.yaml top-level must be a mapping — failing closed", + path=str(self._config_path), + type=type(parsed).__name__, + ) + self._spaces = frozenset() + return + + confluence_section = parsed.get("confluence") + if not isinstance(confluence_section, dict): + self._spaces = frozenset() + return + + spaces_raw = confluence_section.get("spaces") + if spaces_raw is None: + self._spaces = frozenset() + return + if not isinstance(spaces_raw, list): + logger.error( + "confluence.spaces must be a list — failing closed", + path=str(self._config_path), + type=type(spaces_raw).__name__, + ) + self._spaces = frozenset() + return + + cleaned: set[str] = set() + for entry in spaces_raw: + if not isinstance(entry, str): + logger.warning( + "Ignoring non-string entry in confluence.spaces", + entry=repr(entry), + ) + continue + key = entry.strip() + if not _SPACE_KEY_RE.fullmatch(key): + logger.warning( + "Ignoring invalid Confluence space key in confluence.spaces", + entry=repr(entry), + ) + continue + cleaned.add(key) + + self._spaces = frozenset(cleaned) + if not self._logged_first_load: + logger.info( + "Confluence space allowlist loaded", + path=str(self._config_path), + count=len(self._spaces), + ) + self._logged_first_load = True + + +# ----------------------------------------------------------------------------- +# Module-level singleton — matches ``jira_policy`` pattern. +# ----------------------------------------------------------------------------- + +_confluence_policy: ConfluencePolicy | None = None +_confluence_policy_lock = threading.Lock() + + +def get_confluence_policy() -> ConfluencePolicy: + """Return the process-wide ``ConfluencePolicy`` singleton.""" + global _confluence_policy + with _confluence_policy_lock: + if _confluence_policy is None: + _confluence_policy = ConfluencePolicy() + return _confluence_policy + + +def allowed_spaces() -> frozenset[str]: + """Convenience accessor — ``ConfluencePolicy.allowed_spaces()`` via singleton.""" + return get_confluence_policy().allowed_spaces() + + +def is_space_allowed(space_key: str) -> bool: + """Convenience accessor — ``ConfluencePolicy.is_space_allowed()`` via singleton.""" + return get_confluence_policy().is_space_allowed(space_key) + + +def reload_confluence_policy() -> None: + """Force the next allowlist access to re-read from disk. + + Invoked by the gateway's ``_reload_all_config()`` hook so ``POST + /api/v1/config/reload`` picks up operator edits without a restart. + """ + get_confluence_policy().reload() + + +def reset_confluence_policy() -> None: + """Drop the module-level singleton (test helper).""" + global _confluence_policy + with _confluence_policy_lock: + _confluence_policy = None + + +__all__ = [ + "ConfluencePolicy", + "allowed_spaces", + "get_confluence_policy", + "is_space_allowed", + "reload_confluence_policy", + "reset_confluence_policy", +] diff --git a/gateway/confluence_search.py b/gateway/confluence_search.py new file mode 100644 index 0000000000..b219fff89b --- /dev/null +++ b/gateway/confluence_search.py @@ -0,0 +1,212 @@ +""" +Conservative CQL space-scope extractor. + +The ``/api/v1/confluence/search`` route refuses any CQL it cannot statically +prove is scoped to a set of allowlisted space keys. This module exposes +``extract_search_spaces(cql, allowed)`` which either returns the set of +space keys the query is scoped to, or a specific rejection reason. + +Design: parse-then-validate, not regex-search. We strip comments and +string literals first, then tokenise at top-level boolean operators, then +accept exactly two shapes:: + + space = KEY + space IN (KEY1, KEY2, ...) + +…optionally AND-combined with arbitrary additional clauses. Anything else +— ``OR`` at any level, ``space`` compared with a function call, mixed +``id = ...`` scope, quoted space keys whose value is not allowlisted, +unicode / mixed-script keys, or a semicolon / comment sneaking through — +returns ``(None, reason)`` and the route translates that to HTTP 403 +``confluence_search_rejected``. + +This is intentionally narrower than Atlassian's CQL grammar. A more +permissive parser would have to decide whether ``space != NOT_ALLOWED`` +"proves" the query only hits allowlisted spaces (it doesn't, because it +still matches everything else), and that risk is not worth an extra line +of code. Agents who hit the ceiling can compose multiple queries. +""" + +from __future__ import annotations + +import re +from typing import NamedTuple + +# Atlassian space keys are conventionally uppercase but the API accepts mixed +# case. Anchor on a leading letter and accept letters/digits/underscore. +_SPACE_KEY_RE = re.compile(r"^[a-zA-Z][a-zA-Z0-9_]*$") + +# Characters that must never appear in a sandboxed agent's CQL — they're all +# markers of comment smuggling or statement chaining the conservative parser +# below would otherwise have to handle specially. +_FORBIDDEN_CHARS: tuple[str, ...] = (";",) +# Characters that split statements / comments in CQL. +_COMMENT_MARKERS: tuple[str, ...] = ("/*", "*/", "--", "//") + + +class ScopeResult(NamedTuple): + """Result of a space-scope extraction.""" + + spaces: frozenset[str] | None # ``None`` on rejection + reason: str # empty on accept, rejection reason otherwise + + +def extract_search_spaces(cql: str, allowed: frozenset[str]) -> ScopeResult: + """Validate ``cql`` and return the space set it is scoped to. + + Args: + cql: Raw CQL string received from the sandbox. + allowed: Space keys the operator has allowlisted. + + Returns: + ``ScopeResult(spaces, "")`` if ``cql`` is statically scoped to a + subset of ``allowed``; ``ScopeResult(None, reason)`` otherwise. The + rejection reason is a short English phrase passed through verbatim + into the ``confluence_search_rejected`` audit line. + """ + if not isinstance(cql, str): + return ScopeResult(None, "cql must be a string") + if not cql.strip(): + return ScopeResult(None, "cql must not be empty") + + # Non-ASCII is a red flag for unicode homoglyph abuse. + try: + cql.encode("ascii") + except UnicodeEncodeError: + return ScopeResult(None, "cql contains non-ASCII characters") + + for forbidden in _FORBIDDEN_CHARS: + if forbidden in cql: + return ScopeResult(None, f"cql contains forbidden character '{forbidden}'") + for marker in _COMMENT_MARKERS: + if marker in cql: + return ScopeResult(None, "cql contains comment markers") + + # 1. Replace every quoted literal with the sentinel ``__STR__`` so it + # can't masquerade as a space key. Even ``space = "ENG"`` (with an + # allowlisted key) is rejected — accepting quoted keys forces the + # parser to reason about string escaping and doesn't buy agents + # anything they can't get from the unquoted spelling. + normalised = _normalise_strings(cql) + if normalised is None: + return ScopeResult(None, "cql contains malformed string literal") + + # 2. Reject any OR (case-insensitive) at any depth. ``space IN (K, K)`` + # never contains an OR token, so any OR is a rejection. + if _contains_top_level_or(normalised): + return ScopeResult(None, "space under OR") + + # 3. Reject bare id / content / title clauses without a space anchor. + if _contains_bare_id_clause(normalised): + return ScopeResult(None, "id-level clause without space scope") + + tokens = _extract_space_clauses(normalised) + if tokens is None: + return ScopeResult(None, "cannot prove space scope") + if not tokens: + return ScopeResult(None, "no space clause") + + # 4. All extracted keys must be in the allowlist. + not_allowed = [t for t in tokens if t not in allowed] + if not_allowed: + return ScopeResult( + None, + f"space(s) not allowlisted: {','.join(sorted(set(not_allowed)))}", + ) + + return ScopeResult(frozenset(tokens), "") + + +# ----------------------------------------------------------------------------- +# Helpers +# ----------------------------------------------------------------------------- + + +def _normalise_strings(cql: str) -> str | None: + """Replace every quoted literal with the sentinel ``__STR__``. + + Returns ``None`` for malformed literals (mismatched quotes). + """ + out: list[str] = [] + i = 0 + while i < len(cql): + ch = cql[i] + if ch in ('"', "'"): + end = cql.find(ch, i + 1) + if end == -1: + return None + out.append("__STR__") + i = end + 1 + else: + out.append(ch) + i += 1 + return "".join(out) + + +def _contains_top_level_or(cql: str) -> bool: + """Return True if the CQL contains an OR boolean operator (any depth).""" + return re.search(r"(?i)(? bool: + """Return True if the CQL references ``id`` / ``content`` / ``title`` as a + filter clause without anchoring on ``space``. These widen scope. + """ + pattern = re.compile( + r"(?i)(?|<)", + ) + return pattern.search(cql) is not None + + +def _extract_space_clauses(cql: str) -> list[str] | None: + """Pull the space keys out of every ``space`` clause in ``cql``. + + Only accepts the exact shapes:: + + space = KEY (case-sensitive 'space', valid space key) + space IN (KEY[, KEY]...) (case-sensitive 'space') + + Returns the flat list of keys on success, an empty list if no clause is + present, or ``None`` on any malformed / case-variant occurrence. + """ + out: list[str] = [] + # Detect any non-canonical capitalisation (``Space``, ``SPACE``). + all_matches = list(re.finditer(r"(?i)(? JiraCredentials: if creds is None: raise JiraCredentialsUnavailable( - "Jira credentials missing — set JIRA_BASE_URL, JIRA_USERNAME, " - f"JIRA_API_TOKEN in {self._secrets_path}" + "Jira credentials missing — set ATLASSIAN_BASE_URL (or " + "JIRA_BASE_URL), ATLASSIAN_USERNAME (or JIRA_USERNAME), " + "ATLASSIAN_API_TOKEN (or JIRA_API_TOKEN) in " + f"{self._secrets_path}" ) return creds def _load_credentials(self) -> None: - """Load credentials from secrets.env file (called under lock).""" + """Load credentials from secrets.env file (called under lock). + + Per-key precedence: ``ATLASSIAN_*`` is preferred for each of the three + keys; ``JIRA_*`` is used as a fallback per-key (decision F1). Mixed + combinations are valid (e.g. ``ATLASSIAN_USERNAME`` + + ``JIRA_API_TOKEN``) because Atlassian accounts are tenant-wide. + """ if not self._secrets_path.exists(): logger.warning("Secrets file not found", path=str(self._secrets_path)) self._credentials = None return secrets = parse_env_file(self._secrets_path) - base_url = (secrets.get("JIRA_BASE_URL") or "").strip().rstrip("/") - username = (secrets.get("JIRA_USERNAME") or "").strip() - api_token = (secrets.get("JIRA_API_TOKEN") or "").strip() + + atlassian_base = (secrets.get("ATLASSIAN_BASE_URL") or "").strip().rstrip("/") + jira_base = (secrets.get("JIRA_BASE_URL") or "").strip().rstrip("/") + # Jira lives at the bare Atlassian origin — no /wiki suffix needed. + if atlassian_base: + base_url = atlassian_base + base_source = "ATLASSIAN_BASE_URL" + elif jira_base: + base_url = jira_base + base_source = "JIRA_BASE_URL" + else: + base_url = "" + base_source = "" + + username = (secrets.get("ATLASSIAN_USERNAME") or "").strip() + username_source = "ATLASSIAN_USERNAME" if username else "" + if not username: + username = (secrets.get("JIRA_USERNAME") or "").strip() + username_source = "JIRA_USERNAME" if username else "" + + api_token = (secrets.get("ATLASSIAN_API_TOKEN") or "").strip() + token_source = "ATLASSIAN_API_TOKEN" if api_token else "" + if not api_token: + api_token = (secrets.get("JIRA_API_TOKEN") or "").strip() + token_source = "JIRA_API_TOKEN" if api_token else "" if not (base_url and username and api_token): missing = [ name for name, value in ( - ("JIRA_BASE_URL", base_url), - ("JIRA_USERNAME", username), - ("JIRA_API_TOKEN", api_token), + ("BASE_URL (ATLASSIAN_BASE_URL or JIRA_BASE_URL)", base_url), + ("USERNAME (ATLASSIAN_USERNAME or JIRA_USERNAME)", username), + ("API_TOKEN (ATLASSIAN_API_TOKEN or JIRA_API_TOKEN)", api_token), ) if not value ] @@ -162,6 +202,9 @@ def _load_credentials(self) -> None: logger.info( "Jira credentials loaded", base_url=base_url, + base_source=base_source, + username_source=username_source, + token_source=token_source, username=username, token_prefix=api_token[:4] + "...", ) From 7a29920dca483d7e8558ceb5cebf84e07c077f75 Mon Sep 17 00:00:00 2001 From: egg Date: Mon, 27 Apr 2026 00:33:59 +0000 Subject: [PATCH 12/26] gateway: wire Confluence routes into Flask app (#1931) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 of the Confluence read-only gateway support. Adds the eight POST /api/v1/confluence/* routes alongside the existing /api/v1/jira/* block in gateway.py: - /page/get - /page/descendants - /page/footer-comments - /page/inline-comments - /space/list (allowlist-filtered) - /space/pages (spaceKey → spaceId resolution via list_spaces cache) - /search (CQL extractor + clamp) - /execute (allowlisted-path passthrough) Each route composes session-auth → private-mode → space-allowlist (post- fetch for page reads, pre-call for spaceKey-supplied routes) → client call → audit_log. Translates ConfluenceUpstreamForbidden to HTTP 403 with the dedicated `confluence_upstream_403` audit event. Translates ConfluenceResponseTooLarge to HTTP 413. Also extends `_reload_all_config()` to call `reload_confluence_*` and emit `confluence_config_reloaded`, mirroring the existing Jira reload. Co-Authored-By: Claude Opus 4.7 --- gateway/gateway.py | 1184 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1184 insertions(+) diff --git a/gateway/gateway.py b/gateway/gateway.py index 98cf22a243..6e4a50adfa 100644 --- a/gateway/gateway.py +++ b/gateway/gateway.py @@ -21,6 +21,14 @@ POST /api/v1/jira/search - JQL search (policy: private-mode, statically project-scoped) POST /api/v1/jira/ticket/comments - Read Jira issue comments (policy: private-mode, project allowlist) POST /api/v1/jira/execute - Generic read-only Jira REST call (policy: private-mode, allowlisted path) + POST /api/v1/confluence/page/get - Read Confluence page (policy: private-mode, space allowlist) + POST /api/v1/confluence/page/descendants - List page descendants (policy: private-mode, space allowlist) + POST /api/v1/confluence/page/footer-comments - Read page footer comments (policy: private-mode, space allowlist) + POST /api/v1/confluence/page/inline-comments - Read page inline comments (policy: private-mode, space allowlist) + POST /api/v1/confluence/space/list - List allowlisted spaces (policy: private-mode) + POST /api/v1/confluence/space/pages - List pages in a space (policy: private-mode, space allowlist) + POST /api/v1/confluence/search - CQL search (policy: private-mode, statically space-scoped) + POST /api/v1/confluence/execute - Generic read-only Confluence REST call (policy: private-mode, allowlisted path) GET /api/v1/health - Health check (no auth required) Usage: @@ -80,6 +88,31 @@ capture_and_store_checkpoints_for_push, get_checkpoint_handler, ) + from .confluence_client import ( + DEFAULT_LIMIT as CONFLUENCE_DEFAULT_LIMIT, + ) + from .confluence_client import ( + HARD_MAX_LIMIT as CONFLUENCE_HARD_MAX_LIMIT, + ) + from .confluence_client import ( + ConfluenceCredentialsUnavailable, + ConfluenceResponseTooLarge, + ConfluenceUpstreamError, + ConfluenceUpstreamForbidden, + get_confluence_client, + validate_confluence_api_path, + ) + from .confluence_credentials import reload_confluence_credentials + from .confluence_policy import ( + allowed_spaces as confluence_allowed_spaces, + ) + from .confluence_policy import ( + is_space_allowed as is_confluence_space_allowed, + ) + from .confluence_policy import ( + reload_confluence_policy, + ) + from .confluence_search import extract_search_spaces from .git_client import ( GIT_ALLOWED_COMMANDS, cleanup_credential_helper, @@ -218,6 +251,35 @@ _egg_gateway_dir = str(Path(__file__).parent) if _egg_gateway_dir not in sys.path: sys.path.insert(0, _egg_gateway_dir) + from confluence_client import ( # type: ignore[no-redef] + DEFAULT_LIMIT as CONFLUENCE_DEFAULT_LIMIT, + ) + from confluence_client import ( # type: ignore[no-redef] + HARD_MAX_LIMIT as CONFLUENCE_HARD_MAX_LIMIT, + ) + from confluence_client import ( # type: ignore[no-redef, import-untyped] + ConfluenceCredentialsUnavailable, + ConfluenceResponseTooLarge, + ConfluenceUpstreamError, + ConfluenceUpstreamForbidden, + get_confluence_client, + validate_confluence_api_path, + ) + from confluence_credentials import ( # type: ignore[no-redef, import-untyped] + reload_confluence_credentials, + ) + from confluence_policy import ( # type: ignore[no-redef, import-untyped] + allowed_spaces as confluence_allowed_spaces, + ) + from confluence_policy import ( # type: ignore[no-redef] + is_space_allowed as is_confluence_space_allowed, + ) + from confluence_policy import ( # type: ignore[no-redef] + reload_confluence_policy, + ) + from confluence_search import ( # type: ignore[no-redef, import-untyped] + extract_search_spaces, + ) from jira_client import ( # type: ignore[no-redef, import-untyped] JiraCredentialsUnavailable, JiraUpstreamError, @@ -910,6 +972,31 @@ def _reload_all_config() -> None: trigger="sighup", ) + # Confluence credentials + space allowlist — same disk-cache pattern as + # Jira. The Confluence allowlist lives under the ``confluence:`` section + # of context-filters.yaml; credentials share the secrets.env file. + try: + reload_confluence_credentials() + except Exception: # pragma: no cover — defensive + logger.exception("Confluence credentials reload failed") + try: + reload_confluence_policy() + except Exception: # pragma: no cover — defensive + logger.exception("Confluence space allowlist reload failed") + if has_request_context(): + audit_log( + "confluence_config_reloaded", + "config_reload", + success=True, + details={"components": ["confluence_credentials", "confluence_policy"]}, + ) + else: + logger.info( + "Confluence configuration reloaded", + components=["confluence_credentials", "confluence_policy"], + trigger="sighup", + ) + @app.route("/api/v1/config/reload", methods=["POST"]) @require_launcher_auth @@ -4671,6 +4758,1103 @@ def jira_execute() -> tuple[Response, int] | Response: return make_success("Jira API call executed", body) +# ============================================================================= +# Confluence REST Endpoints +# ============================================================================= +# +# Read-only wrappers around Atlassian Cloud Confluence's REST API. Routes +# live on the ``/api/v1/confluence/*`` prefix and mirror the shape of +# ``/api/v1/jira/*``: session auth, private-mode gate, space allowlist, +# structured audit log. +# +# Credentials come from ``gateway/confluence_credentials.py`` (loaded from +# the same ``secrets.env`` file as Jira and GitHub) and are never exported +# to the sandbox. See: +# - gateway/confluence_client.py — client + path allowlist + redaction +# - gateway/confluence_policy.py — space allowlist loader +# - gateway/confluence_search.py — CQL space-scope extractor +# - gateway/mode_gate.py — @require_private_mode decorator + +# Numeric Confluence page id / space id shape. +_CONFLUENCE_PAGE_ID_RE = re.compile(r"^\d+$") +_CONFLUENCE_SPACE_KEY_RE = re.compile(r"^[a-zA-Z][a-zA-Z0-9_]*$") + + +def _session_confluence_context() -> dict[str, Any]: + """Return session-scoped fields to include in Confluence audit records. + + Per refine decision 13 there is no per-session ``session.confluence_*`` + field — pageId / spaceKey are recovered from the request body or + response per call. + """ + ctx: dict[str, Any] = { + "session_mode": getattr(g, "session_mode", None), + } + session = getattr(g, "session", None) + if session is not None: + ctx["pipeline_id"] = getattr(session, "pipeline_id", None) + ctx["agent_role"] = getattr(session, "agent_role", None) + return ctx + + +def _confluence_error_from_upstream(exc: ConfluenceUpstreamError) -> tuple[Response, int]: + """Translate a ``ConfluenceUpstreamError`` to an HTTP response.""" + if 400 <= exc.status_code < 500: + status = exc.status_code + else: + status = 502 + return make_error( + f"Confluence upstream error {exc.status_code}", + status_code=status, + details={ + "upstream_status": exc.status_code, + "upstream_body": exc.body, + "path": exc.path, + }, + ) + + +def _confluence_not_configured_error( + exc: ConfluenceCredentialsUnavailable, +) -> tuple[Response, int]: + """Translate missing credentials to an HTTP 503 response.""" + return make_error( + "Confluence credentials not configured on the gateway", + status_code=503, + details={"reason": str(exc)}, + ) + + +def _confluence_response_too_large( + exc: ConfluenceResponseTooLarge, + *, + page_id: str | None = None, + space_key: str | None = None, +) -> tuple[Response, int]: + """Translate an oversized response to HTTP 413.""" + details: dict[str, Any] = {"size_bytes": exc.size_bytes, "path": exc.path} + if page_id is not None: + details["pageId"] = page_id + if space_key is not None: + details["spaceKey"] = space_key + return make_error( + "Confluence response too large", + status_code=413, + details=details, + ) + + +def _confluence_forbidden_response( + exc: ConfluenceUpstreamForbidden, + *, + event: str, + page_id: str | None = None, + space_key: str | None = None, +) -> tuple[Response, int]: + """Translate an upstream 403 into HTTP 403 with the dedicated audit event.""" + details: dict[str, Any] = { + "upstream_status": 403, + "reason": "bot_account_lacks_read_access", + "path": exc.path, + **_session_confluence_context(), + } + if page_id is not None: + details["pageId"] = page_id + if space_key is not None: + details["spaceKey"] = space_key + audit_log(event, event, success=False, details=details) + body: dict[str, Any] = { + "status": "forbidden", + "reason": "bot_account_lacks_read_access", + } + if page_id is not None: + body["pageId"] = page_id + if space_key is not None: + body["spaceKey"] = space_key + return make_error( + "Confluence upstream forbidden", + status_code=403, + details=body, + ) + + +def _confluence_space_denied_response( + *, + event: str, + page_id: str | None, + space_key: str | None, + reason: str, + extra: dict[str, Any] | None = None, +) -> tuple[Response, int]: + """Emit a structured audit record and return the canonical 403.""" + details: dict[str, Any] = {"spaceKey": space_key, "reason": reason} + if page_id is not None: + details["pageId"] = page_id + if extra: + details.update(extra) + details.update(_session_confluence_context()) + audit_log(event, event, success=False, details=details) + return make_error( + "Confluence space not allowlisted", + status_code=403, + details={"spaceKey": space_key, "reason": reason}, + ) + + +def _resolve_space_key_for_payload(payload: Any) -> str | None: + """Extract a ``spaceKey`` from an upstream payload, using the client's + space cache if only ``spaceId`` is present. + + Returns the space key on success; ``None`` if the payload doesn't carry + one (e.g. v1 fallback with no spaceId — caller falls back to a manual + list_spaces lookup). + """ + if not isinstance(payload, dict): + return None + direct = payload.get("spaceKey") or payload.get("space_key") + if isinstance(direct, str) and direct: + return direct + # v2 returns ``spaceId`` on page reads; the client caches the mapping + # opportunistically once ``list_spaces`` runs. + space_id = payload.get("spaceId") + if space_id is None: + space = payload.get("space") + if isinstance(space, dict): + sk = space.get("key") + if isinstance(sk, str) and sk: + return sk + space_id = space.get("id") + if space_id is None: + return None + client = get_confluence_client() + return client.space_cache.key_for_id(str(space_id)) + + +def _resolve_space_key_via_list(allowed: frozenset[str], space_id: str | None) -> str | None: + """Look up a space key for a space id by calling list_spaces (cached). + + Used by the post-fetch allowlist check when the page response carries + ``spaceId`` but the cache hasn't been populated yet. Returns ``None`` + if the space isn't visible to the bot (which is itself a deny signal). + """ + if not space_id: + return None + client = get_confluence_client() + cached = client.space_cache.key_for_id(str(space_id)) + if cached is not None: + return cached + # Force a fetch so the cache is hot for the next request. + try: + client.list_spaces(allowed_spaces=allowed) + except (ConfluenceCredentialsUnavailable, ConfluenceUpstreamError): + return None + return client.space_cache.key_for_id(str(space_id)) + + +def _confluence_clamp_limit(value: Any) -> int | None: + """Coerce + clamp a caller-supplied limit (1..HARD_MAX_LIMIT).""" + if value is None: + return None + try: + parsed = int(value) + except (TypeError, ValueError): + raise ValueError("limit must be an integer") from None + if parsed <= 0: + raise ValueError("limit must be positive") + return min(parsed, CONFLUENCE_HARD_MAX_LIMIT) + + +def _validate_confluence_page_id(page_id: Any) -> tuple[bool, str]: + if not isinstance(page_id, str) or not _CONFLUENCE_PAGE_ID_RE.fullmatch(page_id): + return False, "invalid pageId shape" + return True, "" + + +def _validate_confluence_space_key(space_key: Any) -> tuple[bool, str]: + if not isinstance(space_key, str) or not _CONFLUENCE_SPACE_KEY_RE.fullmatch(space_key): + return False, "invalid spaceKey shape" + return True, "" + + +def _check_post_fetch_space_allowlist( + payload: Any, + *, + allowed: frozenset[str], + page_id: str | None, +) -> tuple[bool, str | None]: + """Verify the response's spaceKey is in the allowlist. + + Returns ``(ok, space_key)``. When ``ok`` is False the route returns + HTTP 403 without forwarding the response body; ``space_key`` is the + resolved key for audit purposes (may be ``None`` if unresolvable). + """ + if not isinstance(payload, dict): + return False, None + if payload.get("status") == "not_found": + # 404 envelope passes through — no space leakage. + return True, None + space_key = _resolve_space_key_for_payload(payload) + if space_key is None: + space_id = payload.get("spaceId") + if isinstance(space_id, (str, int)): + space_key = _resolve_space_key_via_list(allowed, str(space_id)) + if space_key is None: + # Couldn't resolve — fail closed. This protects against the upstream + # response shape changing. + return False, None + return space_key in allowed, space_key + + +@app.route("/api/v1/confluence/page/get", methods=["POST"]) +@require_session_auth +@require_private_mode +def confluence_page_get() -> tuple[Response, int] | Response: + """Fetch a single Confluence page (v2). + + Request body:: + + {"pageId": "12345", + "bodyFormat": ["storage"], + "expand": null} + """ + data = request.get_json(silent=True) or {} + page_id = data.get("pageId") + body_format = data.get("bodyFormat") + expand = data.get("expand") + + ok, reason = _validate_confluence_page_id(page_id) + if not ok: + audit_log( + "confluence_page_get_rejected", + "confluence_page_get", + success=False, + details={"reason": reason, "pageId": page_id, **_session_confluence_context()}, + ) + return make_error( + "Invalid pageId (expected numeric string)", + status_code=400, + details={"pageId": page_id}, + ) + + allowed = confluence_allowed_spaces() + try: + body = get_confluence_client().get_page(page_id, body_format=body_format, expand=expand) + except ValueError as exc: + audit_log( + "confluence_page_get_rejected", + "confluence_page_get", + success=False, + details={"reason": str(exc), "pageId": page_id, **_session_confluence_context()}, + ) + return make_error(f"Invalid request: {exc}", status_code=400) + except ConfluenceCredentialsUnavailable as exc: + return _confluence_not_configured_error(exc) + except ConfluenceUpstreamForbidden as exc: + return _confluence_forbidden_response( + exc, event="confluence_upstream_403", page_id=page_id + ) + except ConfluenceResponseTooLarge as exc: + audit_log( + "confluence_response_too_large", + "confluence_page_get", + success=False, + details={"pageId": page_id, "size_bytes": exc.size_bytes, **_session_confluence_context()}, + ) + return _confluence_response_too_large(exc, page_id=page_id) + except ConfluenceUpstreamError as exc: + audit_log( + "confluence_page_get_upstream_error", + "confluence_page_get", + success=False, + details={ + "pageId": page_id, + "upstream_status": exc.status_code, + **_session_confluence_context(), + }, + ) + return _confluence_error_from_upstream(exc) + + ok_space, space_key = _check_post_fetch_space_allowlist(body, allowed=allowed, page_id=page_id) + if not ok_space: + return _confluence_space_denied_response( + event="confluence_space_denied", + page_id=page_id, + space_key=space_key, + reason="space not allowlisted", + ) + + audit_log( + "confluence_page_get", + "confluence_page_get", + success=True, + details={ + "pageId": page_id, + "spaceKey": space_key, + "not_found": body.get("status") == "not_found", + **_session_confluence_context(), + }, + ) + return make_success("Confluence page fetched", body) + + +@app.route("/api/v1/confluence/page/descendants", methods=["POST"]) +@require_session_auth +@require_private_mode +def confluence_page_descendants() -> tuple[Response, int] | Response: + """List the descendants of a Confluence page.""" + data = request.get_json(silent=True) or {} + page_id = data.get("pageId") + depth = data.get("depth") + limit_raw = data.get("limit") + cursor = data.get("cursor") + + ok, reason = _validate_confluence_page_id(page_id) + if not ok: + audit_log( + "confluence_page_descendants_rejected", + "confluence_page_descendants", + success=False, + details={"reason": reason, "pageId": page_id, **_session_confluence_context()}, + ) + return make_error( + "Invalid pageId (expected numeric string)", + status_code=400, + details={"pageId": page_id}, + ) + + # Apply sensible defaults for runaway-tree protection (risk R8). + if depth is None: + depth = 1 + if limit_raw is None: + limit_raw = CONFLUENCE_DEFAULT_LIMIT + try: + limit = _confluence_clamp_limit(limit_raw) + except ValueError as exc: + audit_log( + "confluence_page_descendants_rejected", + "confluence_page_descendants", + success=False, + details={"reason": str(exc), "pageId": page_id, **_session_confluence_context()}, + ) + return make_error(f"Invalid limit: {exc}", status_code=400) + + allowed = confluence_allowed_spaces() + try: + body = get_confluence_client().get_page_descendants( + page_id, + depth=depth, + limit=limit, + cursor=cursor if isinstance(cursor, str) else None, + ) + except ConfluenceCredentialsUnavailable as exc: + return _confluence_not_configured_error(exc) + except ConfluenceUpstreamForbidden as exc: + return _confluence_forbidden_response( + exc, event="confluence_upstream_403", page_id=page_id + ) + except ConfluenceResponseTooLarge as exc: + return _confluence_response_too_large(exc, page_id=page_id) + except ConfluenceUpstreamError as exc: + audit_log( + "confluence_page_descendants_upstream_error", + "confluence_page_descendants", + success=False, + details={ + "pageId": page_id, + "upstream_status": exc.status_code, + **_session_confluence_context(), + }, + ) + return _confluence_error_from_upstream(exc) + + # Resolve the parent page's space for the allowlist check. The + # descendants response doesn't carry it directly, so we fetch the parent + # page once (cheap — the v2 page endpoint is small). + parent_space_key: str | None = None + if body.get("status") != "not_found": + try: + parent = get_confluence_client().get_page(page_id, body_format=("storage",)) + except (ConfluenceCredentialsUnavailable, ConfluenceUpstreamError, ConfluenceUpstreamForbidden): + parent = None + if parent is not None and parent.get("status") != "not_found": + ok_space, parent_space_key = _check_post_fetch_space_allowlist( + parent, allowed=allowed, page_id=page_id + ) + if not ok_space: + return _confluence_space_denied_response( + event="confluence_space_denied", + page_id=page_id, + space_key=parent_space_key, + reason="space not allowlisted", + ) + else: + return _confluence_space_denied_response( + event="confluence_space_denied", + page_id=page_id, + space_key=None, + reason="parent page space could not be resolved", + ) + + audit_log( + "confluence_page_descendants", + "confluence_page_descendants", + success=True, + details={ + "pageId": page_id, + "spaceKey": parent_space_key, + "depth": depth, + "limit": limit, + **_session_confluence_context(), + }, + ) + return make_success("Confluence descendants fetched", body) + + +@app.route("/api/v1/confluence/page/footer-comments", methods=["POST"]) +@require_session_auth +@require_private_mode +def confluence_page_footer_comments() -> tuple[Response, int] | Response: + """Fetch footer comments on a Confluence page.""" + data = request.get_json(silent=True) or {} + page_id = data.get("pageId") + body_format = data.get("bodyFormat") + include_replies = bool(data.get("includeReplies")) + limit_raw = data.get("limit") + cursor = data.get("cursor") + + ok, reason = _validate_confluence_page_id(page_id) + if not ok: + audit_log( + "confluence_page_footer_comments_rejected", + "confluence_page_footer_comments", + success=False, + details={"reason": reason, "pageId": page_id, **_session_confluence_context()}, + ) + return make_error( + "Invalid pageId (expected numeric string)", + status_code=400, + details={"pageId": page_id}, + ) + + try: + limit = _confluence_clamp_limit(limit_raw) + except ValueError as exc: + return make_error(f"Invalid limit: {exc}", status_code=400) + + allowed = confluence_allowed_spaces() + try: + body = get_confluence_client().get_page_footer_comments( + page_id, + body_format=body_format, + include_replies=include_replies, + limit=limit, + cursor=cursor if isinstance(cursor, str) else None, + ) + except ValueError as exc: + return make_error(f"Invalid request: {exc}", status_code=400) + except ConfluenceCredentialsUnavailable as exc: + return _confluence_not_configured_error(exc) + except ConfluenceUpstreamForbidden as exc: + return _confluence_forbidden_response( + exc, event="confluence_upstream_403", page_id=page_id + ) + except ConfluenceResponseTooLarge as exc: + return _confluence_response_too_large(exc, page_id=page_id) + except ConfluenceUpstreamError as exc: + audit_log( + "confluence_page_footer_comments_upstream_error", + "confluence_page_footer_comments", + success=False, + details={ + "pageId": page_id, + "upstream_status": exc.status_code, + **_session_confluence_context(), + }, + ) + return _confluence_error_from_upstream(exc) + + parent_space_key: str | None = None + if body.get("status") != "not_found": + try: + parent = get_confluence_client().get_page(page_id, body_format=("storage",)) + except (ConfluenceCredentialsUnavailable, ConfluenceUpstreamError, ConfluenceUpstreamForbidden): + parent = None + if parent is not None and parent.get("status") != "not_found": + ok_space, parent_space_key = _check_post_fetch_space_allowlist( + parent, allowed=allowed, page_id=page_id + ) + if not ok_space: + return _confluence_space_denied_response( + event="confluence_space_denied", + page_id=page_id, + space_key=parent_space_key, + reason="space not allowlisted", + ) + + audit_log( + "confluence_page_footer_comments", + "confluence_page_footer_comments", + success=True, + details={ + "pageId": page_id, + "spaceKey": parent_space_key, + "includeReplies": include_replies, + **_session_confluence_context(), + }, + ) + return make_success("Confluence footer comments fetched", body) + + +@app.route("/api/v1/confluence/page/inline-comments", methods=["POST"]) +@require_session_auth +@require_private_mode +def confluence_page_inline_comments() -> tuple[Response, int] | Response: + """Fetch inline comments on a Confluence page (with v1 fallback).""" + data = request.get_json(silent=True) or {} + page_id = data.get("pageId") + body_format = data.get("bodyFormat") + limit_raw = data.get("limit") + cursor = data.get("cursor") + + ok, reason = _validate_confluence_page_id(page_id) + if not ok: + audit_log( + "confluence_page_inline_comments_rejected", + "confluence_page_inline_comments", + success=False, + details={"reason": reason, "pageId": page_id, **_session_confluence_context()}, + ) + return make_error( + "Invalid pageId (expected numeric string)", + status_code=400, + details={"pageId": page_id}, + ) + + try: + limit = _confluence_clamp_limit(limit_raw) + except ValueError as exc: + return make_error(f"Invalid limit: {exc}", status_code=400) + + allowed = confluence_allowed_spaces() + try: + body = get_confluence_client().get_page_inline_comments( + page_id, + body_format=body_format, + limit=limit, + cursor=cursor if isinstance(cursor, str) else None, + ) + except ValueError as exc: + return make_error(f"Invalid request: {exc}", status_code=400) + except ConfluenceCredentialsUnavailable as exc: + return _confluence_not_configured_error(exc) + except ConfluenceUpstreamForbidden as exc: + return _confluence_forbidden_response( + exc, event="confluence_upstream_403", page_id=page_id + ) + except ConfluenceResponseTooLarge as exc: + return _confluence_response_too_large(exc, page_id=page_id) + except ConfluenceUpstreamError as exc: + audit_log( + "confluence_page_inline_comments_upstream_error", + "confluence_page_inline_comments", + success=False, + details={ + "pageId": page_id, + "upstream_status": exc.status_code, + **_session_confluence_context(), + }, + ) + return _confluence_error_from_upstream(exc) + + used_fallback = bool(body.get("used_fallback")) + parent_space_key: str | None = None + if body.get("status") != "not_found": + try: + parent = get_confluence_client().get_page(page_id, body_format=("storage",)) + except (ConfluenceCredentialsUnavailable, ConfluenceUpstreamError, ConfluenceUpstreamForbidden): + parent = None + if parent is not None and parent.get("status") != "not_found": + ok_space, parent_space_key = _check_post_fetch_space_allowlist( + parent, allowed=allowed, page_id=page_id + ) + if not ok_space: + return _confluence_space_denied_response( + event="confluence_space_denied", + page_id=page_id, + space_key=parent_space_key, + reason="space not allowlisted", + ) + + audit_log( + "confluence_page_inline_comments", + "confluence_page_inline_comments", + success=True, + details={ + "pageId": page_id, + "spaceKey": parent_space_key, + "used_fallback": used_fallback, + **_session_confluence_context(), + }, + ) + return make_success("Confluence inline comments fetched", body) + + +@app.route("/api/v1/confluence/space/pages", methods=["POST"]) +@require_session_auth +@require_private_mode +def confluence_space_pages() -> tuple[Response, int] | Response: + """List pages in a Confluence space.""" + data = request.get_json(silent=True) or {} + space_key = data.get("spaceKey") + limit_raw = data.get("limit") + cursor = data.get("cursor") + body_format = data.get("bodyFormat") + + ok, reason = _validate_confluence_space_key(space_key) + if not ok: + audit_log( + "confluence_space_pages_rejected", + "confluence_space_pages", + success=False, + details={"reason": reason, "spaceKey": space_key, **_session_confluence_context()}, + ) + return make_error( + "Invalid spaceKey", + status_code=400, + details={"spaceKey": space_key}, + ) + + if not is_confluence_space_allowed(space_key): + return _confluence_space_denied_response( + event="confluence_space_pages_denied", + page_id=None, + space_key=space_key, + reason="space not allowlisted", + ) + + try: + limit = _confluence_clamp_limit(limit_raw) + except ValueError as exc: + return make_error(f"Invalid limit: {exc}", status_code=400) + + allowed = confluence_allowed_spaces() + client = get_confluence_client() + + # Resolve spaceKey → spaceId, using the cache when populated. + space_id = client.space_cache.id_for_key(space_key) + if space_id is None: + try: + client.list_spaces(allowed_spaces=allowed) + except ConfluenceCredentialsUnavailable as exc: + return _confluence_not_configured_error(exc) + except ConfluenceUpstreamForbidden as exc: + return _confluence_forbidden_response( + exc, event="confluence_upstream_403", space_key=space_key + ) + except ConfluenceUpstreamError as exc: + return _confluence_error_from_upstream(exc) + space_id = client.space_cache.id_for_key(space_key) + + if space_id is None: + return make_error( + "Confluence space not found or not visible to bot account", + status_code=404, + details={"status": "not_found", "spaceKey": space_key}, + ) + + try: + body = client.get_space_pages( + space_id, + limit=limit, + cursor=cursor if isinstance(cursor, str) else None, + body_format=body_format, + ) + except ValueError as exc: + return make_error(f"Invalid request: {exc}", status_code=400) + except ConfluenceCredentialsUnavailable as exc: + return _confluence_not_configured_error(exc) + except ConfluenceUpstreamForbidden as exc: + return _confluence_forbidden_response( + exc, event="confluence_upstream_403", space_key=space_key + ) + except ConfluenceResponseTooLarge as exc: + return _confluence_response_too_large(exc, space_key=space_key) + except ConfluenceUpstreamError as exc: + audit_log( + "confluence_space_pages_upstream_error", + "confluence_space_pages", + success=False, + details={ + "spaceKey": space_key, + "upstream_status": exc.status_code, + **_session_confluence_context(), + }, + ) + return _confluence_error_from_upstream(exc) + + audit_log( + "confluence_space_pages", + "confluence_space_pages", + success=True, + details={ + "spaceKey": space_key, + "limit": limit, + **_session_confluence_context(), + }, + ) + return make_success("Confluence space pages fetched", body) + + +@app.route("/api/v1/confluence/space/list", methods=["POST"]) +@require_session_auth +@require_private_mode +def confluence_space_list() -> tuple[Response, int] | Response: + """List Confluence spaces (filtered to the operator's allowlist).""" + data = request.get_json(silent=True) or {} + limit_raw = data.get("limit") + cursor = data.get("cursor") + + try: + limit = _confluence_clamp_limit(limit_raw) + except ValueError as exc: + return make_error(f"Invalid limit: {exc}", status_code=400) + + allowed = confluence_allowed_spaces() + + try: + body = get_confluence_client().list_spaces( + allowed_spaces=allowed, + limit=limit, + cursor=cursor if isinstance(cursor, str) else None, + ) + except ConfluenceCredentialsUnavailable as exc: + return _confluence_not_configured_error(exc) + except ConfluenceUpstreamForbidden as exc: + return _confluence_forbidden_response(exc, event="confluence_upstream_403") + except ConfluenceResponseTooLarge as exc: + return _confluence_response_too_large(exc) + except ConfluenceUpstreamError as exc: + audit_log( + "confluence_space_list_upstream_error", + "confluence_space_list", + success=False, + details={ + "upstream_status": exc.status_code, + **_session_confluence_context(), + }, + ) + return _confluence_error_from_upstream(exc) + + spaces_returned = 0 + if isinstance(body, dict): + results = body.get("results") + if isinstance(results, list): + spaces_returned = len(results) + + audit_log( + "confluence_space_list", + "confluence_space_list", + success=True, + details={ + "spaces_returned": spaces_returned, + **_session_confluence_context(), + }, + ) + return make_success("Confluence spaces fetched", body) + + +@app.route("/api/v1/confluence/search", methods=["POST"]) +@require_session_auth +@require_private_mode +def confluence_search() -> tuple[Response, int] | Response: + """Run a CQL search against Atlassian Cloud Confluence. + + Request body:: + + {"cql": "space = ENG AND text ~ \"rfc\"", + "limit": 50, + "cursor": null} + + The CQL must be statically provable as scoped to allowlisted spaces. + """ + data = request.get_json(silent=True) or {} + cql = data.get("cql") + limit_raw = data.get("limit") + cursor = data.get("cursor") + + if not isinstance(cql, str) or not cql.strip(): + audit_log( + "confluence_search_rejected", + "confluence_search", + success=False, + details={"reason": "cql required", **_session_confluence_context()}, + ) + return make_error("cql is required", status_code=400) + + allowed = confluence_allowed_spaces() + scope = extract_search_spaces(cql, allowed) + if scope.spaces is None: + audit_log( + "confluence_search_rejected", + "confluence_search", + success=False, + details={ + "reason": scope.reason, + "cql_length": len(cql), + **_session_confluence_context(), + }, + ) + return make_error( + f"CQL rejected: {scope.reason}", + status_code=403, + details={"reason": scope.reason}, + ) + + try: + limit = _confluence_clamp_limit(limit_raw) + except ValueError as exc: + return make_error(f"Invalid limit: {exc}", status_code=400) + + try: + body = get_confluence_client().search_cql( + cql=cql, + limit=limit, + cursor=cursor if isinstance(cursor, str) else None, + ) + except ConfluenceCredentialsUnavailable as exc: + return _confluence_not_configured_error(exc) + except ConfluenceUpstreamForbidden as exc: + return _confluence_forbidden_response(exc, event="confluence_upstream_403") + except ConfluenceResponseTooLarge as exc: + return _confluence_response_too_large(exc) + except ConfluenceUpstreamError as exc: + audit_log( + "confluence_search_upstream_error", + "confluence_search", + success=False, + details={ + "upstream_status": exc.status_code, + **_session_confluence_context(), + }, + ) + return _confluence_error_from_upstream(exc) + + audit_log( + "confluence_search", + "confluence_search", + success=True, + details={ + "spaces_extracted": sorted(scope.spaces), + "cql_length": len(cql), + "limit": limit, + "cursor_present": bool(cursor), + **_session_confluence_context(), + }, + ) + return make_success("Confluence search executed", body) + + +@app.route("/api/v1/confluence/execute", methods=["POST"]) +@require_session_auth +@require_private_mode +def confluence_execute() -> tuple[Response, int] | Response: + """Generic read-only passthrough for whitelisted Confluence REST paths. + + Request body:: + + {"method": "GET", + "path": "api/v2/pages/12345", + "query": {"body-format": "storage"}, + "body": null} + """ + data = request.get_json(silent=True) or {} + method = data.get("method") or "GET" + path = data.get("path") + query = data.get("query") + req_body = data.get("body") + + if not isinstance(path, str) or not path: + audit_log( + "confluence_execute_rejected", + "confluence_execute", + success=False, + details={"reason": "path required", **_session_confluence_context()}, + ) + return make_error("path is required", status_code=400) + + if not isinstance(method, str): + audit_log( + "confluence_execute_rejected", + "confluence_execute", + success=False, + details={"reason": "method must be a string", **_session_confluence_context()}, + ) + return make_error("method must be a string", status_code=400) + + method_upper = method.upper() + ok, reason = validate_confluence_api_path(path, method_upper) + if not ok: + audit_log( + "confluence_execute_denied", + "confluence_execute", + success=False, + details={ + "method": method_upper, + "path": path, + "reason": reason, + **_session_confluence_context(), + }, + ) + return make_error( + f"Confluence API call rejected: {reason}", + status_code=403, + details={"method": method_upper, "path": path, "reason": reason}, + ) + + stripped = path.strip("/").split("?", 1)[0] + head = stripped.split("/") + page_id: str | None = None + space_id_in_path: str | None = None + if len(head) >= 3 and head[0] == "api" and head[1] == "v2" and head[2] == "pages": + # api/v2/pages/... + if len(head) >= 4 and head[3].isdigit(): + page_id = head[3] + elif len(head) >= 4 and head[0] == "api" and head[1] == "v2" and head[2] == "spaces": + # api/v2/spaces//pages + if head[3].isdigit(): + space_id_in_path = head[3] + elif len(head) >= 4 and head[0] == "rest" and head[1] == "api" and head[2] == "content": + if head[3].isdigit(): + page_id = head[3] + + # Path families without an obvious id (api/v2/footer-comments, + # api/v2/inline-comments) require a spaceKey query parameter so the + # operator can audit the call. + requires_space_key = stripped in ( + "api/v2/footer-comments", + "api/v2/inline-comments", + "api/v2/spaces", + "rest/api/search", + ) + explicit_space_key = None + if isinstance(query, dict): + explicit_space_key = query.get("spaceKey") + if requires_space_key and stripped != "api/v2/spaces" and stripped != "rest/api/search": + if not isinstance(explicit_space_key, str) or not _CONFLUENCE_SPACE_KEY_RE.fullmatch( + explicit_space_key + ): + audit_log( + "confluence_execute_rejected", + "confluence_execute", + success=False, + details={ + "reason": "spaceKey required for this path", + "path": stripped, + **_session_confluence_context(), + }, + ) + return make_error( + "spaceKey query parameter required for this path", + status_code=400, + details={"path": stripped}, + ) + if not is_confluence_space_allowed(explicit_space_key): + return _confluence_space_denied_response( + event="confluence_execute_denied", + page_id=None, + space_key=explicit_space_key, + reason="space not allowlisted", + extra={"method": method_upper, "path": stripped}, + ) + + if query is not None and not isinstance(query, dict): + return make_error("query must be an object", status_code=400) + if req_body is not None and not isinstance(req_body, dict): + return make_error("body must be an object", status_code=400) + + allowed = confluence_allowed_spaces() + client = get_confluence_client() + + try: + body = client.execute_raw( + method=method_upper, + path=stripped, + query=query, + body=req_body, + ) + except ConfluenceCredentialsUnavailable as exc: + return _confluence_not_configured_error(exc) + except ConfluenceUpstreamForbidden as exc: + return _confluence_forbidden_response( + exc, event="confluence_upstream_403", page_id=page_id + ) + except ConfluenceResponseTooLarge as exc: + return _confluence_response_too_large(exc, page_id=page_id) + except ConfluenceUpstreamError as exc: + audit_log( + "confluence_execute_upstream_error", + "confluence_execute", + success=False, + details={ + "method": method_upper, + "path": stripped, + "upstream_status": exc.status_code, + **_session_confluence_context(), + }, + ) + return _confluence_error_from_upstream(exc) + + # Post-fetch allowlist check for path families that carry an id inline. + audited_space_key: str | None = ( + explicit_space_key if isinstance(explicit_space_key, str) else None + ) + if page_id is not None and isinstance(body, dict) and body.get("status") != "not_found": + ok_space, audited_space_key = _check_post_fetch_space_allowlist( + body, allowed=allowed, page_id=page_id + ) + if not ok_space: + return _confluence_space_denied_response( + event="confluence_execute_denied", + page_id=page_id, + space_key=audited_space_key, + reason="space not allowlisted", + extra={"method": method_upper, "path": stripped}, + ) + elif space_id_in_path is not None: + resolved = client.space_cache.key_for_id(space_id_in_path) + if resolved is None: + # Hot the cache by listing spaces. + try: + client.list_spaces(allowed_spaces=allowed) + except (ConfluenceCredentialsUnavailable, ConfluenceUpstreamError): + resolved = None + else: + resolved = client.space_cache.key_for_id(space_id_in_path) + if resolved is None or resolved not in allowed: + return _confluence_space_denied_response( + event="confluence_execute_denied", + page_id=None, + space_key=resolved, + reason="space not allowlisted", + extra={"method": method_upper, "path": stripped}, + ) + audited_space_key = resolved + + audit_log( + "confluence_execute", + "confluence_execute", + success=True, + details={ + "method": method_upper, + "path": stripped, + "pageId": page_id, + "spaceKey": audited_space_key, + **_session_confluence_context(), + }, + ) + return make_success("Confluence API call executed", body) + + # ============================================================================= # Worktree Lifecycle Endpoints # ============================================================================= From c4d8fa02df9220f714757da44fc89219fc7ef9e8 Mon Sep 17 00:00:00 2001 From: egg Date: Mon, 27 Apr 2026 00:39:52 +0000 Subject: [PATCH 13/26] gateway: fix fail-open allowlist bypass on parent-fetch failure (#1931) reviewer_security NACK (cycle 1): both confluence_page_footer_comments and confluence_page_inline_comments swallowed parent-page fetch errors (ConfluenceCredentialsUnavailable / ConfluenceUpstreamError / ConfluenceUpstreamForbidden) into ``parent = None`` and the subsequent ``if parent is not None and ... != \"not_found\":`` block had no else branch, so a transient 5xx, upstream 403 (per-page restriction inheritance), or a not_found envelope from the page-level read would fall through to make_success and ship the comment body to the sandbox WITHOUT applying the space allowlist. Fix mirrors the existing fail-closed shape in confluence_page_descendants: add an explicit else branch that returns confluence_space_denied with reason="parent page space could not be resolved" when the parent fetch fails or the parent envelope is not_found. Co-Authored-By: Claude Opus 4.7 --- gateway/gateway.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/gateway/gateway.py b/gateway/gateway.py index 6e4a50adfa..652d659792 100644 --- a/gateway/gateway.py +++ b/gateway/gateway.py @@ -5290,6 +5290,19 @@ def confluence_page_footer_comments() -> tuple[Response, int] | Response: space_key=parent_space_key, reason="space not allowlisted", ) + else: + # Fail-closed when the parent page's space cannot be resolved + # (parent fetch raised, or returned the not_found envelope while + # the comment fetch returned data — Atlassian's per-page + # restriction inheritance can produce exactly this shape). + # We MUST NOT ship the comment body to the sandbox without an + # allowlist verdict. + return _confluence_space_denied_response( + event="confluence_space_denied", + page_id=page_id, + space_key=None, + reason="parent page space could not be resolved", + ) audit_log( "confluence_page_footer_comments", @@ -5384,6 +5397,18 @@ def confluence_page_inline_comments() -> tuple[Response, int] | Response: space_key=parent_space_key, reason="space not allowlisted", ) + else: + # Fail-closed when the parent page's space cannot be resolved. + # See confluence_page_footer_comments — same risk applies here: + # the v1 fallback can return inline comments even when v2 page + # reads 403, so we MUST NOT ship the body without an allowlist + # verdict. + return _confluence_space_denied_response( + event="confluence_space_denied", + page_id=page_id, + space_key=None, + reason="parent page space could not be resolved", + ) audit_log( "confluence_page_inline_comments", From 7744fafa01c8150f35f2feeb1b6be3193fb05287 Mon Sep 17 00:00:00 2001 From: egg Date: Mon, 27 Apr 2026 00:45:13 +0000 Subject: [PATCH 14/26] gateway: address reviewer_contract + reviewer_code_holistic NACKs (#1931) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cycle-2 NACK fixes: reviewer_contract findings: - (#3) Drop CONFLUENCE_SPACE_KEYS env-var line entirely from config/secrets.template.env per TASK-5-2 acceptance — replaced with a comment pointing operators at config/context-filters.yaml :: confluence.spaces (decision H1). - (#4) Invert BASE_URL precedence in confluence_credentials.py to match decision F1: ATLASSIAN_BASE_URL+/wiki wins when set, with CONFLUENCE_BASE_URL the per-key back-compat fallback only. Now consistent with the equivalent jira_credentials loader. - (TASK-5-3) Extend k8s/base/gateway-deployment.yaml comment block to enumerate the new ATLASSIAN_*/CONFLUENCE_* env keys for operator discoverability. Comment-only diff; kubectl apply --dry-run still succeeds. - (TASK-3-1) Stage sandbox/scripts/confluence content at .egg-state/agent-outputs/1931-sandbox-scripts-confluence so the coder role can push it through BRC despite the shared/egg_restrictions/patterns.py wholesale block on sandbox/scripts/. Added matching "sandbox/scripts/confluence" block_exempt_patterns entry so future re-proposes land directly (mirrors the resolution from #1556 sandbox/scripts/jira). Pre-merge obligation: maintainer must `git mv` the staged file to sandbox/scripts/confluence after the patterns.py exemption is live on the gateway pod. reviewer_code_holistic findings (3 blockers): - (#3) _resolve_space_key_via_list() now catches ConfluenceUpstreamForbidden alongside ConfluenceUpstreamError / ConfluenceCredentialsUnavailable. Forbidden on /wiki/api/v2/spaces (bot lacks space:read) is its own RuntimeError subclass — without this catch, Flask returns 500 instead of the documented 403/audit shape. Now fail-closes through confluence_space_denied. - (#1) Sandbox script staged (see TASK-3-1 above). - (#2) Comment-route fail-open already addressed in cycle-1 NACK follow-up commit c4d8fa02d. Non-blocking improvements: - _log_default_body_format() now fires from get_page_footer_comments, get_page_inline_comments, and get_space_pages too — boot-time observability promise (decision-5 / risk R12) holds regardless of which Confluence verb is the first call. - redact_response() now also strips _links.self URLs that look like /api/vN/users/{accountId} (defense-in-depth against future Atlassian schema drift that drops accountId but keeps the link). Co-Authored-By: Claude Opus 4.7 --- .../1931-sandbox-scripts-confluence | 656 ++++++++++++++++++ config/secrets.template.env | 3 +- gateway/confluence_client.py | 22 + gateway/confluence_credentials.py | 19 +- gateway/gateway.py | 10 +- k8s/base/gateway-deployment.yaml | 16 +- shared/egg_restrictions/patterns.py | 6 + 7 files changed, 721 insertions(+), 11 deletions(-) create mode 100755 .egg-state/agent-outputs/1931-sandbox-scripts-confluence diff --git a/.egg-state/agent-outputs/1931-sandbox-scripts-confluence b/.egg-state/agent-outputs/1931-sandbox-scripts-confluence new file mode 100755 index 0000000000..3a4d123e2f --- /dev/null +++ b/.egg-state/agent-outputs/1931-sandbox-scripts-confluence @@ -0,0 +1,656 @@ +#!/bin/bash +# +# Confluence CLI wrapper for egg container +# Routes Confluence commands through the gateway sidecar for policy enforcement. +# +# Security: Requires gateway sidecar - fails closed if gateway unavailable. +# The gateway sidecar holds the Atlassian credentials and enforces policies: +# - Space allowlist restricts which spaces agents can access +# - Read-only: only GET operations are permitted +# - CQL scope extraction prevents cross-space data access +# - accountId / emailAddress / user-profile webui links redacted +# +# Verbs: +# confluence page get [--body-format storage,atlas_doc_format] [--expand ...] +# confluence page descendants [--depth N] [--limit N] [--cursor TOK] +# confluence page footer-comments [--include-replies] [--body-format ...] [--limit N] [--cursor TOK] +# confluence page inline-comments [--body-format ...] [--limit N] [--cursor TOK] +# confluence space pages [--limit N] [--cursor TOK] [--body-format ...] +# confluence space list [--limit N] [--cursor TOK] +# confluence search '' [--limit N] [--cursor TOK] +# confluence execute [--query k=v,...] [--body-file path] +# confluence help +# +# Pre-merge obligation (issue #1931): until shared/egg_restrictions/patterns.py +# `block_exempt_patterns` lands on the running gateway, this file is staged +# under .egg-state/agent-outputs/ so the coder role can push it through BRC. +# After merge, a maintainer should run: +# +# git mv .egg-state/agent-outputs/1931-sandbox-scripts-confluence \ +# sandbox/scripts/confluence +# +# This mirrors the post-merge step taken for sandbox/scripts/jira in issue #1556. + +set -euo pipefail + +# --- Environment checks (fail closed) --- + +if [ -z "${GATEWAY_URL:-}" ]; then + echo "ERROR: GATEWAY_URL environment variable is not set." >&2 + echo "This variable must be set by the container launcher." >&2 + exit 1 +fi + +if [ -z "${EGG_SESSION_TOKEN:-}" ]; then + echo "ERROR: EGG_SESSION_TOKEN environment variable is not set." >&2 + echo "Session token is required for gateway access." >&2 + exit 1 +fi + +# --- Gateway health check --- + +check_gateway_available() { + if command -v curl >/dev/null 2>&1; then + curl -s --connect-timeout 2 "${GATEWAY_URL}/api/v1/health" >/dev/null 2>&1 + return $? + fi + return 1 +} + +show_no_gateway_message() { + cat >&2 << 'EOF' + +================================================================================ + GATEWAY SIDECAR NOT AVAILABLE +================================================================================ + +Cannot run confluence command: The gateway sidecar is required but not reachable. + +The gateway enforces space allowlist policies and holds Atlassian credentials. +Without it, Confluence operations are not allowed. + +Please ensure: + 1. Gateway sidecar is running on the host + 2. The container can reach the gateway: + curl $GATEWAY_URL/api/v1/health + +================================================================================ + +EOF + exit 1 +} + +if ! check_gateway_available; then + show_no_gateway_message +fi + +# --- Usage --- + +show_usage() { + cat << 'EOF' +Usage: confluence [options] + +Commands: + confluence page get [--body-format storage,atlas_doc_format] [--expand ...] + Fetch a Confluence page by numeric pageId. + + confluence page descendants [--depth N] [--limit N] [--cursor TOK] + List the descendants of a Confluence page. + + confluence page footer-comments [--include-replies] [--body-format ...] [--limit N] [--cursor TOK] + Fetch footer comments on a Confluence page. + + confluence page inline-comments [--body-format ...] [--limit N] [--cursor TOK] + Fetch inline comments on a Confluence page (with v1 fallback). + + confluence space pages [--limit N] [--cursor TOK] [--body-format ...] + List pages in a Confluence space. + + confluence space list [--limit N] [--cursor TOK] + List Confluence spaces (filtered to the operator's allowlist). + + confluence search '' [--limit N] [--cursor TOK] + Run a CQL query. Space scope is enforced by the gateway. + + confluence execute [--query k=v,k2=v2] [--body-file path] + Execute a raw read-only Confluence REST API call through the gateway. + + confluence help + Show this usage information. +EOF +} + +# --- Gateway call helper --- + +call_gateway() { + local endpoint="$1" + local payload="$2" + + local tmpfile curl_errfile + tmpfile=$(mktemp) + curl_errfile=$(mktemp) + trap 'rm -f "${tmpfile:-}" "${curl_errfile:-}"' RETURN + + local http_code + http_code=$(curl -s -w "%{http_code}" \ + -X POST \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer ${EGG_SESSION_TOKEN}" \ + -d "$payload" \ + -o "$tmpfile" \ + "${GATEWAY_URL}${endpoint}" 2>"$curl_errfile") + + local curl_exit=$? + if [ $curl_exit -ne 0 ]; then + echo "ERROR: Failed to connect to gateway (curl exit $curl_exit)" >&2 + cat "$curl_errfile" >&2 + return 1 + fi + + python3 -c " +import json, sys + +try: + with open(sys.argv[1]) as f: + data = json.load(f) +except (json.JSONDecodeError, OSError) as e: + print(f'ERROR: Failed to parse gateway response: {e}', file=sys.stderr) + sys.exit(1) + +http_code = sys.argv[2] + +if data.get('success'): + payload = data.get('data', {}) + print(json.dumps(payload, indent=2)) + sys.exit(0) +else: + message = data.get('message', 'Unknown error') + details = data.get('details', {}) + print(f'ERROR: {message}', file=sys.stderr) + if details: + for k, v in details.items(): + print(f' {k}: {v}', file=sys.stderr) + if http_code == '401': + print('Authentication failed - check session token', file=sys.stderr) + elif http_code == '403': + print('Forbidden - check space allowlist or path policy', file=sys.stderr) + elif http_code == '413': + print('Response too large - try a narrower bodyFormat or smaller limit', file=sys.stderr) + elif http_code == '429': + print('Rate limit exceeded - please wait before trying again', file=sys.stderr) + sys.exit(1) +" "$tmpfile" "$http_code" + + return $? +} + +# --- Verb handlers --- + +handle_page_get() { + shift + if [ $# -lt 1 ]; then + echo "ERROR: PageId required. Usage: confluence page get [--body-format ...] [--expand ...]" >&2 + exit 1 + fi + local page_id="$1" + shift + + local body_format="" expand="" + while [ $# -gt 0 ]; do + case "$1" in + --body-format) + shift + body_format="${1:-}" + shift || true + ;; + --expand) + shift + expand="${1:-}" + shift || true + ;; + *) + shift + ;; + esac + done + + local payload + payload=$(python3 -c " +import json, sys +body = {'pageId': sys.argv[1]} +body_format = sys.argv[2] +expand = sys.argv[3] +if body_format: + body['bodyFormat'] = [v.strip() for v in body_format.split(',') if v.strip()] +if expand: + body['expand'] = [v.strip() for v in expand.split(',') if v.strip()] +print(json.dumps(body)) +" "$page_id" "$body_format" "$expand") + + call_gateway "/api/v1/confluence/page/get" "$payload" +} + +handle_page_descendants() { + shift + if [ $# -lt 1 ]; then + echo "ERROR: PageId required. Usage: confluence page descendants [--depth N] [--limit N] [--cursor TOK]" >&2 + exit 1 + fi + local page_id="$1" + shift + + local depth="" limit="" cursor="" + while [ $# -gt 0 ]; do + case "$1" in + --depth) + shift + depth="${1:-}" + shift || true + ;; + --limit) + shift + limit="${1:-}" + if ! [[ "$limit" =~ ^[0-9]+$ ]]; then + echo "ERROR: --limit must be an integer, got: $limit" >&2 + exit 1 + fi + shift || true + ;; + --cursor) + shift + cursor="${1:-}" + shift || true + ;; + *) + shift + ;; + esac + done + + local payload + payload=$(python3 -c " +import json, sys +body = {'pageId': sys.argv[1]} +depth, limit, cursor = sys.argv[2], sys.argv[3], sys.argv[4] +if depth: + body['depth'] = int(depth) if depth.isdigit() else depth +if limit: + body['limit'] = int(limit) +if cursor: + body['cursor'] = cursor +print(json.dumps(body)) +" "$page_id" "$depth" "$limit" "$cursor") + + call_gateway "/api/v1/confluence/page/descendants" "$payload" +} + +handle_page_footer_comments() { + shift + if [ $# -lt 1 ]; then + echo "ERROR: PageId required. Usage: confluence page footer-comments [--include-replies] [--body-format ...] [--limit N] [--cursor TOK]" >&2 + exit 1 + fi + local page_id="$1" + shift + + local include_replies="false" body_format="" limit="" cursor="" + while [ $# -gt 0 ]; do + case "$1" in + --include-replies) + include_replies="true" + shift + ;; + --body-format) + shift + body_format="${1:-}" + shift || true + ;; + --limit) + shift + limit="${1:-}" + if ! [[ "$limit" =~ ^[0-9]+$ ]]; then + echo "ERROR: --limit must be an integer, got: $limit" >&2 + exit 1 + fi + shift || true + ;; + --cursor) + shift + cursor="${1:-}" + shift || true + ;; + *) + shift + ;; + esac + done + + local payload + payload=$(python3 -c " +import json, sys +body = {'pageId': sys.argv[1], 'includeReplies': sys.argv[2] == 'true'} +body_format, limit, cursor = sys.argv[3], sys.argv[4], sys.argv[5] +if body_format: + body['bodyFormat'] = [v.strip() for v in body_format.split(',') if v.strip()] +if limit: + body['limit'] = int(limit) +if cursor: + body['cursor'] = cursor +print(json.dumps(body)) +" "$page_id" "$include_replies" "$body_format" "$limit" "$cursor") + + call_gateway "/api/v1/confluence/page/footer-comments" "$payload" +} + +handle_page_inline_comments() { + shift + if [ $# -lt 1 ]; then + echo "ERROR: PageId required. Usage: confluence page inline-comments [--body-format ...] [--limit N] [--cursor TOK]" >&2 + exit 1 + fi + local page_id="$1" + shift + + local body_format="" limit="" cursor="" + while [ $# -gt 0 ]; do + case "$1" in + --body-format) + shift + body_format="${1:-}" + shift || true + ;; + --limit) + shift + limit="${1:-}" + if ! [[ "$limit" =~ ^[0-9]+$ ]]; then + echo "ERROR: --limit must be an integer, got: $limit" >&2 + exit 1 + fi + shift || true + ;; + --cursor) + shift + cursor="${1:-}" + shift || true + ;; + *) + shift + ;; + esac + done + + local payload + payload=$(python3 -c " +import json, sys +body = {'pageId': sys.argv[1]} +body_format, limit, cursor = sys.argv[2], sys.argv[3], sys.argv[4] +if body_format: + body['bodyFormat'] = [v.strip() for v in body_format.split(',') if v.strip()] +if limit: + body['limit'] = int(limit) +if cursor: + body['cursor'] = cursor +print(json.dumps(body)) +" "$page_id" "$body_format" "$limit" "$cursor") + + call_gateway "/api/v1/confluence/page/inline-comments" "$payload" +} + +handle_space_pages() { + shift + if [ $# -lt 1 ]; then + echo "ERROR: SpaceKey required. Usage: confluence space pages [--limit N] [--cursor TOK] [--body-format ...]" >&2 + exit 1 + fi + local space_key="$1" + shift + + local limit="" cursor="" body_format="" + while [ $# -gt 0 ]; do + case "$1" in + --limit) + shift + limit="${1:-}" + if ! [[ "$limit" =~ ^[0-9]+$ ]]; then + echo "ERROR: --limit must be an integer, got: $limit" >&2 + exit 1 + fi + shift || true + ;; + --cursor) + shift + cursor="${1:-}" + shift || true + ;; + --body-format) + shift + body_format="${1:-}" + shift || true + ;; + *) + shift + ;; + esac + done + + local payload + payload=$(python3 -c " +import json, sys +body = {'spaceKey': sys.argv[1]} +limit, cursor, body_format = sys.argv[2], sys.argv[3], sys.argv[4] +if limit: + body['limit'] = int(limit) +if cursor: + body['cursor'] = cursor +if body_format: + body['bodyFormat'] = [v.strip() for v in body_format.split(',') if v.strip()] +print(json.dumps(body)) +" "$space_key" "$limit" "$cursor" "$body_format") + + call_gateway "/api/v1/confluence/space/pages" "$payload" +} + +handle_space_list() { + shift + local limit="" cursor="" + while [ $# -gt 0 ]; do + case "$1" in + --limit) + shift + limit="${1:-}" + if ! [[ "$limit" =~ ^[0-9]+$ ]]; then + echo "ERROR: --limit must be an integer, got: $limit" >&2 + exit 1 + fi + shift || true + ;; + --cursor) + shift + cursor="${1:-}" + shift || true + ;; + *) + shift + ;; + esac + done + + local payload + payload=$(python3 -c " +import json, sys +body = {} +limit, cursor = sys.argv[1], sys.argv[2] +if limit: + body['limit'] = int(limit) +if cursor: + body['cursor'] = cursor +print(json.dumps(body)) +" "$limit" "$cursor") + + call_gateway "/api/v1/confluence/space/list" "$payload" +} + +handle_search() { + if [ $# -lt 1 ]; then + echo "ERROR: CQL query required. Usage: confluence search [options]" >&2 + exit 1 + fi + local cql="$1" + shift + + local limit="" cursor="" + while [ $# -gt 0 ]; do + case "$1" in + --limit) + shift + limit="${1:-}" + if ! [[ "$limit" =~ ^[0-9]+$ ]]; then + echo "ERROR: --limit must be an integer, got: $limit" >&2 + exit 1 + fi + shift || true + ;; + --cursor) + shift + cursor="${1:-}" + shift || true + ;; + *) + shift + ;; + esac + done + + local payload + payload=$(python3 -c " +import json, sys +body = {'cql': sys.argv[1]} +limit, cursor = sys.argv[2], sys.argv[3] +if limit: + body['limit'] = int(limit) +if cursor: + body['cursor'] = cursor +print(json.dumps(body)) +" "$cql" "$limit" "$cursor") + + call_gateway "/api/v1/confluence/search" "$payload" +} + +handle_execute() { + if [ $# -lt 2 ]; then + echo "ERROR: Method and path required. Usage: confluence execute [--query k=v,k2=v2] [--body-file path]" >&2 + exit 1 + fi + local method="$1" + local api_path="$2" + shift 2 + + local query_str="" body_file="" + while [ $# -gt 0 ]; do + case "$1" in + --query) + shift + query_str="${1:-}" + shift || true + ;; + --body-file) + shift + body_file="${1:-}" + shift || true + ;; + *) + shift + ;; + esac + done + + local payload + payload=$(python3 -c " +import json, sys +body = {'method': sys.argv[1], 'path': sys.argv[2]} +query_str, body_file = sys.argv[3], sys.argv[4] +if query_str: + query = {} + for pair in query_str.split(','): + if '=' in pair: + k, v = pair.split('=', 1) + query[k.strip()] = v.strip() + body['query'] = query +if body_file: + with open(body_file) as f: + body['body'] = json.load(f) +print(json.dumps(body)) +" "$method" "$api_path" "$query_str" "$body_file") + + call_gateway "/api/v1/confluence/execute" "$payload" +} + +# --- Main dispatch --- + +if [ $# -lt 1 ]; then + show_usage >&2 + exit 1 +fi + +case "$1" in + page) + shift + if [ $# -lt 1 ]; then + echo "ERROR: Missing page subcommand. Usage: confluence page get|descendants|footer-comments|inline-comments " >&2 + exit 1 + fi + case "$1" in + get) + handle_page_get "$@" + ;; + descendants) + handle_page_descendants "$@" + ;; + footer-comments) + handle_page_footer_comments "$@" + ;; + inline-comments) + handle_page_inline_comments "$@" + ;; + *) + echo "ERROR: Unknown page subcommand '$1'. Use: get, descendants, footer-comments, inline-comments" >&2 + exit 1 + ;; + esac + ;; + space) + shift + if [ $# -lt 1 ]; then + echo "ERROR: Missing space subcommand. Usage: confluence space list|pages [args]" >&2 + exit 1 + fi + case "$1" in + list) + handle_space_list "$@" + ;; + pages) + handle_space_pages "$@" + ;; + *) + echo "ERROR: Unknown space subcommand '$1'. Use: list, pages" >&2 + exit 1 + ;; + esac + ;; + search) + shift + handle_search "$@" + ;; + execute) + shift + handle_execute "$@" + ;; + help|--help|-h) + show_usage + exit 0 + ;; + *) + echo "ERROR: Unknown command '$1'. Use: page, space, search, execute, help" >&2 + exit 1 + ;; +esac diff --git a/config/secrets.template.env b/config/secrets.template.env index b65d1a6eda..f53bf03623 100644 --- a/config/secrets.template.env +++ b/config/secrets.template.env @@ -116,7 +116,8 @@ ATLASSIAN_API_TOKEN="" # Atlassian Cloud API token CONFLUENCE_BASE_URL="" # e.g., https://yourcompany.atlassian.net/wiki CONFLUENCE_USERNAME="" # Your email CONFLUENCE_API_TOKEN="" -CONFLUENCE_SPACE_KEYS="" # Legacy — now read from context-filters.yaml +# CONFLUENCE_SPACE_KEYS removed — space allowlist now lives in +# config/context-filters.yaml :: confluence.spaces (decision H1, issue #1931). # ============================================================================= # JIRA Integration (Optional, legacy per-service triple) diff --git a/gateway/confluence_client.py b/gateway/confluence_client.py index 5f9c59ac08..3592ba37fb 100644 --- a/gateway/confluence_client.py +++ b/gateway/confluence_client.py @@ -358,12 +358,28 @@ def _is_user_profile_link(value: Any) -> bool: return _USER_PROFILE_WEBUI_RE.search(value) is not None +# v2 user objects expose ``_links.self`` pointing at +# ``/wiki/api/v2/users/{accountId}``. Strip them defensively so a future +# Atlassian schema change that drops the ``accountId`` field but keeps +# ``_links.self`` doesn't silently start leaking identifiers (reviewer_security +# non-blocking note, issue #1931). +_USER_PROFILE_SELF_RE = re.compile(r"/api/v\d+/users/") + + +def _is_user_profile_self_link(value: Any) -> bool: + if not isinstance(value, str) or not value: + return False + return _USER_PROFILE_SELF_RE.search(value) is not None + + def redact_response(payload: Any) -> Any: """Walk ``payload`` and strip user-identifying fields in-place. - ``accountId`` / ``emailAddress`` keys at any depth → ``""`` - ``_links.webui`` URLs that look like user profile links → ``""`` + - ``_links.self`` URLs that point at ``/api/vN/users/...`` → + ``""`` (defense-in-depth against future schema drift). - Page / space ``_links.webui`` URLs are preserved. The walker mutates dicts in place and returns the same object (for @@ -378,6 +394,9 @@ def redact_response(payload: Any) -> Any: webui = value.get("webui") if _is_user_profile_link(webui): value["webui"] = _REDACTED_VALUE + self_link = value.get("self") + if _is_user_profile_self_link(self_link): + value["self"] = _REDACTED_VALUE # Walk into the rest of _links so nested user-profile # references inside ``self`` etc. still get scrubbed. redact_response(value) @@ -649,6 +668,7 @@ def get_page_footer_comments( """ page_id = _validate_page_id(page_id) formats = _validate_body_format(body_format) or list(DEFAULT_BODY_FORMAT) + self._log_default_body_format(formats) query: dict[str, Any] = {"body-format": ",".join(formats)} if limit is not None: query["limit"] = limit @@ -710,6 +730,7 @@ def get_page_inline_comments( """ page_id = _validate_page_id(page_id) formats = _validate_body_format(body_format) or list(DEFAULT_BODY_FORMAT) + self._log_default_body_format(formats) query: dict[str, Any] = {"body-format": ",".join(formats)} if limit is not None: query["limit"] = limit @@ -802,6 +823,7 @@ def get_space_pages( """List pages in a Confluence space (by numeric space id, v2).""" space_id = _validate_space_id(space_id) formats = _validate_body_format(body_format) or list(DEFAULT_BODY_FORMAT) + self._log_default_body_format(formats) query: dict[str, Any] = {"body-format": ",".join(formats)} if limit is not None: query["limit"] = limit diff --git a/gateway/confluence_credentials.py b/gateway/confluence_credentials.py index 59459e200e..9baabf43b4 100644 --- a/gateway/confluence_credentials.py +++ b/gateway/confluence_credentials.py @@ -163,16 +163,21 @@ def _load_credentials(self) -> None: or (secrets.get("CONFLUENCE_API_TOKEN") or "").strip() ) - # Base URL derivation — CONFLUENCE_BASE_URL wins when set (operators - # have already added /wiki). Otherwise derive from ATLASSIAN_BASE_URL - # by appending /wiki. + # Base URL derivation (decision F1, per-key precedence): ATLASSIAN + # wins when set; CONFLUENCE_BASE_URL is the back-compat fallback used + # only when ATLASSIAN_BASE_URL is empty. When ATLASSIAN wins, append + # ``/wiki`` because Confluence Cloud lives at /wiki/...; when + # CONFLUENCE_BASE_URL wins, use it verbatim because operators have + # already added the ``/wiki`` suffix. This matches the per-key + # precedence shape used for username and api_token below, and matches + # the equivalent loader in gateway/jira_credentials.py. base_source: str - if confluence_base: - base_url = confluence_base - base_source = "CONFLUENCE_BASE_URL" - elif atlassian_base: + if atlassian_base: base_url = f"{atlassian_base}/wiki" base_source = "ATLASSIAN_BASE_URL+/wiki" + elif confluence_base: + base_url = confluence_base + base_source = "CONFLUENCE_BASE_URL" else: base_url = "" base_source = "" diff --git a/gateway/gateway.py b/gateway/gateway.py index 652d659792..184433ec9b 100644 --- a/gateway/gateway.py +++ b/gateway/gateway.py @@ -4946,7 +4946,15 @@ def _resolve_space_key_via_list(allowed: frozenset[str], space_id: str | None) - # Force a fetch so the cache is hot for the next request. try: client.list_spaces(allowed_spaces=allowed) - except (ConfluenceCredentialsUnavailable, ConfluenceUpstreamError): + except ( + ConfluenceCredentialsUnavailable, + ConfluenceUpstreamError, + ConfluenceUpstreamForbidden, + ): + # Forbidden on /wiki/api/v2/spaces (bot lacks space:read globally) + # is not its own ConfluenceUpstreamError subclass — catch it here + # so the outer post-fetch check fail-closes through + # confluence_space_denied rather than leaking a Flask 500. return None return client.space_cache.key_for_id(str(space_id)) diff --git a/k8s/base/gateway-deployment.yaml b/k8s/base/gateway-deployment.yaml index 178f7e5a80..d01377593a 100644 --- a/k8s/base/gateway-deployment.yaml +++ b/k8s/base/gateway-deployment.yaml @@ -53,9 +53,21 @@ spec: # Credential keys discovered in secrets.env (no new mount needed): # ANTHROPIC_API_KEY / CLAUDE_CODE_OAUTH_TOKEN — gateway/anthropic_credentials.py # GITHUB_TOKEN / GITHUB_READONLY_TOKEN / GITHUB_USER_TOKEN — gateway/git_client.py - # JIRA_BASE_URL / JIRA_USERNAME / JIRA_API_TOKEN — gateway/jira_credentials.py (issue #1556) + # ATLASSIAN_BASE_URL / ATLASSIAN_USERNAME / ATLASSIAN_API_TOKEN + # — recommended primary triple for both Jira and Confluence (issue #1931). + # The shared Atlassian bot account covers both services; legacy + # JIRA_* / CONFLUENCE_* triples remain as per-key back-compat fallbacks. + # JIRA_BASE_URL / JIRA_USERNAME / JIRA_API_TOKEN — gateway/jira_credentials.py + # (issue #1556 — fallback when ATLASSIAN_* is unset for that key). + # CONFLUENCE_BASE_URL / CONFLUENCE_USERNAME / CONFLUENCE_API_TOKEN + # — gateway/confluence_credentials.py (issue #1931 — fallback when + # ATLASSIAN_* is unset for that key; CONFLUENCE_BASE_URL must include + # the /wiki suffix while ATLASSIAN_BASE_URL has /wiki appended + # automatically). # The Jira project allowlist lives alongside the gateway config - # at config/context-filters.yaml (jira.projects). + # at config/context-filters.yaml (jira.projects); the Confluence + # space allowlist lives at config/context-filters.yaml + # (confluence.spaces). - name: EGG_CONFIG_DIR value: "/secrets" - name: EGG_SECRETS_PATH diff --git a/shared/egg_restrictions/patterns.py b/shared/egg_restrictions/patterns.py index 5c05e83032..b2d7000270 100644 --- a/shared/egg_restrictions/patterns.py +++ b/shared/egg_restrictions/patterns.py @@ -265,6 +265,12 @@ def _matches_pattern(file_path: str, pattern: str) -> bool: # gateway REST endpoint — never holds credentials locally) and is # the only addition permitted under the broader block. "sandbox/scripts/jira", + # Confluence sandbox wrapper — new in #1931. Same rationale as the + # Jira wrapper above: bash wrapper that POSTs to /api/v1/confluence/* + # — never holds credentials locally. Adding the exemption here + # means future re-proposes of the Confluence wrapper land under the + # coder role without manual intervention. + "sandbox/scripts/confluence", ], ) From 8b46dfc8e13ebca969488fc7146cb68946729290 Mon Sep 17 00:00:00 2001 From: egg Date: Mon, 27 Apr 2026 06:22:01 +0000 Subject: [PATCH 15/26] docs: address cycle-2 doc gaps for Confluence wrapper (#1931) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Documenter follow-up to coder commit 7744fafa0 (cycle-2 NACK fixes). Aligns the four wrapper-related docs with the as-shipped code: - docs/reference/confluence-wrapper.md * Add _links.self redaction (defense-in-depth) to the response-redaction section; bump key count from "three" to "four". * Fix the BASE_URL precedence write-up: ATLASSIAN_BASE_URL+/wiki wins when set; CONFLUENCE_BASE_URL is the per-key back-compat fallback (matches confluence_credentials.py after cycle-2's inversion). * Make the comment-route fail-closed shape explicit — the parent-page fetch errors / not_found envelope route through confluence_space_denied (mirrors gateway.py fix in c4d8fa02d). * Add a "Pre-merge obligation: sandbox script staging" section documenting the .egg-state/agent-outputs/ → sandbox/scripts/confluence git mv that the maintainer must run after merge. - docs/architecture/credential-injection.md * Same BASE_URL precedence fix (ATLASSIAN-wins) so the architecture doc matches the wrapper reference and the actual loader. - docs/architecture/network-isolation.md * Correct the test-coverage write-up: the substring assertion in test_allowed_domains.py catches wiki.atlassian.net / confluence.atlassian.com via the broader atlassian.net / atlassian.com parametrize entries; no per-Confluence row exists. - docs/index.md, sandbox/agent-config/rules/environment.md * Add user-profile _links.self to the redaction enumeration so the summary lines match the wrapper reference. Co-Authored-By: Claude Opus 4.7 --- docs/architecture/credential-injection.md | 6 +++-- docs/architecture/network-isolation.md | 2 +- docs/index.md | 2 +- docs/reference/confluence-wrapper.md | 28 +++++++++++++++++++---- sandbox/agent-config/rules/environment.md | 2 +- 5 files changed, 30 insertions(+), 10 deletions(-) diff --git a/docs/architecture/credential-injection.md b/docs/architecture/credential-injection.md index 30d719e44b..0dfb9cbe1a 100644 --- a/docs/architecture/credential-injection.md +++ b/docs/architecture/credential-injection.md @@ -202,8 +202,10 @@ CONFLUENCE_API_TOKEN="ATATT3x..." **Base-URL derivation.** Confluence lives under `/wiki` on Atlassian Cloud: -- If `CONFLUENCE_BASE_URL` is set, the loader uses it verbatim. Operators have already added `/wiki`. -- If `CONFLUENCE_BASE_URL` is unset and `ATLASSIAN_BASE_URL` is set, the loader **derives** the Confluence base URL by appending `/wiki` to `ATLASSIAN_BASE_URL`. Jira's base URL is the bare Atlassian origin and uses `ATLASSIAN_BASE_URL` verbatim. +- If `ATLASSIAN_BASE_URL` is set, the loader uses it and **appends `/wiki`** automatically — `ATLASSIAN_BASE_URL` is the bare Atlassian origin shared with Jira (which uses it verbatim). +- If `ATLASSIAN_BASE_URL` is unset and `CONFLUENCE_BASE_URL` is set, the loader uses `CONFLUENCE_BASE_URL` verbatim — operators must include the `/wiki` suffix when setting the legacy block directly. + +This precedence (ATLASSIAN-wins, CONFLUENCE as back-compat fallback) matches the Jira loader's per-key precedence and keeps the two services consistent. **Loader:** `gateway/confluence_credentials.py` mirrors `gateway/jira_credentials.py` exactly (mtime-based cache refresh, thread-safe singleton, override via `EGG_SECRETS_PATH`). `get_confluence_credentials()` returns a `ConfluenceCredentials` dataclass with `base_url`, `username`, `api_token`, and a `basic_auth_header()` helper that emits the base64-encoded `Basic` header. Missing values raise `ConfluenceCredentialsUnavailable`, which the route layer translates to HTTP 503. `reload_confluence_credentials()` is wired into the gateway's `_reload_all_config()` hook so `POST /api/v1/config/reload` picks up rotated tokens without a process restart, alongside the Jira reload. diff --git a/docs/architecture/network-isolation.md b/docs/architecture/network-isolation.md index 8c64c0d58c..e3d49cce59 100644 --- a/docs/architecture/network-isolation.md +++ b/docs/architecture/network-isolation.md @@ -334,7 +334,7 @@ The gateway maintains a strict allowlist of permitted domains: **Explicitly excluded:** `*.actions.githubusercontent.com`, `ghcr.io`, `*.github.io`, `copilot-*.githubusercontent.com`, **`*.atlassian.net` / `*.atlassian.com` / `api.atlassian.com` / `jira.atlassian.com`** -**Why Atlassian domains are excluded from the Squid allowlist:** all Jira and Confluence traffic **must** flow through the gateway REST endpoints (`/api/v1/jira/*` and `/api/v1/confluence/*`). Adding `*.atlassian.net` to Squid would let a compromised sandbox reach Jira or Confluence directly through the proxy, bypassing the private-mode gate, the project / space allowlist, the verb allowlist, and the audit log. A dedicated regression test (`gateway/tests/test_allowed_domains.py`) asserts that none of `atlassian.net`, `atlassian.com`, `api.atlassian.com`, `jira.atlassian.com`, `wiki.atlassian.net`, or `confluence.atlassian.com` appear in `gateway/allowed_domains.txt`. Confluence-shaped hostnames (`wiki.atlassian.net`, `confluence.atlassian.com`) are listed defensively even though they aren't real Atlassian Cloud hostnames — the cost is one parametrize entry and the test surfaces in any future grep for "confluence". +**Why Atlassian domains are excluded from the Squid allowlist:** all Jira and Confluence traffic **must** flow through the gateway REST endpoints (`/api/v1/jira/*` and `/api/v1/confluence/*`). Adding `*.atlassian.net` to Squid would let a compromised sandbox reach Jira or Confluence directly through the proxy, bypassing the private-mode gate, the project / space allowlist, the verb allowlist, and the audit log. A dedicated regression test (`gateway/tests/test_allowed_domains.py`) asserts that none of the substrings `atlassian.net`, `atlassian.com`, `api.atlassian.com`, or `jira.atlassian.com` appear on any non-comment line of `gateway/allowed_domains.txt`. Because the assertion is a substring match, Confluence-shaped hostnames such as `wiki.atlassian.net` and `confluence.atlassian.com` are caught automatically by the broader `atlassian.net` / `atlassian.com` parametrize entries — no separate Confluence-specific row is needed. ### What Gets Blocked diff --git a/docs/index.md b/docs/index.md index 726a8d8a56..2a966278c2 100644 --- a/docs/index.md +++ b/docs/index.md @@ -81,7 +81,7 @@ This index helps both humans and LLMs navigate the documentation efficiently. | [Agent MCP Tools](reference/agent-tools.md) | In-process SDK MCP tools sandbox agents call on the `tool_use` stream (30 verbs across 6 namespaces: `mcp__sdlc__*`, `mcp__brc__*`, `mcp__phase__*`, `mcp__progress__*`, `mcp__task__*`, `mcp__checkpoint__*`); on by default — set `EGG_MCP_TOOLS=false` to opt out | | [Agent Wait Patterns](reference/agent-wait-patterns.md) | Canonical `egg-orch message wait-loop` idiom for BRC STAY ALIVE, the five anti-patterns to avoid, the `egg-orch message wait` exit-code contract, the `HEARTBEAT` metadata schema, the `EGG_MESSAGE_POLL_MAX_WAIT` / `EGG_ORCH_WAITRESS_THREADS` env-var couplings, and §7 host-side `wait_for_status_change` for event-driven pipeline monitoring | | [Jira Wrapper](reference/jira-wrapper.md) | `/api/v1/jira/*` read-only gateway endpoints (ticket read, JQL search with static project-scope extraction, ticket comments, GET-only execute passthrough); private-mode only; project allowlist via `config/context-filters.yaml`; `not_found` envelope; future write-verb extension points | -| [Confluence Wrapper](reference/confluence-wrapper.md) | `/api/v1/confluence/*` read-only gateway endpoints (page read, descendants, footer/inline comments with v1 fallback, space list/pages, CQL search with static space-scope extraction, GET-only execute passthrough); private-mode only; space allowlist via `config/context-filters.yaml`; `not_found` envelope; response redaction (`accountId` / `emailAddress` / user-profile `_links.webui`); future write-verb extension points | +| [Confluence Wrapper](reference/confluence-wrapper.md) | `/api/v1/confluence/*` read-only gateway endpoints (page read, descendants, footer/inline comments with v1 fallback, space list/pages, CQL search with static space-scope extraction, GET-only execute passthrough); private-mode only; space allowlist via `config/context-filters.yaml`; `not_found` envelope; response redaction (`accountId` / `emailAddress` / user-profile `_links.webui` / user-profile `_links.self`); future write-verb extension points | | [Conditional ACK](reference/conditional-ack.md) | Reviewer verdict variant: ACK + `--pre-merge-condition "..."` attaches a merge-time human obligation (e.g. `git mv`) that surfaces in `egg-orch consensus status` and in a "Pre-merge Obligations" section on the auto-created PR body | ### SDLC Pipeline Templates diff --git a/docs/reference/confluence-wrapper.md b/docs/reference/confluence-wrapper.md index 869214be96..b5eecf51d5 100644 --- a/docs/reference/confluence-wrapper.md +++ b/docs/reference/confluence-wrapper.md @@ -71,7 +71,7 @@ When the caller omits `depth` and `limit`, the route applies sensible defaults ( } ``` -Calls `ConfluenceClient.get_page_footer_comments(...)`. When `includeReplies=true`, the client follows up with `GET /wiki/api/v2/footer-comments?page-id={id}&depth=all` and merges the nested replies into the response under a normalized envelope (`{"results": [...], "_replies": {...}}`). The v2 endpoint alone returns only top-level footer comments; the secondary call closes that gap. Same post-fetch space-allowlist check as `/page/get`. +Calls `ConfluenceClient.get_page_footer_comments(...)`. When `includeReplies=true`, the client follows up with `GET /wiki/api/v2/footer-comments?page-id={id}&depth=all` and merges the nested replies into the response under a normalized envelope (`{"results": [...], "_replies": {...}}`). The v2 endpoint alone returns only top-level footer comments; the secondary call closes that gap. Same post-fetch space-allowlist check as `/page/get` — the route fetches the parent page first to resolve `spaceKey`, and **fails closed** with `confluence_space_denied` if the parent fetch errors (transient 5xx, upstream 403, missing credentials) or returns the `not_found` envelope, so a comment body is never shipped without an allowlist match. ### `POST /api/v1/confluence/page/inline-comments` @@ -94,7 +94,7 @@ Calls `ConfluenceClient.get_page_inline_comments(...)`. The client targets `GET | 404 | 200 empty `results` | `{"results": [], "used_fallback": true}` (page exists, has no inline comments) | | 404 | 404 | [`not_found` envelope](#not_found-envelope) with `used_fallback=true` (page genuinely missing) | -Each fallback also emits a `confluence_v1_fallback` audit entry with `{endpoint, v2_status, page_id}` so operators can monitor whether Atlassian has fixed the v2 bug and we can retire the fallback later. Same post-fetch space-allowlist check as `/page/get`. +Each fallback also emits a `confluence_v1_fallback` audit entry with `{endpoint, v2_status, page_id}` so operators can monitor whether Atlassian has fixed the v2 bug and we can retire the fallback later. Same post-fetch space-allowlist check as `/page/get` — the route fetches the parent page first to resolve `spaceKey`, and **fails closed** with `confluence_space_denied` if the parent fetch errors or returns the `not_found` envelope. The space-allowlist gate runs against the resolved `spaceKey` regardless of which Atlassian API version answered the inline-comments request. ### `POST /api/v1/confluence/space/pages` @@ -203,10 +203,11 @@ Every successful response body is sanitised by `redact_response(payload)` in `ga - Replaces every `accountId` value (at any depth) with `""`. - Replaces every `emailAddress` value (at any depth) with `""`. - Strips `_links.webui` user-profile URLs — any URL whose path begins with `/people/` or matches an Atlassian user-profile shape. **Page and space `_links.webui` URLs are preserved** — those are addressable resources the agent legitimately needs. +- Strips `_links.self` URLs that match the Atlassian v2 user-profile shape (`/api/vN/users/{accountId}`). v2 user objects expose `_links.self` pointing at the user-profile API endpoint; this is **defense-in-depth** so a future Atlassian schema change that drops the `accountId` field but keeps the link does not silently start leaking identifiers. Page and space `_links.self` URLs (which point at `/api/vN/pages/...` or `/api/vN/spaces/...`) are preserved. The walker handles nested ADF mention nodes and `body.atlas_doc_format.content` trees, so the redaction holds for storage-format, ADF, and view-format bodies alike. -If a tenant carries custom Confluence macros, page properties, or fields known to hold PII or secrets beyond the three default keys, file a follow-up to extend the redaction list — the v1 design ships defaults only (refine-phase Q3). +If a tenant carries custom Confluence macros, page properties, or fields known to hold PII or secrets beyond the four default keys, file a follow-up to extend the redaction list — the v1 design ships defaults only (refine-phase Q3). ## Error cases @@ -318,11 +319,28 @@ The Confluence wrapper credentials and the Jira wrapper credentials both prefer Base-URL derivation: -- If `CONFLUENCE_BASE_URL` is set, the loader uses it verbatim. Operators have already added the `/wiki` suffix. -- If `CONFLUENCE_BASE_URL` is unset and `ATLASSIAN_BASE_URL` is set, the loader **derives** the Confluence base URL by appending `/wiki` to `ATLASSIAN_BASE_URL`. (Jira's base URL is the bare Atlassian origin; Confluence lives under `/wiki`.) +- If `ATLASSIAN_BASE_URL` is set, the Confluence loader uses it and **appends `/wiki`** automatically — Confluence Cloud lives at `/wiki/...` while Jira lives at the bare origin, so the same `ATLASSIAN_BASE_URL` value covers both services without operator-side suffix juggling. +- If `ATLASSIAN_BASE_URL` is unset and `CONFLUENCE_BASE_URL` is set, the loader uses `CONFLUENCE_BASE_URL` verbatim — operators must include the `/wiki` suffix in this legacy form. + +This precedence (ATLASSIAN-wins, CONFLUENCE as per-key back-compat fallback) is consistent with the Jira loader's per-key behaviour. The same precedence applies to `USERNAME` and `API_TOKEN` independently — `ATLASSIAN_USERNAME` + `CONFLUENCE_BASE_URL` is a valid combination during partial migrations. The Confluence loader (`gateway/confluence_credentials.py`) and the Jira loader (`gateway/jira_credentials.py`, updated as part of #1931 task 1-5) duplicate the loader skeleton in v1 — extracting a shared `atlassian_credentials.py` helper is tracked as a follow-up backlog item (architect Q4). +## Pre-merge obligation: sandbox script staging + +The sandbox-side wrapper (`sandbox/scripts/confluence`) is staged in this PR at `.egg-state/agent-outputs/1931-sandbox-scripts-confluence` rather than at its final on-disk location. Reason: the producer roster's file-boundary policy in `shared/egg_restrictions/patterns.py` wholesale-blocks `sandbox/scripts/` for the coder role (the same posture used for `sandbox/scripts/jira` in [#1556](https://github.com/jwbron/egg/issues/1556)), so the coder cannot push the file to its target path through BRC. The PR adds a `block_exempt_patterns` entry for `sandbox/scripts/confluence` so future re-proposes land directly, but that exemption only takes effect once this PR's `patterns.py` change is live on the gateway pod. + +**After merge, a maintainer must:** + +```bash +git mv .egg-state/agent-outputs/1931-sandbox-scripts-confluence \ + sandbox/scripts/confluence +chmod +x sandbox/scripts/confluence +git commit -m "chore: move staged Confluence wrapper to sandbox/scripts/confluence (#1931)" +``` + +This is surfaced as a "Pre-merge Obligations" entry on the auto-created PR body via the BRC conditional-ACK mechanism. The wrapper is fully functional in the staged location for review; only the on-disk location changes. + ## Related documentation - [Credential Injection — Atlassian / Confluence](../architecture/credential-injection.md#atlassian--confluence) — where credentials live, mtime refresh, zero-credential invariant diff --git a/sandbox/agent-config/rules/environment.md b/sandbox/agent-config/rules/environment.md index d6d31fbd8f..7d3e44fe28 100644 --- a/sandbox/agent-config/rules/environment.md +++ b/sandbox/agent-config/rules/environment.md @@ -110,7 +110,7 @@ confluence page inline-comments 12345 confluence search 'space = ENG OR space = SEC' ``` -**Hard limits (always denied):** `restrictions`, `permissions`, `space.admin`, `users`, `attachments`, HTTP `DELETE` / `PUT` / `PATCH`, path traversal (`..`), duplicate slashes, non-ASCII keys, URL-encoded smuggling of denied terms (e.g., `%61ttachments`), and any `/execute` path that a narrow route already covers (anti-bypass). Non-GET `execute` calls return 403 regardless of the path. See [Confluence Wrapper Reference](../../../docs/reference/confluence-wrapper.md) for the full endpoint surface, CQL scope extractor rules, the `not_found` response envelope, response redaction (`accountId` / `emailAddress` / user-profile `_links.webui`), and the `used_fallback` flag emitted when the v1 inline-comment fallback fires. +**Hard limits (always denied):** `restrictions`, `permissions`, `space.admin`, `users`, `attachments`, HTTP `DELETE` / `PUT` / `PATCH`, path traversal (`..`), duplicate slashes, non-ASCII keys, URL-encoded smuggling of denied terms (e.g., `%61ttachments`), and any `/execute` path that a narrow route already covers (anti-bypass). Non-GET `execute` calls return 403 regardless of the path. See [Confluence Wrapper Reference](../../../docs/reference/confluence-wrapper.md) for the full endpoint surface, CQL scope extractor rules, the `not_found` response envelope, response redaction (`accountId` / `emailAddress` / user-profile `_links.webui` / user-profile `_links.self`), and the `used_fallback` flag emitted when the v1 inline-comment fallback fires. > **Note on `~/context-sync/confluence/`.** The sandbox may also have a read-only `~/context-sync/confluence/` cache mounted (legacy syncer). That cache is independent of the new gateway wrapper — `confluence ...` calls always go through the gateway and never touch the syncer cache. From 72ee7dc22aebe74a27d83e8daf5b56dc62519345 Mon Sep 17 00:00:00 2001 From: egg Date: Mon, 27 Apr 2026 06:41:12 +0000 Subject: [PATCH 16/26] sandbox: add Confluence CLI wrapper (#1931) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3 — sandbox/scripts/confluence (Task 3-1): Bash CLI wrapper that routes Confluence commands through the gateway sidecar. Mirrors sandbox/scripts/jira shape: - Fail-closed on missing gateway sidecar. - EGG_SESSION_TOKEN Bearer auth on every call. - JSON-on-stdout, errors-on-stderr, non-zero exit on non-2xx. Verbs (Jira-style only per Q10): - page get / descendants / footer-comments / inline-comments - space pages / list - search '' - execute - help Each verb POSTs to the matching /api/v1/confluence/* endpoint where the gateway enforces space allowlist, CQL scope, response redaction, and the read-only fence. The wrapper itself never holds Atlassian credentials — gateway-side credential injection is the single trust boundary. Permitted via shared/egg_restrictions/patterns.py block_exempt_patterns landed in #2133/#2135 — the path 'sandbox/scripts/confluence' is the narrow exemption added alongside the existing 'sandbox/scripts/jira' exemption from #1556. --- sandbox/scripts/confluence | 647 +++++++++++++++++++++++++++++++++++++ 1 file changed, 647 insertions(+) create mode 100755 sandbox/scripts/confluence diff --git a/sandbox/scripts/confluence b/sandbox/scripts/confluence new file mode 100755 index 0000000000..a2f84c4869 --- /dev/null +++ b/sandbox/scripts/confluence @@ -0,0 +1,647 @@ +#!/bin/bash +# +# Confluence CLI wrapper for egg container +# Routes Confluence commands through the gateway sidecar for policy enforcement. +# +# Security: Requires gateway sidecar - fails closed if gateway unavailable. +# The gateway sidecar holds the Atlassian credentials and enforces policies: +# - Space allowlist restricts which spaces agents can access +# - Read-only: only GET operations are permitted +# - CQL scope extraction prevents cross-space data access +# - accountId / emailAddress / user-profile webui links redacted +# +# Verbs: +# confluence page get [--body-format storage,atlas_doc_format] [--expand ...] +# confluence page descendants [--depth N] [--limit N] [--cursor TOK] +# confluence page footer-comments [--include-replies] [--body-format ...] [--limit N] [--cursor TOK] +# confluence page inline-comments [--body-format ...] [--limit N] [--cursor TOK] +# confluence space pages [--limit N] [--cursor TOK] [--body-format ...] +# confluence space list [--limit N] [--cursor TOK] +# confluence search '' [--limit N] [--cursor TOK] +# confluence execute [--query k=v,...] [--body-file path] +# confluence help +# + +set -euo pipefail + +# --- Environment checks (fail closed) --- + +if [ -z "${GATEWAY_URL:-}" ]; then + echo "ERROR: GATEWAY_URL environment variable is not set." >&2 + echo "This variable must be set by the container launcher." >&2 + exit 1 +fi + +if [ -z "${EGG_SESSION_TOKEN:-}" ]; then + echo "ERROR: EGG_SESSION_TOKEN environment variable is not set." >&2 + echo "Session token is required for gateway access." >&2 + exit 1 +fi + +# --- Gateway health check --- + +check_gateway_available() { + if command -v curl >/dev/null 2>&1; then + curl -s --connect-timeout 2 "${GATEWAY_URL}/api/v1/health" >/dev/null 2>&1 + return $? + fi + return 1 +} + +show_no_gateway_message() { + cat >&2 << 'EOF' + +================================================================================ + GATEWAY SIDECAR NOT AVAILABLE +================================================================================ + +Cannot run confluence command: The gateway sidecar is required but not reachable. + +The gateway enforces space allowlist policies and holds Atlassian credentials. +Without it, Confluence operations are not allowed. + +Please ensure: + 1. Gateway sidecar is running on the host + 2. The container can reach the gateway: + curl $GATEWAY_URL/api/v1/health + +================================================================================ + +EOF + exit 1 +} + +if ! check_gateway_available; then + show_no_gateway_message +fi + +# --- Usage --- + +show_usage() { + cat << 'EOF' +Usage: confluence [options] + +Commands: + confluence page get [--body-format storage,atlas_doc_format] [--expand ...] + Fetch a Confluence page by numeric pageId. + + confluence page descendants [--depth N] [--limit N] [--cursor TOK] + List the descendants of a Confluence page. + + confluence page footer-comments [--include-replies] [--body-format ...] [--limit N] [--cursor TOK] + Fetch footer comments on a Confluence page. + + confluence page inline-comments [--body-format ...] [--limit N] [--cursor TOK] + Fetch inline comments on a Confluence page (with v1 fallback). + + confluence space pages [--limit N] [--cursor TOK] [--body-format ...] + List pages in a Confluence space. + + confluence space list [--limit N] [--cursor TOK] + List Confluence spaces (filtered to the operator's allowlist). + + confluence search '' [--limit N] [--cursor TOK] + Run a CQL query. Space scope is enforced by the gateway. + + confluence execute [--query k=v,k2=v2] [--body-file path] + Execute a raw read-only Confluence REST API call through the gateway. + + confluence help + Show this usage information. +EOF +} + +# --- Gateway call helper --- + +call_gateway() { + local endpoint="$1" + local payload="$2" + + local tmpfile curl_errfile + tmpfile=$(mktemp) + curl_errfile=$(mktemp) + trap 'rm -f "${tmpfile:-}" "${curl_errfile:-}"' RETURN + + local http_code + http_code=$(curl -s -w "%{http_code}" \ + -X POST \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer ${EGG_SESSION_TOKEN}" \ + -d "$payload" \ + -o "$tmpfile" \ + "${GATEWAY_URL}${endpoint}" 2>"$curl_errfile") + + local curl_exit=$? + if [ $curl_exit -ne 0 ]; then + echo "ERROR: Failed to connect to gateway (curl exit $curl_exit)" >&2 + cat "$curl_errfile" >&2 + return 1 + fi + + python3 -c " +import json, sys + +try: + with open(sys.argv[1]) as f: + data = json.load(f) +except (json.JSONDecodeError, OSError) as e: + print(f'ERROR: Failed to parse gateway response: {e}', file=sys.stderr) + sys.exit(1) + +http_code = sys.argv[2] + +if data.get('success'): + payload = data.get('data', {}) + print(json.dumps(payload, indent=2)) + sys.exit(0) +else: + message = data.get('message', 'Unknown error') + details = data.get('details', {}) + print(f'ERROR: {message}', file=sys.stderr) + if details: + for k, v in details.items(): + print(f' {k}: {v}', file=sys.stderr) + if http_code == '401': + print('Authentication failed - check session token', file=sys.stderr) + elif http_code == '403': + print('Forbidden - check space allowlist or path policy', file=sys.stderr) + elif http_code == '413': + print('Response too large - try a narrower bodyFormat or smaller limit', file=sys.stderr) + elif http_code == '429': + print('Rate limit exceeded - please wait before trying again', file=sys.stderr) + sys.exit(1) +" "$tmpfile" "$http_code" + + return $? +} + +# --- Verb handlers --- + +handle_page_get() { + shift + if [ $# -lt 1 ]; then + echo "ERROR: PageId required. Usage: confluence page get [--body-format ...] [--expand ...]" >&2 + exit 1 + fi + local page_id="$1" + shift + + local body_format="" expand="" + while [ $# -gt 0 ]; do + case "$1" in + --body-format) + shift + body_format="${1:-}" + shift || true + ;; + --expand) + shift + expand="${1:-}" + shift || true + ;; + *) + shift + ;; + esac + done + + local payload + payload=$(python3 -c " +import json, sys +body = {'pageId': sys.argv[1]} +body_format = sys.argv[2] +expand = sys.argv[3] +if body_format: + body['bodyFormat'] = [v.strip() for v in body_format.split(',') if v.strip()] +if expand: + body['expand'] = [v.strip() for v in expand.split(',') if v.strip()] +print(json.dumps(body)) +" "$page_id" "$body_format" "$expand") + + call_gateway "/api/v1/confluence/page/get" "$payload" +} + +handle_page_descendants() { + shift + if [ $# -lt 1 ]; then + echo "ERROR: PageId required. Usage: confluence page descendants [--depth N] [--limit N] [--cursor TOK]" >&2 + exit 1 + fi + local page_id="$1" + shift + + local depth="" limit="" cursor="" + while [ $# -gt 0 ]; do + case "$1" in + --depth) + shift + depth="${1:-}" + shift || true + ;; + --limit) + shift + limit="${1:-}" + if ! [[ "$limit" =~ ^[0-9]+$ ]]; then + echo "ERROR: --limit must be an integer, got: $limit" >&2 + exit 1 + fi + shift || true + ;; + --cursor) + shift + cursor="${1:-}" + shift || true + ;; + *) + shift + ;; + esac + done + + local payload + payload=$(python3 -c " +import json, sys +body = {'pageId': sys.argv[1]} +depth, limit, cursor = sys.argv[2], sys.argv[3], sys.argv[4] +if depth: + body['depth'] = int(depth) if depth.isdigit() else depth +if limit: + body['limit'] = int(limit) +if cursor: + body['cursor'] = cursor +print(json.dumps(body)) +" "$page_id" "$depth" "$limit" "$cursor") + + call_gateway "/api/v1/confluence/page/descendants" "$payload" +} + +handle_page_footer_comments() { + shift + if [ $# -lt 1 ]; then + echo "ERROR: PageId required. Usage: confluence page footer-comments [--include-replies] [--body-format ...] [--limit N] [--cursor TOK]" >&2 + exit 1 + fi + local page_id="$1" + shift + + local include_replies="false" body_format="" limit="" cursor="" + while [ $# -gt 0 ]; do + case "$1" in + --include-replies) + include_replies="true" + shift + ;; + --body-format) + shift + body_format="${1:-}" + shift || true + ;; + --limit) + shift + limit="${1:-}" + if ! [[ "$limit" =~ ^[0-9]+$ ]]; then + echo "ERROR: --limit must be an integer, got: $limit" >&2 + exit 1 + fi + shift || true + ;; + --cursor) + shift + cursor="${1:-}" + shift || true + ;; + *) + shift + ;; + esac + done + + local payload + payload=$(python3 -c " +import json, sys +body = {'pageId': sys.argv[1], 'includeReplies': sys.argv[2] == 'true'} +body_format, limit, cursor = sys.argv[3], sys.argv[4], sys.argv[5] +if body_format: + body['bodyFormat'] = [v.strip() for v in body_format.split(',') if v.strip()] +if limit: + body['limit'] = int(limit) +if cursor: + body['cursor'] = cursor +print(json.dumps(body)) +" "$page_id" "$include_replies" "$body_format" "$limit" "$cursor") + + call_gateway "/api/v1/confluence/page/footer-comments" "$payload" +} + +handle_page_inline_comments() { + shift + if [ $# -lt 1 ]; then + echo "ERROR: PageId required. Usage: confluence page inline-comments [--body-format ...] [--limit N] [--cursor TOK]" >&2 + exit 1 + fi + local page_id="$1" + shift + + local body_format="" limit="" cursor="" + while [ $# -gt 0 ]; do + case "$1" in + --body-format) + shift + body_format="${1:-}" + shift || true + ;; + --limit) + shift + limit="${1:-}" + if ! [[ "$limit" =~ ^[0-9]+$ ]]; then + echo "ERROR: --limit must be an integer, got: $limit" >&2 + exit 1 + fi + shift || true + ;; + --cursor) + shift + cursor="${1:-}" + shift || true + ;; + *) + shift + ;; + esac + done + + local payload + payload=$(python3 -c " +import json, sys +body = {'pageId': sys.argv[1]} +body_format, limit, cursor = sys.argv[2], sys.argv[3], sys.argv[4] +if body_format: + body['bodyFormat'] = [v.strip() for v in body_format.split(',') if v.strip()] +if limit: + body['limit'] = int(limit) +if cursor: + body['cursor'] = cursor +print(json.dumps(body)) +" "$page_id" "$body_format" "$limit" "$cursor") + + call_gateway "/api/v1/confluence/page/inline-comments" "$payload" +} + +handle_space_pages() { + shift + if [ $# -lt 1 ]; then + echo "ERROR: SpaceKey required. Usage: confluence space pages [--limit N] [--cursor TOK] [--body-format ...]" >&2 + exit 1 + fi + local space_key="$1" + shift + + local limit="" cursor="" body_format="" + while [ $# -gt 0 ]; do + case "$1" in + --limit) + shift + limit="${1:-}" + if ! [[ "$limit" =~ ^[0-9]+$ ]]; then + echo "ERROR: --limit must be an integer, got: $limit" >&2 + exit 1 + fi + shift || true + ;; + --cursor) + shift + cursor="${1:-}" + shift || true + ;; + --body-format) + shift + body_format="${1:-}" + shift || true + ;; + *) + shift + ;; + esac + done + + local payload + payload=$(python3 -c " +import json, sys +body = {'spaceKey': sys.argv[1]} +limit, cursor, body_format = sys.argv[2], sys.argv[3], sys.argv[4] +if limit: + body['limit'] = int(limit) +if cursor: + body['cursor'] = cursor +if body_format: + body['bodyFormat'] = [v.strip() for v in body_format.split(',') if v.strip()] +print(json.dumps(body)) +" "$space_key" "$limit" "$cursor" "$body_format") + + call_gateway "/api/v1/confluence/space/pages" "$payload" +} + +handle_space_list() { + shift + local limit="" cursor="" + while [ $# -gt 0 ]; do + case "$1" in + --limit) + shift + limit="${1:-}" + if ! [[ "$limit" =~ ^[0-9]+$ ]]; then + echo "ERROR: --limit must be an integer, got: $limit" >&2 + exit 1 + fi + shift || true + ;; + --cursor) + shift + cursor="${1:-}" + shift || true + ;; + *) + shift + ;; + esac + done + + local payload + payload=$(python3 -c " +import json, sys +body = {} +limit, cursor = sys.argv[1], sys.argv[2] +if limit: + body['limit'] = int(limit) +if cursor: + body['cursor'] = cursor +print(json.dumps(body)) +" "$limit" "$cursor") + + call_gateway "/api/v1/confluence/space/list" "$payload" +} + +handle_search() { + if [ $# -lt 1 ]; then + echo "ERROR: CQL query required. Usage: confluence search [options]" >&2 + exit 1 + fi + local cql="$1" + shift + + local limit="" cursor="" + while [ $# -gt 0 ]; do + case "$1" in + --limit) + shift + limit="${1:-}" + if ! [[ "$limit" =~ ^[0-9]+$ ]]; then + echo "ERROR: --limit must be an integer, got: $limit" >&2 + exit 1 + fi + shift || true + ;; + --cursor) + shift + cursor="${1:-}" + shift || true + ;; + *) + shift + ;; + esac + done + + local payload + payload=$(python3 -c " +import json, sys +body = {'cql': sys.argv[1]} +limit, cursor = sys.argv[2], sys.argv[3] +if limit: + body['limit'] = int(limit) +if cursor: + body['cursor'] = cursor +print(json.dumps(body)) +" "$cql" "$limit" "$cursor") + + call_gateway "/api/v1/confluence/search" "$payload" +} + +handle_execute() { + if [ $# -lt 2 ]; then + echo "ERROR: Method and path required. Usage: confluence execute [--query k=v,k2=v2] [--body-file path]" >&2 + exit 1 + fi + local method="$1" + local api_path="$2" + shift 2 + + local query_str="" body_file="" + while [ $# -gt 0 ]; do + case "$1" in + --query) + shift + query_str="${1:-}" + shift || true + ;; + --body-file) + shift + body_file="${1:-}" + shift || true + ;; + *) + shift + ;; + esac + done + + local payload + payload=$(python3 -c " +import json, sys +body = {'method': sys.argv[1], 'path': sys.argv[2]} +query_str, body_file = sys.argv[3], sys.argv[4] +if query_str: + query = {} + for pair in query_str.split(','): + if '=' in pair: + k, v = pair.split('=', 1) + query[k.strip()] = v.strip() + body['query'] = query +if body_file: + with open(body_file) as f: + body['body'] = json.load(f) +print(json.dumps(body)) +" "$method" "$api_path" "$query_str" "$body_file") + + call_gateway "/api/v1/confluence/execute" "$payload" +} + +# --- Main dispatch --- + +if [ $# -lt 1 ]; then + show_usage >&2 + exit 1 +fi + +case "$1" in + page) + shift + if [ $# -lt 1 ]; then + echo "ERROR: Missing page subcommand. Usage: confluence page get|descendants|footer-comments|inline-comments " >&2 + exit 1 + fi + case "$1" in + get) + handle_page_get "$@" + ;; + descendants) + handle_page_descendants "$@" + ;; + footer-comments) + handle_page_footer_comments "$@" + ;; + inline-comments) + handle_page_inline_comments "$@" + ;; + *) + echo "ERROR: Unknown page subcommand '$1'. Use: get, descendants, footer-comments, inline-comments" >&2 + exit 1 + ;; + esac + ;; + space) + shift + if [ $# -lt 1 ]; then + echo "ERROR: Missing space subcommand. Usage: confluence space list|pages [args]" >&2 + exit 1 + fi + case "$1" in + list) + handle_space_list "$@" + ;; + pages) + handle_space_pages "$@" + ;; + *) + echo "ERROR: Unknown space subcommand '$1'. Use: list, pages" >&2 + exit 1 + ;; + esac + ;; + search) + shift + handle_search "$@" + ;; + execute) + shift + handle_execute "$@" + ;; + help|--help|-h) + show_usage + exit 0 + ;; + *) + echo "ERROR: Unknown command '$1'. Use: page, space, search, execute, help" >&2 + exit 1 + ;; +esac From 74627a6ca029c887b544d50d3823b5dceba3881e Mon Sep 17 00:00:00 2001 From: egg Date: Mon, 27 Apr 2026 06:49:38 +0000 Subject: [PATCH 17/26] =?UTF-8?q?gateway:=20address=20tester=20NACK=20?= =?UTF-8?q?=E2=80=94=20ruff=20format=20+=20mypy=20clean=20(#1931)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tester NACK identified two blocking issues for ``make lint`` once the sandbox/scripts/confluence wrapper landed: 1. ``ruff format --check`` reported three coder-owned source files (gateway/confluence_client.py, gateway/confluence_credentials.py, gateway/gateway.py) needed reformatting — pure whitespace / line-break style, no logic change. Applied ``ruff format`` per the tester's diagnostic. 2. ``mypy`` then surfaced 12 latent type errors across the Confluence routes — ``data.get("pageId")`` returns ``Any | None``, but the ``ConfluenceClient`` methods are typed ``str``. After the existing ``_validate_confluence_page_id`` / ``_validate_confluence_space_key`` guard, the value is provably ``str``, but mypy can't follow ``tuple[bool, str]`` returns for narrowing. Added explicit ``assert isinstance(page_id|space_key, str)`` lines after each validator's reject-and-return block — single statement per route, no runtime cost beyond the assert, no behavioral change. 3. Reordered the import-ignore tags on the lazy-fallback Confluence imports in gateway.py: ``[no-redef, import-untyped]`` belongs on the FIRST import of each module (which is when mypy actually surfaces the missing-stubs error); subsequent imports of the same module only need ``[no-redef]``. Mypy was reporting the missing-stubs error at line 254 (first confluence_client import) and complaining that the same tag at line 260 was unused. Verification: - ``make lint`` passes cleanly (ruff check, ruff format check, mypy, shellcheck, custom checks). - ``pytest gateway/tests/test_jira_*.py`` all green (173 tests) — confirms the format reorderings on confluence_credentials.py didn't break the ATLASSIAN_*/JIRA_* per-key precedence path that test_jira_credentials.py exercises. Non-blocking findings from the tester (CQL function-name collision in ``_extract_space_clauses``, ``space_cache`` populates pre-allowlist-filter, ``CONFLUENCE_DENIED_VERBS`` mixes path segments and HTTP methods) are documented for follow-up but out of scope for this BRC round per the tester's own classification. --- gateway/confluence_client.py | 39 ++++++----------------- gateway/confluence_credentials.py | 14 ++++---- gateway/gateway.py | 53 +++++++++++++++++++------------ 3 files changed, 48 insertions(+), 58 deletions(-) diff --git a/gateway/confluence_client.py b/gateway/confluence_client.py index 3592ba37fb..974f57ada0 100644 --- a/gateway/confluence_client.py +++ b/gateway/confluence_client.py @@ -310,8 +310,7 @@ def _validate_body_format(body_format: Any) -> list[str]: raise ValueError("body_format entries must be strings") if entry not in ALLOWED_BODY_FORMATS: raise ValueError( - f"invalid body_format: {entry!r} (allowed: " - f"{sorted(ALLOWED_BODY_FORMATS)})" + f"invalid body_format: {entry!r} (allowed: {sorted(ALLOWED_BODY_FORMATS)})" ) cleaned.append(entry) return cleaned @@ -606,9 +605,7 @@ def get_page( if response.status_code == 404: return _not_found_envelope(page_id) if response.status_code == 403: - raise ConfluenceUpstreamForbidden( - 403, _safe_response_body(response), path - ) + raise ConfluenceUpstreamForbidden(403, _safe_response_body(response), path) _raise_for_status(response, path) body_json = _safe_json(response, path) @@ -645,9 +642,7 @@ def get_page_descendants( if response.status_code == 404: return _not_found_envelope(page_id) if response.status_code == 403: - raise ConfluenceUpstreamForbidden( - 403, _safe_response_body(response), path - ) + raise ConfluenceUpstreamForbidden(403, _safe_response_body(response), path) _raise_for_status(response, path) return _finalize_response(_safe_json(response, path), path) @@ -680,9 +675,7 @@ def get_page_footer_comments( if response.status_code == 404: return _not_found_envelope(page_id) if response.status_code == 403: - raise ConfluenceUpstreamForbidden( - 403, _safe_response_body(response), path - ) + raise ConfluenceUpstreamForbidden(403, _safe_response_body(response), path) _raise_for_status(response, path) primary = _safe_json(response, path) @@ -740,9 +733,7 @@ def get_page_inline_comments( path = f"api/v2/pages/{page_id}/inline-comments" response = self._request("GET", path, query=query) if response.status_code == 403: - raise ConfluenceUpstreamForbidden( - 403, _safe_response_body(response), path - ) + raise ConfluenceUpstreamForbidden(403, _safe_response_body(response), path) if response.status_code == 404: # v1 fallback (decision D1). v1_path = f"rest/api/content/{page_id}/child/comment" @@ -758,9 +749,7 @@ def get_page_inline_comments( envelope["used_fallback"] = True return envelope if v1_response.status_code == 403: - raise ConfluenceUpstreamForbidden( - 403, _safe_response_body(v1_response), v1_path - ) + raise ConfluenceUpstreamForbidden(403, _safe_response_body(v1_response), v1_path) _raise_for_status(v1_response, v1_path) v1_body = _safe_json(v1_response, v1_path) v1_body["used_fallback"] = True @@ -791,9 +780,7 @@ def list_spaces( path = "api/v2/spaces" response = self._request("GET", path, query=query or None) if response.status_code == 403: - raise ConfluenceUpstreamForbidden( - 403, _safe_response_body(response), path - ) + raise ConfluenceUpstreamForbidden(403, _safe_response_body(response), path) _raise_for_status(response, path) body_json = _safe_json(response, path) @@ -835,9 +822,7 @@ def get_space_pages( if response.status_code == 404: return _not_found_envelope(space_id) if response.status_code == 403: - raise ConfluenceUpstreamForbidden( - 403, _safe_response_body(response), path - ) + raise ConfluenceUpstreamForbidden(403, _safe_response_body(response), path) _raise_for_status(response, path) return _finalize_response(_safe_json(response, path), path) @@ -859,9 +844,7 @@ def search_cql( path = "rest/api/search" response = self._request("GET", path, query=query) if response.status_code == 403: - raise ConfluenceUpstreamForbidden( - 403, _safe_response_body(response), path - ) + raise ConfluenceUpstreamForbidden(403, _safe_response_body(response), path) _raise_for_status(response, path) return _finalize_response(_safe_json(response, path), path) @@ -880,9 +863,7 @@ def execute_raw( """ response = self._request(method, path, query=query, body=body) if response.status_code == 403: - raise ConfluenceUpstreamForbidden( - 403, _safe_response_body(response), path - ) + raise ConfluenceUpstreamForbidden(403, _safe_response_body(response), path) _raise_for_status(response, path) return _finalize_response(_safe_json(response, path), path) diff --git a/gateway/confluence_credentials.py b/gateway/confluence_credentials.py index 9baabf43b4..3d8fe3c5de 100644 --- a/gateway/confluence_credentials.py +++ b/gateway/confluence_credentials.py @@ -154,14 +154,12 @@ def _load_credentials(self) -> None: # Per-key ATLASSIAN_* → CONFLUENCE_* fallback (decision F1). atlassian_base = (secrets.get("ATLASSIAN_BASE_URL") or "").strip().rstrip("/") confluence_base = (secrets.get("CONFLUENCE_BASE_URL") or "").strip().rstrip("/") - username = ( - (secrets.get("ATLASSIAN_USERNAME") or "").strip() - or (secrets.get("CONFLUENCE_USERNAME") or "").strip() - ) - api_token = ( - (secrets.get("ATLASSIAN_API_TOKEN") or "").strip() - or (secrets.get("CONFLUENCE_API_TOKEN") or "").strip() - ) + username = (secrets.get("ATLASSIAN_USERNAME") or "").strip() or ( + secrets.get("CONFLUENCE_USERNAME") or "" + ).strip() + api_token = (secrets.get("ATLASSIAN_API_TOKEN") or "").strip() or ( + secrets.get("CONFLUENCE_API_TOKEN") or "" + ).strip() # Base URL derivation (decision F1, per-key precedence): ATLASSIAN # wins when set; CONFLUENCE_BASE_URL is the back-compat fallback used diff --git a/gateway/gateway.py b/gateway/gateway.py index 184433ec9b..93f461899d 100644 --- a/gateway/gateway.py +++ b/gateway/gateway.py @@ -251,13 +251,13 @@ _egg_gateway_dir = str(Path(__file__).parent) if _egg_gateway_dir not in sys.path: sys.path.insert(0, _egg_gateway_dir) - from confluence_client import ( # type: ignore[no-redef] + from confluence_client import ( # type: ignore[no-redef, import-untyped] DEFAULT_LIMIT as CONFLUENCE_DEFAULT_LIMIT, ) from confluence_client import ( # type: ignore[no-redef] HARD_MAX_LIMIT as CONFLUENCE_HARD_MAX_LIMIT, ) - from confluence_client import ( # type: ignore[no-redef, import-untyped] + from confluence_client import ( # type: ignore[no-redef] ConfluenceCredentialsUnavailable, ConfluenceResponseTooLarge, ConfluenceUpstreamError, @@ -5043,6 +5043,7 @@ def confluence_page_get() -> tuple[Response, int] | Response: status_code=400, details={"pageId": page_id}, ) + assert isinstance(page_id, str) # narrowed by _validate_confluence_page_id allowed = confluence_allowed_spaces() try: @@ -5058,15 +5059,17 @@ def confluence_page_get() -> tuple[Response, int] | Response: except ConfluenceCredentialsUnavailable as exc: return _confluence_not_configured_error(exc) except ConfluenceUpstreamForbidden as exc: - return _confluence_forbidden_response( - exc, event="confluence_upstream_403", page_id=page_id - ) + return _confluence_forbidden_response(exc, event="confluence_upstream_403", page_id=page_id) except ConfluenceResponseTooLarge as exc: audit_log( "confluence_response_too_large", "confluence_page_get", success=False, - details={"pageId": page_id, "size_bytes": exc.size_bytes, **_session_confluence_context()}, + details={ + "pageId": page_id, + "size_bytes": exc.size_bytes, + **_session_confluence_context(), + }, ) return _confluence_response_too_large(exc, page_id=page_id) except ConfluenceUpstreamError as exc: @@ -5129,6 +5132,7 @@ def confluence_page_descendants() -> tuple[Response, int] | Response: status_code=400, details={"pageId": page_id}, ) + assert isinstance(page_id, str) # narrowed by _validate_confluence_page_id # Apply sensible defaults for runaway-tree protection (risk R8). if depth is None: @@ -5157,9 +5161,7 @@ def confluence_page_descendants() -> tuple[Response, int] | Response: except ConfluenceCredentialsUnavailable as exc: return _confluence_not_configured_error(exc) except ConfluenceUpstreamForbidden as exc: - return _confluence_forbidden_response( - exc, event="confluence_upstream_403", page_id=page_id - ) + return _confluence_forbidden_response(exc, event="confluence_upstream_403", page_id=page_id) except ConfluenceResponseTooLarge as exc: return _confluence_response_too_large(exc, page_id=page_id) except ConfluenceUpstreamError as exc: @@ -5182,7 +5184,11 @@ def confluence_page_descendants() -> tuple[Response, int] | Response: if body.get("status") != "not_found": try: parent = get_confluence_client().get_page(page_id, body_format=("storage",)) - except (ConfluenceCredentialsUnavailable, ConfluenceUpstreamError, ConfluenceUpstreamForbidden): + except ( + ConfluenceCredentialsUnavailable, + ConfluenceUpstreamError, + ConfluenceUpstreamForbidden, + ): parent = None if parent is not None and parent.get("status") != "not_found": ok_space, parent_space_key = _check_post_fetch_space_allowlist( @@ -5243,6 +5249,7 @@ def confluence_page_footer_comments() -> tuple[Response, int] | Response: status_code=400, details={"pageId": page_id}, ) + assert isinstance(page_id, str) # narrowed by _validate_confluence_page_id try: limit = _confluence_clamp_limit(limit_raw) @@ -5263,9 +5270,7 @@ def confluence_page_footer_comments() -> tuple[Response, int] | Response: except ConfluenceCredentialsUnavailable as exc: return _confluence_not_configured_error(exc) except ConfluenceUpstreamForbidden as exc: - return _confluence_forbidden_response( - exc, event="confluence_upstream_403", page_id=page_id - ) + return _confluence_forbidden_response(exc, event="confluence_upstream_403", page_id=page_id) except ConfluenceResponseTooLarge as exc: return _confluence_response_too_large(exc, page_id=page_id) except ConfluenceUpstreamError as exc: @@ -5285,7 +5290,11 @@ def confluence_page_footer_comments() -> tuple[Response, int] | Response: if body.get("status") != "not_found": try: parent = get_confluence_client().get_page(page_id, body_format=("storage",)) - except (ConfluenceCredentialsUnavailable, ConfluenceUpstreamError, ConfluenceUpstreamForbidden): + except ( + ConfluenceCredentialsUnavailable, + ConfluenceUpstreamError, + ConfluenceUpstreamForbidden, + ): parent = None if parent is not None and parent.get("status") != "not_found": ok_space, parent_space_key = _check_post_fetch_space_allowlist( @@ -5350,6 +5359,7 @@ def confluence_page_inline_comments() -> tuple[Response, int] | Response: status_code=400, details={"pageId": page_id}, ) + assert isinstance(page_id, str) # narrowed by _validate_confluence_page_id try: limit = _confluence_clamp_limit(limit_raw) @@ -5369,9 +5379,7 @@ def confluence_page_inline_comments() -> tuple[Response, int] | Response: except ConfluenceCredentialsUnavailable as exc: return _confluence_not_configured_error(exc) except ConfluenceUpstreamForbidden as exc: - return _confluence_forbidden_response( - exc, event="confluence_upstream_403", page_id=page_id - ) + return _confluence_forbidden_response(exc, event="confluence_upstream_403", page_id=page_id) except ConfluenceResponseTooLarge as exc: return _confluence_response_too_large(exc, page_id=page_id) except ConfluenceUpstreamError as exc: @@ -5392,7 +5400,11 @@ def confluence_page_inline_comments() -> tuple[Response, int] | Response: if body.get("status") != "not_found": try: parent = get_confluence_client().get_page(page_id, body_format=("storage",)) - except (ConfluenceCredentialsUnavailable, ConfluenceUpstreamError, ConfluenceUpstreamForbidden): + except ( + ConfluenceCredentialsUnavailable, + ConfluenceUpstreamError, + ConfluenceUpstreamForbidden, + ): parent = None if parent is not None and parent.get("status") != "not_found": ok_space, parent_space_key = _check_post_fetch_space_allowlist( @@ -5456,6 +5468,7 @@ def confluence_space_pages() -> tuple[Response, int] | Response: status_code=400, details={"spaceKey": space_key}, ) + assert isinstance(space_key, str) # narrowed by _validate_confluence_space_key if not is_confluence_space_allowed(space_key): return _confluence_space_denied_response( @@ -5818,9 +5831,7 @@ def confluence_execute() -> tuple[Response, int] | Response: except ConfluenceCredentialsUnavailable as exc: return _confluence_not_configured_error(exc) except ConfluenceUpstreamForbidden as exc: - return _confluence_forbidden_response( - exc, event="confluence_upstream_403", page_id=page_id - ) + return _confluence_forbidden_response(exc, event="confluence_upstream_403", page_id=page_id) except ConfluenceResponseTooLarge as exc: return _confluence_response_too_large(exc, page_id=page_id) except ConfluenceUpstreamError as exc: From c38535dad258ec2ef07d25c97e7f3b65667e547f Mon Sep 17 00:00:00 2001 From: egg Date: Mon, 27 Apr 2026 06:51:10 +0000 Subject: [PATCH 18/26] docs: address reviewer_code NACK on cycle-2 doc commit (#1931) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two reviewer_code blockers from NACK 2d5b7b98 on commit 8b46dfc8e: 1. Drop the "Pre-merge obligation: sandbox script staging" section from docs/reference/confluence-wrapper.md. Coder commit 72ee7dc22 committed sandbox/scripts/confluence at its canonical path; the shared/egg_restrictions/patterns.py block_exempt_patterns landed in #2133/#2135 already, so the staging route is obsolete and the git-mv instructions in the section were stale and would mislead maintainers. 2. Soften the "Anti-bypass invariant" paragraph in the /execute section. The previous wording claimed /execute rejects narrow- route path families (api/v2/pages/{id}, api/v2/spaces/{id}/pages, rest/api/search) and a regression test enforces this; neither is true at branch HEAD — gateway/confluence_client.py:163-175 ships those three patterns IN the /execute allowlist. Replaced with a "Known gap (tracked under issue #1931 cycle-2 NACK)" note that honestly describes the current state and points at the in-flight coder fix. Updated the Error cases table row and the Hard limits line in sandbox/agent-config/rules/environment.md to match. Non-blocking incorporated: spelled out the _USER_PROFILE_SELF_RE regex shape (/api/v\d+/users/) in the response-redaction bullet so future readers know v3+ users endpoints are also covered. Verified each change against gateway/confluence_client.py at the current branch HEAD (72ee7dc22). Co-Authored-By: Claude Opus 4.7 --- docs/reference/confluence-wrapper.md | 25 +++++------------------ sandbox/agent-config/rules/environment.md | 2 +- 2 files changed, 6 insertions(+), 21 deletions(-) diff --git a/docs/reference/confluence-wrapper.md b/docs/reference/confluence-wrapper.md index b5eecf51d5..3a28f860bd 100644 --- a/docs/reference/confluence-wrapper.md +++ b/docs/reference/confluence-wrapper.md @@ -174,11 +174,11 @@ Only `GET` is accepted. The `path` is validated against a hardened regex allowli - Allowed path families (GET-only): `^api/v2/pages/\d+$`, `^api/v2/pages/\d+/descendants$`, `^api/v2/pages/\d+/footer-comments$`, `^api/v2/pages/\d+/inline-comments$`, `^api/v2/footer-comments$`, `^api/v2/inline-comments$`, `^api/v2/spaces$`, `^api/v2/spaces/\d+/pages$`, `^rest/api/search$`, `^rest/api/content/\d+/child/comment$` (the v1 fallback for inline comments). - Any path containing `restrictions`, `permissions`, `space.admin`, `users`, or `attachments` is rejected — these are the permanent "out of scope ever" verbs (decision 12). The `CONFLUENCE_DENIED_VERBS` frozenset checks for the term in any path position so `pages/123/attachments` is refused as well. -For path families that target a specific resource (`pages/{id}`, `spaces/{id}/pages`), the post-fetch space-allowlist check runs once the upstream response arrives, identical to the narrow routes. For families that don't carry an obvious `spaceId` in the response (e.g., `api/v2/footer-comments?page-id=...`), the route requires a `spaceKey` query parameter and validates it up-front before issuing the upstream call. +For path families that target a specific resource (`pages/{id}`), the post-fetch space-allowlist check runs once the upstream response arrives, identical to the narrow routes. For families that don't carry an obvious `spaceId` in the response (e.g., `api/v2/footer-comments?page-id=...`), the route requires the agent to supply a `spaceKey` query parameter and validates it up-front before issuing the upstream call. -**Anti-bypass invariant.** `/execute` does **not** accept paths that the narrow routes already cover (`api/v2/pages/{id}`, `api/v2/spaces/{id}/pages`, `rest/api/search`); routing those through `/execute` is refused with the same `confluence_execute_denied` audit category. This prevents an attacker from bypassing narrow-route policy checks (e.g., the CQL extractor) by re-routing through `/execute`. A regression test asserts that every narrow-route path family fails the `/execute` validator. +**Known gap (tracked under issue #1931 cycle-2 NACK).** As of v1, the `/execute` regex allowlist also accepts the path families that the narrow routes already cover — `api/v2/pages/{id}`, `api/v2/spaces/{id}/pages`, and `rest/api/search`. Routing those through `/execute` does **not** apply the narrow route's policy (e.g., the CQL static space-scope extractor on `rest/api/search`, or the response-side allowlist filtering on `api/v2/spaces`). This is a real bypass surface and is being closed in a follow-up commit on this PR; the reviewer_code NACK on the underlying code change drives that work. Until that fix lands, treat `/execute` as authorised against the same regex + denied-verbs gate but **without** the narrow-route policy layered on top — operators relying on the anti-bypass property must wait for the cycle-2 fix. -`/execute` is a pragmatic escape hatch for future read verbs not yet promoted to narrow routes. It is **not** a general passthrough — the regex allowlist plus the anti-bypass invariant is the fence. +`/execute` is a pragmatic escape hatch for future read verbs not yet promoted to narrow routes. The regex allowlist plus the `CONFLUENCE_DENIED_VERBS` frozenset is the fence today; once the cycle-2 fix lands, `/execute` will additionally refuse paths that overlap with narrow-route coverage and emit `confluence_execute_denied` for those attempts, restoring the strict anti-bypass invariant. ## `not_found` envelope @@ -203,7 +203,7 @@ Every successful response body is sanitised by `redact_response(payload)` in `ga - Replaces every `accountId` value (at any depth) with `""`. - Replaces every `emailAddress` value (at any depth) with `""`. - Strips `_links.webui` user-profile URLs — any URL whose path begins with `/people/` or matches an Atlassian user-profile shape. **Page and space `_links.webui` URLs are preserved** — those are addressable resources the agent legitimately needs. -- Strips `_links.self` URLs that match the Atlassian v2 user-profile shape (`/api/vN/users/{accountId}`). v2 user objects expose `_links.self` pointing at the user-profile API endpoint; this is **defense-in-depth** so a future Atlassian schema change that drops the `accountId` field but keeps the link does not silently start leaking identifiers. Page and space `_links.self` URLs (which point at `/api/vN/pages/...` or `/api/vN/spaces/...`) are preserved. +- Strips `_links.self` URLs that match the Atlassian v2 user-profile shape (regex `/api/v\d+/users/`, so `/api/v2/users/{accountId}` and any future v3+ users endpoint shape are both covered). v2 user objects expose `_links.self` pointing at the user-profile API endpoint; this is **defense-in-depth** so a future Atlassian schema change that drops the `accountId` field but keeps the link does not silently start leaking identifiers. Page and space `_links.self` URLs (which point at `/api/vN/pages/...` or `/api/vN/spaces/...`) are preserved. The walker handles nested ADF mention nodes and `body.atlas_doc_format.content` trees, so the redaction holds for storage-format, ADF, and view-format bodies alike. @@ -218,7 +218,7 @@ If a tenant carries custom Confluence macros, page properties, or fields known t | 403 | Public mode (private-mode gate) | `private_mode_required` | | 403 | Resolved space not in `confluence.spaces` | `confluence_space_denied` | | 403 | `/search` CQL fails the static scope extractor | `confluence_search_rejected` with the specific reason | -| 403 | `/execute` denied verb, non-GET method, path traversal, disallowed path family, duplicate slash, non-ASCII, narrow-route bypass attempt | `confluence_execute_denied` with reason | +| 403 | `/execute` denied verb, non-GET method, path traversal, disallowed path family, duplicate slash, non-ASCII | `confluence_execute_denied` with reason (the narrow-route bypass refusal is being added in the cycle-2 fix — see the "Known gap" note in the [`/execute`](#post-apiv1confluenceexecute) section above) | | 403 | Atlassian returned 403 (bot lacks read access on the resource) | `confluence_upstream_403` (distinct from generic `confluence_upstream_error`) — body: `{"status": "forbidden", "reason": "bot_account_lacks_read_access", "pageId" \| "spaceKey": "..."}` | | 413 | Response body exceeds `CONFLUENCE_RESPONSE_MAX_BYTES` (5 MiB) post-redaction | `confluence_response_too_large` | | 503 | Atlassian credentials not configured (`ConfluenceCredentialsUnavailable`) | `confluence_credentials_unavailable` | @@ -326,21 +326,6 @@ This precedence (ATLASSIAN-wins, CONFLUENCE as per-key back-compat fallback) is The Confluence loader (`gateway/confluence_credentials.py`) and the Jira loader (`gateway/jira_credentials.py`, updated as part of #1931 task 1-5) duplicate the loader skeleton in v1 — extracting a shared `atlassian_credentials.py` helper is tracked as a follow-up backlog item (architect Q4). -## Pre-merge obligation: sandbox script staging - -The sandbox-side wrapper (`sandbox/scripts/confluence`) is staged in this PR at `.egg-state/agent-outputs/1931-sandbox-scripts-confluence` rather than at its final on-disk location. Reason: the producer roster's file-boundary policy in `shared/egg_restrictions/patterns.py` wholesale-blocks `sandbox/scripts/` for the coder role (the same posture used for `sandbox/scripts/jira` in [#1556](https://github.com/jwbron/egg/issues/1556)), so the coder cannot push the file to its target path through BRC. The PR adds a `block_exempt_patterns` entry for `sandbox/scripts/confluence` so future re-proposes land directly, but that exemption only takes effect once this PR's `patterns.py` change is live on the gateway pod. - -**After merge, a maintainer must:** - -```bash -git mv .egg-state/agent-outputs/1931-sandbox-scripts-confluence \ - sandbox/scripts/confluence -chmod +x sandbox/scripts/confluence -git commit -m "chore: move staged Confluence wrapper to sandbox/scripts/confluence (#1931)" -``` - -This is surfaced as a "Pre-merge Obligations" entry on the auto-created PR body via the BRC conditional-ACK mechanism. The wrapper is fully functional in the staged location for review; only the on-disk location changes. - ## Related documentation - [Credential Injection — Atlassian / Confluence](../architecture/credential-injection.md#atlassian--confluence) — where credentials live, mtime refresh, zero-credential invariant diff --git a/sandbox/agent-config/rules/environment.md b/sandbox/agent-config/rules/environment.md index 7d3e44fe28..d497da2663 100644 --- a/sandbox/agent-config/rules/environment.md +++ b/sandbox/agent-config/rules/environment.md @@ -110,7 +110,7 @@ confluence page inline-comments 12345 confluence search 'space = ENG OR space = SEC' ``` -**Hard limits (always denied):** `restrictions`, `permissions`, `space.admin`, `users`, `attachments`, HTTP `DELETE` / `PUT` / `PATCH`, path traversal (`..`), duplicate slashes, non-ASCII keys, URL-encoded smuggling of denied terms (e.g., `%61ttachments`), and any `/execute` path that a narrow route already covers (anti-bypass). Non-GET `execute` calls return 403 regardless of the path. See [Confluence Wrapper Reference](../../../docs/reference/confluence-wrapper.md) for the full endpoint surface, CQL scope extractor rules, the `not_found` response envelope, response redaction (`accountId` / `emailAddress` / user-profile `_links.webui` / user-profile `_links.self`), and the `used_fallback` flag emitted when the v1 inline-comment fallback fires. +**Hard limits (always denied):** `restrictions`, `permissions`, `space.admin`, `users`, `attachments`, HTTP `DELETE` / `PUT` / `PATCH`, path traversal (`..`), duplicate slashes, non-ASCII keys, URL-encoded smuggling of denied terms (e.g., `%61ttachments`). Non-GET `execute` calls return 403 regardless of the path. **Note:** in v1 the `/execute` regex allowlist still accepts paths that the narrow routes also cover (`api/v2/pages/{id}`, `api/v2/spaces/{id}/pages`, `rest/api/search`); the strict narrow-route anti-bypass refusal is being added in a follow-up commit on issue #1931 — see [Confluence Wrapper Reference](../../../docs/reference/confluence-wrapper.md) for the current state, full endpoint surface, CQL scope extractor rules, the `not_found` response envelope, response redaction (`accountId` / `emailAddress` / user-profile `_links.webui` / user-profile `_links.self`), and the `used_fallback` flag emitted when the v1 inline-comment fallback fires. > **Note on `~/context-sync/confluence/`.** The sandbox may also have a read-only `~/context-sync/confluence/` cache mounted (legacy syncer). That cache is independent of the new gateway wrapper — `confluence ...` calls always go through the gateway and never touch the syncer cache. From f3f552eb9aa076ca7a32e764c69ee91b39180ab1 Mon Sep 17 00:00:00 2001 From: egg Date: Mon, 27 Apr 2026 07:02:50 +0000 Subject: [PATCH 19/26] =?UTF-8?q?gateway:=20address=20reviewer=5Fcode=20+?= =?UTF-8?q?=20reviewer=5Fsecurity=20NACK=20=E2=80=94=20drop=20/execute=20b?= =?UTF-8?q?ypass=20paths=20(#1931)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cycle-3 NACKs from reviewer_code (9ae21669) and reviewer_security (ec5985ff) identified three concrete cross-partition bypasses in the /execute path allowlist that exactly match the pattern PR #1964 had to fix in the Jira allowlist (the ``^project$`` / ``search/jql`` exclusions in gateway/jira_client.py). All three were exploitable from inside a private-mode sandbox today and contradicted the explicit "Anti-bypass invariant" promised in docs/reference/confluence-wrapper.md and sandbox/agent-config/rules/environment.md. Blocking #1: ``rest/api/search`` in CONFLUENCE_API_ALLOWED_PATHS lets an agent POST {"method":"GET","path":"rest/api/search","query":{"cql":"text ~ \"secret\""}} through /execute and run arbitrary CQL — bypassing extract_search_spaces() entirely. Fix: drop ``re.compile(r"^rest/api/search$")`` from the allowlist. All CQL must now flow through /api/v1/confluence/search where the static extractor enforces space-scope. Blocking #2: ``api/v2/spaces`` in CONFLUENCE_API_ALLOWED_PATHS lets an agent enumerate the full tenant space catalog via execute_raw, which does NOT apply the allowlist filter that list_spaces does. Defeats decision-11 ("agents cannot enumerate the full tenant space set"). Fix: drop ``re.compile(r"^api/v2/spaces$")`` from the allowlist. Space enumeration must now flow through /api/v1/confluence/space/list which filters to the operator's allowlist. Blocking #3: ``api/v2/footer-comments`` and ``api/v2/inline-comments`` (the flat v2 endpoints) accept ``page-id`` in the query string, but no ``spaceKey`` filter exists at upstream. An agent passes {"path":"api/v2/footer-comments","query":{"spaceKey":"ALLOWED", "page-id":""}} — the up-front spaceKey gate sees ``ALLOWED``, Atlassian ignores ``spaceKey`` and returns comments from the non-allowlisted page. The audit log records ``spaceKey=ALLOWED``, masking the exfil. Fix: drop both ``re.compile(r"^api/v2/footer-comments$")`` and ``re.compile(r"^api/v2/inline-comments$")`` from the allowlist. The narrow /api/v1/confluence/page/{footer,inline}-comments routes already cover the agent-facing use case AND correctly fetch the parent page to verify its space (with the cycle-1 fail-closed fix on parent-fetch failure from c4d8fa02d). Important: these four paths remain reachable INTERNALLY by ConfluenceClient methods that construct them directly (the include_replies side-call inside get_page_footer_comments and the v2-bug fallback inside get_page_inline_comments) — those paths do NOT go through validate_confluence_api_path. Only the agent-facing /execute escape hatch is closed. Stale-artifact cleanup (reviewer_code_holistic 183e44a0 partial fix): delete the now-redundant staged copy at .egg-state/agent-outputs/1931-sandbox-scripts-confluence. Cycle-3 commit 72ee7dc22 already pushed the wrapper to its real on-disk path (sandbox/scripts/confluence), making the staging artifact dead weight. gateway/gateway.py /execute route: strip the dead ``requires_space_key`` branch that special-cased the four removed paths. Reaching the post-validation block now implies a page- or space-scoped path family, all of which carry an id inline that the existing post-fetch allowlist check resolves to a spaceKey. Verification: - ``make lint`` passes (ruff check + ruff format check + mypy + shellcheck + custom checks). - ``pytest gateway/tests/test_jira_*.py`` all green (173 tests) — Jira reads / search / execute paths unchanged. Out of scope for this commit (handed back to reviewer_code_holistic): - Aligning patterns.py with main #2135 (drop wholesale ``sandbox/scripts/`` block + drop both ``sandbox/scripts/jira`` and ``sandbox/scripts/confluence`` exemptions) requires a coordinated tester-scope test update on the same branch (the gh / git-credential-github-token test assertions in gateway/tests/test_agent_restrictions_*.py and shared/tests/test_egg_restrictions.py expect the wholesale block to remain — they all flip in main #2135). Those test files are blocked for the coder role (tester scope), so the patterns.py alignment cannot be done unilaterally without breaking the test suite on this branch. Recommend resolving via main merge at PR time, or via a dedicated tester-coder coordinated cycle. - The non-blocking cosmetic / observability findings from the three reviewer NACKs (CQL function-name collision, audit-log gap on 413, redundant ``self.space_cache.put`` skip in get_page, eager spaceId cache population, log-once flag scoping, Jira-loader log spam, heredoc quote on gateway-down message, %-encoded smuggling decoder) are tracked for follow-up tickets — every adversarial probe in the test grid is rejected today. --- .../1931-sandbox-scripts-confluence | 656 ------------------ gateway/confluence_client.py | 27 +- gateway/gateway.py | 54 +- 3 files changed, 34 insertions(+), 703 deletions(-) delete mode 100755 .egg-state/agent-outputs/1931-sandbox-scripts-confluence diff --git a/.egg-state/agent-outputs/1931-sandbox-scripts-confluence b/.egg-state/agent-outputs/1931-sandbox-scripts-confluence deleted file mode 100755 index 3a4d123e2f..0000000000 --- a/.egg-state/agent-outputs/1931-sandbox-scripts-confluence +++ /dev/null @@ -1,656 +0,0 @@ -#!/bin/bash -# -# Confluence CLI wrapper for egg container -# Routes Confluence commands through the gateway sidecar for policy enforcement. -# -# Security: Requires gateway sidecar - fails closed if gateway unavailable. -# The gateway sidecar holds the Atlassian credentials and enforces policies: -# - Space allowlist restricts which spaces agents can access -# - Read-only: only GET operations are permitted -# - CQL scope extraction prevents cross-space data access -# - accountId / emailAddress / user-profile webui links redacted -# -# Verbs: -# confluence page get [--body-format storage,atlas_doc_format] [--expand ...] -# confluence page descendants [--depth N] [--limit N] [--cursor TOK] -# confluence page footer-comments [--include-replies] [--body-format ...] [--limit N] [--cursor TOK] -# confluence page inline-comments [--body-format ...] [--limit N] [--cursor TOK] -# confluence space pages [--limit N] [--cursor TOK] [--body-format ...] -# confluence space list [--limit N] [--cursor TOK] -# confluence search '' [--limit N] [--cursor TOK] -# confluence execute [--query k=v,...] [--body-file path] -# confluence help -# -# Pre-merge obligation (issue #1931): until shared/egg_restrictions/patterns.py -# `block_exempt_patterns` lands on the running gateway, this file is staged -# under .egg-state/agent-outputs/ so the coder role can push it through BRC. -# After merge, a maintainer should run: -# -# git mv .egg-state/agent-outputs/1931-sandbox-scripts-confluence \ -# sandbox/scripts/confluence -# -# This mirrors the post-merge step taken for sandbox/scripts/jira in issue #1556. - -set -euo pipefail - -# --- Environment checks (fail closed) --- - -if [ -z "${GATEWAY_URL:-}" ]; then - echo "ERROR: GATEWAY_URL environment variable is not set." >&2 - echo "This variable must be set by the container launcher." >&2 - exit 1 -fi - -if [ -z "${EGG_SESSION_TOKEN:-}" ]; then - echo "ERROR: EGG_SESSION_TOKEN environment variable is not set." >&2 - echo "Session token is required for gateway access." >&2 - exit 1 -fi - -# --- Gateway health check --- - -check_gateway_available() { - if command -v curl >/dev/null 2>&1; then - curl -s --connect-timeout 2 "${GATEWAY_URL}/api/v1/health" >/dev/null 2>&1 - return $? - fi - return 1 -} - -show_no_gateway_message() { - cat >&2 << 'EOF' - -================================================================================ - GATEWAY SIDECAR NOT AVAILABLE -================================================================================ - -Cannot run confluence command: The gateway sidecar is required but not reachable. - -The gateway enforces space allowlist policies and holds Atlassian credentials. -Without it, Confluence operations are not allowed. - -Please ensure: - 1. Gateway sidecar is running on the host - 2. The container can reach the gateway: - curl $GATEWAY_URL/api/v1/health - -================================================================================ - -EOF - exit 1 -} - -if ! check_gateway_available; then - show_no_gateway_message -fi - -# --- Usage --- - -show_usage() { - cat << 'EOF' -Usage: confluence [options] - -Commands: - confluence page get [--body-format storage,atlas_doc_format] [--expand ...] - Fetch a Confluence page by numeric pageId. - - confluence page descendants [--depth N] [--limit N] [--cursor TOK] - List the descendants of a Confluence page. - - confluence page footer-comments [--include-replies] [--body-format ...] [--limit N] [--cursor TOK] - Fetch footer comments on a Confluence page. - - confluence page inline-comments [--body-format ...] [--limit N] [--cursor TOK] - Fetch inline comments on a Confluence page (with v1 fallback). - - confluence space pages [--limit N] [--cursor TOK] [--body-format ...] - List pages in a Confluence space. - - confluence space list [--limit N] [--cursor TOK] - List Confluence spaces (filtered to the operator's allowlist). - - confluence search '' [--limit N] [--cursor TOK] - Run a CQL query. Space scope is enforced by the gateway. - - confluence execute [--query k=v,k2=v2] [--body-file path] - Execute a raw read-only Confluence REST API call through the gateway. - - confluence help - Show this usage information. -EOF -} - -# --- Gateway call helper --- - -call_gateway() { - local endpoint="$1" - local payload="$2" - - local tmpfile curl_errfile - tmpfile=$(mktemp) - curl_errfile=$(mktemp) - trap 'rm -f "${tmpfile:-}" "${curl_errfile:-}"' RETURN - - local http_code - http_code=$(curl -s -w "%{http_code}" \ - -X POST \ - -H "Content-Type: application/json" \ - -H "Authorization: Bearer ${EGG_SESSION_TOKEN}" \ - -d "$payload" \ - -o "$tmpfile" \ - "${GATEWAY_URL}${endpoint}" 2>"$curl_errfile") - - local curl_exit=$? - if [ $curl_exit -ne 0 ]; then - echo "ERROR: Failed to connect to gateway (curl exit $curl_exit)" >&2 - cat "$curl_errfile" >&2 - return 1 - fi - - python3 -c " -import json, sys - -try: - with open(sys.argv[1]) as f: - data = json.load(f) -except (json.JSONDecodeError, OSError) as e: - print(f'ERROR: Failed to parse gateway response: {e}', file=sys.stderr) - sys.exit(1) - -http_code = sys.argv[2] - -if data.get('success'): - payload = data.get('data', {}) - print(json.dumps(payload, indent=2)) - sys.exit(0) -else: - message = data.get('message', 'Unknown error') - details = data.get('details', {}) - print(f'ERROR: {message}', file=sys.stderr) - if details: - for k, v in details.items(): - print(f' {k}: {v}', file=sys.stderr) - if http_code == '401': - print('Authentication failed - check session token', file=sys.stderr) - elif http_code == '403': - print('Forbidden - check space allowlist or path policy', file=sys.stderr) - elif http_code == '413': - print('Response too large - try a narrower bodyFormat or smaller limit', file=sys.stderr) - elif http_code == '429': - print('Rate limit exceeded - please wait before trying again', file=sys.stderr) - sys.exit(1) -" "$tmpfile" "$http_code" - - return $? -} - -# --- Verb handlers --- - -handle_page_get() { - shift - if [ $# -lt 1 ]; then - echo "ERROR: PageId required. Usage: confluence page get [--body-format ...] [--expand ...]" >&2 - exit 1 - fi - local page_id="$1" - shift - - local body_format="" expand="" - while [ $# -gt 0 ]; do - case "$1" in - --body-format) - shift - body_format="${1:-}" - shift || true - ;; - --expand) - shift - expand="${1:-}" - shift || true - ;; - *) - shift - ;; - esac - done - - local payload - payload=$(python3 -c " -import json, sys -body = {'pageId': sys.argv[1]} -body_format = sys.argv[2] -expand = sys.argv[3] -if body_format: - body['bodyFormat'] = [v.strip() for v in body_format.split(',') if v.strip()] -if expand: - body['expand'] = [v.strip() for v in expand.split(',') if v.strip()] -print(json.dumps(body)) -" "$page_id" "$body_format" "$expand") - - call_gateway "/api/v1/confluence/page/get" "$payload" -} - -handle_page_descendants() { - shift - if [ $# -lt 1 ]; then - echo "ERROR: PageId required. Usage: confluence page descendants [--depth N] [--limit N] [--cursor TOK]" >&2 - exit 1 - fi - local page_id="$1" - shift - - local depth="" limit="" cursor="" - while [ $# -gt 0 ]; do - case "$1" in - --depth) - shift - depth="${1:-}" - shift || true - ;; - --limit) - shift - limit="${1:-}" - if ! [[ "$limit" =~ ^[0-9]+$ ]]; then - echo "ERROR: --limit must be an integer, got: $limit" >&2 - exit 1 - fi - shift || true - ;; - --cursor) - shift - cursor="${1:-}" - shift || true - ;; - *) - shift - ;; - esac - done - - local payload - payload=$(python3 -c " -import json, sys -body = {'pageId': sys.argv[1]} -depth, limit, cursor = sys.argv[2], sys.argv[3], sys.argv[4] -if depth: - body['depth'] = int(depth) if depth.isdigit() else depth -if limit: - body['limit'] = int(limit) -if cursor: - body['cursor'] = cursor -print(json.dumps(body)) -" "$page_id" "$depth" "$limit" "$cursor") - - call_gateway "/api/v1/confluence/page/descendants" "$payload" -} - -handle_page_footer_comments() { - shift - if [ $# -lt 1 ]; then - echo "ERROR: PageId required. Usage: confluence page footer-comments [--include-replies] [--body-format ...] [--limit N] [--cursor TOK]" >&2 - exit 1 - fi - local page_id="$1" - shift - - local include_replies="false" body_format="" limit="" cursor="" - while [ $# -gt 0 ]; do - case "$1" in - --include-replies) - include_replies="true" - shift - ;; - --body-format) - shift - body_format="${1:-}" - shift || true - ;; - --limit) - shift - limit="${1:-}" - if ! [[ "$limit" =~ ^[0-9]+$ ]]; then - echo "ERROR: --limit must be an integer, got: $limit" >&2 - exit 1 - fi - shift || true - ;; - --cursor) - shift - cursor="${1:-}" - shift || true - ;; - *) - shift - ;; - esac - done - - local payload - payload=$(python3 -c " -import json, sys -body = {'pageId': sys.argv[1], 'includeReplies': sys.argv[2] == 'true'} -body_format, limit, cursor = sys.argv[3], sys.argv[4], sys.argv[5] -if body_format: - body['bodyFormat'] = [v.strip() for v in body_format.split(',') if v.strip()] -if limit: - body['limit'] = int(limit) -if cursor: - body['cursor'] = cursor -print(json.dumps(body)) -" "$page_id" "$include_replies" "$body_format" "$limit" "$cursor") - - call_gateway "/api/v1/confluence/page/footer-comments" "$payload" -} - -handle_page_inline_comments() { - shift - if [ $# -lt 1 ]; then - echo "ERROR: PageId required. Usage: confluence page inline-comments [--body-format ...] [--limit N] [--cursor TOK]" >&2 - exit 1 - fi - local page_id="$1" - shift - - local body_format="" limit="" cursor="" - while [ $# -gt 0 ]; do - case "$1" in - --body-format) - shift - body_format="${1:-}" - shift || true - ;; - --limit) - shift - limit="${1:-}" - if ! [[ "$limit" =~ ^[0-9]+$ ]]; then - echo "ERROR: --limit must be an integer, got: $limit" >&2 - exit 1 - fi - shift || true - ;; - --cursor) - shift - cursor="${1:-}" - shift || true - ;; - *) - shift - ;; - esac - done - - local payload - payload=$(python3 -c " -import json, sys -body = {'pageId': sys.argv[1]} -body_format, limit, cursor = sys.argv[2], sys.argv[3], sys.argv[4] -if body_format: - body['bodyFormat'] = [v.strip() for v in body_format.split(',') if v.strip()] -if limit: - body['limit'] = int(limit) -if cursor: - body['cursor'] = cursor -print(json.dumps(body)) -" "$page_id" "$body_format" "$limit" "$cursor") - - call_gateway "/api/v1/confluence/page/inline-comments" "$payload" -} - -handle_space_pages() { - shift - if [ $# -lt 1 ]; then - echo "ERROR: SpaceKey required. Usage: confluence space pages [--limit N] [--cursor TOK] [--body-format ...]" >&2 - exit 1 - fi - local space_key="$1" - shift - - local limit="" cursor="" body_format="" - while [ $# -gt 0 ]; do - case "$1" in - --limit) - shift - limit="${1:-}" - if ! [[ "$limit" =~ ^[0-9]+$ ]]; then - echo "ERROR: --limit must be an integer, got: $limit" >&2 - exit 1 - fi - shift || true - ;; - --cursor) - shift - cursor="${1:-}" - shift || true - ;; - --body-format) - shift - body_format="${1:-}" - shift || true - ;; - *) - shift - ;; - esac - done - - local payload - payload=$(python3 -c " -import json, sys -body = {'spaceKey': sys.argv[1]} -limit, cursor, body_format = sys.argv[2], sys.argv[3], sys.argv[4] -if limit: - body['limit'] = int(limit) -if cursor: - body['cursor'] = cursor -if body_format: - body['bodyFormat'] = [v.strip() for v in body_format.split(',') if v.strip()] -print(json.dumps(body)) -" "$space_key" "$limit" "$cursor" "$body_format") - - call_gateway "/api/v1/confluence/space/pages" "$payload" -} - -handle_space_list() { - shift - local limit="" cursor="" - while [ $# -gt 0 ]; do - case "$1" in - --limit) - shift - limit="${1:-}" - if ! [[ "$limit" =~ ^[0-9]+$ ]]; then - echo "ERROR: --limit must be an integer, got: $limit" >&2 - exit 1 - fi - shift || true - ;; - --cursor) - shift - cursor="${1:-}" - shift || true - ;; - *) - shift - ;; - esac - done - - local payload - payload=$(python3 -c " -import json, sys -body = {} -limit, cursor = sys.argv[1], sys.argv[2] -if limit: - body['limit'] = int(limit) -if cursor: - body['cursor'] = cursor -print(json.dumps(body)) -" "$limit" "$cursor") - - call_gateway "/api/v1/confluence/space/list" "$payload" -} - -handle_search() { - if [ $# -lt 1 ]; then - echo "ERROR: CQL query required. Usage: confluence search [options]" >&2 - exit 1 - fi - local cql="$1" - shift - - local limit="" cursor="" - while [ $# -gt 0 ]; do - case "$1" in - --limit) - shift - limit="${1:-}" - if ! [[ "$limit" =~ ^[0-9]+$ ]]; then - echo "ERROR: --limit must be an integer, got: $limit" >&2 - exit 1 - fi - shift || true - ;; - --cursor) - shift - cursor="${1:-}" - shift || true - ;; - *) - shift - ;; - esac - done - - local payload - payload=$(python3 -c " -import json, sys -body = {'cql': sys.argv[1]} -limit, cursor = sys.argv[2], sys.argv[3] -if limit: - body['limit'] = int(limit) -if cursor: - body['cursor'] = cursor -print(json.dumps(body)) -" "$cql" "$limit" "$cursor") - - call_gateway "/api/v1/confluence/search" "$payload" -} - -handle_execute() { - if [ $# -lt 2 ]; then - echo "ERROR: Method and path required. Usage: confluence execute [--query k=v,k2=v2] [--body-file path]" >&2 - exit 1 - fi - local method="$1" - local api_path="$2" - shift 2 - - local query_str="" body_file="" - while [ $# -gt 0 ]; do - case "$1" in - --query) - shift - query_str="${1:-}" - shift || true - ;; - --body-file) - shift - body_file="${1:-}" - shift || true - ;; - *) - shift - ;; - esac - done - - local payload - payload=$(python3 -c " -import json, sys -body = {'method': sys.argv[1], 'path': sys.argv[2]} -query_str, body_file = sys.argv[3], sys.argv[4] -if query_str: - query = {} - for pair in query_str.split(','): - if '=' in pair: - k, v = pair.split('=', 1) - query[k.strip()] = v.strip() - body['query'] = query -if body_file: - with open(body_file) as f: - body['body'] = json.load(f) -print(json.dumps(body)) -" "$method" "$api_path" "$query_str" "$body_file") - - call_gateway "/api/v1/confluence/execute" "$payload" -} - -# --- Main dispatch --- - -if [ $# -lt 1 ]; then - show_usage >&2 - exit 1 -fi - -case "$1" in - page) - shift - if [ $# -lt 1 ]; then - echo "ERROR: Missing page subcommand. Usage: confluence page get|descendants|footer-comments|inline-comments " >&2 - exit 1 - fi - case "$1" in - get) - handle_page_get "$@" - ;; - descendants) - handle_page_descendants "$@" - ;; - footer-comments) - handle_page_footer_comments "$@" - ;; - inline-comments) - handle_page_inline_comments "$@" - ;; - *) - echo "ERROR: Unknown page subcommand '$1'. Use: get, descendants, footer-comments, inline-comments" >&2 - exit 1 - ;; - esac - ;; - space) - shift - if [ $# -lt 1 ]; then - echo "ERROR: Missing space subcommand. Usage: confluence space list|pages [args]" >&2 - exit 1 - fi - case "$1" in - list) - handle_space_list "$@" - ;; - pages) - handle_space_pages "$@" - ;; - *) - echo "ERROR: Unknown space subcommand '$1'. Use: list, pages" >&2 - exit 1 - ;; - esac - ;; - search) - shift - handle_search "$@" - ;; - execute) - shift - handle_execute "$@" - ;; - help|--help|-h) - show_usage - exit 0 - ;; - *) - echo "ERROR: Unknown command '$1'. Use: page, space, search, execute, help" >&2 - exit 1 - ;; -esac diff --git a/gateway/confluence_client.py b/gateway/confluence_client.py index 974f57ada0..d7aba2f91c 100644 --- a/gateway/confluence_client.py +++ b/gateway/confluence_client.py @@ -160,17 +160,34 @@ _PAGE_ID = r"\d+" _SPACE_ID = r"\d+" +# Anti-bypass invariant (reviewer_code 9ae21669 + reviewer_security ec5985ff +# cycle-3 NACK on issue #1931): the /execute path allowlist must NOT include +# any path family that a narrow route already covers, because routing those +# through /execute skips the route-level safeguards. The four removed paths +# and their bypass shapes: +# +# - ``rest/api/search`` — bypasses extract_search_spaces (CQL extractor) +# - ``api/v2/spaces`` — bypasses list_spaces' allowlist filter +# - ``api/v2/footer-comments`` (flat) — page-id-in-query, no upstream +# spaceKey filter; post-fetch space-allowlist +# check cannot resolve the targeted page +# - ``api/v2/inline-comments`` (flat) — same flat-endpoint shape +# +# These paths remain reachable INTERNALLY (the client methods construct them +# directly without going through validate_confluence_api_path) for the +# include_replies side-call inside get_page_footer_comments and the v2-bug +# fallback inside get_page_inline_comments. They are simply not exposed to +# the agent via the /execute escape hatch. Mirrors gateway/jira_client.py's +# permanent denylist of search/jql + bare project for the same anti-bypass +# reason (PR #1964). CONFLUENCE_API_ALLOWED_PATHS: list[re.Pattern[str]] = [ re.compile(rf"^api/v2/pages/{_PAGE_ID}$"), re.compile(rf"^api/v2/pages/{_PAGE_ID}/descendants$"), re.compile(rf"^api/v2/pages/{_PAGE_ID}/footer-comments$"), re.compile(rf"^api/v2/pages/{_PAGE_ID}/inline-comments$"), - re.compile(r"^api/v2/footer-comments$"), - re.compile(r"^api/v2/inline-comments$"), - re.compile(r"^api/v2/spaces$"), re.compile(rf"^api/v2/spaces/{_SPACE_ID}/pages$"), - re.compile(r"^rest/api/search$"), - # v1 fallback for inline / footer comments (decision D1). + # v1 fallback for inline comments (decision D1) — page-scoped, the + # /execute post-fetch space-allowlist check covers it. re.compile(rf"^rest/api/content/{_PAGE_ID}/child/comment$"), ] diff --git a/gateway/gateway.py b/gateway/gateway.py index 93f461899d..a629a9a682 100644 --- a/gateway/gateway.py +++ b/gateway/gateway.py @@ -5770,48 +5770,20 @@ def confluence_execute() -> tuple[Response, int] | Response: if head[3].isdigit(): space_id_in_path = head[3] elif len(head) >= 4 and head[0] == "rest" and head[1] == "api" and head[2] == "content": + # v1 fallback for inline comments — page-scoped. if head[3].isdigit(): page_id = head[3] - # Path families without an obvious id (api/v2/footer-comments, - # api/v2/inline-comments) require a spaceKey query parameter so the - # operator can audit the call. - requires_space_key = stripped in ( - "api/v2/footer-comments", - "api/v2/inline-comments", - "api/v2/spaces", - "rest/api/search", - ) - explicit_space_key = None - if isinstance(query, dict): - explicit_space_key = query.get("spaceKey") - if requires_space_key and stripped != "api/v2/spaces" and stripped != "rest/api/search": - if not isinstance(explicit_space_key, str) or not _CONFLUENCE_SPACE_KEY_RE.fullmatch( - explicit_space_key - ): - audit_log( - "confluence_execute_rejected", - "confluence_execute", - success=False, - details={ - "reason": "spaceKey required for this path", - "path": stripped, - **_session_confluence_context(), - }, - ) - return make_error( - "spaceKey query parameter required for this path", - status_code=400, - details={"path": stripped}, - ) - if not is_confluence_space_allowed(explicit_space_key): - return _confluence_space_denied_response( - event="confluence_execute_denied", - page_id=None, - space_key=explicit_space_key, - reason="space not allowlisted", - extra={"method": method_upper, "path": stripped}, - ) + # Anti-bypass invariant (issue #1931 cycle-3 NACK from reviewer_code + + # reviewer_security): the four path families an attacker could use to + # bypass narrow-route safeguards — ``rest/api/search`` (CQL extractor + # bypass), ``api/v2/spaces`` (allowlist-filter bypass), + # ``api/v2/footer-comments`` / ``api/v2/inline-comments`` (flat + # endpoints with page-id-in-query and no upstream spaceKey filter) — + # are dropped from CONFLUENCE_API_ALLOWED_PATHS in confluence_client.py, + # so reaching this point implies a page- or space-scoped path family. + # All of those carry an id inline that the post-fetch allowlist check + # below resolves to a spaceKey. if query is not None and not isinstance(query, dict): return make_error("query must be an object", status_code=400) @@ -5849,9 +5821,7 @@ def confluence_execute() -> tuple[Response, int] | Response: return _confluence_error_from_upstream(exc) # Post-fetch allowlist check for path families that carry an id inline. - audited_space_key: str | None = ( - explicit_space_key if isinstance(explicit_space_key, str) else None - ) + audited_space_key: str | None = None if page_id is not None and isinstance(body, dict) and body.get("status") != "not_found": ok_space, audited_space_key = _check_post_fetch_space_allowlist( body, allowed=allowed, page_id=page_id From 6b44b59326a3662ee3a1957c846a873477e5be04 Mon Sep 17 00:00:00 2001 From: egg Date: Mon, 27 Apr 2026 06:32:38 +0000 Subject: [PATCH 20/26] tests: add Confluence gateway test suite (#1931) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds 245+ tests covering Phase 4 acceptance criteria for the Confluence gateway wrapper introduced in #1931: - gateway/tests/test_confluence_credentials.py — F1 ATLASSIAN_*/CONFLUENCE_* per-key precedence matrix, /wiki suffix derivation, mtime cache + reload. - gateway/tests/test_confluence_policy.py — context-filters.yaml round-trip, fail-closed semantics, mixed-case key preservation, mtime + manual reload. - gateway/tests/test_confluence_search.py — CQL space-scope extractor: positive shapes (space = K, space IN (K, ...)) plus 17 adversarial cases (OR, capitalisation, quoted keys, CQL functions, comments, semicolons, unicode homoglyphs, bare id/title/content clauses, missing scope). - gateway/tests/test_confluence_client.py — httpx.MockTransport coverage of every public verb, validate_confluence_api_path positive + negative grids, 429 single-retry with Retry-After clamp at 30s, 404 envelope vs raise semantics, 403 → ConfluenceUpstreamForbidden, v1 inline-comment fallback, footer-comment nested-reply merge, list_spaces case-sensitive allowlist filter, redact_response (incl. ADF mention nodes), payload-size cap. - gateway/tests/test_confluence_routes.py — eight POST routes end-to-end: public-mode 403 + private_mode_required audit, route-enumeration regression (every view carries __egg_requires_private_mode__), disallowed-space body-leak guard, route-vs-execute anti-bypass for /execute, adversarial CQL through the route, used_fallback observability, page/descendants risk-R8 default depth=1/limit=25, audit-shape regression. - tests/sandbox/test_confluence_wrapper.py — subprocess-driven tests for the bash wrapper: per-verb request body shape, Authorization header, exit-code contract, fail-closed on missing token / unreachable gateway. - gateway/tests/test_jira_credentials.py — extends the existing suite with the ATLASSIAN_* precedence matrix called for in plan task 4-1b / risk R11. - gateway/tests/test_allowed_domains.py — extends parametrize list with wiki.atlassian.net and confluence.atlassian.com defensive entries. - gateway/tests/conftest.py — loads confluence_{credentials,client,policy, search} modules so the route-tests see the same Flask app the production loader builds. All tests pass via `pytest gateway/tests/test_confluence_*.py gateway/tests/test_jira_credentials.py gateway/tests/test_allowed_domains.py tests/sandbox/test_confluence_wrapper.py` (245 tests, 0 failures). Co-Authored-By: Claude Opus 4.7 --- gateway/tests/conftest.py | 34 + gateway/tests/test_allowed_domains.py | 37 +- gateway/tests/test_confluence_client.py | 836 +++++++++++++++++++ gateway/tests/test_confluence_credentials.py | 334 ++++++++ gateway/tests/test_confluence_policy.py | 211 +++++ gateway/tests/test_confluence_routes.py | 727 ++++++++++++++++ gateway/tests/test_confluence_search.py | 181 ++++ gateway/tests/test_jira_credentials.py | 117 +++ tests/sandbox/test_confluence_wrapper.py | 527 ++++++++++++ 9 files changed, 2996 insertions(+), 8 deletions(-) create mode 100644 gateway/tests/test_confluence_client.py create mode 100644 gateway/tests/test_confluence_credentials.py create mode 100644 gateway/tests/test_confluence_policy.py create mode 100644 gateway/tests/test_confluence_routes.py create mode 100644 gateway/tests/test_confluence_search.py create mode 100644 tests/sandbox/test_confluence_wrapper.py diff --git a/gateway/tests/conftest.py b/gateway/tests/conftest.py index 9293a3cd61..490c2aac39 100644 --- a/gateway/tests/conftest.py +++ b/gateway/tests/conftest.py @@ -204,6 +204,36 @@ def _load_module_with_replaced_imports( GATEWAY_DIR / "jira_search.py", ) +# confluence_credentials imports parse_env_file from anthropic_credentials +confluence_credentials = _load_module_with_replaced_imports( + "confluence_credentials", + GATEWAY_DIR / "confluence_credentials.py", + import_replacements={ + "from .anthropic_credentials import": "from anthropic_credentials import", + }, +) + +# confluence_client imports from confluence_credentials (plus lazy ref to gateway.audit_log) +confluence_client = _load_module_with_replaced_imports( + "confluence_client", + GATEWAY_DIR / "confluence_client.py", + import_replacements={ + "from .confluence_credentials import": "from confluence_credentials import", + }, +) + +# confluence_policy has no relative imports to other gateway modules +confluence_policy = _load_module_with_replaced_imports( + "confluence_policy", + GATEWAY_DIR / "confluence_policy.py", +) + +# confluence_search has no relative imports to other gateway modules +confluence_search = _load_module_with_replaced_imports( + "confluence_search", + GATEWAY_DIR / "confluence_search.py", +) + # mode_gate has a lazy gateway import for audit_log — no eager relative import mode_gate = _load_module_with_replaced_imports( "mode_gate", @@ -335,6 +365,10 @@ def _load_module_with_replaced_imports( "from .jira_credentials import": "from jira_credentials import", "from .jira_policy import": "from jira_policy import", "from .jira_search import": "from jira_search import", + "from .confluence_client import": "from confluence_client import", + "from .confluence_credentials import": "from confluence_credentials import", + "from .confluence_policy import": "from confluence_policy import", + "from .confluence_search import": "from confluence_search import", "from .mode_gate import": "from mode_gate import", }, ) diff --git a/gateway/tests/test_allowed_domains.py b/gateway/tests/test_allowed_domains.py index 19eace61c1..daaeb31c5c 100644 --- a/gateway/tests/test_allowed_domains.py +++ b/gateway/tests/test_allowed_domains.py @@ -2,11 +2,19 @@ Sanity tests for ``gateway/allowed_domains.txt``. Enforces risk-analysis R10 and the refine-phase constraint: all Atlassian -traffic must flow through the gateway's ``/api/v1/jira/*`` endpoints, never -through the Squid egress proxy. Adding an ``atlassian.*`` entry to the -allowlist would let sandbox containers bypass the gateway's project -allowlist via a direct HTTPS call — this test makes that accidental addition -impossible to land. +traffic must flow through the gateway's ``/api/v1/jira/*`` and (since +#1931) ``/api/v1/confluence/*`` endpoints, never through the Squid egress +proxy. Adding an ``atlassian.*`` entry to the allowlist would let sandbox +containers bypass the gateway's project / space allowlist via a direct +HTTPS call — this test makes that accidental addition impossible to land. + +The Confluence wrapper added in #1931 reuses the same Atlassian Cloud +hostnames, so the existing ``*.atlassian.*`` block-list invariant covers +Confluence by extension; the Confluence-specific entries below are +defensive — even if Atlassian ever exposes a separate hostname like +``wiki.atlassian.net`` or ``confluence.atlassian.com``, the allowlist must +still reject it. ``grep -i confluence gateway/tests/`` should find this +file so the invariant is discoverable from the Confluence side too. """ from __future__ import annotations @@ -41,16 +49,29 @@ def test_allowed_domains_file_exists(): "atlassian.com", "api.atlassian.com", "jira.atlassian.com", + # Confluence-specific hostnames (#1931). Defence-in-depth even + # though Atlassian Cloud uses ``.atlassian.net/wiki/...`` + # — if Atlassian ever exposes a Confluence-only hostname the + # allowlist must still reject it. + "wiki.atlassian.net", + "confluence.atlassian.com", ], ) def test_atlassian_domains_absent(bad_substr: str): - """No non-comment line may reference an Atlassian domain.""" + """No non-comment line may reference an Atlassian (Jira / Confluence) domain. + + All Atlassian traffic — Jira read endpoints (#1556) and Confluence + read endpoints (#1931) — must flow through the gateway's + ``/api/v1/jira/*`` / ``/api/v1/confluence/*`` routes, not directly + through Squid. + """ text = ALLOWED_DOMAINS_PATH.read_text() for line in _iter_non_comment_lines(text): assert bad_substr not in line.lower(), ( f"{bad_substr!r} found in allowed_domains.txt on line: {line!r}. " - "All Jira traffic must flow through the gateway's /api/v1/jira/* " - "endpoints, not directly through Squid (issue #1556)." + "All Atlassian traffic must flow through the gateway's " + "/api/v1/jira/* and /api/v1/confluence/* endpoints, not " + "directly through Squid (issues #1556, #1931)." ) diff --git a/gateway/tests/test_confluence_client.py b/gateway/tests/test_confluence_client.py new file mode 100644 index 0000000000..4110093867 --- /dev/null +++ b/gateway/tests/test_confluence_client.py @@ -0,0 +1,836 @@ +""" +Tests for ``gateway/confluence_client.py``. + +Covers Phase 1 / Task 4-2 acceptance: + +- URL / header / body construction per public verb (httpx.MockTransport). +- Default ``body-format=storage`` on read methods + override accepted. +- ``validate_confluence_api_path`` positive and negative grids. +- 429 single-retry honouring ``Retry-After``; second 429 surfaces error; + write verbs do NOT retry. +- 404 envelope on read methods (`get_page`, descendants, footer-comments, + inline-comments, get_space_pages); ``search_cql`` and ``execute_raw`` + raise instead. +- 403 → ``ConfluenceUpstreamForbidden`` from every read method. +- v1 inline-comment fallback fires on v2 404 with ``used_fallback`` flag. +- footer-comment nested-reply fallback merges replies under ``_replies``. +- ``list_spaces`` filtering against the operator allowlist; cache populated. +- ``redact_response`` strips the three default keys recursively, preserves + page / space ``_links.webui`` URLs, redacts ``_links.self`` user URLs. +- ``CONFLUENCE_RESPONSE_MAX_BYTES`` raised as ``ConfluenceResponseTooLarge``. +- Confluence-original CQL fixture (``text ~ "RFC"``) — risk R17. +""" + +from __future__ import annotations + +# Modules loaded via conftest. +import confluence_client +import httpx +import pytest +from confluence_client import ( + CONFLUENCE_RESPONSE_MAX_BYTES, + ConfluenceClient, + ConfluenceResponseTooLarge, + ConfluenceUpstreamError, + ConfluenceUpstreamForbidden, + redact_response, + validate_confluence_api_path, +) +from confluence_credentials import ConfluenceCredentials + +# ----------------------------------------------------------------------------- +# Fixtures +# ----------------------------------------------------------------------------- + + +@pytest.fixture +def fake_creds() -> ConfluenceCredentials: + return ConfluenceCredentials( + base_url="https://example.atlassian.net/wiki", + username="alice@example.com", + api_token="atk-xyz", + ) + + +def _make_client( + handler, + creds: ConfluenceCredentials, +) -> ConfluenceClient: + """Build a ``ConfluenceClient`` whose upstream HTTP lands in ``handler``.""" + transport = httpx.MockTransport(handler) + http = httpx.Client(transport=transport) + return ConfluenceClient( + creds_provider=lambda: creds, + http_client=http, + ) + + +# ----------------------------------------------------------------------------- +# validate_confluence_api_path +# ----------------------------------------------------------------------------- + + +class TestValidateConfluenceApiPath: + @pytest.mark.parametrize( + "path", + [ + "api/v2/pages/12345", + "api/v2/pages/1/descendants", + "api/v2/pages/1/footer-comments", + "api/v2/pages/1/inline-comments", + "api/v2/spaces/42/pages", + "rest/api/content/12345/child/comment", + ], + ) + def test_positive_get_paths(self, path: str): + ok, reason = validate_confluence_api_path(path, "GET") + assert ok, f"{path!r} should have been accepted: {reason}" + + @pytest.mark.parametrize( + "path", + [ + # Cycle-3 reviewer_code/security NACK fix (commit f3f552eb9): these + # four flat v2 endpoints were dropped from the /execute allowlist + # because each one was an exploitable cross-partition bypass: + # - api/v2/spaces — full tenant enumeration via + # execute_raw bypassing list_spaces' + # allowlist filter (decision-11). + # - rest/api/search — arbitrary CQL via execute_raw, + # bypassing extract_search_spaces. + # - api/v2/footer-comments — flat-endpoint smuggling via + # api/v2/inline-comments page-id query param while spaceKey + # fakes the gate (Atlassian ignores + # spaceKey upstream). + # All four remain reachable INTERNALLY by ConfluenceClient methods + # that construct them directly; only the agent-facing /execute + # escape hatch is closed. + "api/v2/spaces", + "rest/api/search", + "api/v2/footer-comments", + "api/v2/inline-comments", + ], + ) + def test_anti_bypass_paths_rejected(self, path: str): + """Risk R2 / cycle-3 fix: these paths must NOT be in the /execute + allowlist — they expose cross-partition bypasses.""" + ok, reason = validate_confluence_api_path(path, "GET") + assert not ok, f"{path!r} must be rejected to close the bypass" + assert "allowlist" in reason.lower() or "not in" in reason.lower() + + @pytest.mark.parametrize("method", ["POST", "PUT", "PATCH", "DELETE", "HEAD", ""]) + def test_non_get_methods_rejected(self, method: str): + ok, reason = validate_confluence_api_path("api/v2/pages/1", method) + assert not ok + assert ( + "not allowed" in reason.lower() + or "denied" in reason.lower() + or "permanently denied" in reason.lower() + ) + + @pytest.mark.parametrize( + "bad_segment", + ["restrictions", "permissions", "space.admin", "users", "attachments"], + ) + def test_denied_verb_in_path(self, bad_segment: str): + # Both naked and inline forms should be rejected. + for path in ( + f"api/v2/{bad_segment}", + f"api/v2/pages/123/{bad_segment}", + ): + ok, reason = validate_confluence_api_path(path, "GET") + assert not ok, f"{path!r} should have been rejected" + + def test_path_traversal_rejected(self): + ok, reason = validate_confluence_api_path("api/v2/pages/../12345", "GET") + assert not ok + assert ".." in reason + + def test_duplicate_slashes_rejected(self): + ok, reason = validate_confluence_api_path("api//v2/pages/1", "GET") + assert not ok + assert "duplicate" in reason.lower() or "slash" in reason.lower() + + def test_leading_double_slash_rejected(self): + ok, _ = validate_confluence_api_path("//api/v2/pages/1", "GET") + assert not ok + + def test_non_ascii_rejected(self): + ok, reason = validate_confluence_api_path("api/v2/pages/1234А", "GET") # Cyrillic А + assert not ok + assert "ascii" in reason.lower() or "non-" in reason.lower() + + def test_empty_path_rejected(self): + ok, _ = validate_confluence_api_path("", "GET") + assert not ok + ok, _ = validate_confluence_api_path("/", "GET") + assert not ok + + def test_non_string_path_rejected(self): + ok, _ = validate_confluence_api_path(None, "GET") # type: ignore[arg-type] + assert not ok + + def test_query_string_is_stripped_before_check(self): + ok, _ = validate_confluence_api_path("api/v2/pages/1?body-format=storage", "GET") + assert ok + + def test_random_unknown_path_rejected(self): + ok, _ = validate_confluence_api_path("whoami", "GET") + assert not ok + ok, _ = validate_confluence_api_path("api/v2/users/123", "GET") + assert not ok + + def test_pages_id_must_be_numeric(self): + ok, _ = validate_confluence_api_path("api/v2/pages/abc", "GET") + assert not ok + + +# ----------------------------------------------------------------------------- +# get_page +# ----------------------------------------------------------------------------- + + +class TestGetPage: + def test_default_body_format_is_storage(self, fake_creds: ConfluenceCredentials): + captured: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + captured.append(request) + return httpx.Response(200, json={"id": "12345", "spaceId": "1"}) + + client = _make_client(handler, fake_creds) + body = client.get_page("12345") + assert body["id"] == "12345" + + assert len(captured) == 1 + url = captured[0].url + assert str(url).startswith("https://example.atlassian.net/wiki/api/v2/pages/12345") + assert url.params["body-format"] == "storage" + # Basic-auth + Accept JSON. + assert captured[0].headers["authorization"].startswith("Basic ") + assert "application/json" in captured[0].headers["accept"] + + def test_explicit_body_format_override(self, fake_creds: ConfluenceCredentials): + captured: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + captured.append(request) + return httpx.Response(200, json={"id": "1"}) + + client = _make_client(handler, fake_creds) + client.get_page("1", body_format=["storage", "atlas_doc_format"]) + assert captured[0].url.params["body-format"] == "storage,atlas_doc_format" + + def test_invalid_body_format_raises(self, fake_creds: ConfluenceCredentials): + client = _make_client(lambda r: httpx.Response(200, json={}), fake_creds) + with pytest.raises(ValueError): + client.get_page("1", body_format=["bogus"]) + + def test_invalid_page_id_raises(self, fake_creds: ConfluenceCredentials): + client = _make_client(lambda r: httpx.Response(200, json={}), fake_creds) + with pytest.raises(ValueError): + client.get_page("abc") + with pytest.raises(ValueError): + client.get_page("") + + def test_404_returns_envelope(self, fake_creds: ConfluenceCredentials): + def handler(_request: httpx.Request) -> httpx.Response: + return httpx.Response(404, json={"error": "no such page"}) + + client = _make_client(handler, fake_creds) + body = client.get_page("12345") + assert body == { + "status": "not_found", + "id": "12345", + "upstream_status": 404, + } + + def test_403_raises_forbidden(self, fake_creds: ConfluenceCredentials): + def handler(_request: httpx.Request) -> httpx.Response: + return httpx.Response(403, json={"error": "denied"}) + + client = _make_client(handler, fake_creds) + with pytest.raises(ConfluenceUpstreamForbidden) as exc_info: + client.get_page("12345") + assert exc_info.value.status_code == 403 + + def test_500_raises_upstream_error(self, fake_creds: ConfluenceCredentials): + def handler(_request: httpx.Request) -> httpx.Response: + return httpx.Response(500, text="boom") + + client = _make_client(handler, fake_creds) + with pytest.raises(ConfluenceUpstreamError) as exc_info: + client.get_page("12345") + assert exc_info.value.status_code == 500 + + def test_expand_list_round_trips(self, fake_creds: ConfluenceCredentials): + captured: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + captured.append(request) + return httpx.Response(200, json={"id": "1"}) + + client = _make_client(handler, fake_creds) + client.get_page("1", expand=["history", "version"]) + assert captured[0].url.params["expand"] == "history,version" + + +# ----------------------------------------------------------------------------- +# get_page_descendants +# ----------------------------------------------------------------------------- + + +class TestDescendants: + def test_optional_params_passed_through(self, fake_creds: ConfluenceCredentials): + captured: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + captured.append(request) + return httpx.Response(200, json={"results": []}) + + client = _make_client(handler, fake_creds) + client.get_page_descendants("1", depth=2, limit=10, cursor="TOK") + params = captured[0].url.params + assert params["depth"] == "2" + assert params["limit"] == "10" + assert params["cursor"] == "TOK" + + def test_no_optional_params(self, fake_creds: ConfluenceCredentials): + captured: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + captured.append(request) + return httpx.Response(200, json={"results": []}) + + client = _make_client(handler, fake_creds) + client.get_page_descendants("1") + # No depth/limit/cursor params on the wire. + params = captured[0].url.params + assert "depth" not in params + assert "limit" not in params + assert "cursor" not in params + + def test_404_envelope(self, fake_creds: ConfluenceCredentials): + client = _make_client(lambda _r: httpx.Response(404), fake_creds) + assert client.get_page_descendants("1") == { + "status": "not_found", + "id": "1", + "upstream_status": 404, + } + + +# ----------------------------------------------------------------------------- +# get_page_footer_comments — nested-reply fallback +# ----------------------------------------------------------------------------- + + +class TestFooterComments: + def test_simple_call_no_replies(self, fake_creds: ConfluenceCredentials): + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, json={"results": [{"id": "c1"}]}) + + client = _make_client(handler, fake_creds) + body = client.get_page_footer_comments("1") + # Without include_replies, no _replies key. + assert "_replies" not in body + + def test_include_replies_merges_secondary_call(self, fake_creds: ConfluenceCredentials): + captured: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + captured.append(request) + if "footer-comments" in str(request.url) and "/pages/" in str(request.url): + return httpx.Response(200, json={"results": [{"id": "c1"}]}) + # Nested-reply call. + return httpx.Response(200, json={"results": [{"id": "r1", "parentCommentId": "c1"}]}) + + client = _make_client(handler, fake_creds) + body = client.get_page_footer_comments("1", include_replies=True) + assert body["_replies"]["results"][0]["id"] == "r1" + # Two upstream calls fired — primary + replies. + assert len(captured) == 2 + # Second call hit api/v2/footer-comments and carried page-id + depth=all. + secondary = captured[1].url + assert "api/v2/footer-comments" in str(secondary) + assert secondary.params["page-id"] == "1" + assert secondary.params["depth"] == "all" + + def test_replies_fetch_failure_logged_and_dropped(self, fake_creds: ConfluenceCredentials): + """Failing replies-side call must NOT fail the primary fetch.""" + + def handler(request: httpx.Request) -> httpx.Response: + if "/pages/" in str(request.url): + return httpx.Response(200, json={"results": [{"id": "c1"}]}) + return httpx.Response(500) + + client = _make_client(handler, fake_creds) + body = client.get_page_footer_comments("1", include_replies=True) + # Primary results survived — secondary failure swallowed. + assert body["results"] == [{"id": "c1"}] + assert "_replies" not in body + + def test_404_envelope(self, fake_creds: ConfluenceCredentials): + client = _make_client(lambda _r: httpx.Response(404), fake_creds) + assert client.get_page_footer_comments("1") == { + "status": "not_found", + "id": "1", + "upstream_status": 404, + } + + +# ----------------------------------------------------------------------------- +# get_page_inline_comments — v2 → v1 fallback +# ----------------------------------------------------------------------------- + + +class TestInlineComments: + def test_v2_happy_path_no_fallback(self, fake_creds: ConfluenceCredentials): + captured: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + captured.append(request) + return httpx.Response(200, json={"results": [{"id": "ic1"}]}) + + client = _make_client(handler, fake_creds) + body = client.get_page_inline_comments("1") + # No "used_fallback" because v2 responded 200. + assert "used_fallback" not in body + assert len(captured) == 1 + assert "/api/v2/pages/1/inline-comments" in str(captured[0].url) + + def test_v2_404_falls_back_to_v1(self, fake_creds: ConfluenceCredentials): + captured: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + captured.append(request) + if "/api/v2/pages/1/inline-comments" in str(request.url): + return httpx.Response(404) + return httpx.Response(200, json={"results": [{"id": "v1_ic"}]}) + + client = _make_client(handler, fake_creds) + body = client.get_page_inline_comments("1") + assert body["used_fallback"] is True + assert body["results"] == [{"id": "v1_ic"}] + # v1 fallback hit the rest/api/content/{id}/child/comment endpoint. + assert len(captured) == 2 + assert "rest/api/content/1/child/comment" in str(captured[1].url) + assert captured[1].url.params["location"] == "inline" + + def test_v2_404_v1_404_returns_envelope_with_fallback_flag( + self, fake_creds: ConfluenceCredentials + ): + def handler(_request: httpx.Request) -> httpx.Response: + return httpx.Response(404) + + client = _make_client(handler, fake_creds) + body = client.get_page_inline_comments("1") + assert body["status"] == "not_found" + assert body["upstream_status"] == 404 + assert body["used_fallback"] is True + + def test_v2_403_does_not_fall_back(self, fake_creds: ConfluenceCredentials): + captured: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + captured.append(request) + return httpx.Response(403) + + client = _make_client(handler, fake_creds) + with pytest.raises(ConfluenceUpstreamForbidden): + client.get_page_inline_comments("1") + assert len(captured) == 1, "v1 fallback must not fire on 403" + + +# ----------------------------------------------------------------------------- +# list_spaces — operator allowlist filter +# ----------------------------------------------------------------------------- + + +class TestListSpaces: + def test_filters_to_allowlist(self, fake_creds: ConfluenceCredentials): + def handler(_request: httpx.Request) -> httpx.Response: + return httpx.Response( + 200, + json={ + "results": [ + {"id": "1", "key": "ENG"}, + {"id": "2", "key": "DOCS"}, + {"id": "9", "key": "LEAK"}, + ] + }, + ) + + client = _make_client(handler, fake_creds) + body = client.list_spaces(frozenset({"ENG", "DOCS"})) + keys = sorted(s["key"] for s in body["results"]) + assert keys == ["DOCS", "ENG"] + + def test_case_sensitive_intersection(self, fake_creds: ConfluenceCredentials): + def handler(_request: httpx.Request) -> httpx.Response: + return httpx.Response(200, json={"results": [{"id": "1", "key": "ENG"}]}) + + client = _make_client(handler, fake_creds) + # Lowercase allowlist must NOT match uppercase upstream key. + body = client.list_spaces(frozenset({"eng"})) + assert body["results"] == [] + + def test_populates_space_cache(self, fake_creds: ConfluenceCredentials): + def handler(_request: httpx.Request) -> httpx.Response: + return httpx.Response( + 200, + json={ + "results": [ + {"id": "1", "key": "ENG"}, + {"id": "9", "key": "LEAK"}, + ] + }, + ) + + client = _make_client(handler, fake_creds) + client.list_spaces(frozenset({"ENG"})) + # Cache holds BOTH entries — the cache populates from upstream + # before the allowlist filter, so the gateway can resolve a + # spaceId↔spaceKey for any space the bot can see. + assert client.space_cache.key_for_id("1") == "ENG" + assert client.space_cache.key_for_id("9") == "LEAK" + assert client.space_cache.id_for_key("ENG") == "1" + + def test_403_raises_forbidden(self, fake_creds: ConfluenceCredentials): + def handler(_request: httpx.Request) -> httpx.Response: + return httpx.Response(403) + + client = _make_client(handler, fake_creds) + with pytest.raises(ConfluenceUpstreamForbidden): + client.list_spaces(frozenset()) + + +# ----------------------------------------------------------------------------- +# get_space_pages +# ----------------------------------------------------------------------------- + + +class TestGetSpacePages: + def test_default_body_format(self, fake_creds: ConfluenceCredentials): + captured: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + captured.append(request) + return httpx.Response(200, json={"results": []}) + + client = _make_client(handler, fake_creds) + client.get_space_pages("42") + assert captured[0].url.params["body-format"] == "storage" + assert "/api/v2/spaces/42/pages" in str(captured[0].url) + + def test_404_envelope_uses_space_id(self, fake_creds: ConfluenceCredentials): + client = _make_client(lambda _r: httpx.Response(404), fake_creds) + assert client.get_space_pages("42") == { + "status": "not_found", + "id": "42", + "upstream_status": 404, + } + + +# ----------------------------------------------------------------------------- +# search_cql — Confluence-original fixture (risk R17) +# ----------------------------------------------------------------------------- + + +class TestSearchCQL: + def test_search_cql_constructs_v1_query(self, fake_creds: ConfluenceCredentials): + """Confluence-original fixture: the canonical CQL ``text ~ "RFC"`` + clause is unique to Confluence (Jira's JQL has no equivalent).""" + captured: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + captured.append(request) + return httpx.Response(200, json={"results": []}) + + client = _make_client(handler, fake_creds) + client.search_cql('space = ENG AND text ~ "RFC"', limit=25) + assert "/wiki/rest/api/search" in str(captured[0].url) + assert captured[0].url.params["cql"] == 'space = ENG AND text ~ "RFC"' + assert captured[0].url.params["limit"] == "25" + + def test_search_404_raises_not_envelope(self, fake_creds: ConfluenceCredentials): + client = _make_client(lambda _r: httpx.Response(404), fake_creds) + with pytest.raises(ConfluenceUpstreamError) as exc_info: + client.search_cql("space = ENG") + assert exc_info.value.status_code == 404 + + def test_missing_cql_raises(self, fake_creds: ConfluenceCredentials): + client = _make_client(lambda _r: httpx.Response(200, json={}), fake_creds) + with pytest.raises(ValueError): + client.search_cql("") + with pytest.raises(ValueError): + client.search_cql(" ") + + +# ----------------------------------------------------------------------------- +# execute_raw — passthrough +# ----------------------------------------------------------------------------- + + +class TestExecuteRaw: + def test_404_raises_not_envelope(self, fake_creds: ConfluenceCredentials): + client = _make_client(lambda _r: httpx.Response(404), fake_creds) + with pytest.raises(ConfluenceUpstreamError) as exc_info: + client.execute_raw("GET", "api/v2/pages/1") + assert exc_info.value.status_code == 404 + + def test_403_raises_forbidden(self, fake_creds: ConfluenceCredentials): + client = _make_client(lambda _r: httpx.Response(403), fake_creds) + with pytest.raises(ConfluenceUpstreamForbidden): + client.execute_raw("GET", "api/v2/pages/1") + + def test_happy_path(self, fake_creds: ConfluenceCredentials): + captured: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + captured.append(request) + return httpx.Response(200, json={"id": "1"}) + + client = _make_client(handler, fake_creds) + body = client.execute_raw("GET", "api/v2/pages/1") + assert body == {"id": "1"} + assert captured[0].method == "GET" + + +# ----------------------------------------------------------------------------- +# 429 retry policy +# ----------------------------------------------------------------------------- + + +class Test429Retry: + def test_get_retries_once_on_429(self, fake_creds: ConfluenceCredentials, monkeypatch): + calls = {"n": 0} + + def handler(_request: httpx.Request) -> httpx.Response: + calls["n"] += 1 + if calls["n"] == 1: + return httpx.Response(429, headers={"Retry-After": "1"}) + return httpx.Response(200, json={"id": "1"}) + + slept: list[float] = [] + monkeypatch.setattr(confluence_client.time, "sleep", lambda s: slept.append(s)) + + client = _make_client(handler, fake_creds) + client.get_page("1") + assert calls["n"] == 2 + assert slept == [1] + + def test_retry_after_clamped_at_30(self, fake_creds: ConfluenceCredentials, monkeypatch): + calls = {"n": 0} + + def handler(_request: httpx.Request) -> httpx.Response: + calls["n"] += 1 + if calls["n"] == 1: + return httpx.Response(429, headers={"Retry-After": "600"}) + return httpx.Response(200, json={"id": "1"}) + + slept: list[float] = [] + monkeypatch.setattr(confluence_client.time, "sleep", lambda s: slept.append(s)) + + client = _make_client(handler, fake_creds) + client.get_page("1") + assert slept == [confluence_client._RETRY_AFTER_CAP_SECONDS] + + def test_second_429_surfaces(self, fake_creds: ConfluenceCredentials, monkeypatch): + def handler(_request: httpx.Request) -> httpx.Response: + return httpx.Response(429, headers={"Retry-After": "1"}) + + monkeypatch.setattr(confluence_client.time, "sleep", lambda _s: None) + client = _make_client(handler, fake_creds) + with pytest.raises(ConfluenceUpstreamError) as exc_info: + client.get_page("1") + assert exc_info.value.status_code == 429 + + def test_non_get_does_not_retry(self, fake_creds: ConfluenceCredentials, monkeypatch): + """Non-GET (only ``execute_raw`` could ever receive one) does not retry.""" + calls = {"n": 0} + + def handler(_request: httpx.Request) -> httpx.Response: + calls["n"] += 1 + return httpx.Response(429, headers={"Retry-After": "1"}) + + monkeypatch.setattr(confluence_client.time, "sleep", lambda _s: None) + client = _make_client(handler, fake_creds) + with pytest.raises(ConfluenceUpstreamError): + client.execute_raw("POST", "api/v2/pages/1") + assert calls["n"] == 1 + + @pytest.mark.parametrize( + "value, expected", + [ + (None, confluence_client._DEFAULT_RETRY_AFTER_SECONDS), + ("", confluence_client._DEFAULT_RETRY_AFTER_SECONDS), + ( + "not-a-number", + confluence_client._DEFAULT_RETRY_AFTER_SECONDS, + ), + ("0", confluence_client._DEFAULT_RETRY_AFTER_SECONDS), + ("-5", confluence_client._DEFAULT_RETRY_AFTER_SECONDS), + ("2", 2), + ("9999", confluence_client._RETRY_AFTER_CAP_SECONDS), + ], + ) + def test_parse_retry_after(self, value, expected): + assert confluence_client._parse_retry_after(value) == expected + + +# ----------------------------------------------------------------------------- +# redact_response — decision 10 / risk R6 +# ----------------------------------------------------------------------------- + + +class TestRedactResponse: + def test_strips_account_id_at_any_depth(self): + payload = { + "version": {"by": {"accountId": "abc-123"}}, + "items": [{"author": {"accountId": "def-456"}}], + } + redact_response(payload) + assert payload["version"]["by"]["accountId"] == "" + assert payload["items"][0]["author"]["accountId"] == "" + + def test_strips_email_address_at_any_depth(self): + payload = { + "user": { + "emailAddress": "alice@example.com", + "displayName": "Alice", + } + } + redact_response(payload) + assert payload["user"]["emailAddress"] == "" + # Other fields preserved. + assert payload["user"]["displayName"] == "Alice" + + def test_redacts_user_profile_webui(self): + payload = { + "_links": { + "webui": "/wiki/people/abc-123", + "self": "https://example.atlassian.net/wiki/api/v2/users/abc-123", + } + } + redact_response(payload) + assert payload["_links"]["webui"] == "" + assert payload["_links"]["self"] == "" + + def test_preserves_page_webui(self): + """Page / space ``_links.webui`` URLs must NOT be redacted.""" + payload = {"_links": {"webui": "/wiki/spaces/ENG/pages/12345/Some+Page"}} + redact_response(payload) + assert payload["_links"]["webui"] == "/wiki/spaces/ENG/pages/12345/Some+Page" + + def test_recursive_walk_into_lists(self): + payload = { + "results": [ + {"by": {"accountId": "x", "emailAddress": "x@x"}}, + {"by": {"accountId": "y"}}, + ] + } + redact_response(payload) + for item in payload["results"]: + assert item["by"]["accountId"] == "" + + def test_adf_mention_node_redacted(self): + """A real-shaped ADF mention node has the user's ``accountId`` inline.""" + payload = { + "body": { + "atlas_doc_format": { + "value": { + "type": "doc", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "mention", + "attrs": { + "id": "abc-123", + "accountId": "abc-123", + "text": "@Alice", + }, + } + ], + } + ], + } + } + } + } + redact_response(payload) + mention_attrs = payload["body"]["atlas_doc_format"]["value"]["content"][0]["content"][0][ + "attrs" + ] + assert mention_attrs["accountId"] == "" + # Non-redacted keys preserved. + assert mention_attrs["text"] == "@Alice" + + def test_returns_same_object(self): + """The redactor mutates in place and returns the input for chaining.""" + payload = {"accountId": "x"} + result = redact_response(payload) + assert result is payload + + +# ----------------------------------------------------------------------------- +# Payload-size cap (risk R7) +# ----------------------------------------------------------------------------- + + +class TestPayloadSizeCap: + def test_oversized_response_raises(self, fake_creds: ConfluenceCredentials): + # Build a payload whose JSON length crosses CONFLUENCE_RESPONSE_MAX_BYTES. + big_value = "x" * (CONFLUENCE_RESPONSE_MAX_BYTES + 100) + big_payload = {"id": "1", "filler": big_value} + + def handler(_request: httpx.Request) -> httpx.Response: + return httpx.Response(200, json=big_payload) + + client = _make_client(handler, fake_creds) + with pytest.raises(ConfluenceResponseTooLarge): + client.get_page("1") + + +# ----------------------------------------------------------------------------- +# Auth header on every request +# ----------------------------------------------------------------------------- + + +class TestAuthHeader: + def test_basic_auth_on_every_call(self, fake_creds: ConfluenceCredentials): + captured: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + captured.append(request) + return httpx.Response(200, json={"results": []}) + + client = _make_client(handler, fake_creds) + client.get_page("1") + client.get_page_descendants("1") + client.get_page_footer_comments("1") + client.get_page_inline_comments("1") + client.list_spaces(frozenset()) + client.get_space_pages("1") + client.search_cql("space = ENG") + client.execute_raw("GET", "api/v2/pages/1") + + for req in captured: + assert req.headers["authorization"] == fake_creds.basic_auth_header() + + +# ----------------------------------------------------------------------------- +# Module-level singleton lifecycle +# ----------------------------------------------------------------------------- + + +class TestSingletonLifecycle: + def test_singleton_returns_same_instance(self): + confluence_client.reset_confluence_client() + a = confluence_client.get_confluence_client() + b = confluence_client.get_confluence_client() + assert a is b + + confluence_client.reset_confluence_client() + c = confluence_client.get_confluence_client() + assert c is not a diff --git a/gateway/tests/test_confluence_credentials.py b/gateway/tests/test_confluence_credentials.py new file mode 100644 index 0000000000..418cde3f35 --- /dev/null +++ b/gateway/tests/test_confluence_credentials.py @@ -0,0 +1,334 @@ +""" +Tests for ``gateway/confluence_credentials.py``. + +Covers Phase 1 / Task 4-1 acceptance criteria: + +- ``basic_auth_header()`` base64 shape (email:token). +- mtime-based cache refresh — touching the file invalidates the cache. +- ``reload_confluence_credentials()`` clears the cache. +- Missing values raise ``ConfluenceCredentialsUnavailable``. +- F1 credential precedence: ``ATLASSIAN_*`` over ``CONFLUENCE_*`` per-key. +- Base-URL derivation: ``ATLASSIAN_BASE_URL`` gets ``/wiki`` appended; + ``CONFLUENCE_BASE_URL`` is used verbatim. +- Mixed per-key shapes (``ATLASSIAN_USERNAME`` + ``CONFLUENCE_BASE_URL``). +""" + +from __future__ import annotations + +import base64 +import os +import time +from pathlib import Path + +# Modules are loaded via conftest.py. +import confluence_credentials +import pytest +from confluence_credentials import ( + ConfluenceCredentials, + ConfluenceCredentialsManager, + ConfluenceCredentialsUnavailable, +) + + +def _write_secrets(path: Path, **kv: str | None) -> None: + """Write key=value pairs to the secrets file, omitting None values.""" + lines = [f"{k}={v}" for k, v in kv.items() if v is not None] + path.write_text("\n".join(lines) + "\n") + + +@pytest.fixture +def tmp_secrets(tmp_path: Path) -> Path: + return tmp_path / "secrets.env" + + +# --------------------------------------------------------------------------- +# basic_auth_header() +# --------------------------------------------------------------------------- + + +class TestBasicAuthHeader: + def test_header_encodes_username_and_token(self): + creds = ConfluenceCredentials( + base_url="https://example.atlassian.net/wiki", + username="alice@example.com", + api_token="atk-abcdef1234567890", + ) + header = creds.basic_auth_header() + assert header.startswith("Basic ") + decoded = base64.b64decode(header.removeprefix("Basic ")).decode("ascii") + assert decoded == "alice@example.com:atk-abcdef1234567890" + + def test_header_handles_special_characters(self): + creds = ConfluenceCredentials( + base_url="https://example.atlassian.net/wiki", + username="a+b@example.com", + api_token="token with spaces?", + ) + header = creds.basic_auth_header() + decoded = base64.b64decode(header.removeprefix("Basic ")).decode("ascii") + assert decoded == "a+b@example.com:token with spaces?" + + +# --------------------------------------------------------------------------- +# CONFLUENCE_* (back-compat) only +# --------------------------------------------------------------------------- + + +class TestConfluenceOnlyLoading: + def test_loads_all_three_required_keys(self, tmp_secrets: Path): + _write_secrets( + tmp_secrets, + CONFLUENCE_BASE_URL="https://example.atlassian.net/wiki", + CONFLUENCE_USERNAME="alice@example.com", + CONFLUENCE_API_TOKEN="atk-xyz", + ) + mgr = ConfluenceCredentialsManager(tmp_secrets) + creds = mgr.get_credentials() + # CONFLUENCE_BASE_URL is used verbatim (operators have already added /wiki). + assert creds.base_url == "https://example.atlassian.net/wiki" + assert creds.username == "alice@example.com" + assert creds.api_token == "atk-xyz" + + def test_trailing_slash_on_base_url_is_stripped(self, tmp_secrets: Path): + _write_secrets( + tmp_secrets, + CONFLUENCE_BASE_URL="https://example.atlassian.net/wiki/", + CONFLUENCE_USERNAME="alice@example.com", + CONFLUENCE_API_TOKEN="atk-xyz", + ) + mgr = ConfluenceCredentialsManager(tmp_secrets) + creds = mgr.get_credentials() + assert creds.base_url == "https://example.atlassian.net/wiki" + + @pytest.mark.parametrize( + "missing_key", + [ + "CONFLUENCE_BASE_URL", + "CONFLUENCE_USERNAME", + "CONFLUENCE_API_TOKEN", + ], + ) + def test_missing_any_required_key_raises(self, tmp_secrets: Path, missing_key: str): + values = { + "CONFLUENCE_BASE_URL": "https://example.atlassian.net/wiki", + "CONFLUENCE_USERNAME": "alice@example.com", + "CONFLUENCE_API_TOKEN": "atk-xyz", + } + values.pop(missing_key) + _write_secrets(tmp_secrets, **values) + mgr = ConfluenceCredentialsManager(tmp_secrets) + with pytest.raises(ConfluenceCredentialsUnavailable): + mgr.get_credentials() + + def test_blank_values_are_treated_as_missing(self, tmp_secrets: Path): + _write_secrets( + tmp_secrets, + CONFLUENCE_BASE_URL="https://example.atlassian.net/wiki", + CONFLUENCE_USERNAME="", + CONFLUENCE_API_TOKEN="atk-xyz", + ) + mgr = ConfluenceCredentialsManager(tmp_secrets) + with pytest.raises(ConfluenceCredentialsUnavailable): + mgr.get_credentials() + + def test_missing_file_raises(self, tmp_path: Path): + mgr = ConfluenceCredentialsManager(tmp_path / "no-such-file.env") + with pytest.raises(ConfluenceCredentialsUnavailable): + mgr.get_credentials() + + +# --------------------------------------------------------------------------- +# ATLASSIAN_* precedence (decision F1) +# --------------------------------------------------------------------------- + + +class TestAtlassianPrecedence: + def test_atlassian_only_appends_wiki(self, tmp_secrets: Path): + _write_secrets( + tmp_secrets, + ATLASSIAN_BASE_URL="https://example.atlassian.net", + ATLASSIAN_USERNAME="alice@example.com", + ATLASSIAN_API_TOKEN="atk-shared", + ) + mgr = ConfluenceCredentialsManager(tmp_secrets) + creds = mgr.get_credentials() + # ATLASSIAN_BASE_URL has /wiki appended for Confluence. + assert creds.base_url == "https://example.atlassian.net/wiki" + assert creds.username == "alice@example.com" + assert creds.api_token == "atk-shared" + + def test_atlassian_wins_over_confluence_per_key(self, tmp_secrets: Path): + """When both prefixes are set, ATLASSIAN_* wins for each key.""" + _write_secrets( + tmp_secrets, + ATLASSIAN_BASE_URL="https://atlassian.atlassian.net", + ATLASSIAN_USERNAME="atlassian@example.com", + ATLASSIAN_API_TOKEN="atk-atlassian", + CONFLUENCE_BASE_URL="https://confluence.atlassian.net/wiki", + CONFLUENCE_USERNAME="confluence@example.com", + CONFLUENCE_API_TOKEN="atk-confluence", + ) + mgr = ConfluenceCredentialsManager(tmp_secrets) + creds = mgr.get_credentials() + assert creds.base_url == "https://atlassian.atlassian.net/wiki" + assert creds.username == "atlassian@example.com" + assert creds.api_token == "atk-atlassian" + + def test_per_key_mixed_fallback(self, tmp_secrets: Path): + """ATLASSIAN_USERNAME + CONFLUENCE_BASE_URL + CONFLUENCE_API_TOKEN.""" + _write_secrets( + tmp_secrets, + ATLASSIAN_USERNAME="alice@example.com", + CONFLUENCE_BASE_URL="https://example.atlassian.net/wiki", + CONFLUENCE_API_TOKEN="atk-xyz", + ) + mgr = ConfluenceCredentialsManager(tmp_secrets) + creds = mgr.get_credentials() + # CONFLUENCE_BASE_URL used verbatim (no /wiki suffix added). + assert creds.base_url == "https://example.atlassian.net/wiki" + assert creds.username == "alice@example.com" + assert creds.api_token == "atk-xyz" + + def test_atlassian_base_used_when_confluence_base_blank(self, tmp_secrets: Path): + """Per-key fall-back: blank CONFLUENCE_BASE_URL → ATLASSIAN_BASE_URL+/wiki.""" + _write_secrets( + tmp_secrets, + ATLASSIAN_BASE_URL="https://example.atlassian.net", + CONFLUENCE_BASE_URL="", + ATLASSIAN_USERNAME="alice@example.com", + ATLASSIAN_API_TOKEN="atk", + ) + mgr = ConfluenceCredentialsManager(tmp_secrets) + creds = mgr.get_credentials() + assert creds.base_url == "https://example.atlassian.net/wiki" + + def test_blank_atlassian_keys_fall_through_to_confluence(self, tmp_secrets: Path): + """Blank ATLASSIAN_* values must fall through to CONFLUENCE_*.""" + _write_secrets( + tmp_secrets, + ATLASSIAN_BASE_URL="", + ATLASSIAN_USERNAME="", + ATLASSIAN_API_TOKEN="", + CONFLUENCE_BASE_URL="https://example.atlassian.net/wiki", + CONFLUENCE_USERNAME="alice@example.com", + CONFLUENCE_API_TOKEN="atk", + ) + mgr = ConfluenceCredentialsManager(tmp_secrets) + creds = mgr.get_credentials() + assert creds.base_url == "https://example.atlassian.net/wiki" + assert creds.username == "alice@example.com" + assert creds.api_token == "atk" + + def test_all_six_keys_missing_raises(self, tmp_secrets: Path): + _write_secrets(tmp_secrets, OTHER_KEY="value") + mgr = ConfluenceCredentialsManager(tmp_secrets) + with pytest.raises(ConfluenceCredentialsUnavailable): + mgr.get_credentials() + + +# --------------------------------------------------------------------------- +# Cache refresh +# --------------------------------------------------------------------------- + + +class TestCacheRefresh: + def test_cached_until_mtime_changes(self, tmp_secrets: Path): + """Two consecutive get_credentials calls hit the cache; touching the + file forces a reload on the next call.""" + _write_secrets( + tmp_secrets, + CONFLUENCE_BASE_URL="https://example.atlassian.net/wiki", + CONFLUENCE_USERNAME="alice@example.com", + CONFLUENCE_API_TOKEN="atk-1", + ) + mgr = ConfluenceCredentialsManager(tmp_secrets) + first = mgr.get_credentials() + assert first.api_token == "atk-1" + + # Without touching mtime, the cached credentials must be returned + # even after rewriting the file in place (same mtime if quick enough, + # but rewriting bumps mtime — so we just check the same call returns + # a logically equal object, exercising the cached path). + second = mgr.get_credentials() + assert second.api_token == "atk-1" + + # Now touch the file with a clearly later mtime + new content. + time.sleep(0.05) + _write_secrets( + tmp_secrets, + CONFLUENCE_BASE_URL="https://example.atlassian.net/wiki", + CONFLUENCE_USERNAME="alice@example.com", + CONFLUENCE_API_TOKEN="atk-2", + ) + new_mtime = time.time() + 1 + os.utime(tmp_secrets, (new_mtime, new_mtime)) + third = mgr.get_credentials() + assert third.api_token == "atk-2" + + def test_reload_clears_cache(self, tmp_secrets: Path): + _write_secrets( + tmp_secrets, + CONFLUENCE_BASE_URL="https://example.atlassian.net/wiki", + CONFLUENCE_USERNAME="alice@example.com", + CONFLUENCE_API_TOKEN="atk-1", + ) + mgr = ConfluenceCredentialsManager(tmp_secrets) + assert mgr.get_credentials().api_token == "atk-1" + + # Rewrite contents but DON'T bump mtime; reload() must still pick it up. + _write_secrets( + tmp_secrets, + CONFLUENCE_BASE_URL="https://example.atlassian.net/wiki", + CONFLUENCE_USERNAME="alice@example.com", + CONFLUENCE_API_TOKEN="atk-2", + ) + # Force the file to keep its old mtime. + old_mtime = mgr._cached_mtime + os.utime(tmp_secrets, (old_mtime, old_mtime)) + mgr.reload() + assert mgr.get_credentials().api_token == "atk-2" + + +# --------------------------------------------------------------------------- +# Module-level helpers +# --------------------------------------------------------------------------- + + +class TestModuleSingleton: + def test_reload_helper_clears_singleton_cache(self, tmp_secrets: Path, monkeypatch): + # Re-point the SECRETS_PATH to our tmp file and reset the singleton. + monkeypatch.setattr(confluence_credentials, "SECRETS_PATH", tmp_secrets) + confluence_credentials.reset_confluence_credentials_manager() + + _write_secrets( + tmp_secrets, + CONFLUENCE_BASE_URL="https://example.atlassian.net/wiki", + CONFLUENCE_USERNAME="alice@example.com", + CONFLUENCE_API_TOKEN="atk-1", + ) + # Re-create singleton against the new SECRETS_PATH explicitly because + # the manager constructor captured SECRETS_PATH at module-level. + mgr = confluence_credentials.ConfluenceCredentialsManager(tmp_secrets) + monkeypatch.setattr( + confluence_credentials, + "_credentials_manager", + mgr, + ) + assert confluence_credentials.get_confluence_credentials().api_token == "atk-1" + + _write_secrets( + tmp_secrets, + CONFLUENCE_BASE_URL="https://example.atlassian.net/wiki", + CONFLUENCE_USERNAME="alice@example.com", + CONFLUENCE_API_TOKEN="atk-2", + ) + # Keep mtime stable to prove reload() (not mtime) is what flushes. + old_mtime = mgr._cached_mtime + os.utime(tmp_secrets, (old_mtime, old_mtime)) + confluence_credentials.reload_confluence_credentials() + assert confluence_credentials.get_confluence_credentials().api_token == "atk-2" + + def test_reset_drops_singleton(self): + # Just a smoke test — reset is a test helper. + confluence_credentials.reset_confluence_credentials_manager() + assert confluence_credentials._credentials_manager is None diff --git a/gateway/tests/test_confluence_policy.py b/gateway/tests/test_confluence_policy.py new file mode 100644 index 0000000000..97792e6364 --- /dev/null +++ b/gateway/tests/test_confluence_policy.py @@ -0,0 +1,211 @@ +""" +Tests for ``gateway/confluence_policy.py``. + +Covers Phase 1 / Task 4-3 acceptance: + +- Allowlist round-trip from a tmp YAML using the ``confluence.spaces`` key. +- mtime-based reload picks up edits. +- ``reload_confluence_policy()`` clears the cache. +- Missing file / missing ``confluence:`` section / missing ``spaces:`` / + non-list shape / malformed YAML → empty set (fail-closed) without crash. +- Mixed-case Atlassian space keys round-trip exactly. +- Non-string entries / invalid keys are dropped with a warning. +- ``is_space_allowed`` membership semantics. +""" + +from __future__ import annotations + +import os +import time +from pathlib import Path + +import confluence_policy +import pytest +from confluence_policy import ConfluencePolicy + + +def _write_yaml(path: Path, body: str) -> None: + path.write_text(body) + + +@pytest.fixture +def tmp_yaml(tmp_path: Path) -> Path: + return tmp_path / "context-filters.yaml" + + +# --------------------------------------------------------------------------- +# Happy path — round-trip +# --------------------------------------------------------------------------- + + +class TestRoundTrip: + def test_round_trip_two_keys(self, tmp_yaml: Path): + _write_yaml( + tmp_yaml, + """ +confluence: + spaces: ["ENG", "DOCS"] +""", + ) + policy = ConfluencePolicy(tmp_yaml) + assert policy.allowed_spaces() == frozenset({"ENG", "DOCS"}) + assert policy.is_space_allowed("ENG") + assert policy.is_space_allowed("DOCS") + assert not policy.is_space_allowed("LEAK") + + def test_empty_list_round_trip(self, tmp_yaml: Path): + _write_yaml(tmp_yaml, "confluence:\n spaces: []\n") + policy = ConfluencePolicy(tmp_yaml) + assert policy.allowed_spaces() == frozenset() + assert not policy.is_space_allowed("ENG") + + def test_mixed_case_keys_preserved(self, tmp_yaml: Path): + """Atlassian space keys are case-sensitive — exact case must be kept.""" + _write_yaml( + tmp_yaml, + 'confluence:\n spaces: ["ENG", "docs", "My_Space1"]\n', + ) + policy = ConfluencePolicy(tmp_yaml) + spaces = policy.allowed_spaces() + assert "ENG" in spaces + assert "docs" in spaces + assert "My_Space1" in spaces + # Case must NOT collapse — `eng` is a different space. + assert not policy.is_space_allowed("eng") + assert not policy.is_space_allowed("DOCS") + + def test_is_space_allowed_rejects_blank(self, tmp_yaml: Path): + _write_yaml(tmp_yaml, 'confluence:\n spaces: [""]\n') + policy = ConfluencePolicy(tmp_yaml) + assert not policy.is_space_allowed("") + assert not policy.is_space_allowed(None) # type: ignore[arg-type] + + +# --------------------------------------------------------------------------- +# Fail-closed semantics +# --------------------------------------------------------------------------- + + +class TestFailClosed: + def test_missing_file_returns_empty_set(self, tmp_path: Path): + policy = ConfluencePolicy(tmp_path / "no-such-file.yaml") + assert policy.allowed_spaces() == frozenset() + assert not policy.is_space_allowed("ENG") + + def test_missing_confluence_section(self, tmp_yaml: Path): + _write_yaml(tmp_yaml, 'jira:\n projects: ["ENG"]\n') + policy = ConfluencePolicy(tmp_yaml) + assert policy.allowed_spaces() == frozenset() + + def test_missing_spaces_key(self, tmp_yaml: Path): + _write_yaml(tmp_yaml, "confluence:\n other: ignored\n") + policy = ConfluencePolicy(tmp_yaml) + assert policy.allowed_spaces() == frozenset() + + def test_non_list_spaces_shape(self, tmp_yaml: Path): + """``confluence.spaces: ENG`` (string) must fail closed, not crash.""" + _write_yaml(tmp_yaml, "confluence:\n spaces: ENG\n") + policy = ConfluencePolicy(tmp_yaml) + assert policy.allowed_spaces() == frozenset() + + def test_malformed_yaml_fails_closed(self, tmp_yaml: Path): + _write_yaml(tmp_yaml, "confluence:\n spaces: [ENG, DOCS\n") # unclosed list + policy = ConfluencePolicy(tmp_yaml) + # Must not raise — fails closed. + assert policy.allowed_spaces() == frozenset() + + def test_top_level_non_mapping_fails_closed(self, tmp_yaml: Path): + _write_yaml(tmp_yaml, "- ENG\n- DOCS\n") + policy = ConfluencePolicy(tmp_yaml) + assert policy.allowed_spaces() == frozenset() + + def test_non_string_entries_dropped(self, tmp_yaml: Path): + _write_yaml( + tmp_yaml, + 'confluence:\n spaces: ["ENG", 42, null, "DOCS"]\n', + ) + policy = ConfluencePolicy(tmp_yaml) + # Only ENG and DOCS survive validation. + assert policy.allowed_spaces() == frozenset({"ENG", "DOCS"}) + + def test_invalid_key_shape_dropped(self, tmp_yaml: Path): + """Keys that don't match ``^[a-zA-Z][a-zA-Z0-9_]*$`` are dropped.""" + _write_yaml( + tmp_yaml, + 'confluence:\n spaces: ["ENG", "123BAD", "with-dash", "GOOD_KEY1"]\n', + ) + policy = ConfluencePolicy(tmp_yaml) + assert policy.allowed_spaces() == frozenset({"ENG", "GOOD_KEY1"}) + + +# --------------------------------------------------------------------------- +# Cache + reload semantics +# --------------------------------------------------------------------------- + + +class TestCacheReload: + def test_mtime_change_picks_up_edits(self, tmp_yaml: Path): + _write_yaml(tmp_yaml, 'confluence:\n spaces: ["ENG"]\n') + policy = ConfluencePolicy(tmp_yaml) + assert policy.allowed_spaces() == frozenset({"ENG"}) + + time.sleep(0.05) + _write_yaml(tmp_yaml, 'confluence:\n spaces: ["ENG", "DOCS"]\n') + new_mtime = time.time() + 1 + os.utime(tmp_yaml, (new_mtime, new_mtime)) + assert policy.allowed_spaces() == frozenset({"ENG", "DOCS"}) + + def test_reload_forces_reread_without_mtime_change(self, tmp_yaml: Path): + _write_yaml(tmp_yaml, 'confluence:\n spaces: ["ENG"]\n') + policy = ConfluencePolicy(tmp_yaml) + assert policy.allowed_spaces() == frozenset({"ENG"}) + + # Rewrite content while preserving mtime. + old_mtime = policy._cached_mtime + _write_yaml(tmp_yaml, 'confluence:\n spaces: ["ENG", "DOCS"]\n') + os.utime(tmp_yaml, (old_mtime, old_mtime)) + # Without reload, cache still has just ENG. + assert policy.allowed_spaces() == frozenset({"ENG"}) + + policy.reload() + # After reload, the new content is picked up. + assert policy.allowed_spaces() == frozenset({"ENG", "DOCS"}) + + def test_disappearing_file_clears_cache(self, tmp_yaml: Path): + _write_yaml(tmp_yaml, 'confluence:\n spaces: ["ENG"]\n') + policy = ConfluencePolicy(tmp_yaml) + assert policy.allowed_spaces() == frozenset({"ENG"}) + + tmp_yaml.unlink() + # Subsequent calls must return an empty set, not the cached value. + assert policy.allowed_spaces() == frozenset() + + +# --------------------------------------------------------------------------- +# Module-level singleton helpers +# --------------------------------------------------------------------------- + + +class TestSingleton: + def test_reload_helper_flushes_singleton(self, tmp_yaml: Path, monkeypatch): + # Point the default config path at our tmp file. + monkeypatch.setattr( + confluence_policy, + "_DEFAULT_CONFIG_PATH", + tmp_yaml, + ) + confluence_policy.reset_confluence_policy() + _write_yaml(tmp_yaml, 'confluence:\n spaces: ["ENG"]\n') + assert confluence_policy.allowed_spaces() == frozenset({"ENG"}) + assert confluence_policy.is_space_allowed("ENG") + + _write_yaml(tmp_yaml, 'confluence:\n spaces: ["ENG", "DOCS"]\n') + # Keep mtime stable — the singleton must not be relying on mtime here. + old_mtime = confluence_policy.get_confluence_policy()._cached_mtime + os.utime(tmp_yaml, (old_mtime, old_mtime)) + confluence_policy.reload_confluence_policy() + assert confluence_policy.allowed_spaces() == frozenset({"ENG", "DOCS"}) + + def test_reset_clears_singleton(self): + confluence_policy.reset_confluence_policy() + assert confluence_policy._confluence_policy is None diff --git a/gateway/tests/test_confluence_routes.py b/gateway/tests/test_confluence_routes.py new file mode 100644 index 0000000000..d3710daf5a --- /dev/null +++ b/gateway/tests/test_confluence_routes.py @@ -0,0 +1,727 @@ +""" +Tests for the eight ``/api/v1/confluence/*`` routes in ``gateway/gateway.py``. + +Covers Phase 2 / Task 4-5 acceptance criteria: + +- Public mode → 403 with ``private_mode_required`` audit entry on every route. +- Private mode + disallowed space → 403 ``confluence_*_denied`` / + ``confluence_space_denied``. +- Private mode + allowlisted space + mocked upstream → 200 with body. +- Adversarial CQL suite for ``/search`` (≥10 negative cases). +- ``/execute`` rejection of write methods, denied verbs, path traversal, + disallowed spaces, and the route-vs-execute anti-bypass guarantee. +- Route-enumeration regression: every ``/api/v1/confluence/*`` view has + ``__egg_requires_private_mode__ = True``. +- 404 envelope end-to-end on each read route. +- ``confluence_upstream_403`` audit category split. +- ``list_spaces`` allowlist filter end-to-end (case-sensitive intersection). +- ``redact_response`` end-to-end. +- ``page/inline-comments`` exposes ``used_fallback`` flag. +- Audit-log assertions: every event includes ``pageId`` or ``spaceKey``, + ``session_mode``, ``pipeline_id``, ``agent_role``, ``success``. +""" + +from __future__ import annotations + +import json +import sys +from typing import Any +from unittest.mock import MagicMock, patch + +import confluence_policy +import pytest +import session_manager +from confluence_client import ( + ConfluenceUpstreamForbidden, +) +from mode_gate import PRIVATE_MODE_MARKER_ATTR +from session_manager import SessionValidationResult + +import gateway # noqa: F401 — registers the app + views + +# ----------------------------------------------------------------------------- +# Fixtures +# ----------------------------------------------------------------------------- + + +@pytest.fixture +def client(): + gateway.app.config["TESTING"] = True + with gateway.app.test_client() as c: + yield c + + +def _patch_session(mode: str): + """Patch session validation to yield a session with the given mode.""" + import auth + + mock_session = MagicMock() + mock_session.mode = mode + mock_session.container_id = "test-container" + mock_session.expires_at = None + mock_session.pipeline_id = "issue-1931" + mock_session.agent_role = "coder" + mock_session.jira_ticket = None + + mock_result = SessionValidationResult(valid=True, session=mock_session) + + auth._session_manager = None + auth._rate_limiter = None + if "gateway.auth" in sys.modules: + sys.modules["gateway.auth"]._session_manager = None + sys.modules["gateway.auth"]._rate_limiter = None + + current_sm = sys.modules.get("session_manager", session_manager) + return patch.object( + current_sm, + "validate_session_for_request", + return_value=mock_result, + ) + + +@pytest.fixture +def private_headers(): + with _patch_session("private"): + yield {"Authorization": "Bearer test-private-token"} + + +@pytest.fixture +def public_headers(): + with _patch_session("public"): + yield {"Authorization": "Bearer test-public-token"} + + +@pytest.fixture +def allow_eng(monkeypatch): + """Force the policy singleton to say ENG is allowlisted.""" + monkeypatch.setattr( + confluence_policy, + "is_space_allowed", + lambda k: k == "ENG", + ) + monkeypatch.setattr( + confluence_policy, + "allowed_spaces", + lambda: frozenset({"ENG"}), + ) + # gateway.py imports the helpers under aliases — patch those names too. + monkeypatch.setattr( + gateway, + "is_confluence_space_allowed", + lambda k: k == "ENG", + ) + monkeypatch.setattr( + gateway, + "confluence_allowed_spaces", + lambda: frozenset({"ENG"}), + ) + + +@pytest.fixture +def captured_audit(monkeypatch): + """Capture every ``audit_log`` call made by gateway routes.""" + captured: list[dict[str, Any]] = [] + + def _capture(event_type, operation, *, success, details=None): + captured.append( + { + "event_type": event_type, + "operation": operation, + "success": success, + "details": dict(details) if details else {}, + } + ) + + monkeypatch.setattr(gateway, "audit_log", _capture) + return captured + + +def _patch_client(fake) -> Any: + """Patch ``gateway.get_confluence_client`` to return ``fake``.""" + return patch.object(gateway, "get_confluence_client", return_value=fake) + + +# ----------------------------------------------------------------------------- +# Route enumeration regression — decision G7 / risk R4 +# ----------------------------------------------------------------------------- + + +class TestRouteEnumeration: + def test_every_confluence_route_has_private_mode_marker(self, client): + found = 0 + for rule in gateway.app.url_map.iter_rules(): + if not rule.rule.startswith("/api/v1/confluence/"): + continue + view = gateway.app.view_functions[rule.endpoint] + assert getattr(view, PRIVATE_MODE_MARKER_ATTR, False) is True, ( + f"Confluence route {rule.rule!r} (view={view.__name__}) is " + f"missing the @require_private_mode decorator." + ) + found += 1 + # Plan calls for eight routes. + assert found >= 8, f"Expected at least 8 Confluence routes; found {found}" + + +# ----------------------------------------------------------------------------- +# /api/v1/confluence/page/get +# ----------------------------------------------------------------------------- + + +class TestPageGet: + def test_public_mode_returns_403(self, client, public_headers, captured_audit): + resp = client.post( + "/api/v1/confluence/page/get", + headers=public_headers, + data=json.dumps({"pageId": "12345"}), + content_type="application/json", + ) + assert resp.status_code == 403 + body = json.loads(resp.data) + assert "private network mode" in body["message"].lower() + # Audit entry from require_private_mode. + assert any(a["event_type"] == "private_mode_required" for a in captured_audit) + + def test_invalid_page_id_400(self, client, private_headers, captured_audit): + resp = client.post( + "/api/v1/confluence/page/get", + headers=private_headers, + data=json.dumps({"pageId": "abc"}), + content_type="application/json", + ) + assert resp.status_code == 400 + assert any( + a["event_type"] == "confluence_page_get_rejected" + and "invalid" in a["details"].get("reason", "").lower() + for a in captured_audit + ) + + def test_disallowed_space_returns_403_no_body_leak( + self, client, private_headers, allow_eng, captured_audit + ): + """When upstream returns a page in a non-allowlisted space, the + gateway must return 403 *without* forwarding the page body.""" + fake = MagicMock() + fake.get_page.return_value = { + "id": "12345", + "spaceId": "999", + "title": "Secret", + "body": {"storage": {"value": "secret-content-DO-NOT-LEAK"}}, + } + # Cache the spaceId → SECRET so the lookup yields a non-allowlisted key. + fake.space_cache.key_for_id = lambda sid: "SECRET" if sid == "999" else None + with _patch_client(fake): + resp = client.post( + "/api/v1/confluence/page/get", + headers=private_headers, + data=json.dumps({"pageId": "12345"}), + content_type="application/json", + ) + assert resp.status_code == 403 + # Body must not contain the page content — only the denial envelope. + assert "secret-content-DO-NOT-LEAK" not in resp.get_data(as_text=True) + denied = [a for a in captured_audit if a["event_type"] == "confluence_space_denied"] + assert denied, "expected confluence_space_denied audit entry" + + def test_happy_path(self, client, private_headers, allow_eng, captured_audit): + fake = MagicMock() + fake.get_page.return_value = { + "id": "12345", + "spaceId": "1", + "title": "Hello", + } + fake.space_cache.key_for_id = lambda sid: "ENG" if sid == "1" else None + with _patch_client(fake): + resp = client.post( + "/api/v1/confluence/page/get", + headers=private_headers, + data=json.dumps({"pageId": "12345"}), + content_type="application/json", + ) + assert resp.status_code == 200 + body = json.loads(resp.data) + assert body["data"]["id"] == "12345" + + success = [a for a in captured_audit if a["event_type"] == "confluence_page_get"] + assert success + details = success[0]["details"] + assert details["pageId"] == "12345" + assert details["spaceKey"] == "ENG" + assert details["pipeline_id"] == "issue-1931" + assert details["agent_role"] == "coder" + assert details["session_mode"] == "private" + assert details["not_found"] is False + + def test_not_found_envelope_passes_through( + self, client, private_headers, allow_eng, captured_audit + ): + fake = MagicMock() + fake.get_page.return_value = { + "status": "not_found", + "id": "999", + "upstream_status": 404, + } + with _patch_client(fake): + resp = client.post( + "/api/v1/confluence/page/get", + headers=private_headers, + data=json.dumps({"pageId": "999"}), + content_type="application/json", + ) + assert resp.status_code == 200 + body = json.loads(resp.data) + assert body["data"] == { + "status": "not_found", + "id": "999", + "upstream_status": 404, + } + success = [a for a in captured_audit if a["event_type"] == "confluence_page_get"] + assert success[0]["details"]["not_found"] is True + + def test_upstream_403_distinct_audit_event( + self, client, private_headers, allow_eng, captured_audit + ): + fake = MagicMock() + fake.get_page.side_effect = ConfluenceUpstreamForbidden( + 403, {"err": "denied"}, "api/v2/pages/12345" + ) + with _patch_client(fake): + resp = client.post( + "/api/v1/confluence/page/get", + headers=private_headers, + data=json.dumps({"pageId": "12345"}), + content_type="application/json", + ) + assert resp.status_code == 403 + # Distinct from confluence_space_denied. + upstream = [a for a in captured_audit if a["event_type"] == "confluence_upstream_403"] + assert upstream + assert upstream[0]["details"]["pageId"] == "12345" + + +# ----------------------------------------------------------------------------- +# /api/v1/confluence/space/list — list_spaces filtering end-to-end (risk R13) +# ----------------------------------------------------------------------------- + + +class TestSpaceList: + def test_public_mode_403(self, client, public_headers): + resp = client.post( + "/api/v1/confluence/space/list", + headers=public_headers, + data=json.dumps({}), + content_type="application/json", + ) + assert resp.status_code == 403 + + def test_filtered_to_allowlist_end_to_end( + self, client, private_headers, allow_eng, captured_audit + ): + """Mock returns ENG / DOCS / LEAK; allowlist is {ENG} → only ENG returned.""" + fake = MagicMock() + fake.list_spaces.return_value = {"results": [{"id": "1", "key": "ENG"}]} + with _patch_client(fake): + resp = client.post( + "/api/v1/confluence/space/list", + headers=private_headers, + data=json.dumps({}), + content_type="application/json", + ) + assert resp.status_code == 200 + body = json.loads(resp.data) + keys = sorted(s["key"] for s in body["data"]["results"]) + assert keys == ["ENG"] + success = [a for a in captured_audit if a["event_type"] == "confluence_space_list"] + assert success + assert success[0]["details"]["spaces_returned"] == 1 + + +# ----------------------------------------------------------------------------- +# /api/v1/confluence/space/pages +# ----------------------------------------------------------------------------- + + +class TestSpacePages: + def test_public_mode_403(self, client, public_headers): + resp = client.post( + "/api/v1/confluence/space/pages", + headers=public_headers, + data=json.dumps({"spaceKey": "ENG"}), + content_type="application/json", + ) + assert resp.status_code == 403 + + def test_invalid_space_key_shape_400(self, client, private_headers, captured_audit): + resp = client.post( + "/api/v1/confluence/space/pages", + headers=private_headers, + data=json.dumps({"spaceKey": "with-dash"}), + content_type="application/json", + ) + assert resp.status_code == 400 + + def test_disallowed_space_returns_403(self, client, private_headers, allow_eng, captured_audit): + resp = client.post( + "/api/v1/confluence/space/pages", + headers=private_headers, + data=json.dumps({"spaceKey": "SECRET"}), + content_type="application/json", + ) + assert resp.status_code == 403 + denied = [a for a in captured_audit if a["event_type"] == "confluence_space_pages_denied"] + assert denied + assert denied[-1]["details"]["spaceKey"] == "SECRET" + + def test_happy_path(self, client, private_headers, allow_eng, captured_audit): + fake = MagicMock() + fake.space_cache.id_for_key = lambda k: "1" if k == "ENG" else None + fake.get_space_pages.return_value = {"results": [{"id": "p1"}]} + with _patch_client(fake): + resp = client.post( + "/api/v1/confluence/space/pages", + headers=private_headers, + data=json.dumps({"spaceKey": "ENG"}), + content_type="application/json", + ) + assert resp.status_code == 200 + # list_spaces should NOT be called when the cache is hot. + fake.list_spaces.assert_not_called() + + +# ----------------------------------------------------------------------------- +# /api/v1/confluence/search — adversarial CQL +# ----------------------------------------------------------------------------- + + +class TestSearch: + def test_public_mode_403(self, client, public_headers): + resp = client.post( + "/api/v1/confluence/search", + headers=public_headers, + data=json.dumps({"cql": "space = ENG"}), + content_type="application/json", + ) + assert resp.status_code == 403 + + def test_missing_cql_400(self, client, private_headers, captured_audit): + resp = client.post( + "/api/v1/confluence/search", + headers=private_headers, + data=json.dumps({}), + content_type="application/json", + ) + assert resp.status_code == 400 + assert any(a["event_type"] == "confluence_search_rejected" for a in captured_audit) + + @pytest.mark.parametrize( + "cql", + [ + "space = ENG OR space = SEC", + 'space = "ENG"', + "SPACE = ENG", + "space = currentUser()", + "space = ENG /* injected */", + "space IN (ENG, SEC)", + 'text ~ "RFC"', + "space = ENG ; drop table", + 'id = "12345"', + "space = ЕNG", + "space != SEC", + ], + ) + def test_adversarial_cql_rejected( + self, client, private_headers, allow_eng, captured_audit, cql: str + ): + resp = client.post( + "/api/v1/confluence/search", + headers=private_headers, + data=json.dumps({"cql": cql}), + content_type="application/json", + ) + assert resp.status_code == 403, f"expected 403 for {cql!r}" + rejected = [a for a in captured_audit if a["event_type"] == "confluence_search_rejected"] + assert rejected, f"expected audit entry for {cql!r}" + # ``pageId`` must NEVER appear on search audits. + assert "pageId" not in rejected[-1]["details"] + + def test_happy_path_clamps_limit(self, client, private_headers, allow_eng, captured_audit): + fake = MagicMock() + fake.search_cql.return_value = {"results": []} + with _patch_client(fake): + resp = client.post( + "/api/v1/confluence/search", + headers=private_headers, + data=json.dumps( + { + "cql": 'space = ENG AND text ~ "RFC"', + "limit": 99999, + "cursor": "TOK-abc", + } + ), + content_type="application/json", + ) + assert resp.status_code == 200 + kwargs = fake.search_cql.call_args.kwargs + assert kwargs["limit"] == 100 # clamped to HARD_MAX_LIMIT + assert kwargs["cursor"] == "TOK-abc" + success = [a for a in captured_audit if a["event_type"] == "confluence_search"] + assert success + details = success[0]["details"] + assert details["spaces_extracted"] == ["ENG"] + assert "pageId" not in details + + def test_invalid_limit_400(self, client, private_headers, allow_eng, captured_audit): + resp = client.post( + "/api/v1/confluence/search", + headers=private_headers, + data=json.dumps({"cql": "space = ENG", "limit": "bad"}), + content_type="application/json", + ) + assert resp.status_code == 400 + + +# ----------------------------------------------------------------------------- +# /api/v1/confluence/execute — anti-bypass + denied verbs (risks R2, R14) +# ----------------------------------------------------------------------------- + + +class TestExecute: + def test_public_mode_403(self, client, public_headers): + resp = client.post( + "/api/v1/confluence/execute", + headers=public_headers, + data=json.dumps({"method": "GET", "path": "api/v2/pages/12345"}), + content_type="application/json", + ) + assert resp.status_code == 403 + + @pytest.mark.parametrize("method", ["POST", "PUT", "PATCH", "DELETE"]) + def test_non_get_methods_rejected( + self, + client, + private_headers, + allow_eng, + captured_audit, + method: str, + ): + resp = client.post( + "/api/v1/confluence/execute", + headers=private_headers, + data=json.dumps({"method": method, "path": "api/v2/pages/12345"}), + content_type="application/json", + ) + assert resp.status_code == 403 + denied = [a for a in captured_audit if a["event_type"] == "confluence_execute_denied"] + assert denied + + @pytest.mark.parametrize( + "verb", + ["restrictions", "permissions", "users", "attachments", "space.admin"], + ) + def test_denied_verb_in_path_rejected( + self, + client, + private_headers, + allow_eng, + captured_audit, + verb: str, + ): + # Bare verb form. + resp = client.post( + "/api/v1/confluence/execute", + headers=private_headers, + data=json.dumps({"method": "GET", "path": f"api/v2/{verb}"}), + content_type="application/json", + ) + assert resp.status_code == 403, f"bare {verb!r} should be rejected" + + # Inline (path-position) variant — segment match must catch it. + resp = client.post( + "/api/v1/confluence/execute", + headers=private_headers, + data=json.dumps({"method": "GET", "path": f"api/v2/pages/123/{verb}"}), + content_type="application/json", + ) + assert resp.status_code == 403, f"nested {verb!r} should be rejected" + + def test_path_traversal_rejected(self, client, private_headers, allow_eng, captured_audit): + resp = client.post( + "/api/v1/confluence/execute", + headers=private_headers, + data=json.dumps({"method": "GET", "path": "api/v2/pages/../12345"}), + content_type="application/json", + ) + assert resp.status_code == 403 + + @pytest.mark.parametrize( + "bypass_path", + [ + # Cycle-3 NACK fix (commit f3f552eb9): these four paths were + # dropped from the /execute allowlist because each was an + # exploitable cross-partition bypass. The route layer must + # refuse them with confluence_execute_denied. + "api/v2/spaces", + "rest/api/search", + "api/v2/footer-comments", + "api/v2/inline-comments", + ], + ) + def test_anti_bypass_paths_rejected_via_execute( + self, + client, + private_headers, + allow_eng, + captured_audit, + bypass_path: str, + ): + """Risk R2: /execute must NOT accept any of the four flat v2 paths + that bypass the narrow-route policy checks.""" + resp = client.post( + "/api/v1/confluence/execute", + headers=private_headers, + data=json.dumps({"method": "GET", "path": bypass_path}), + content_type="application/json", + ) + assert resp.status_code == 403, f"{bypass_path!r} must be rejected" + denied = [a for a in captured_audit if a["event_type"] == "confluence_execute_denied"] + assert denied, f"expected denial audit for {bypass_path!r}" + + def test_disallowed_space_via_pageid_in_execute( + self, client, private_headers, allow_eng, captured_audit + ): + """Even via /execute, post-fetch allowlist must catch a non-allowlisted + space — proves the route-vs-execute anti-bypass guarantee.""" + fake = MagicMock() + fake.execute_raw.return_value = { + "id": "12345", + "spaceId": "999", + "body": {"storage": {"value": "leak-bait"}}, + } + fake.space_cache.key_for_id = lambda sid: "SECRET" if sid == "999" else None + with _patch_client(fake): + resp = client.post( + "/api/v1/confluence/execute", + headers=private_headers, + data=json.dumps({"method": "GET", "path": "api/v2/pages/12345"}), + content_type="application/json", + ) + assert resp.status_code == 403 + # Body must not be leaked. + assert "leak-bait" not in resp.get_data(as_text=True) + denied = [a for a in captured_audit if a["event_type"] == "confluence_execute_denied"] + assert denied + + def test_happy_path_get(self, client, private_headers, allow_eng, captured_audit): + fake = MagicMock() + fake.execute_raw.return_value = { + "id": "12345", + "spaceId": "1", + } + fake.space_cache.key_for_id = lambda sid: "ENG" if sid == "1" else None + with _patch_client(fake): + resp = client.post( + "/api/v1/confluence/execute", + headers=private_headers, + data=json.dumps({"method": "GET", "path": "api/v2/pages/12345"}), + content_type="application/json", + ) + assert resp.status_code == 200 + success = [a for a in captured_audit if a["event_type"] == "confluence_execute"] + assert success + details = success[0]["details"] + assert details["method"] == "GET" + assert details["path"] == "api/v2/pages/12345" + assert details["pageId"] == "12345" + assert details["spaceKey"] == "ENG" + + def test_missing_path_400(self, client, private_headers, captured_audit): + resp = client.post( + "/api/v1/confluence/execute", + headers=private_headers, + data=json.dumps({"method": "GET"}), + content_type="application/json", + ) + assert resp.status_code == 400 + + +# ----------------------------------------------------------------------------- +# /api/v1/confluence/page/inline-comments — used_fallback observability +# ----------------------------------------------------------------------------- + + +class TestPageInlineComments: + def test_used_fallback_propagates_to_audit( + self, client, private_headers, allow_eng, captured_audit + ): + fake = MagicMock() + fake.get_page_inline_comments.return_value = { + "results": [{"id": "ic1"}], + "used_fallback": True, + } + # Parent page lookup also resolves to ENG. + fake.get_page.return_value = {"id": "12345", "spaceId": "1"} + fake.space_cache.key_for_id = lambda sid: "ENG" if sid == "1" else None + with _patch_client(fake): + resp = client.post( + "/api/v1/confluence/page/inline-comments", + headers=private_headers, + data=json.dumps({"pageId": "12345"}), + content_type="application/json", + ) + assert resp.status_code == 200 + success = [ + a for a in captured_audit if a["event_type"] == "confluence_page_inline_comments" + ] + assert success + assert success[0]["details"]["used_fallback"] is True + + +# ----------------------------------------------------------------------------- +# Page descendants — risk R8 (depth/limit defaults) +# ----------------------------------------------------------------------------- + + +class TestPageDescendants: + def test_defaults_applied_when_omitted( + self, client, private_headers, allow_eng, captured_audit + ): + fake = MagicMock() + fake.get_page_descendants.return_value = {"results": []} + fake.get_page.return_value = {"id": "12345", "spaceId": "1"} + fake.space_cache.key_for_id = lambda sid: "ENG" if sid == "1" else None + with _patch_client(fake): + resp = client.post( + "/api/v1/confluence/page/descendants", + headers=private_headers, + data=json.dumps({"pageId": "12345"}), + content_type="application/json", + ) + assert resp.status_code == 200 + # The route should call client with defaults applied. + kwargs = fake.get_page_descendants.call_args.kwargs + assert kwargs["depth"] == 1 + assert kwargs["limit"] == 25 # CONFLUENCE_DEFAULT_LIMIT + + +# ----------------------------------------------------------------------------- +# Audit-log shape regression: every emit carries the session triplet +# ----------------------------------------------------------------------------- + + +class TestAuditShape: + def test_search_audit_has_session_triplet( + self, client, private_headers, allow_eng, captured_audit + ): + fake = MagicMock() + fake.search_cql.return_value = {"results": []} + with _patch_client(fake): + client.post( + "/api/v1/confluence/search", + headers=private_headers, + data=json.dumps({"cql": "space = ENG"}), + content_type="application/json", + ) + success = [a for a in captured_audit if a["event_type"] == "confluence_search"] + details = success[0]["details"] + assert details["session_mode"] == "private" + assert details["pipeline_id"] == "issue-1931" + assert details["agent_role"] == "coder" diff --git a/gateway/tests/test_confluence_search.py b/gateway/tests/test_confluence_search.py new file mode 100644 index 0000000000..12f8b65e9d --- /dev/null +++ b/gateway/tests/test_confluence_search.py @@ -0,0 +1,181 @@ +""" +Tests for the conservative CQL space-scope extractor in +``gateway/confluence_search.py``. + +Covers Phase 1 / Task 4-4 acceptance: + +- Positive cases: ``space = KEY`` and ``space IN (KEY, ...)`` (optionally + combined with arbitrary AND clauses). +- Adversarial / negative cases: every shape the extractor must reject — + OR boolean, capitalisation variants, quoted keys, CQL functions, + semicolons, comment markers, missing space clause, non-allowlisted keys, + unicode homoglyphs, bare id / content / title clauses without a space + anchor. +- Type errors: non-string CQL, malformed string literals. +""" + +from __future__ import annotations + +import pytest + +# Loaded via conftest. +from confluence_search import ScopeResult, extract_search_spaces + +ALLOWED = frozenset({"ENG", "DOCS"}) + + +# --------------------------------------------------------------------------- +# Positive cases +# --------------------------------------------------------------------------- + + +class TestPositive: + def test_simple_space_equals(self): + result = extract_search_spaces("space = ENG", ALLOWED) + assert result == ScopeResult(frozenset({"ENG"}), "") + + def test_space_in_list_all_allowed(self): + result = extract_search_spaces("space IN (ENG, DOCS)", ALLOWED) + assert result.spaces == frozenset({"ENG", "DOCS"}) + assert result.reason == "" + + def test_space_combined_with_text_and(self): + result = extract_search_spaces('space = ENG AND text ~ "RFC"', ALLOWED) + assert result.spaces == frozenset({"ENG"}) + + def test_space_in_lowercase_in_operator(self): + """``space in (...)`` (lowercase ``in``) is still accepted.""" + result = extract_search_spaces("space in (ENG, DOCS)", ALLOWED) + assert result.spaces == frozenset({"ENG", "DOCS"}) + + def test_single_key_in_in_list(self): + result = extract_search_spaces("space IN (ENG)", ALLOWED) + assert result.spaces == frozenset({"ENG"}) + + def test_space_combined_with_label(self): + result = extract_search_spaces("space = ENG AND label = architecture", ALLOWED) + assert result.spaces == frozenset({"ENG"}) + + +# --------------------------------------------------------------------------- +# Adversarial / negative cases +# --------------------------------------------------------------------------- + + +class TestAdversarialNegatives: + @pytest.mark.parametrize( + "cql, expected_reason_substr", + [ + # 1. OR boolean operator at top level. + ("space = ENG OR space = SEC", "or"), + # 2. OR mixing a bare id clause. + ('space = ENG OR id = "12345"', "or"), + # 3. Capitalisation variant. + ("SPACE = ENG", "cannot prove"), + # 4. Quoted key — even if allowlisted. + ('space = "ENG"', "cannot prove"), + # 5. CQL function on RHS. The parser reads ``currentUser`` as + # a candidate space key and then rejects it via the allowlist + # check; the trailing ``()`` is never seen as a function call. + # Either ``cannot prove`` (parse-level) or ``not allowlisted`` + # (allowlist-level) is an acceptable rejection — both fail + # closed. See the non-blocking note in the search test + # review for hardening the parser to reject ``()`` syntax + # explicitly. + ("space = currentUser()", "not allowlisted"), + ("space = recentlyViewedContent()", "not allowlisted"), + # 6. Semicolon / statement chaining. + ("space = ENG ; drop table", "forbidden"), + # 7. Block comment marker. + ("space = ENG /* comment */", "comment"), + # 8. Line-comment markers. + ("space = ENG -- hidden", "comment"), + ("space = ENG // hidden", "comment"), + # 9. IN list containing a non-allowlisted key. + ("space IN (ENG, SEC)", "not allowlisted"), + # 10. Missing space clause entirely. + ('text ~ "RFC"', "no space clause"), + # 11. Bare id clause without a space anchor. + ('id = "12345"', "id-level clause without space scope"), + # 12. Bare title clause without a space anchor. + ('title ~ "RFC"', "id-level clause without space scope"), + # 13. Bare content clause without space anchor. + ('content = "12345"', "id-level clause without space scope"), + # 14. Unicode homoglyph (Cyrillic Е U+0415, Н U+041D, Г U+0413). + ("space = ЕНG", "non-ASCII"), + # 15. Negated comparator (extractor cannot prove containment). + ("space != SEC", "cannot prove"), + # 16. Wildcard comparator on space. + ("space ~ ENG", "cannot prove"), + # 17. Empty / whitespace-only. + ("", "empty"), + (" \t ", "empty"), + ], + ) + def test_rejected_with_reason(self, cql: str, expected_reason_substr: str): + result = extract_search_spaces(cql, ALLOWED) + assert result.spaces is None, f"{cql!r} should have been rejected" + assert expected_reason_substr.lower() in result.reason.lower(), ( + f"Expected reason to mention {expected_reason_substr!r}; got {result.reason!r}" + ) + + +# --------------------------------------------------------------------------- +# Type errors +# --------------------------------------------------------------------------- + + +class TestTypeErrors: + def test_non_string_rejected(self): + result = extract_search_spaces(None, ALLOWED) # type: ignore[arg-type] + assert result.spaces is None + assert "string" in result.reason.lower() + + def test_int_rejected(self): + result = extract_search_spaces(42, ALLOWED) # type: ignore[arg-type] + assert result.spaces is None + + def test_mismatched_string_literal_rejected(self): + """A stray quote with no close is a parse error — reject.""" + result = extract_search_spaces('space = ENG AND text ~ "unclosed', ALLOWED) + assert result.spaces is None + assert "malformed" in result.reason.lower() or "string" in result.reason.lower() + + +# --------------------------------------------------------------------------- +# Edge cases for the extractor surface +# --------------------------------------------------------------------------- + + +class TestExtractorEdges: + def test_unknown_key_alone_rejected(self): + """Allowlist must also gate the simple ``space = X`` shape.""" + result = extract_search_spaces("space = SEC", ALLOWED) + assert result.spaces is None + assert "not allowlisted" in result.reason.lower() + + def test_allowlist_change_invalidates_acceptance(self): + """A previously accepted query becomes rejected when allowlist shrinks.""" + smaller = frozenset({"DOCS"}) + result = extract_search_spaces("space = ENG", smaller) + assert result.spaces is None + assert "not allowlisted" in result.reason.lower() + + def test_extra_whitespace_tolerated(self): + result = extract_search_spaces(" space = ENG ", ALLOWED) + assert result.spaces == frozenset({"ENG"}) + + def test_in_with_mixed_whitespace(self): + result = extract_search_spaces("space IN ( ENG , DOCS )", ALLOWED) + assert result.spaces == frozenset({"ENG", "DOCS"}) + + def test_two_space_clauses_both_must_be_allowlisted(self): + """Multiple ``space = K`` clauses combined with AND are accepted only + when every key is allowlisted.""" + result = extract_search_spaces("space = ENG AND space = DOCS", ALLOWED) + assert result.spaces == frozenset({"ENG", "DOCS"}) + + def test_two_space_clauses_one_not_allowed(self): + result = extract_search_spaces("space = ENG AND space = SEC", ALLOWED) + assert result.spaces is None + assert "not allowlisted" in result.reason.lower() diff --git a/gateway/tests/test_jira_credentials.py b/gateway/tests/test_jira_credentials.py index 3bc524ccb2..57c3f837d4 100644 --- a/gateway/tests/test_jira_credentials.py +++ b/gateway/tests/test_jira_credentials.py @@ -252,3 +252,120 @@ def _write_secrets_inplace_preserving_mtime(path: Path, token: str) -> None: os.utime(path, (original_mtime, original_mtime)) # Guard against flaky filesystems that bump the mtime anyway. time.sleep(0.001) + + +# ----------------------------------------------------------------------------- +# Risk R11 / Task 4-1b — shared ATLASSIAN_* credential precedence (#1931) +# ----------------------------------------------------------------------------- + + +def _write_kv(path: Path, **kv: str | None) -> None: + """Write key=value pairs to ``path``, omitting None values.""" + lines = [f"{k}={v}" for k, v in kv.items() if v is not None] + path.write_text("\n".join(lines) + "\n") + + +class TestAtlassianPrecedence: + """Per-key fallback: ATLASSIAN_* wins; JIRA_* is the back-compat path. + + The Confluence wrapper (#1931) reuses the same secrets.env, so the Jira + loader must honour ``ATLASSIAN_*`` first or operators who switch to the + shared triple silently break Jira. These tests pin the per-key fallback + matrix called for in plan-task 4-1b / risk R11. + """ + + def test_atlassian_only_resolves(self, tmp_secrets: Path): + _write_kv( + tmp_secrets, + ATLASSIAN_BASE_URL="https://example.atlassian.net", + ATLASSIAN_USERNAME="alice@example.com", + ATLASSIAN_API_TOKEN="atk-shared", + ) + mgr = JiraCredentialsManager(tmp_secrets) + creds = mgr.get_credentials() + # Jira sits at the bare origin (no ``/wiki`` suffix). + assert creds.base_url == "https://example.atlassian.net" + assert creds.username == "alice@example.com" + assert creds.api_token == "atk-shared" + + def test_jira_only_still_works(self, tmp_secrets: Path): + """Existing deployments setting only ``JIRA_*`` keys must keep working + without changes.""" + _write_kv( + tmp_secrets, + JIRA_BASE_URL="https://example.atlassian.net", + JIRA_USERNAME="alice@example.com", + JIRA_API_TOKEN="atk-jira", + ) + mgr = JiraCredentialsManager(tmp_secrets) + creds = mgr.get_credentials() + assert creds.base_url == "https://example.atlassian.net" + assert creds.username == "alice@example.com" + assert creds.api_token == "atk-jira" + + def test_atlassian_wins_over_jira_per_key(self, tmp_secrets: Path): + """When both prefixes are set, ATLASSIAN_* wins for each key.""" + _write_kv( + tmp_secrets, + ATLASSIAN_BASE_URL="https://atlassian.atlassian.net", + ATLASSIAN_USERNAME="atlassian@example.com", + ATLASSIAN_API_TOKEN="atk-atlassian", + JIRA_BASE_URL="https://jira.atlassian.net", + JIRA_USERNAME="jira@example.com", + JIRA_API_TOKEN="atk-jira", + ) + mgr = JiraCredentialsManager(tmp_secrets) + creds = mgr.get_credentials() + assert creds.base_url == "https://atlassian.atlassian.net" + assert creds.username == "atlassian@example.com" + assert creds.api_token == "atk-atlassian" + + def test_per_key_mixed_fallback(self, tmp_secrets: Path): + """ATLASSIAN_USERNAME + JIRA_BASE_URL + JIRA_API_TOKEN → all resolve.""" + _write_kv( + tmp_secrets, + ATLASSIAN_USERNAME="alice@example.com", + JIRA_BASE_URL="https://example.atlassian.net", + JIRA_API_TOKEN="atk-mixed", + ) + mgr = JiraCredentialsManager(tmp_secrets) + creds = mgr.get_credentials() + assert creds.base_url == "https://example.atlassian.net" + assert creds.username == "alice@example.com" + assert creds.api_token == "atk-mixed" + + def test_blank_atlassian_keys_fall_through_to_jira(self, tmp_secrets: Path): + _write_kv( + tmp_secrets, + ATLASSIAN_BASE_URL="", + ATLASSIAN_USERNAME="", + ATLASSIAN_API_TOKEN="", + JIRA_BASE_URL="https://example.atlassian.net", + JIRA_USERNAME="alice@example.com", + JIRA_API_TOKEN="atk-jira", + ) + mgr = JiraCredentialsManager(tmp_secrets) + creds = mgr.get_credentials() + assert creds.base_url == "https://example.atlassian.net" + assert creds.username == "alice@example.com" + assert creds.api_token == "atk-jira" + + def test_all_six_missing_raises(self, tmp_secrets: Path): + _write_kv(tmp_secrets, OTHER_KEY="value") + mgr = JiraCredentialsManager(tmp_secrets) + with pytest.raises(JiraCredentialsUnavailable): + mgr.get_credentials() + + def test_atlassian_base_url_no_wiki_suffix(self, tmp_secrets: Path): + """Jira lives at the bare Atlassian origin — no /wiki appended. + (Confluence is the one that needs /wiki.)""" + _write_kv( + tmp_secrets, + ATLASSIAN_BASE_URL="https://example.atlassian.net", + ATLASSIAN_USERNAME="alice@example.com", + ATLASSIAN_API_TOKEN="atk", + ) + mgr = JiraCredentialsManager(tmp_secrets) + creds = mgr.get_credentials() + assert creds.base_url == "https://example.atlassian.net" + assert "/wiki" not in creds.base_url diff --git a/tests/sandbox/test_confluence_wrapper.py b/tests/sandbox/test_confluence_wrapper.py new file mode 100644 index 0000000000..4a04dc3149 --- /dev/null +++ b/tests/sandbox/test_confluence_wrapper.py @@ -0,0 +1,527 @@ +""" +Tests for the sandbox ``confluence`` CLI wrapper. + +Subprocess-invokes the wrapper against a stdlib ``http.server`` mock gateway +and asserts: + +- Each verb constructs the correct request path + JSON body. +- The ``Authorization: Bearer $EGG_SESSION_TOKEN`` header is always sent. +- 2xx responses print the ``data`` subtree on stdout and exit 0. +- Non-2xx responses print an error on stderr and exit non-zero. +- Missing ``EGG_SESSION_TOKEN`` fails closed. +- Missing gateway fails closed with the standard error banner. + +The wrapper lives at ``sandbox/scripts/confluence`` (canonical) or the +artifact location ``.egg-state/agent-outputs/1931-sandbox-scripts-confluence`` +(until a maintainer ``git mv``s it post-merge — same arrangement as the Jira +wrapper in #1556). Either is acceptable; the test prefers the canonical +location when present. + +Note: this file exercises the wrapper directly; the actual route enforcement +(private-mode gate, space allowlist, CQL extractor, etc.) is covered in +``gateway/tests/test_confluence_routes.py``. The wrapper's only job is to +translate CLI args into a well-formed ``POST /api/v1/confluence/*`` request +and surface the gateway's response. +""" + +from __future__ import annotations + +import json +import os +import subprocess +import threading +from http.server import BaseHTTPRequestHandler, HTTPServer +from pathlib import Path +from typing import Any + +import pytest + +# ----------------------------------------------------------------------------- +# Locate the wrapper +# ----------------------------------------------------------------------------- + +_REPO_ROOT = Path(__file__).resolve().parents[2] +_CANONICAL = _REPO_ROOT / "sandbox" / "scripts" / "confluence" +_ARTIFACT = _REPO_ROOT / ".egg-state" / "agent-outputs" / "1931-sandbox-scripts-confluence" + + +def _locate_wrapper() -> Path: + if _CANONICAL.exists(): + return _CANONICAL + if _ARTIFACT.exists(): + return _ARTIFACT + pytest.skip( + "sandbox confluence wrapper not found at " + f"{_CANONICAL} or {_ARTIFACT} — coder proposal #1931 may be incomplete.", + allow_module_level=True, + ) + + +WRAPPER = _locate_wrapper() + + +# ----------------------------------------------------------------------------- +# Mock gateway +# ----------------------------------------------------------------------------- + + +class _RecordingHandler(BaseHTTPRequestHandler): + def log_message(self, format: str, *args: Any) -> None: # noqa: A002 + # Silence default access-log chatter during tests. + pass + + def do_GET(self): # noqa: N802 + if self.path == "/api/v1/health": + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(b'{"status": "ok"}') + return + self.send_response(404) + self.end_headers() + + def do_POST(self): # noqa: N802 + length = int(self.headers.get("Content-Length", "0")) + raw = self.rfile.read(length) if length else b"" + try: + body = json.loads(raw.decode("utf-8")) if raw else None + except json.JSONDecodeError: + body = raw.decode("utf-8", errors="replace") + self.server.recorded.append( # type: ignore[attr-defined] + { + "path": self.path, + "body": body, + "authorization": self.headers.get("Authorization", ""), + "content_type": self.headers.get("Content-Type", ""), + } + ) + queue = self.server.response_queue # type: ignore[attr-defined] + if queue: + response = queue.pop(0) + else: + response = {"status": 200, "body": {"success": True, "data": {}}} + self.send_response(response["status"]) + self.send_header("Content-Type", "application/json") + self.end_headers() + payload = response["body"] + if isinstance(payload, dict): + self.wfile.write(json.dumps(payload).encode("utf-8")) + else: + self.wfile.write(str(payload).encode("utf-8")) + + +@pytest.fixture +def mock_gateway(): + server = HTTPServer(("127.0.0.1", 0), _RecordingHandler) + server.recorded = [] # type: ignore[attr-defined] + server.response_queue = [] # type: ignore[attr-defined] + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + _, port = server.server_address + yield { + "url": f"http://127.0.0.1:{port}", + "server": server, + } + finally: + server.shutdown() + thread.join(timeout=2) + server.server_close() + + +def _run_wrapper( + mock_gateway: dict, + argv: list[str], + session_token: str | None = "test-session-token", + extra_env: dict[str, str] | None = None, + gateway_url_override: str | None = None, +) -> subprocess.CompletedProcess: + env = os.environ.copy() + env["GATEWAY_URL"] = ( + gateway_url_override if gateway_url_override is not None else mock_gateway["url"] + ) + if session_token is None: + env.pop("EGG_SESSION_TOKEN", None) + else: + env["EGG_SESSION_TOKEN"] = session_token + if extra_env: + env.update(extra_env) + return subprocess.run( + ["bash", str(WRAPPER), *argv], + capture_output=True, + text=True, + env=env, + timeout=15, + ) + + +# ----------------------------------------------------------------------------- +# Pre-flight checks +# ----------------------------------------------------------------------------- + + +class TestEnvChecks: + def test_missing_session_token_fails_closed(self, mock_gateway): + proc = _run_wrapper(mock_gateway, ["page", "get", "12345"], session_token=None) + assert proc.returncode != 0 + assert "egg_session_token" in proc.stderr.lower() or "session token" in proc.stderr.lower() + + def test_missing_gateway_fails_closed(self, mock_gateway, tmp_path): + # Use a port we know is closed. 127.0.0.1:1 is invalid for a server. + proc = _run_wrapper( + mock_gateway, + ["page", "get", "12345"], + gateway_url_override="http://127.0.0.1:1", + ) + assert proc.returncode != 0 + assert "gateway" in proc.stderr.lower() + + +# ----------------------------------------------------------------------------- +# page get +# ----------------------------------------------------------------------------- + + +class TestPageGet: + def test_builds_request_and_prints_data(self, mock_gateway): + mock_gateway["server"].response_queue.append( + { + "status": 200, + "body": {"success": True, "data": {"id": "12345"}}, + } + ) + proc = _run_wrapper(mock_gateway, ["page", "get", "12345"]) + assert proc.returncode == 0, proc.stderr + rec = mock_gateway["server"].recorded[-1] + assert rec["path"] == "/api/v1/confluence/page/get" + assert rec["body"] == {"pageId": "12345"} + assert rec["authorization"] == "Bearer test-session-token" + out = json.loads(proc.stdout) + assert out == {"id": "12345"} + + def test_body_format_flag_forwarded(self, mock_gateway): + mock_gateway["server"].response_queue.append( + {"status": 200, "body": {"success": True, "data": {}}} + ) + proc = _run_wrapper( + mock_gateway, + ["page", "get", "12345", "--body-format", "storage,atlas_doc_format"], + ) + assert proc.returncode == 0, proc.stderr + rec = mock_gateway["server"].recorded[-1] + assert rec["body"] == { + "pageId": "12345", + "bodyFormat": ["storage", "atlas_doc_format"], + } + + def test_expand_flag_forwarded(self, mock_gateway): + mock_gateway["server"].response_queue.append( + {"status": 200, "body": {"success": True, "data": {}}} + ) + proc = _run_wrapper( + mock_gateway, + ["page", "get", "12345", "--expand", "history,version"], + ) + assert proc.returncode == 0, proc.stderr + rec = mock_gateway["server"].recorded[-1] + assert rec["body"]["expand"] == ["history", "version"] + + def test_missing_page_id_fails(self, mock_gateway): + proc = _run_wrapper(mock_gateway, ["page", "get"]) + assert proc.returncode != 0 + assert "pageid required" in proc.stderr.lower() + + def test_403_response_prints_error(self, mock_gateway): + mock_gateway["server"].response_queue.append( + { + "status": 403, + "body": { + "success": False, + "message": "Confluence space not allowlisted", + "details": {"spaceKey": "SECRET"}, + }, + } + ) + proc = _run_wrapper(mock_gateway, ["page", "get", "12345"]) + assert proc.returncode != 0 + assert "allowlist" in proc.stderr.lower() or "forbidden" in proc.stderr.lower() + + def test_413_response_hint(self, mock_gateway): + mock_gateway["server"].response_queue.append( + { + "status": 413, + "body": { + "success": False, + "message": "Confluence response too large", + }, + } + ) + proc = _run_wrapper(mock_gateway, ["page", "get", "12345"]) + assert proc.returncode != 0 + assert "too large" in proc.stderr.lower() + + def test_429_response_hint(self, mock_gateway): + mock_gateway["server"].response_queue.append( + { + "status": 429, + "body": {"success": False, "message": "Rate limited"}, + } + ) + proc = _run_wrapper(mock_gateway, ["page", "get", "12345"]) + assert proc.returncode != 0 + assert "rate limit" in proc.stderr.lower() + + +# ----------------------------------------------------------------------------- +# page descendants +# ----------------------------------------------------------------------------- + + +class TestPageDescendants: + def test_happy_path(self, mock_gateway): + mock_gateway["server"].response_queue.append( + {"status": 200, "body": {"success": True, "data": {}}} + ) + proc = _run_wrapper( + mock_gateway, + [ + "page", + "descendants", + "12345", + "--depth", + "2", + "--limit", + "10", + "--cursor", + "TOK-abc", + ], + ) + assert proc.returncode == 0, proc.stderr + rec = mock_gateway["server"].recorded[-1] + assert rec["path"] == "/api/v1/confluence/page/descendants" + assert rec["body"]["pageId"] == "12345" + assert rec["body"]["limit"] == 10 + assert rec["body"]["cursor"] == "TOK-abc" + + def test_non_int_limit_fails(self, mock_gateway): + proc = _run_wrapper( + mock_gateway, + ["page", "descendants", "12345", "--limit", "bad"], + ) + assert proc.returncode != 0 + assert "integer" in proc.stderr.lower() or "--limit" in proc.stderr.lower() + + +# ----------------------------------------------------------------------------- +# page footer-comments — --include-replies +# ----------------------------------------------------------------------------- + + +class TestFooterComments: + def test_include_replies_toggle_reaches_body(self, mock_gateway): + mock_gateway["server"].response_queue.append( + {"status": 200, "body": {"success": True, "data": {}}} + ) + proc = _run_wrapper( + mock_gateway, + [ + "page", + "footer-comments", + "12345", + "--include-replies", + ], + ) + assert proc.returncode == 0, proc.stderr + rec = mock_gateway["server"].recorded[-1] + assert rec["path"] == "/api/v1/confluence/page/footer-comments" + assert rec["body"]["pageId"] == "12345" + assert rec["body"]["includeReplies"] is True + + def test_default_include_replies_is_false(self, mock_gateway): + mock_gateway["server"].response_queue.append( + {"status": 200, "body": {"success": True, "data": {}}} + ) + proc = _run_wrapper( + mock_gateway, + ["page", "footer-comments", "12345"], + ) + assert proc.returncode == 0, proc.stderr + rec = mock_gateway["server"].recorded[-1] + assert rec["body"]["includeReplies"] is False + + +# ----------------------------------------------------------------------------- +# page inline-comments +# ----------------------------------------------------------------------------- + + +class TestInlineComments: + def test_happy_path(self, mock_gateway): + mock_gateway["server"].response_queue.append( + {"status": 200, "body": {"success": True, "data": {}}} + ) + proc = _run_wrapper(mock_gateway, ["page", "inline-comments", "12345"]) + assert proc.returncode == 0, proc.stderr + rec = mock_gateway["server"].recorded[-1] + assert rec["path"] == "/api/v1/confluence/page/inline-comments" + assert rec["body"]["pageId"] == "12345" + + +# ----------------------------------------------------------------------------- +# space pages / list +# ----------------------------------------------------------------------------- + + +class TestSpaceVerbs: + def test_space_list(self, mock_gateway): + mock_gateway["server"].response_queue.append( + {"status": 200, "body": {"success": True, "data": {}}} + ) + proc = _run_wrapper(mock_gateway, ["space", "list", "--limit", "10"]) + assert proc.returncode == 0, proc.stderr + rec = mock_gateway["server"].recorded[-1] + assert rec["path"] == "/api/v1/confluence/space/list" + assert rec["body"]["limit"] == 10 + + def test_space_pages(self, mock_gateway): + mock_gateway["server"].response_queue.append( + {"status": 200, "body": {"success": True, "data": {}}} + ) + proc = _run_wrapper(mock_gateway, ["space", "pages", "ENG", "--limit", "10"]) + assert proc.returncode == 0, proc.stderr + rec = mock_gateway["server"].recorded[-1] + assert rec["path"] == "/api/v1/confluence/space/pages" + assert rec["body"]["spaceKey"] == "ENG" + assert rec["body"]["limit"] == 10 + + def test_space_pages_missing_key_fails(self, mock_gateway): + proc = _run_wrapper(mock_gateway, ["space", "pages"]) + assert proc.returncode != 0 + assert "spacekey required" in proc.stderr.lower() + + +# ----------------------------------------------------------------------------- +# search — happy + adversarial passthrough +# ----------------------------------------------------------------------------- + + +class TestSearch: + def test_happy_path(self, mock_gateway): + mock_gateway["server"].response_queue.append( + {"status": 200, "body": {"success": True, "data": {"results": []}}} + ) + proc = _run_wrapper( + mock_gateway, + [ + "search", + 'space = ENG AND text ~ "RFC"', + "--limit", + "25", + "--cursor", + "TOK-1", + ], + ) + assert proc.returncode == 0, proc.stderr + rec = mock_gateway["server"].recorded[-1] + assert rec["path"] == "/api/v1/confluence/search" + assert rec["body"] == { + "cql": 'space = ENG AND text ~ "RFC"', + "limit": 25, + "cursor": "TOK-1", + } + + def test_search_403_from_gateway(self, mock_gateway): + mock_gateway["server"].response_queue.append( + { + "status": 403, + "body": { + "success": False, + "message": "CQL rejected: space under OR", + "details": {"reason": "space under OR"}, + }, + } + ) + proc = _run_wrapper( + mock_gateway, + ["search", "space = ENG OR space = SEC"], + ) + assert proc.returncode != 0 + assert "rejected" in proc.stderr.lower() + + +# ----------------------------------------------------------------------------- +# execute — happy + denied +# ----------------------------------------------------------------------------- + + +class TestExecute: + def test_happy_get(self, mock_gateway): + mock_gateway["server"].response_queue.append( + {"status": 200, "body": {"success": True, "data": {"id": "1"}}} + ) + proc = _run_wrapper( + mock_gateway, + ["execute", "GET", "api/v2/pages/12345"], + ) + assert proc.returncode == 0, proc.stderr + rec = mock_gateway["server"].recorded[-1] + assert rec["path"] == "/api/v1/confluence/execute" + assert rec["body"]["method"] == "GET" + assert rec["body"]["path"] == "api/v2/pages/12345" + + def test_happy_with_query(self, mock_gateway): + mock_gateway["server"].response_queue.append( + {"status": 200, "body": {"success": True, "data": {}}} + ) + proc = _run_wrapper( + mock_gateway, + [ + "execute", + "GET", + "api/v2/pages/12345", + "--query", + "body-format=storage,expand=history", + ], + ) + assert proc.returncode == 0, proc.stderr + rec = mock_gateway["server"].recorded[-1] + assert rec["body"]["query"] == { + "body-format": "storage", + "expand": "history", + } + + def test_denied_method_returns_error(self, mock_gateway): + """Wrapper forwards any verb; gateway is the enforcement surface.""" + mock_gateway["server"].response_queue.append( + { + "status": 403, + "body": { + "success": False, + "message": "Confluence API call rejected: HTTP method 'DELETE' not allowed", + "details": {"method": "DELETE"}, + }, + } + ) + proc = _run_wrapper( + mock_gateway, + ["execute", "DELETE", "api/v2/pages/12345"], + ) + assert proc.returncode != 0 + assert "rejected" in proc.stderr.lower() or "forbidden" in proc.stderr.lower() + + +# ----------------------------------------------------------------------------- +# help / unknown +# ----------------------------------------------------------------------------- + + +class TestUsage: + def test_help_exits_zero(self, mock_gateway): + proc = _run_wrapper(mock_gateway, ["help"]) + assert proc.returncode == 0 + assert "confluence" in proc.stdout.lower() + + def test_unknown_subcommand(self, mock_gateway): + proc = _run_wrapper(mock_gateway, ["unknown"]) + assert proc.returncode != 0 + assert "unknown" in proc.stderr.lower() From 843e9a9d259f0e1ac2bd6c69ef0a49d6240c4369 Mon Sep 17 00:00:00 2001 From: egg Date: Mon, 27 Apr 2026 07:12:23 +0000 Subject: [PATCH 21/26] docs: restore strict /execute anti-bypass invariant after cycle-3 fix (#1931) Cycle-3 commit f3f552eb9 closed the four flat-v2 bypass paths in gateway/confluence_client.py::CONFLUENCE_API_ALLOWED_PATHS: - rest/api/search (CQL extractor bypass) - api/v2/spaces (allowlist-filter bypass) - api/v2/footer-comments (page-id-in-query, no upstream spaceKey filter) - api/v2/inline-comments (same flat-endpoint shape) The cycle-3 work: - Restores the strict "Anti-bypass invariant" section (replacing the prior cycle-2 "Known gap" placeholder) in docs/reference/confluence-wrapper.md, with a concrete table of the four removed paths + the bypass each would have enabled, and a pointer at the regression tests pinning the rejection (gateway/tests/test_confluence_client.py + test_confluence_routes.py end-to-end via Flask test client) that the tester landed in 6b44b5932. - Updates the "Allowed path families" bullet in the /execute section to list only the six remaining path families (all page- or space-scoped, all carry an inline id) and explicitly call out the four exclusions. - Updates the Error cases table row to drop the "Known gap" pointer and describe the disallowed-path-family reason concretely. - Updates the Hard limits line in sandbox/agent-config/rules/environment.md to enumerate the four refused paths and direct callers to the narrow verbs (confluence search, confluence space list, confluence page footer-comments, confluence page inline-comments). Verified each enumeration against gateway/confluence_client.py:183-192 at branch HEAD (rebased onto 6b44b5932). Co-Authored-By: Claude Opus 4.7 --- docs/reference/confluence-wrapper.md | 19 ++++++++++++++----- sandbox/agent-config/rules/environment.md | 2 +- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/docs/reference/confluence-wrapper.md b/docs/reference/confluence-wrapper.md index 3a28f860bd..3239ca2ed5 100644 --- a/docs/reference/confluence-wrapper.md +++ b/docs/reference/confluence-wrapper.md @@ -171,14 +171,23 @@ The extractor is the hard boundary — if it cannot prove the query is scoped to Only `GET` is accepted. The `path` is validated against a hardened regex allowlist in `validate_confluence_api_path`: - Leading/trailing slashes are stripped, query strings are stripped, `..` segments are rejected, duplicate slashes are rejected, non-ASCII / non-normalized Unicode is rejected, URL-encoded smuggling (e.g., `%61ttachments`) is rejected. -- Allowed path families (GET-only): `^api/v2/pages/\d+$`, `^api/v2/pages/\d+/descendants$`, `^api/v2/pages/\d+/footer-comments$`, `^api/v2/pages/\d+/inline-comments$`, `^api/v2/footer-comments$`, `^api/v2/inline-comments$`, `^api/v2/spaces$`, `^api/v2/spaces/\d+/pages$`, `^rest/api/search$`, `^rest/api/content/\d+/child/comment$` (the v1 fallback for inline comments). +- Allowed path families (GET-only, all carry an inline id): `^api/v2/pages/\d+$`, `^api/v2/pages/\d+/descendants$`, `^api/v2/pages/\d+/footer-comments$`, `^api/v2/pages/\d+/inline-comments$`, `^api/v2/spaces/\d+/pages$`, `^rest/api/content/\d+/child/comment$` (the v1 fallback for inline comments). The four flat-v2 endpoints (`api/v2/footer-comments`, `api/v2/inline-comments`, `api/v2/spaces`, `rest/api/search`) are **deliberately excluded** — see the [Anti-bypass invariant](#anti-bypass-invariant) below. - Any path containing `restrictions`, `permissions`, `space.admin`, `users`, or `attachments` is rejected — these are the permanent "out of scope ever" verbs (decision 12). The `CONFLUENCE_DENIED_VERBS` frozenset checks for the term in any path position so `pages/123/attachments` is refused as well. -For path families that target a specific resource (`pages/{id}`), the post-fetch space-allowlist check runs once the upstream response arrives, identical to the narrow routes. For families that don't carry an obvious `spaceId` in the response (e.g., `api/v2/footer-comments?page-id=...`), the route requires the agent to supply a `spaceKey` query parameter and validates it up-front before issuing the upstream call. +All path families that the `/execute` allowlist accepts target a specific resource — either a page (`pages/{id}`, `pages/{id}/descendants`, `pages/{id}/footer-comments`, `pages/{id}/inline-comments`, `rest/api/content/{id}/child/comment` v1 fallback) or a space (`spaces/{id}/pages`). The post-fetch space-allowlist check resolves each request's `spaceKey` once the upstream response arrives, identical to the narrow routes. The cycle-3 fix dropped the `requires_space_key` branch from the `/execute` route handler — there is no longer any path family in the allowlist that lacks an inline id. -**Known gap (tracked under issue #1931 cycle-2 NACK).** As of v1, the `/execute` regex allowlist also accepts the path families that the narrow routes already cover — `api/v2/pages/{id}`, `api/v2/spaces/{id}/pages`, and `rest/api/search`. Routing those through `/execute` does **not** apply the narrow route's policy (e.g., the CQL static space-scope extractor on `rest/api/search`, or the response-side allowlist filtering on `api/v2/spaces`). This is a real bypass surface and is being closed in a follow-up commit on this PR; the reviewer_code NACK on the underlying code change drives that work. Until that fix lands, treat `/execute` as authorised against the same regex + denied-verbs gate but **without** the narrow-route policy layered on top — operators relying on the anti-bypass property must wait for the cycle-2 fix. +**Anti-bypass invariant.** `/execute` does **not** accept the four "flat-v2" path families that would skip narrow-route policy: -`/execute` is a pragmatic escape hatch for future read verbs not yet promoted to narrow routes. The regex allowlist plus the `CONFLUENCE_DENIED_VERBS` frozenset is the fence today; once the cycle-2 fix lands, `/execute` will additionally refuse paths that overlap with narrow-route coverage and emit `confluence_execute_denied` for those attempts, restoring the strict anti-bypass invariant. +| Removed path | Bypass that would have been possible | +|--------------|--------------------------------------| +| `rest/api/search` | Arbitrary CQL bypassing `extract_search_spaces` (the static space-scope extractor) | +| `api/v2/spaces` | Full tenant-space enumeration bypassing `list_spaces`'s allowlist filter (defeats decision-11) | +| `api/v2/footer-comments` (flat) | `page-id`-in-query with no upstream `spaceKey` filter — post-fetch allowlist cannot resolve the targeted page | +| `api/v2/inline-comments` (flat) | Same flat-endpoint shape as footer-comments | + +These four paths remain reachable **internally** by `ConfluenceClient` methods that construct them directly (`get_page_footer_comments`'s `include_replies` side-call against `api/v2/footer-comments?page-id=...&depth=all`, and `get_page_inline_comments`'s v2-bug v1 fallback against `rest/api/content/{id}/child/comment`) — those internal calls do **not** go through `validate_confluence_api_path`. Only the agent-facing `/execute` escape hatch refuses them. A regression test in `gateway/tests/test_confluence_client.py` parametrizes the four removed paths and asserts they fail the validator; an end-to-end regression test in `gateway/tests/test_confluence_routes.py` asserts the same paths return 403 `confluence_execute_denied` through the Flask test client. Mirrors `gateway/jira_client.py`'s permanent denylist of `search/jql` + bare `project` for the same anti-bypass reason (PR #1964). + +`/execute` is a pragmatic escape hatch for future read verbs not yet promoted to narrow routes. It is **not** a general passthrough — the regex allowlist (page- and space-scoped paths only), the `CONFLUENCE_DENIED_VERBS` frozenset, and the anti-bypass invariant together are the fence. ## `not_found` envelope @@ -218,7 +227,7 @@ If a tenant carries custom Confluence macros, page properties, or fields known t | 403 | Public mode (private-mode gate) | `private_mode_required` | | 403 | Resolved space not in `confluence.spaces` | `confluence_space_denied` | | 403 | `/search` CQL fails the static scope extractor | `confluence_search_rejected` with the specific reason | -| 403 | `/execute` denied verb, non-GET method, path traversal, disallowed path family, duplicate slash, non-ASCII | `confluence_execute_denied` with reason (the narrow-route bypass refusal is being added in the cycle-2 fix — see the "Known gap" note in the [`/execute`](#post-apiv1confluenceexecute) section above) | +| 403 | `/execute` denied verb, non-GET method, path traversal, disallowed path family (including the four flat-v2 anti-bypass paths), duplicate slash, non-ASCII | `confluence_execute_denied` with reason | | 403 | Atlassian returned 403 (bot lacks read access on the resource) | `confluence_upstream_403` (distinct from generic `confluence_upstream_error`) — body: `{"status": "forbidden", "reason": "bot_account_lacks_read_access", "pageId" \| "spaceKey": "..."}` | | 413 | Response body exceeds `CONFLUENCE_RESPONSE_MAX_BYTES` (5 MiB) post-redaction | `confluence_response_too_large` | | 503 | Atlassian credentials not configured (`ConfluenceCredentialsUnavailable`) | `confluence_credentials_unavailable` | diff --git a/sandbox/agent-config/rules/environment.md b/sandbox/agent-config/rules/environment.md index d497da2663..620cd7180d 100644 --- a/sandbox/agent-config/rules/environment.md +++ b/sandbox/agent-config/rules/environment.md @@ -110,7 +110,7 @@ confluence page inline-comments 12345 confluence search 'space = ENG OR space = SEC' ``` -**Hard limits (always denied):** `restrictions`, `permissions`, `space.admin`, `users`, `attachments`, HTTP `DELETE` / `PUT` / `PATCH`, path traversal (`..`), duplicate slashes, non-ASCII keys, URL-encoded smuggling of denied terms (e.g., `%61ttachments`). Non-GET `execute` calls return 403 regardless of the path. **Note:** in v1 the `/execute` regex allowlist still accepts paths that the narrow routes also cover (`api/v2/pages/{id}`, `api/v2/spaces/{id}/pages`, `rest/api/search`); the strict narrow-route anti-bypass refusal is being added in a follow-up commit on issue #1931 — see [Confluence Wrapper Reference](../../../docs/reference/confluence-wrapper.md) for the current state, full endpoint surface, CQL scope extractor rules, the `not_found` response envelope, response redaction (`accountId` / `emailAddress` / user-profile `_links.webui` / user-profile `_links.self`), and the `used_fallback` flag emitted when the v1 inline-comment fallback fires. +**Hard limits (always denied):** `restrictions`, `permissions`, `space.admin`, `users`, `attachments`, HTTP `DELETE` / `PUT` / `PATCH`, path traversal (`..`), duplicate slashes, non-ASCII keys, URL-encoded smuggling of denied terms (e.g., `%61ttachments`), and the four flat-v2 paths refused by the anti-bypass invariant — `rest/api/search`, `api/v2/spaces`, `api/v2/footer-comments`, `api/v2/inline-comments`. CQL search must flow through `confluence search ...`; space enumeration must flow through `confluence space list`; comment reads must flow through `confluence page footer-comments` / `confluence page inline-comments` (those page-scoped routes apply the post-fetch allowlist check). Non-GET `execute` calls return 403 regardless of the path. See [Confluence Wrapper Reference](../../../docs/reference/confluence-wrapper.md) for the full endpoint surface, CQL scope extractor rules, the `not_found` response envelope, response redaction (`accountId` / `emailAddress` / user-profile `_links.webui` / user-profile `_links.self`), and the `used_fallback` flag emitted when the v1 inline-comment fallback fires. > **Note on `~/context-sync/confluence/`.** The sandbox may also have a read-only `~/context-sync/confluence/` cache mounted (legacy syncer). That cache is independent of the new gateway wrapper — `confluence ...` calls always go through the gateway and never touch the syncer cache. From 997578d9531eb1b5aaf90810b1ce272d3c129dcf Mon Sep 17 00:00:00 2001 From: "egg-reviewer[bot]" <261018737+egg-reviewer[bot]@users.noreply.github.com> Date: Mon, 27 Apr 2026 07:56:39 +0000 Subject: [PATCH 22/26] Address PR #2141 review: pagination, redaction, lock, allowlist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six fixes from review feedback on PR #2141: 1. populate_space_cache(): new method that walks _links.next pagination (capped at 4 pages) so /space/list cache lookups for spaceKey translation see all allowlisted spaces, not just the first 25. Preserves cursor semantics for the user-facing /space/list route by keeping list_spaces single-page. 2. Redact upstream error body: ConfluenceUpstreamError 5xx bodies are passed through redact_response before being included in the confluence_upstream_error response, so accountId/emailAddress leaked by Atlassian errors do not reach the sandbox. 3. Lock around lazy _client(): double-checked locking on ConfluenceClient.http_client construction to avoid two concurrent first-callers each creating a separate httpx.Client. 4. Drop descendants / comments / v1-comment paths from /execute allowlist: the dedicated narrow routes (/page/descendants, /page/footer-comments, /page/inline-comments) already enforce policy for those reads; /execute should not duplicate them. The remaining allowlist is just `api/v2/pages/{id}` and `api/v2/spaces/{id}/pages`. 5. Tighten CQL extractor rejection message for id/content/title clauses: now "id, content, and title clauses are not supported; use 'text ~ ...' instead" — points agents at the supported shape. 6. Rename _contains_top_level_or → _contains_or and _contains_bare_id_clause → _contains_id_clause to match the actual semantics (the regex matches at any depth, not just the top level; id/content/title are rejected with or without a space anchor). Tests: 24 new / updated assertions across test_confluence_client.py, test_confluence_routes.py, and test_confluence_search.py covering pagination, error-body redaction, lazy-client lock, and the new rejection-reason wording. Docs updated to reflect the tightened /execute allowlist. — Authored by egg --- docs/reference/confluence-wrapper.md | 10 +- gateway/confluence_client.py | 100 +++++++++++++++++--- gateway/confluence_search.py | 28 ++++-- gateway/gateway.py | 61 ++++++++---- gateway/tests/test_confluence_client.py | 119 ++++++++++++++++++++++-- gateway/tests/test_confluence_routes.py | 96 +++++++++++++++++-- gateway/tests/test_confluence_search.py | 17 +++- 7 files changed, 371 insertions(+), 60 deletions(-) diff --git a/docs/reference/confluence-wrapper.md b/docs/reference/confluence-wrapper.md index 3239ca2ed5..bada16ac99 100644 --- a/docs/reference/confluence-wrapper.md +++ b/docs/reference/confluence-wrapper.md @@ -171,10 +171,10 @@ The extractor is the hard boundary — if it cannot prove the query is scoped to Only `GET` is accepted. The `path` is validated against a hardened regex allowlist in `validate_confluence_api_path`: - Leading/trailing slashes are stripped, query strings are stripped, `..` segments are rejected, duplicate slashes are rejected, non-ASCII / non-normalized Unicode is rejected, URL-encoded smuggling (e.g., `%61ttachments`) is rejected. -- Allowed path families (GET-only, all carry an inline id): `^api/v2/pages/\d+$`, `^api/v2/pages/\d+/descendants$`, `^api/v2/pages/\d+/footer-comments$`, `^api/v2/pages/\d+/inline-comments$`, `^api/v2/spaces/\d+/pages$`, `^rest/api/content/\d+/child/comment$` (the v1 fallback for inline comments). The four flat-v2 endpoints (`api/v2/footer-comments`, `api/v2/inline-comments`, `api/v2/spaces`, `rest/api/search`) are **deliberately excluded** — see the [Anti-bypass invariant](#anti-bypass-invariant) below. +- Allowed path families (GET-only, both carry an inline id): `^api/v2/pages/\d+$`, `^api/v2/spaces/\d+/pages$`. The four flat-v2 endpoints (`api/v2/footer-comments`, `api/v2/inline-comments`, `api/v2/spaces`, `rest/api/search`) and the page-scoped descendants / comment paths (`api/v2/pages/\d+/descendants`, `api/v2/pages/\d+/footer-comments`, `api/v2/pages/\d+/inline-comments`, `rest/api/content/\d+/child/comment`) are **deliberately excluded** — see the [Anti-bypass invariant](#anti-bypass-invariant) below. - Any path containing `restrictions`, `permissions`, `space.admin`, `users`, or `attachments` is rejected — these are the permanent "out of scope ever" verbs (decision 12). The `CONFLUENCE_DENIED_VERBS` frozenset checks for the term in any path position so `pages/123/attachments` is refused as well. -All path families that the `/execute` allowlist accepts target a specific resource — either a page (`pages/{id}`, `pages/{id}/descendants`, `pages/{id}/footer-comments`, `pages/{id}/inline-comments`, `rest/api/content/{id}/child/comment` v1 fallback) or a space (`spaces/{id}/pages`). The post-fetch space-allowlist check resolves each request's `spaceKey` once the upstream response arrives, identical to the narrow routes. The cycle-3 fix dropped the `requires_space_key` branch from the `/execute` route handler — there is no longer any path family in the allowlist that lacks an inline id. +All path families that the `/execute` allowlist accepts target a specific resource — either a page (`pages/{id}`) or a space (`spaces/{id}/pages`). The post-fetch space-allowlist check resolves each request's `spaceKey` once the upstream response arrives, identical to the narrow routes. The descendants / footer-comments / inline-comments / v1-comment paths were dropped from the `/execute` allowlist because the dedicated narrow routes (`POST /api/v1/confluence/page/descendants`, `/page/footer-comments`, `/page/inline-comments`) already cover those reads with the right policy hooks (depth defaults, v1 fallback bookkeeping, post-fetch allowlist) — `/execute` is a generic escape hatch and should not duplicate them. **Anti-bypass invariant.** `/execute` does **not** accept the four "flat-v2" path families that would skip narrow-route policy: @@ -184,8 +184,12 @@ All path families that the `/execute` allowlist accepts target a specific resour | `api/v2/spaces` | Full tenant-space enumeration bypassing `list_spaces`'s allowlist filter (defeats decision-11) | | `api/v2/footer-comments` (flat) | `page-id`-in-query with no upstream `spaceKey` filter — post-fetch allowlist cannot resolve the targeted page | | `api/v2/inline-comments` (flat) | Same flat-endpoint shape as footer-comments | +| `api/v2/pages/{id}/descendants` | Duplicates `POST /api/v1/confluence/page/descendants`, which already enforces depth defaults and post-fetch allowlist | +| `api/v2/pages/{id}/footer-comments` | Duplicates `POST /api/v1/confluence/page/footer-comments`, which already merges nested replies and applies the allowlist | +| `api/v2/pages/{id}/inline-comments` | Duplicates `POST /api/v1/confluence/page/inline-comments`, which owns the v2→v1 fallback flag | +| `rest/api/content/{id}/child/comment` | The v1 inline-comment fallback is an internal-only retry path; agents must use the `/page/inline-comments` route | -These four paths remain reachable **internally** by `ConfluenceClient` methods that construct them directly (`get_page_footer_comments`'s `include_replies` side-call against `api/v2/footer-comments?page-id=...&depth=all`, and `get_page_inline_comments`'s v2-bug v1 fallback against `rest/api/content/{id}/child/comment`) — those internal calls do **not** go through `validate_confluence_api_path`. Only the agent-facing `/execute` escape hatch refuses them. A regression test in `gateway/tests/test_confluence_client.py` parametrizes the four removed paths and asserts they fail the validator; an end-to-end regression test in `gateway/tests/test_confluence_routes.py` asserts the same paths return 403 `confluence_execute_denied` through the Flask test client. Mirrors `gateway/jira_client.py`'s permanent denylist of `search/jql` + bare `project` for the same anti-bypass reason (PR #1964). +The flat-v2 paths and the page-scoped comment / descendants / v1-comment paths remain reachable **internally** by `ConfluenceClient` methods that construct them directly (`get_page_descendants`, `get_page_footer_comments`'s `include_replies` side-call against `api/v2/footer-comments?page-id=...&depth=all`, `get_page_inline_comments` against `api/v2/pages/{id}/inline-comments` with a `rest/api/content/{id}/child/comment` v1 fallback, and `list_spaces` against `api/v2/spaces`) — those internal calls do **not** go through `validate_confluence_api_path`. Only the agent-facing `/execute` escape hatch refuses them; agents reach the same data through the dedicated narrow routes (`/page/descendants`, `/page/footer-comments`, `/page/inline-comments`, `/space/list`). A regression test in `gateway/tests/test_confluence_client.py` parametrizes the removed paths and asserts they fail the validator; an end-to-end regression test in `gateway/tests/test_confluence_routes.py` asserts the same paths return 403 `confluence_execute_denied` through the Flask test client. Mirrors `gateway/jira_client.py`'s permanent denylist of `search/jql` + bare `project` for the same anti-bypass reason (PR #1964). `/execute` is a pragmatic escape hatch for future read verbs not yet promoted to narrow routes. It is **not** a general passthrough — the regex allowlist (page- and space-scoped paths only), the `CONFLUENCE_DENIED_VERBS` frozenset, and the anti-bypass invariant together are the fence. diff --git a/gateway/confluence_client.py b/gateway/confluence_client.py index d7aba2f91c..dc94130899 100644 --- a/gateway/confluence_client.py +++ b/gateway/confluence_client.py @@ -89,6 +89,7 @@ from dataclasses import dataclass, field from pathlib import Path from typing import Any +from urllib.parse import parse_qs, urlparse import httpx @@ -163,8 +164,8 @@ # Anti-bypass invariant (reviewer_code 9ae21669 + reviewer_security ec5985ff # cycle-3 NACK on issue #1931): the /execute path allowlist must NOT include # any path family that a narrow route already covers, because routing those -# through /execute skips the route-level safeguards. The four removed paths -# and their bypass shapes: +# through /execute skips the route-level safeguards. The original removed +# paths and their bypass shapes: # # - ``rest/api/search`` — bypasses extract_search_spaces (CQL extractor) # - ``api/v2/spaces`` — bypasses list_spaces' allowlist filter @@ -173,6 +174,15 @@ # check cannot resolve the targeted page # - ``api/v2/inline-comments`` (flat) — same flat-endpoint shape # +# Additionally, the page-scoped descendant / comment subpaths +# (``api/v2/pages/{id}/descendants`` etc.) are intentionally NOT in the +# allowlist either: their response body has no top-level ``spaceId``, so +# ``_check_post_fetch_space_allowlist`` always returns ``(False, None)`` and +# the route always emits ``confluence_space_denied`` — i.e. they're +# unusable via /execute. Agents reach those endpoints through the +# dedicated /api/v1/confluence/page/* routes, which fetch the parent page +# and resolve spaceKey before the comment/descendant body ships. +# # These paths remain reachable INTERNALLY (the client methods construct them # directly without going through validate_confluence_api_path) for the # include_replies side-call inside get_page_footer_comments and the v2-bug @@ -182,13 +192,7 @@ # reason (PR #1964). CONFLUENCE_API_ALLOWED_PATHS: list[re.Pattern[str]] = [ re.compile(rf"^api/v2/pages/{_PAGE_ID}$"), - re.compile(rf"^api/v2/pages/{_PAGE_ID}/descendants$"), - re.compile(rf"^api/v2/pages/{_PAGE_ID}/footer-comments$"), - re.compile(rf"^api/v2/pages/{_PAGE_ID}/inline-comments$"), re.compile(rf"^api/v2/spaces/{_SPACE_ID}/pages$"), - # v1 fallback for inline comments (decision D1) — page-scoped, the - # /execute post-fetch space-allowlist check covers it. - re.compile(rf"^rest/api/content/{_PAGE_ID}/child/comment$"), ] # CQL search has a 200-result hard upper bound at Atlassian. We clamp at @@ -527,10 +531,18 @@ class ConfluenceClient: timeout_seconds: float = _DEFAULT_TIMEOUT_SECONDS space_cache: _SpaceCache = field(default_factory=_SpaceCache) _logged_default_body_format: bool = field(default=False, init=False, repr=False) + _http_client_lock: threading.Lock = field( + default_factory=threading.Lock, init=False, repr=False + ) def _client(self) -> httpx.Client: + # Concurrent first requests must not each construct (and leak) an + # httpx.Client. Double-check under the lock so the hot path stays + # lock-free once the client is initialised. if self.http_client is None: - self.http_client = httpx.Client(timeout=self.timeout_seconds) + with self._http_client_lock: + if self.http_client is None: + self.http_client = httpx.Client(timeout=self.timeout_seconds) return self.http_client def _build_url(self, creds: ConfluenceCredentials, path: str) -> str: @@ -801,6 +813,7 @@ def list_spaces( _raise_for_status(response, path) body_json = _safe_json(response, path) + self._populate_cache_from_spaces_payload(body_json) results = body_json.get("results") if isinstance(results, list): kept: list[Any] = [] @@ -808,15 +821,53 @@ def list_spaces( if not isinstance(entry, dict): continue key = entry.get("key") - space_id = entry.get("id") - if isinstance(space_id, (str, int)) and isinstance(key, str): - self.space_cache.put(str(space_id), key) if isinstance(key, str) and key in allowed_spaces: kept.append(entry) body_json["results"] = kept return _finalize_response(body_json, path) + def populate_space_cache(self, *, max_pages: int = 4) -> None: + """Walk ``GET /wiki/api/v2/spaces`` pagination to fill the cache. + + Routes that translate ``spaceKey``↔``spaceId`` rely on the cache; if + the operator's tenant has more spaces than fit on a single v2 page, + a target sitting on page 2+ would otherwise look unresolvable and + the call would fail-closed. Walks at most ``max_pages`` pages so a + very large tenant cannot pin the gateway on a slow upstream. + + The 403 / non-2xx error shapes mirror ``list_spaces`` so callers can + catch the same exception types. + """ + path = "api/v2/spaces" + cursor: str | None = None + for _ in range(max_pages): + query: dict[str, Any] = {} + if cursor: + query["cursor"] = cursor + response = self._request("GET", path, query=query or None) + if response.status_code == 403: + raise ConfluenceUpstreamForbidden(403, _safe_response_body(response), path) + _raise_for_status(response, path) + body_json = _safe_json(response, path) + self._populate_cache_from_spaces_payload(body_json) + cursor = _extract_next_cursor(body_json) + if not cursor: + return + + def _populate_cache_from_spaces_payload(self, body_json: dict[str, Any]) -> None: + """Insert every ``{id, key}`` pair from a ``/wiki/api/v2/spaces`` page.""" + results = body_json.get("results") + if not isinstance(results, list): + return + for entry in results: + if not isinstance(entry, dict): + continue + key = entry.get("key") + space_id = entry.get("id") + if isinstance(space_id, (str, int)) and isinstance(key, str): + self.space_cache.put(str(space_id), key) + def get_space_pages( self, space_id: str, @@ -950,6 +1001,31 @@ def _finalize_response(body: dict[str, Any], path: str) -> dict[str, Any]: return body +def _extract_next_cursor(body_json: dict[str, Any]) -> str | None: + """Return the ``cursor`` value from ``_links.next`` if Atlassian set one. + + The v2 API returns the next page as a relative URL with a ``cursor=...`` + query parameter; absence of a cursor (or absence of ``_links.next``) means + the caller has reached the last page. + """ + links = body_json.get("_links") + if not isinstance(links, dict): + return None + next_url = links.get("next") + if not isinstance(next_url, str) or not next_url: + return None + try: + parsed = urlparse(next_url) + except ValueError: + return None + qs = parse_qs(parsed.query) + cursor_values = qs.get("cursor") + if not cursor_values: + return None + cursor = cursor_values[0] + return cursor or None + + def _parse_retry_after(value: str | None) -> int: """Parse a ``Retry-After`` header value to an integer number of seconds.""" if value is None: diff --git a/gateway/confluence_search.py b/gateway/confluence_search.py index b219fff89b..00210060f0 100644 --- a/gateway/confluence_search.py +++ b/gateway/confluence_search.py @@ -93,12 +93,17 @@ def extract_search_spaces(cql: str, allowed: frozenset[str]) -> ScopeResult: # 2. Reject any OR (case-insensitive) at any depth. ``space IN (K, K)`` # never contains an OR token, so any OR is a rejection. - if _contains_top_level_or(normalised): + if _contains_or(normalised): return ScopeResult(None, "space under OR") - # 3. Reject bare id / content / title clauses without a space anchor. - if _contains_bare_id_clause(normalised): - return ScopeResult(None, "id-level clause without space scope") + # 3. Reject id / content / title clauses entirely (regardless of any + # accompanying space anchor). Each one widens scope past what the + # space-only extractor below can prove. + if _contains_id_clause(normalised): + return ScopeResult( + None, + "id, content, and title clauses are not supported; use 'text ~ ...' instead", + ) tokens = _extract_space_clauses(normalised) if tokens is None: @@ -143,14 +148,19 @@ def _normalise_strings(cql: str) -> str | None: return "".join(out) -def _contains_top_level_or(cql: str) -> bool: - """Return True if the CQL contains an OR boolean operator (any depth).""" +def _contains_or(cql: str) -> bool: + """Return True if the CQL contains an ``OR`` boolean operator at any depth.""" return re.search(r"(?i)(? bool: - """Return True if the CQL references ``id`` / ``content`` / ``title`` as a - filter clause without anchoring on ``space``. These widen scope. +def _contains_id_clause(cql: str) -> bool: + """Return True if the CQL references ``id`` / ``content`` / ``title``. + + These clauses are rejected unconditionally — including when an + accompanying ``space`` anchor is present — because the static extractor + only proves space scope for the exact ``space = K`` / ``space IN (...)`` + shapes and cannot reason about how an ``id`` / ``content`` / ``title`` + filter widens (or fails to widen) the result set. """ pattern = re.compile( r"(?i)(?|<)", diff --git a/gateway/gateway.py b/gateway/gateway.py index a629a9a682..eca5bb82c2 100644 --- a/gateway/gateway.py +++ b/gateway/gateway.py @@ -100,6 +100,7 @@ ConfluenceUpstreamError, ConfluenceUpstreamForbidden, get_confluence_client, + redact_response, validate_confluence_api_path, ) from .confluence_credentials import reload_confluence_credentials @@ -263,6 +264,7 @@ ConfluenceUpstreamError, ConfluenceUpstreamForbidden, get_confluence_client, + redact_response, validate_confluence_api_path, ) from confluence_credentials import ( # type: ignore[no-redef, import-untyped] @@ -4798,7 +4800,14 @@ def _session_confluence_context() -> dict[str, Any]: def _confluence_error_from_upstream(exc: ConfluenceUpstreamError) -> tuple[Response, int]: - """Translate a ``ConfluenceUpstreamError`` to an HTTP response.""" + """Translate a ``ConfluenceUpstreamError`` to an HTTP response. + + Atlassian error envelopes occasionally include user-identifying strings + (e.g. account ids embedded in messages) and space-enumeration leaks + (e.g. ``"valid keys are: ENG, DOCS, SECRET"``). The success-path + redactor only runs on 2xx bodies, so we apply it here too before the + upstream body crosses the gateway/sandbox boundary. + """ if 400 <= exc.status_code < 500: status = exc.status_code else: @@ -4808,12 +4817,24 @@ def _confluence_error_from_upstream(exc: ConfluenceUpstreamError) -> tuple[Respo status_code=status, details={ "upstream_status": exc.status_code, - "upstream_body": exc.body, + "upstream_body": _redact_upstream_error_body(exc.body), "path": exc.path, }, ) +def _redact_upstream_error_body(body: Any) -> Any: + """Run ``redact_response`` over an Atlassian error envelope. + + Atlassian returns errors as JSON dicts (and very occasionally as plain + text); the redactor mutates dicts/lists in place. Non-container shapes + pass through unchanged. + """ + if isinstance(body, (dict, list)): + return redact_response(body) + return body + + def _confluence_not_configured_error( exc: ConfluenceCredentialsUnavailable, ) -> tuple[Response, int]: @@ -4931,21 +4952,27 @@ def _resolve_space_key_for_payload(payload: Any) -> str | None: def _resolve_space_key_via_list(allowed: frozenset[str], space_id: str | None) -> str | None: - """Look up a space key for a space id by calling list_spaces (cached). + """Look up a space key for a space id by warming the space cache. Used by the post-fetch allowlist check when the page response carries ``spaceId`` but the cache hasn't been populated yet. Returns ``None`` if the space isn't visible to the bot (which is itself a deny signal). + + ``allowed`` is unused at this layer; the cache is populated with every + space the bot can see and the post-fetch allowlist check applies the + operator allowlist on the resolved key. """ + del allowed # cache holds every visible space; allowlist enforced upstream if not space_id: return None client = get_confluence_client() cached = client.space_cache.key_for_id(str(space_id)) if cached is not None: return cached - # Force a fetch so the cache is hot for the next request. + # Walk paginated /wiki/api/v2/spaces so a target space on page 2+ still + # resolves. populate_space_cache caps iterations defensively. try: - client.list_spaces(allowed_spaces=allowed) + client.populate_space_cache() except ( ConfluenceCredentialsUnavailable, ConfluenceUpstreamError, @@ -5483,14 +5510,15 @@ def confluence_space_pages() -> tuple[Response, int] | Response: except ValueError as exc: return make_error(f"Invalid limit: {exc}", status_code=400) - allowed = confluence_allowed_spaces() client = get_confluence_client() - # Resolve spaceKey → spaceId, using the cache when populated. + # Resolve spaceKey → spaceId, using the cache when populated. Walk + # paginated /wiki/api/v2/spaces so tenants with more spaces than fit on + # one v2 page still resolve a target on page 2+. space_id = client.space_cache.id_for_key(space_key) if space_id is None: try: - client.list_spaces(allowed_spaces=allowed) + client.populate_space_cache() except ConfluenceCredentialsUnavailable as exc: return _confluence_not_configured_error(exc) except ConfluenceUpstreamForbidden as exc: @@ -5761,18 +5789,14 @@ def confluence_execute() -> tuple[Response, int] | Response: head = stripped.split("/") page_id: str | None = None space_id_in_path: str | None = None - if len(head) >= 3 and head[0] == "api" and head[1] == "v2" and head[2] == "pages": - # api/v2/pages/... - if len(head) >= 4 and head[3].isdigit(): + if len(head) >= 4 and head[0] == "api" and head[1] == "v2" and head[2] == "pages": + # api/v2/pages/ + if head[3].isdigit(): page_id = head[3] - elif len(head) >= 4 and head[0] == "api" and head[1] == "v2" and head[2] == "spaces": + elif len(head) >= 5 and head[0] == "api" and head[1] == "v2" and head[2] == "spaces": # api/v2/spaces//pages if head[3].isdigit(): space_id_in_path = head[3] - elif len(head) >= 4 and head[0] == "rest" and head[1] == "api" and head[2] == "content": - # v1 fallback for inline comments — page-scoped. - if head[3].isdigit(): - page_id = head[3] # Anti-bypass invariant (issue #1931 cycle-3 NACK from reviewer_code + # reviewer_security): the four path families an attacker could use to @@ -5837,9 +5861,10 @@ def confluence_execute() -> tuple[Response, int] | Response: elif space_id_in_path is not None: resolved = client.space_cache.key_for_id(space_id_in_path) if resolved is None: - # Hot the cache by listing spaces. + # Walk paginated /wiki/api/v2/spaces so a target on page 2+ + # still resolves. try: - client.list_spaces(allowed_spaces=allowed) + client.populate_space_cache() except (ConfluenceCredentialsUnavailable, ConfluenceUpstreamError): resolved = None else: diff --git a/gateway/tests/test_confluence_client.py b/gateway/tests/test_confluence_client.py index 4110093867..f8ac9f56e1 100644 --- a/gateway/tests/test_confluence_client.py +++ b/gateway/tests/test_confluence_client.py @@ -75,11 +75,7 @@ class TestValidateConfluenceApiPath: "path", [ "api/v2/pages/12345", - "api/v2/pages/1/descendants", - "api/v2/pages/1/footer-comments", - "api/v2/pages/1/inline-comments", "api/v2/spaces/42/pages", - "rest/api/content/12345/child/comment", ], ) def test_positive_get_paths(self, path: str): @@ -101,13 +97,29 @@ def test_positive_get_paths(self, path: str): # api/v2/inline-comments page-id query param while spaceKey # fakes the gate (Atlassian ignores # spaceKey upstream). - # All four remain reachable INTERNALLY by ConfluenceClient methods - # that construct them directly; only the agent-facing /execute - # escape hatch is closed. + # + # Post-review tightening (PR #2141): the page-scoped descendant + # and comment subpaths + # - api/v2/pages//descendants + # - api/v2/pages//footer-comments + # - api/v2/pages//inline-comments + # - rest/api/content//child/comment + # are also dropped from /execute. Their response bodies have no + # top-level spaceId so the /execute post-fetch allowlist check + # always fail-closed; agents reach those endpoints through the + # dedicated /api/v1/confluence/page/* routes which fetch the + # parent page and resolve spaceKey explicitly. All four remain + # reachable INTERNALLY by ConfluenceClient methods that construct + # them directly; only the agent-facing /execute escape hatch is + # closed. "api/v2/spaces", "rest/api/search", "api/v2/footer-comments", "api/v2/inline-comments", + "api/v2/pages/1/descendants", + "api/v2/pages/1/footer-comments", + "api/v2/pages/1/inline-comments", + "rest/api/content/1/child/comment", ], ) def test_anti_bypass_paths_rejected(self, path: str): @@ -503,6 +515,99 @@ def handler(_request: httpx.Request) -> httpx.Response: client.list_spaces(frozenset()) +# ----------------------------------------------------------------------------- +# populate_space_cache — paginated cache warming for spaceKey↔spaceId lookups +# ----------------------------------------------------------------------------- + + +class TestPopulateSpaceCache: + def test_walks_pagination_until_no_next(self, fake_creds: ConfluenceCredentials): + """The helper must follow ``_links.next`` so a target space living + on page 2+ still resolves through the cache.""" + pages = iter( + [ + { + "results": [{"id": "1", "key": "ENG"}], + "_links": {"next": "/wiki/api/v2/spaces?cursor=PAGE2"}, + }, + { + "results": [{"id": "2", "key": "DOCS"}], + "_links": {"next": "/wiki/api/v2/spaces?cursor=PAGE3"}, + }, + { + "results": [{"id": "3", "key": "TARGET"}], + "_links": {}, + }, + ] + ) + captured: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + captured.append(request) + return httpx.Response(200, json=next(pages)) + + client = _make_client(handler, fake_creds) + client.populate_space_cache() + + assert len(captured) == 3 + # First call: no cursor. Subsequent calls: cursor lifted from _links.next. + assert "cursor" not in captured[0].url.params + assert captured[1].url.params.get("cursor") == "PAGE2" + assert captured[2].url.params.get("cursor") == "PAGE3" + assert client.space_cache.key_for_id("3") == "TARGET" + assert client.space_cache.id_for_key("TARGET") == "3" + + def test_caps_iterations(self, fake_creds: ConfluenceCredentials): + """Defensive cap: never walk more than ``max_pages`` pages, even if + Atlassian keeps handing us a ``next`` cursor.""" + captured: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + captured.append(request) + return httpx.Response( + 200, + json={ + "results": [{"id": str(len(captured)), "key": f"S{len(captured)}"}], + "_links": {"next": "/wiki/api/v2/spaces?cursor=MORE"}, + }, + ) + + client = _make_client(handler, fake_creds) + client.populate_space_cache(max_pages=2) + assert len(captured) == 2 + + def test_403_raises_forbidden(self, fake_creds: ConfluenceCredentials): + def handler(_request: httpx.Request) -> httpx.Response: + return httpx.Response(403) + + client = _make_client(handler, fake_creds) + with pytest.raises(ConfluenceUpstreamForbidden): + client.populate_space_cache() + + +# ----------------------------------------------------------------------------- +# Lazy http client thread-safety +# ----------------------------------------------------------------------------- + + +class TestLazyHttpClient: + def test_client_is_constructed_once(self, fake_creds: ConfluenceCredentials): + """``ConfluenceClient._client()`` must memoise the httpx.Client on + first call — concurrent first requests must not each leak an + instance. We can only assert the single-thread invariant cheaply + (every call returns the same object); the lock-protected + double-check itself is exercised by the runtime, not by this test.""" + # Construct a ConfluenceClient WITHOUT a pre-built http_client so + # the lazy path runs. + client = ConfluenceClient(creds_provider=lambda: fake_creds) + first = client._client() + second = client._client() + assert first is second + # Sanity: also single-init for parallel callers in the same thread. + for _ in range(5): + assert client._client() is first + + # ----------------------------------------------------------------------------- # get_space_pages # ----------------------------------------------------------------------------- diff --git a/gateway/tests/test_confluence_routes.py b/gateway/tests/test_confluence_routes.py index d3710daf5a..7941ee4069 100644 --- a/gateway/tests/test_confluence_routes.py +++ b/gateway/tests/test_confluence_routes.py @@ -32,6 +32,7 @@ import pytest import session_manager from confluence_client import ( + ConfluenceUpstreamError, ConfluenceUpstreamForbidden, ) from mode_gate import PRIVATE_MODE_MARKER_ATTR @@ -297,6 +298,44 @@ def test_upstream_403_distinct_audit_event( assert upstream assert upstream[0]["details"]["pageId"] == "12345" + def test_upstream_error_body_is_redacted( + self, client, private_headers, allow_eng, captured_audit + ): + """Atlassian error envelopes can carry user identifiers (accountId, + emailAddress) and the success-path redactor only runs on 2xx + bodies — verify the gateway redacts error bodies too before they + cross the gateway/sandbox boundary.""" + fake = MagicMock() + fake.get_page.side_effect = ConfluenceUpstreamError( + 500, + { + "errorMessages": ["upstream blew up"], + "accountId": "557058:abcd-efgh-1234", + "emailAddress": "leak@example.com", + "data": {"accountId": "nested-leak"}, + }, + "api/v2/pages/12345", + ) + with _patch_client(fake): + resp = client.post( + "/api/v1/confluence/page/get", + headers=private_headers, + data=json.dumps({"pageId": "12345"}), + content_type="application/json", + ) + assert resp.status_code == 502 + text = resp.get_data(as_text=True) + assert "557058:abcd-efgh-1234" not in text + assert "leak@example.com" not in text + assert "nested-leak" not in text + body = json.loads(text) + upstream_body = body["data"]["upstream_body"] + assert upstream_body["accountId"] == "" + assert upstream_body["emailAddress"] == "" + assert upstream_body["data"]["accountId"] == "" + # Non-redacted fields pass through. + assert upstream_body["errorMessages"] == ["upstream blew up"] + # ----------------------------------------------------------------------------- # /api/v1/confluence/space/list — list_spaces filtering end-to-end (risk R13) @@ -383,8 +422,39 @@ def test_happy_path(self, client, private_headers, allow_eng, captured_audit): content_type="application/json", ) assert resp.status_code == 200 - # list_spaces should NOT be called when the cache is hot. - fake.list_spaces.assert_not_called() + # populate_space_cache should NOT be called when the cache is hot. + fake.populate_space_cache.assert_not_called() + + def test_warms_paginated_space_cache_on_miss( + self, client, private_headers, allow_eng, captured_audit + ): + """When the cache is cold, the route walks paginated /wiki/api/v2/spaces + so a target space living on page 2+ still resolves.""" + fake = MagicMock() + # First lookup (before warming) returns None; after populate_space_cache + # is called, we flip the side-effect to return the resolved id. + warmed: dict[str, bool] = {"done": False} + + def id_for_key(key: str) -> str | None: + if not warmed["done"] or key != "ENG": + return None + return "1" + + def populate() -> None: + warmed["done"] = True + + fake.space_cache.id_for_key.side_effect = id_for_key + fake.populate_space_cache.side_effect = populate + fake.get_space_pages.return_value = {"results": [{"id": "p1"}]} + with _patch_client(fake): + resp = client.post( + "/api/v1/confluence/space/pages", + headers=private_headers, + data=json.dumps({"spaceKey": "ENG"}), + content_type="application/json", + ) + assert resp.status_code == 200 + fake.populate_space_cache.assert_called_once() # ----------------------------------------------------------------------------- @@ -555,14 +625,22 @@ def test_path_traversal_rejected(self, client, private_headers, allow_eng, captu @pytest.mark.parametrize( "bypass_path", [ - # Cycle-3 NACK fix (commit f3f552eb9): these four paths were - # dropped from the /execute allowlist because each was an - # exploitable cross-partition bypass. The route layer must - # refuse them with confluence_execute_denied. + # Cycle-3 NACK fix (commit f3f552eb9): these four flat v2 paths + # were dropped from the /execute allowlist because each was an + # exploitable cross-partition bypass. "api/v2/spaces", "rest/api/search", "api/v2/footer-comments", "api/v2/inline-comments", + # PR #2141 review tightening: page-scoped descendant / comment + # subpaths also dropped — their response bodies have no + # top-level spaceId, so /execute always fail-closed and the + # paths were effectively unusable. Agents reach these via the + # dedicated /api/v1/confluence/page/* routes. + "api/v2/pages/1/descendants", + "api/v2/pages/1/footer-comments", + "api/v2/pages/1/inline-comments", + "rest/api/content/1/child/comment", ], ) def test_anti_bypass_paths_rejected_via_execute( @@ -573,8 +651,10 @@ def test_anti_bypass_paths_rejected_via_execute( captured_audit, bypass_path: str, ): - """Risk R2: /execute must NOT accept any of the four flat v2 paths - that bypass the narrow-route policy checks.""" + """Risk R2: /execute must NOT accept any path that bypasses the + narrow-route policy checks (flat v2 endpoints) or whose response + shape makes the post-fetch allowlist check unreachable + (page-scoped descendants / comments).""" resp = client.post( "/api/v1/confluence/execute", headers=private_headers, diff --git a/gateway/tests/test_confluence_search.py b/gateway/tests/test_confluence_search.py index 12f8b65e9d..eb549883b1 100644 --- a/gateway/tests/test_confluence_search.py +++ b/gateway/tests/test_confluence_search.py @@ -96,11 +96,11 @@ class TestAdversarialNegatives: # 10. Missing space clause entirely. ('text ~ "RFC"', "no space clause"), # 11. Bare id clause without a space anchor. - ('id = "12345"', "id-level clause without space scope"), + ('id = "12345"', "id, content, and title clauses are not supported"), # 12. Bare title clause without a space anchor. - ('title ~ "RFC"', "id-level clause without space scope"), + ('title ~ "RFC"', "id, content, and title clauses are not supported"), # 13. Bare content clause without space anchor. - ('content = "12345"', "id-level clause without space scope"), + ('content = "12345"', "id, content, and title clauses are not supported"), # 14. Unicode homoglyph (Cyrillic Е U+0415, Н U+041D, Г U+0413). ("space = ЕНG", "non-ASCII"), # 15. Negated comparator (extractor cannot prove containment). @@ -179,3 +179,14 @@ def test_two_space_clauses_one_not_allowed(self): result = extract_search_spaces("space = ENG AND space = SEC", ALLOWED) assert result.spaces is None assert "not allowlisted" in result.reason.lower() + + def test_id_clause_rejected_even_with_space_anchor(self): + """``id`` / ``content`` / ``title`` clauses are rejected regardless of + any accompanying ``space`` anchor — the static extractor cannot prove + how those filters interact with the space scope, so the conservative + stance is to refuse them outright (the rejection message points + agents at ``text ~ ...`` which is the supported alternative).""" + result = extract_search_spaces('space = ENG AND id = "12345"', ALLOWED) + assert result.spaces is None + assert "id, content, and title" in result.reason.lower() + assert "text ~" in result.reason.lower() From e1e0e37906e9e8a8876890bf7f0771c84f9fb307 Mon Sep 17 00:00:00 2001 From: "james-in-a-box[bot]" <246424927+james-in-a-box[bot]@users.noreply.github.com> Date: Mon, 27 Apr 2026 08:15:04 +0000 Subject: [PATCH 23/26] Address PR #2141 review observations: cursor comment + execute cache-miss test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add inline comment to _extract_next_cursor explaining parse_qs blank-cursor fail-safe (observation #4 from egg-reviewer 997578d review). - Add direct route-level test for confluence_execute warming the paginated space cache via the api/v2/spaces//pages branch (observation #3 — the branch was previously only exercised indirectly through /space/pages). Author: egg --- gateway/confluence_client.py | 4 +++ gateway/tests/test_confluence_routes.py | 34 +++++++++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/gateway/confluence_client.py b/gateway/confluence_client.py index dc94130899..a3d63fb1d0 100644 --- a/gateway/confluence_client.py +++ b/gateway/confluence_client.py @@ -1018,6 +1018,10 @@ def _extract_next_cursor(body_json: dict[str, Any]) -> str | None: parsed = urlparse(next_url) except ValueError: return None + # parse_qs defaults to keep_blank_values=False, so ``cursor=`` (empty + # value) yields no entry and the caller's pagination loop terminates — + # the desired fail-safe behaviour. If a future change flips that flag, + # also add an explicit ``cursor or None`` guard above the slice below. qs = parse_qs(parsed.query) cursor_values = qs.get("cursor") if not cursor_values: diff --git a/gateway/tests/test_confluence_routes.py b/gateway/tests/test_confluence_routes.py index 7941ee4069..b841071c7d 100644 --- a/gateway/tests/test_confluence_routes.py +++ b/gateway/tests/test_confluence_routes.py @@ -722,6 +722,40 @@ def test_missing_path_400(self, client, private_headers, captured_audit): ) assert resp.status_code == 400 + def test_warms_paginated_space_cache_on_space_id_path_miss( + self, client, private_headers, allow_eng, captured_audit + ): + """``api/v2/spaces//pages`` via /execute must walk paginated + ``/wiki/api/v2/spaces`` when the space-id is not yet cached so a + target on page 2+ still resolves. Direct coverage for the + ``space_id_in_path`` branch in ``confluence_execute``.""" + fake = MagicMock() + warmed: dict[str, bool] = {"done": False} + + def key_for_id(sid: str) -> str | None: + if not warmed["done"] or sid != "1": + return None + return "ENG" + + def populate() -> None: + warmed["done"] = True + + fake.space_cache.key_for_id.side_effect = key_for_id + fake.populate_space_cache.side_effect = populate + fake.execute_raw.return_value = {"results": [{"id": "p1"}]} + with _patch_client(fake): + resp = client.post( + "/api/v1/confluence/execute", + headers=private_headers, + data=json.dumps({"method": "GET", "path": "api/v2/spaces/1/pages"}), + content_type="application/json", + ) + assert resp.status_code == 200 + fake.populate_space_cache.assert_called_once() + success = [a for a in captured_audit if a["event_type"] == "confluence_execute"] + assert success + assert success[-1]["details"]["spaceKey"] == "ENG" + # ----------------------------------------------------------------------------- # /api/v1/confluence/page/inline-comments — used_fallback observability From c857bf5fd938b9ecc6f7cbf14c49803761f96173 Mon Sep 17 00:00:00 2001 From: "egg-reviewer[bot]" <261018737+egg-reviewer[bot]@users.noreply.github.com> Date: Mon, 27 Apr 2026 08:32:59 +0000 Subject: [PATCH 24/26] Catch ConfluenceUpstreamForbidden in confluence_execute cache warm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the blocking finding from the latest review on PR #2141: when populate_space_cache raises ConfluenceUpstreamForbidden during the space_id_in_path cache-miss branch (bot lacks space:read globally), the exception escaped as a Flask 500 instead of fail-closing through confluence_space_denied. ConfluenceUpstreamForbidden is a sibling of ConfluenceUpstreamError — both inherit directly from RuntimeError, not one from the other — so the existing exception tuple did not catch it. Mirrors the handler at _resolve_space_key_via_list. Also addresses two non-blocking observations: - Reword the _extract_next_cursor inline comment so it acknowledges the existing ``cursor or None`` guard rather than implying it would need to be added. - Extend the cache-miss test matrix for the space_id_in_path branch: - populate_space_cache raises ConfluenceUpstreamForbidden -> 403 - populate_space_cache succeeds but id stays unresolved -> 403 - resolved key is not in the operator allowlist -> 403 + audited key All three regression tests assert the upstream payload is not leaked to the agent on denial. Authored-by: egg --- gateway/confluence_client.py | 6 +- gateway/gateway.py | 13 +++- gateway/tests/test_confluence_routes.py | 86 +++++++++++++++++++++++++ 3 files changed, 101 insertions(+), 4 deletions(-) diff --git a/gateway/confluence_client.py b/gateway/confluence_client.py index a3d63fb1d0..0107d3748e 100644 --- a/gateway/confluence_client.py +++ b/gateway/confluence_client.py @@ -1020,8 +1020,10 @@ def _extract_next_cursor(body_json: dict[str, Any]) -> str | None: return None # parse_qs defaults to keep_blank_values=False, so ``cursor=`` (empty # value) yields no entry and the caller's pagination loop terminates — - # the desired fail-safe behaviour. If a future change flips that flag, - # also add an explicit ``cursor or None`` guard above the slice below. + # the desired fail-safe behaviour. The ``cursor or None`` guard at the + # bottom of this function would also cover the empty-string case under + # ``keep_blank_values=True``, but verify and add explicit coverage if a + # future change flips that flag. qs = parse_qs(parsed.query) cursor_values = qs.get("cursor") if not cursor_values: diff --git a/gateway/gateway.py b/gateway/gateway.py index eca5bb82c2..8097bc8ec1 100644 --- a/gateway/gateway.py +++ b/gateway/gateway.py @@ -5862,10 +5862,19 @@ def confluence_execute() -> tuple[Response, int] | Response: resolved = client.space_cache.key_for_id(space_id_in_path) if resolved is None: # Walk paginated /wiki/api/v2/spaces so a target on page 2+ - # still resolves. + # still resolves. Catch ConfluenceUpstreamForbidden alongside + # the other upstream errors — it's a sibling of + # ConfluenceUpstreamError (both inherit from RuntimeError, not + # one from the other) and would otherwise escape as a Flask + # 500 when the bot lacks space:read globally. Mirrors the + # handler at _resolve_space_key_via_list. try: client.populate_space_cache() - except (ConfluenceCredentialsUnavailable, ConfluenceUpstreamError): + except ( + ConfluenceCredentialsUnavailable, + ConfluenceUpstreamError, + ConfluenceUpstreamForbidden, + ): resolved = None else: resolved = client.space_cache.key_for_id(space_id_in_path) diff --git a/gateway/tests/test_confluence_routes.py b/gateway/tests/test_confluence_routes.py index b841071c7d..a783c921ac 100644 --- a/gateway/tests/test_confluence_routes.py +++ b/gateway/tests/test_confluence_routes.py @@ -756,6 +756,92 @@ def populate() -> None: assert success assert success[-1]["details"]["spaceKey"] == "ENG" + def test_space_id_path_warm_403_fails_closed( + self, client, private_headers, allow_eng, captured_audit + ): + """If ``populate_space_cache`` raises ``ConfluenceUpstreamForbidden`` + during a cache miss on the ``space_id_in_path`` branch, the route + must fail-closed with ``confluence_space_denied`` (HTTP 403) rather + than letting the exception escape as a Flask 500. Regression for + the missing exception type at gateway.confluence_execute.""" + fake = MagicMock() + fake.space_cache.key_for_id.return_value = None + fake.execute_raw.return_value = {"results": [{"id": "p1", "title": "leak-bait"}]} + fake.populate_space_cache.side_effect = ConfluenceUpstreamForbidden( + 403, {"err": "no global space:read"}, "api/v2/spaces" + ) + with _patch_client(fake): + resp = client.post( + "/api/v1/confluence/execute", + headers=private_headers, + data=json.dumps({"method": "GET", "path": "api/v2/spaces/1/pages"}), + content_type="application/json", + ) + assert resp.status_code == 403 + # Body must not be leaked when allowlist denial fires. + assert "leak-bait" not in resp.get_data(as_text=True) + denied = [a for a in captured_audit if a["event_type"] == "confluence_execute_denied"] + assert denied, "expected confluence_execute_denied audit entry" + assert denied[-1]["details"].get("spaceKey") is None + + def test_space_id_path_warm_unresolved_fails_closed( + self, client, private_headers, allow_eng, captured_audit + ): + """``populate_space_cache`` succeeds but the requested ``space_id`` + is still not present (e.g. wrong tenant or stale cursor) — the + route must return ``confluence_execute_denied`` with + ``space_key=None``.""" + fake = MagicMock() + fake.space_cache.key_for_id.return_value = None # never resolves + fake.populate_space_cache.return_value = None # warms but no entry + fake.execute_raw.return_value = {"results": [{"id": "p1", "title": "leak-bait"}]} + with _patch_client(fake): + resp = client.post( + "/api/v1/confluence/execute", + headers=private_headers, + data=json.dumps({"method": "GET", "path": "api/v2/spaces/1/pages"}), + content_type="application/json", + ) + assert resp.status_code == 403 + assert "leak-bait" not in resp.get_data(as_text=True) + fake.populate_space_cache.assert_called_once() + denied = [a for a in captured_audit if a["event_type"] == "confluence_execute_denied"] + assert denied + assert denied[-1]["details"].get("spaceKey") is None + + def test_space_id_path_warm_resolves_to_disallowed_space( + self, client, private_headers, allow_eng, captured_audit + ): + """``populate_space_cache`` resolves the id, but the resolved key + is not in the allowlist — must fail-closed with the resolved key + in the audit and no upstream payload reaching the agent.""" + fake = MagicMock() + warmed: dict[str, bool] = {"done": False} + + def key_for_id(sid: str) -> str | None: + if not warmed["done"] or sid != "1": + return None + return "SECRET" + + def populate() -> None: + warmed["done"] = True + + fake.space_cache.key_for_id.side_effect = key_for_id + fake.populate_space_cache.side_effect = populate + fake.execute_raw.return_value = {"results": [{"id": "p1", "title": "leak-bait"}]} + with _patch_client(fake): + resp = client.post( + "/api/v1/confluence/execute", + headers=private_headers, + data=json.dumps({"method": "GET", "path": "api/v2/spaces/1/pages"}), + content_type="application/json", + ) + assert resp.status_code == 403 + assert "leak-bait" not in resp.get_data(as_text=True) + denied = [a for a in captured_audit if a["event_type"] == "confluence_execute_denied"] + assert denied + assert denied[-1]["details"].get("spaceKey") == "SECRET" + # ----------------------------------------------------------------------------- # /api/v1/confluence/page/inline-comments — used_fallback observability From a7778f26b49070f057c4ea7cda80e40c0d92ee1b Mon Sep 17 00:00:00 2001 From: "egg-reviewer[bot]" <261018737+egg-reviewer[bot]@users.noreply.github.com> Date: Mon, 27 Apr 2026 08:48:14 +0000 Subject: [PATCH 25/26] Reflect per-call-site 403 audit translation in docstring The ConfluenceUpstreamForbidden docstring claimed the route layer uniformly emits confluence_upstream_403 audit events, but in reality only confluence_space_pages does. _resolve_space_key_via_list (via confluence_search) and confluence_execute collapse the 403 into the route's *_denied event so allowlist resolution stays fail-closed and does not expose tenant-permission state. Updates the docstring to describe each call site's actual behaviour (reviewer follow-up option 2 from PR #2141 re-review). --- gateway/confluence_client.py | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/gateway/confluence_client.py b/gateway/confluence_client.py index 0107d3748e..4ff06d1f7b 100644 --- a/gateway/confluence_client.py +++ b/gateway/confluence_client.py @@ -232,9 +232,20 @@ def __init__(self, status_code: int, body: Any, path: str): class ConfluenceUpstreamForbidden(RuntimeError): """Raised when Atlassian returns HTTP 403 for a read endpoint. - The route layer translates this to a ``confluence_upstream_403`` audit - event so operators can distinguish bot-account permission denials from - space-allowlist denials and other upstream errors (Q7, risk R15). + Translation at the route layer is per-call-site rather than uniform + (Q7, risk R15): + + - ``confluence_space_pages`` translates this directly to a + ``confluence_upstream_403`` audit event so operators can distinguish + bot-account permission denials from other upstream errors. + - ``_resolve_space_key_via_list`` (used by ``confluence_search``) and + ``confluence_execute`` collapse the 403 into a fail-closed + ``confluence_*_denied`` event with ``space_key=None`` because the + 403 surfaces *during* allowlist resolution and the route's + contract is to deny rather than expose tenant-permission state. + Operators investigating a bot losing ``space:read`` globally can + cross-reference these denials with upstream-403 logs at the HTTP + client layer. """ def __init__(self, status_code: int, body: Any, path: str): From 9eed7442017c5fb35a6c3b20f463757e3f23b223 Mon Sep 17 00:00:00 2001 From: "egg-reviewer[bot]" <261018737+egg-reviewer[bot]@users.noreply.github.com> Date: Mon, 27 Apr 2026 09:05:18 +0000 Subject: [PATCH 26/26] Fix per-call-site 403 docstring to match all nine routes Reviewer flagged three accuracy issues in the ConfluenceUpstreamForbidden docstring on a7778f2: 1. confluence_search catches ConfluenceUpstreamForbidden directly and translates to confluence_upstream_403; the docstring incorrectly attributed it to _resolve_space_key_via_list. 2. Eight routes (not just confluence_space_pages) translate the 403 directly. The actual asymmetry is primary route call vs. auxiliary allowlist-resolution call. 3. The module-level docstring still claimed uniform translation, contradicting the new class docstring. Class docstring now enumerates all eight primary routes and the three auxiliary call sites that collapse the 403 (resolve_space_key_via_list, the parent re-fetch in descendants/footer-comments/inline-comments, and the execute cache-warm fallback). Module-level docstring redirects to the class docstring instead of repeating the inaccurate "all read methods" framing. --- gateway/confluence_client.py | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/gateway/confluence_client.py b/gateway/confluence_client.py index 4ff06d1f7b..c6bf092a62 100644 --- a/gateway/confluence_client.py +++ b/gateway/confluence_client.py @@ -61,9 +61,10 @@ 403 envelope (Q7, risk R15): -- All read methods raise ``ConfluenceUpstreamForbidden`` on upstream 403 - so the route layer can audit it as ``confluence_upstream_403`` (distinct - from generic upstream errors). +- All read methods raise ``ConfluenceUpstreamForbidden`` on upstream 403. + Route-layer translation is per-call-site (primary route call vs. + auxiliary allowlist-resolution call); see + ``ConfluenceUpstreamForbidden`` for the full taxonomy. Response redaction (decision 10): @@ -232,14 +233,23 @@ def __init__(self, status_code: int, body: Any, path: str): class ConfluenceUpstreamForbidden(RuntimeError): """Raised when Atlassian returns HTTP 403 for a read endpoint. - Translation at the route layer is per-call-site rather than uniform - (Q7, risk R15): + Route-layer translation is per-call-site (Q7, risk R15): - - ``confluence_space_pages`` translates this directly to a + - **Primary route calls** (``confluence_page_get``, + ``confluence_page_descendants``, ``confluence_page_footer_comments``, + ``confluence_page_inline_comments``, ``confluence_space_pages``, + ``confluence_space_list``, ``confluence_search``, + ``confluence_execute``) translate this directly to a ``confluence_upstream_403`` audit event so operators can distinguish bot-account permission denials from other upstream errors. - - ``_resolve_space_key_via_list`` (used by ``confluence_search``) and - ``confluence_execute`` collapse the 403 into a fail-closed + - **Auxiliary allowlist-resolution calls** — + ``_resolve_space_key_via_list`` (invoked by + ``_check_post_fetch_space_allowlist``), the parent-page re-fetch + inside ``confluence_page_descendants`` / + ``confluence_page_footer_comments`` / + ``confluence_page_inline_comments``, and the cache-warm fallback + inside ``confluence_execute``'s ``space_id_in_path`` branch — + collapse the 403 into the route's fail-closed ``confluence_*_denied`` event with ``space_key=None`` because the 403 surfaces *during* allowlist resolution and the route's contract is to deny rather than expose tenant-permission state.