Skip to content

fix(egress): attribute subprocess proxy audit events to task/invocation (#338)#339

Merged
initializ-mk merged 4 commits into
mainfrom
fix/egress-proxy-audit-identity-338
Jul 18, 2026
Merged

fix(egress): attribute subprocess proxy audit events to task/invocation (#338)#339
initializ-mk merged 4 commits into
mainfrom
fix/egress-proxy-audit-identity-338

Conversation

@initializ-mk

Copy link
Copy Markdown
Contributor

What

Closes #338. Subprocess egress audit events (egress_allowed / egress_blocked with "source": "proxy") now carry task_id and correlation_id, so proxied traffic from skill scripts, the browser, and DB/CLI binaries can be correlated back to the task/invocation that caused it.

Why they were missing

Two emission paths, only one carried identity:

  • In-process enforcer (LLM/MCP/channels) tags IDs from ctx via EmitFromContext — fine.
  • Egress proxy is a separate 127.0.0.1 HTTP forward proxy. Subprocesses reach it through HTTP_PROXY, so the request that lands at the proxy has no Go contextOnAttempt(domain, allowed) had nothing to attach.

Approach

Carry identity across the process boundary in the proxy credentials:

  1. exec.go stamps the task + correlation IDs into the HTTP_PROXY userinfo — http://<taskID>:<correlationID>@127.0.0.1:<port> (proxyURLWithIdentity).
  2. HTTP clients replay userinfo as Proxy-Authorization: Basic base64(taskID:correlationID) on every request and CONNECT.
  3. The proxy decodes it (identityFromRequest) and threads the IDs onto a new EgressAttempt struct; runner.go sets TaskID + CorrelationID on the event.

Safety

  • Graceful fallback — a binary that ignores proxy creds is still enforced + audited, just with empty IDs (identical to pre-fix).
  • No leakProxy-Authorization is stripped before the request is forwarded upstream; CONNECT is a blind byte relay (no headers forwarded).
  • Known limitation — a compromised subprocess inside the trust boundary could spoof another task's ID (correlation, not authz). The tamper-proof alternative (per-task proxy port) is much heavier; documented in the issue for later if needed.

Tests

  • identityFromRequest decode table (both IDs, partial, case-insensitive scheme, malformed, missing).
  • End-to-end attribution through a real proxied request (HTTP path; CONNECT exercised via the existing proxy tests + relay).
  • proxyURLWithIdentity stamping table + SkillCommandExecutor HTTP_PROXY injection.
  • All forge-core/security (incl. -race) and forge-cli/tools + runtime suites green; lint clean.

…on (#338)

Egress events for subprocess traffic (skill scripts, browser, DB/CLI
binaries routed via HTTP_PROXY) were missing task_id and correlation_id.
The in-process enforcer path tags them from ctx via EmitFromContext, but
the egress proxy is a separate 127.0.0.1 HTTP forward proxy — the request
arriving from a subprocess carries no Go context, so OnAttempt had nothing
to attach.

Re-attach identity across the process boundary via proxy credentials:

- exec.go stamps the request's task + correlation IDs into the HTTP_PROXY
  URL userinfo (proxyURLWithIdentity). HTTP clients replay userinfo as a
  Basic Proxy-Authorization header on every request and CONNECT.
- egress_proxy.go decodes it (identityFromRequest) and threads the IDs
  onto a new EgressAttempt struct passed to OnAttempt.
- runner.go sets TaskID + CorrelationID on the proxy egress events.

Degrades gracefully: binaries that ignore proxy creds are still enforced
and audited, just without identity (pre-fix behaviour). Proxy-Authorization
is stripped before forwarding upstream; CONNECT is a blind relay.

Tests: identityFromRequest decode table, end-to-end attribution through a
real proxied request (HTTP + CONNECT), proxyURLWithIdentity stamping, and
executor HTTP_PROXY injection.
…ents (#338)

Update audit-logging.md (example + source note + plain-Emit exception
table), egress-control.md (audit-events section), and the comprehensive
forge.md skill to reflect that subprocess proxy egress events now carry
correlation_id + task_id, recovered from the Proxy-Authorization creds the
subprocess replays. Note the graceful no-identity fallback for
credential-ignoring binaries.

@initializ-mk initializ-mk left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review — attribute subprocess proxy audit events to task/invocation (#338)

Approve. Clean, correctly-scoped fix. I traced every load-bearing claim to source and all hold; findings below are Low/nit only.

Claims verified against code (not the description)

  • No credential leak upstreamProxy-Authorization is Del'd on the cloned outbound request (egress_proxy.go:130) while identityFromRequest reads the incoming request first (:114). CONNECT never forwards headers — it's a raw io.Copy byte relay, TLS negotiated inside the tunnel. ✓
  • Graceful degradation — zero identity on absent/malformed/wrong-scheme/non-base64/no-colon (all 8 table-tested); emits empty IDs, byte-identical to pre-#338. ✓
  • The integration question (would this be silently empty in production?) — SkillTool.Execute forwards ctx verbatim to executor.Run (skill_tool.go:81), and that ctx is the A2A-request-scoped ctx with WithTaskID(params.ID) set in the handlers. This reuses the exact ctx the in-process enforcer already reads successfully — not new plumbing that could silently be empty. ✓
  • Good defensive choices: url.UserPassword + u.String() (percent-encodes, so odd IDs can't break the env-var URL); spoofing-within-trust-boundary correctly framed as a known correlation (not authz) limitation.

Findings (all Low / nit)

  1. [Low] Colon in a task_id mis-splits attribution — see inline on identityFromRequest.
  2. [Nit] CONNECT identity path isn't end-to-end tested — see inline.
  3. [Info] IDs now travel in HTTP_PROXY userinfo (readable via /proc/self/environ by the subprocess + children). Correlation identifiers, not secrets — noting only for completeness.

CI green across all 6 platforms + lint + integration. Nice work.

Comment thread forge-core/security/egress_proxy.go Outdated
if err != nil {
return egressIdentity{}
}
task, corr, found := strings.Cut(string(raw), ":")

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Low] Colon in a task ID mis-splits attribution. This strings.Cut runs on the flattened Basic credential, and Basic auth has no internal escaping. So even though url.UserPassword percent-encodes the URL correctly, the credential is base64(taskID + ":" + corrID) — a taskID of "a:b" produces base64("a:b:corr"), and Cut at the first : yields task="a", corr="b:corr".

params.id is client-supplied, so a task ID containing : corrupts its own correlation. Impact is limited to self-inflicted attribution noise (and the deliberate-spoofing case is already documented), so this is Low — but worth either a one-line note that IDs must be colon-free, or a fixed-shape encoding that can't collide with the separator.

@@ -132,8 +155,9 @@ func (p *EgressProxy) handleHTTP(w http.ResponseWriter, req *http.Request) {
// hostname, then blind-relays bytes without decrypting TLS.
func (p *EgressProxy) handleConnect(w http.ResponseWriter, req *http.Request) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Nit] CONNECT identity attribution isn't end-to-end tested. handleConnect calls the same identityFromRequest, but the new TestEgressProxyAttributesIdentity only drives the plain-HTTP path — the PR body's "CONNECT exercised via… relay" covers the tunnel, not identity surfacing through it. Risk is minimal since it's the identical function call, but given the recurring lesson (a handler passing in isolation while the real wired path differs), a CONNECT-tunnel identity assertion would close the loop cheaply.

…view)

Review findings on PR #339:

- [Low] A task ID containing a raw ':' (params.id is client-supplied)
  would mis-split the flattened Basic credential, since Basic auth has no
  internal escaping and identityFromRequest cut at the first colon. Encode
  each ID with base64url (alphabet [A-Za-z0-9-_], colon-free) before it
  goes into the HTTP_PROXY userinfo, and decode each half on the proxy
  side. The separator can no longer collide with the data. Added a
  colon-bearing round-trip case and an invalid-inner-encoding case.

- [Nit] Added TestEgressProxyAttributesIdentityCONNECT: drives a real
  HTTPS CONNECT tunnel through the proxy and asserts the IDs surface via
  handleConnect (the prior end-to-end test only covered plain HTTP). Uses
  a colon-bearing task ID to exercise the encoding through the tunnel too.

Finding #3 (IDs readable via /proc/self/environ) is correlation data, not
secrets — acknowledged inline in the proxyURLWithIdentity doc comment.
@initializ-mk

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough trace — addressed in 59f7736:

Finding #1 (colon in task ID mis-splits) — fixed. Went with the fixed-shape encoding rather than a doc caveat, since params.id is client-supplied and colons are realistic (urn:task:1, namespace:id). Each ID is now base64url-encoded (RawURLEncoding, alphabet [A-Za-z0-9-_]) before it goes into the HTTP_PROXY userinfo; identityFromRequest decodes each half after the Cut. base64url output can't contain the : separator, so a colon in either ID round-trips verbatim. Covered by a new colon-bearing task id round-trips case plus an inner halves not base64url case (exercises the new decode-failure → empty-identity branch).

Finding #2 (CONNECT identity not end-to-end tested) — fixed. Added TestEgressProxyAttributesIdentityCONNECT: drives a real HTTPS CONNECT tunnel through the proxy and asserts TaskID/CorrelationID surface via handleConnect. It uses a colon-bearing task ID, so it also proves the separator-safe encoding through the tunnel path.

Finding #3 (IDs in /proc/self/environ) — acknowledged. These are correlation identifiers, not secrets; the encoding is for separator-safety, not confidentiality. Noted explicitly in the proxyURLWithIdentity doc comment.

forge-core/security green under -race (incl. both new end-to-end tests); forge-cli/tools green; lint clean on both.

@initializ-mk initializ-mk left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review — fix commit 59f7736

Both findings from my prior review are resolved cleanly; nothing new introduced.

[Low] Colon in a task ID mis-splits attribution — fixed correctly

The IDs are now base64url-encoded (base64.RawURLEncoding) before entering the userinfo, and decoded on the proxy side (identityFromRequest). This is the right fix and it's structural, not cosmetic: base64url's alphabet is [A-Za-z0-9-_], so an encoded half can never contain the : separator — the Basic-credential ambiguity is eliminated rather than discouraged. Edge cases I checked all hold:

  • Empty half (task only / empty task): RawURLEncoding encodes """" and decodes back with no error, so the empty ID round-trips.
  • Malformed inner (!!!:@@@): decode fails → zero identity → fail-closed, matching the graceful-degradation contract. New inner halves not base64url case pins it.
  • Encoder/decoder are exact inverses: on the wire base64(b64url(task) ":" b64url(corr)) ↔ split-then-RawURLEncoding.Decode at the proxy.

[Nit] CONNECT identity not end-to-end tested — fixed

New TestEgressProxyAttributesIdentityCONNECT drives a real httptest.NewTLSServer through an actual CONNECT tunnel and asserts the ID surfaces on OnAttempt — and it deliberately uses a colon-bearing task ID (urn:task:9), so it exercises the separator-safe encoding through the real HTTPS path in the same test. Exactly the loop-closer I asked for.

The comments on both sides now accurately describe the two-layer scheme and correctly note it's separator-safety, not confidentiality (these are correlation IDs, not secrets) — no misleading claims left behind.

Lint green; Test suite running at time of posting (test-only + a localized encode/decode swap, prior commit was green across all 6 platforms). Approve — ready to merge once Test lands.

The initial fix only covered SkillCommandExecutor, but the cli_execute
builtin (curl/gh/kubectl/... driven by the LLM) injects HTTP_PROXY through
its own buildEnv path. So subprocess egress from cli_execute still emitted
source=proxy audit events with no task_id/correlation_id — observed live:

  {"event":"egress_blocked","fields":{"domain":"initializ.ai",
   "mode":"allowlist","source":"proxy"}}   ← no task_id/correlation_id

Thread ctx into CLIExecuteTool.buildEnv and run the proxy URL through the
same proxyURLWithIdentity stamping, so curl (and every other cli_execute
binary) replays the base64url task/correlation IDs as Proxy-Authorization.
These are the only two HTTP_PROXY-injecting executors; the browser uses
chromedp's own proxy path and is out of scope here.

Test: TestBuildEnv_ProxyIdentityStamped pins the stamped HTTP_PROXY for
cli_execute using the exact task/correlation IDs from the live repro.
@initializ-mk
initializ-mk merged commit ff5628a into main Jul 18, 2026
10 checks passed
initializ-mk added a commit that referenced this pull request Jul 18, 2026
…view)

Review findings on PR #339:

- [Low] A task ID containing a raw ':' (params.id is client-supplied)
  would mis-split the flattened Basic credential, since Basic auth has no
  internal escaping and identityFromRequest cut at the first colon. Encode
  each ID with base64url (alphabet [A-Za-z0-9-_], colon-free) before it
  goes into the HTTP_PROXY userinfo, and decode each half on the proxy
  side. The separator can no longer collide with the data. Added a
  colon-bearing round-trip case and an invalid-inner-encoding case.

- [Nit] Added TestEgressProxyAttributesIdentityCONNECT: drives a real
  HTTPS CONNECT tunnel through the proxy and asserts the IDs surface via
  handleConnect (the prior end-to-end test only covered plain HTTP). Uses
  a colon-bearing task ID to exercise the encoding through the tunnel too.

Finding #3 (IDs readable via /proc/self/environ) is correlation data, not
secrets — acknowledged inline in the proxyURLWithIdentity doc comment.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Egress: subprocess proxy audit events missing task_id / correlation_id

1 participant