Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
25c8308
Initialize SDLC contract for issue #1931
Apr 26, 2026
5ca3053
refine: analysis for #1931 (Confluence gateway read-only v1)
Apr 26, 2026
07c9185
Persist statefiles after refine phase
Apr 26, 2026
6015a88
Persist HITL resolution after refine phase gate
Apr 26, 2026
256521c
plan(architect): add architecture analysis for #1931 Confluence gateway
Apr 26, 2026
4151ef6
plan: risk assessment for #1931 (Confluence gateway read-only v1)
Apr 26, 2026
1b0af91
plan: add task plan for #1931 (Confluence gateway read-only v1)
Apr 26, 2026
594fd2b
plan: address reviewer_plan NACK (#1931)
Apr 27, 2026
9e8b405
Persist statefiles after plan phase
Apr 27, 2026
1717c17
docs(implement): add Confluence gateway wrapper docs (#1931)
Apr 27, 2026
efbfd5e
gateway: Confluence foundation modules + ATLASSIAN_* credential prece…
Apr 27, 2026
7a29920
gateway: wire Confluence routes into Flask app (#1931)
Apr 27, 2026
c4d8fa0
gateway: fix fail-open allowlist bypass on parent-fetch failure (#1931)
Apr 27, 2026
7744faf
gateway: address reviewer_contract + reviewer_code_holistic NACKs (#1…
Apr 27, 2026
8b46dfc
docs: address cycle-2 doc gaps for Confluence wrapper (#1931)
Apr 27, 2026
72ee7dc
sandbox: add Confluence CLI wrapper (#1931)
Apr 27, 2026
74627a6
gateway: address tester NACK — ruff format + mypy clean (#1931)
Apr 27, 2026
c38535d
docs: address reviewer_code NACK on cycle-2 doc commit (#1931)
Apr 27, 2026
f3f552e
gateway: address reviewer_code + reviewer_security NACK — drop /execu…
Apr 27, 2026
6b44b59
tests: add Confluence gateway test suite (#1931)
Apr 27, 2026
843e9a9
docs: restore strict /execute anti-bypass invariant after cycle-3 fix…
Apr 27, 2026
3f5e6ea
Merge origin/main into egg/issue-1931: resolve conflict in shared/egg…
jwbron Apr 27, 2026
997578d
Address PR #2141 review: pagination, redaction, lock, allowlist
egg-reviewer[bot] Apr 27, 2026
e1e0e37
Address PR #2141 review observations: cursor comment + execute cache-…
james-in-a-box[bot] Apr 27, 2026
c857bf5
Catch ConfluenceUpstreamForbidden in confluence_execute cache warm
egg-reviewer[bot] Apr 27, 2026
a7778f2
Reflect per-call-site 403 audit translation in docstring
egg-reviewer[bot] Apr 27, 2026
9eed744
Fix per-call-site 403 docstring to match all nine routes
egg-reviewer[bot] Apr 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
713 changes: 713 additions & 0 deletions .egg-state/agent-outputs/1931-architect-output.json

Large diffs are not rendered by default.

533 changes: 533 additions & 0 deletions .egg-state/agent-outputs/1931-risk_analyst-output.json

Large diffs are not rendered by default.

2,015 changes: 2,015 additions & 0 deletions .egg-state/brc-history/1931-plan.json

Large diffs are not rendered by default.

2,444 changes: 2,444 additions & 0 deletions .egg-state/brc-history/1931-plan.md

Large diffs are not rendered by default.

711 changes: 711 additions & 0 deletions .egg-state/brc-history/1931-refine.json

Large diffs are not rendered by default.

853 changes: 853 additions & 0 deletions .egg-state/brc-history/1931-refine.md

Large diffs are not rendered by default.

1,172 changes: 1,172 additions & 0 deletions .egg-state/contracts/issue-1931.json

Large diffs are not rendered by default.

478 changes: 478 additions & 0 deletions .egg-state/drafts/1931-analysis.md

Large diffs are not rendered by default.

1,417 changes: 1,417 additions & 0 deletions .egg-state/drafts/1931-plan.md

Large diffs are not rendered by default.

18 changes: 18 additions & 0 deletions config/context-filters.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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: []
31 changes: 26 additions & 5 deletions config/secrets.template.env
Original file line number Diff line number Diff line change
Expand Up @@ -89,19 +89,40 @@ 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 <ATLASSIAN_BASE_URL>/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 removed — space allowlist now lives in
# config/context-filters.yaml :: confluence.spaces (decision H1, issue #1931).

# =============================================================================
# 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.
Expand Down
75 changes: 64 additions & 11 deletions docs/architecture/credential-injection.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -147,7 +147,8 @@ Gateway enforcement cannot be bypassed because:
|------|--------|-----------------|
| API Key | `ANTHROPIC_API_KEY` in secrets.env | `x-api-key: <key>` |
| OAuth Token | `ANTHROPIC_OAUTH_TOKEN` in secrets.env | `Authorization: Bearer <token>` |
| Atlassian / Jira | `JIRA_BASE_URL` + `JIRA_USERNAME` + `JIRA_API_TOKEN` in secrets.env | `Authorization: Basic <base64(username:token)>` |
| Atlassian / Jira | `ATLASSIAN_*` (preferred) or `JIRA_*` triple in secrets.env | `Authorization: Basic <base64(username:token)>` |
| Atlassian / Confluence | `ATLASSIAN_*` (preferred) or `CONFLUENCE_*` triple in secrets.env | `Authorization: Basic <base64(username:token)>` |

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.

Expand All @@ -158,40 +159,92 @@ 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 `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.

**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
Loading
Loading