Skip to content

feat(workflow): opt-in auto-propagation of workflow headers on allow-listed hosts (closes #186)#199

Merged
initializ-mk merged 1 commit into
mainfrom
feat/issue-186-workflow-propagation
Jun 26, 2026
Merged

feat(workflow): opt-in auto-propagation of workflow headers on allow-listed hosts (closes #186)#199
initializ-mk merged 1 commit into
mainfrom
feat/issue-186-workflow-propagation

Conversation

@initializ-mk

Copy link
Copy Markdown
Contributor

Summary

Adds a workflow_propagation.allowed_hosts block to forge.yaml. Outbound HTTP tool calls targeting a matching host automatically receive the workflow correlation headers (X-Workflow-Id / X-Workflow-Execution-Id / X-Workflow-Stage-Id / X-Workflow-Step-Id / X-Invocation-Caller) from the current request context. Hosts not on the list keep the pre-#186 opt-in posture — tools must call ApplyToHTTPHeaders explicitly. Empty / missing block = zero-overhead pass-through (current behavior).

workflow_propagation:
  allowed_hosts:
    - "orchestrator.svc"         # exact host (port stripped before compare)
    - "*.agents.internal"        # wildcard suffix (strictly-deeper subdomains)

Why

Forge already accepted the workflow headers inbound (FWS-2 / #185) and stamped them on every audit event. But propagation to downstream agents was manual: each HTTP tool had to call WorkflowContextFromContext(ctx).ApplyToHTTPHeaders(req.Header) itself. The skill correctly flagged that auto-propagation is risky (leaks workflow identity to third-party APIs like api.openai.com), but the manual approach broke the trace at every agent-to-agent hop where the tool author forgot.

This change keeps the safe default and adds an opt-in allow-list — the same shape as the existing egress allow-list — so operators get auto-propagation inside their trust boundary without leaking outside it.

Implementation

  • forge-core/types/config.go — new WorkflowPropagationConfig with AllowedHosts []string; attached to ForgeConfig under workflow_propagation.
  • forge-core/runtime/workflow_propagation.go — new WorkflowPropagationMatcher (exact + wildcard, port-stripped, case-insensitive). Wildcards match strictly-deeper subdomains — *.agents.internal matches payments.agents.internal but NOT the bare agents.internal. WrapTransportForWorkflowPropagation returns the underlying transport identity-equal when the matcher is empty, so zero-config deploys pay no per-request overhead.
  • forge-cli/runtime/runner.go — builds the matcher from cfg.WorkflowPropagation.AllowedHosts and wraps the egress client's transport once at startup. Every built-in HTTP tool (http_request, webhook_call, web_search_*) routes through security.EgressTransportFromContext so all of them inherit the auto-apply without per-tool changes.

The wrapper clones the request before stamping headers (http.RoundTripper contract — must not mutate the caller's request) so a caller's req.Header is never modified across retries.

Safety

  • Default-deploy unchanged: an absent workflow_propagation block (or allowed_hosts: []) returns the egress transport identity-equal — no wrapper, no header mutation, no observable change.
  • No leaks: a request to a non-allowlisted host (e.g. api.openai.com) NEVER receives the workflow headers, even with a fully-populated WorkflowContext. Pinned by TestWorkflowPropagationTransport_OmitsHeadersOnUnlistedHost.
  • No mutation across retries: caller's req.Header is preserved. Pinned by TestWorkflowPropagationTransport_DoesNotMutateOriginalRequest.
  • No spurious empty headers: when the request ctx is IsZero (direct A2A invocation, no orchestrator), even allow-listed hosts get nothing — we don't stamp empty values. Pinned by TestWorkflowPropagationTransport_NoOpWhenContextIsZero.
  • Transparent errors: underlying-transport errors pass through unchanged. Pinned by TestWorkflowPropagationTransport_PropagatesUnderlyingError.

Dependency

Builds on FORGE-2 (#185) — the propagated header set includes the new X-Workflow-Execution-Id from that PR.

Out of scope

  • Subprocess egress proxy path: skills running in a subprocess that issue HTTP via the proxy don't route through security.EgressTransportFromContext. If that surface needs the same auto-apply, file a follow-up — needs proxy-side header injection, not in scope for this PR per the issue.

Test plan

  • golangci-lint run across all four modules — 0 issues
  • gofmt -w across all modules
  • go test ./... in forge-core/ and forge-cli/ — all green
  • New unit tests pin: host matcher (exact / wildcard / no-match / port-strip / case-insensitive / apex-doesn't-match / longer-host-doesn't-trick), nil-guard + IsEmpty short-circuit, headers applied on allow-listed host, headers absent on non-allowlisted host, no-op on IsZero context, no mutation of caller's request, full end-to-end against httptest.NewServer, underlying error propagation.
  • Manual smoke: forge run an agent with workflow_propagation.allowed_hosts: ["127.0.0.1"], invoke a tool that POSTs to a local echo server, confirm the inbound request carries all five workflow headers; remove the entry, confirm headers are absent.

…listed hosts (closes #186)

Forge already accepted the X-Workflow-* / X-Invocation-Caller headers
and stamped them on every audit event during an invocation (FWS-2 /
#185), but propagation to downstream agents was manual: each tool
calling out via HTTP had to invoke
WorkflowContextFromContext(ctx).ApplyToHTTPHeaders(req.Header)
itself. Auto-propagation was deliberately off to stop the workflow
identity from leaking to third-party APIs — which broke the trace at
every agent-to-agent hop where the tool author forgot the manual
plumbing.

This change keeps the safe default but adds an allow-list so
operators can opt specific downstream hosts in. The forge.yaml block
mirrors the egress allow-list shape (exact + wildcard suffix), the
matcher reuses the same conventions, and the runner wraps the egress
transport once at startup so every built-in HTTP tool inherits the
auto-apply without per-tool changes.

  workflow_propagation:
    allowed_hosts:
      - "orchestrator.svc"
      - "*.agents.internal"

Implementation details:

- forge-core/types/config.go: new WorkflowPropagationConfig struct
  with AllowedHosts []string, attached to ForgeConfig under
  workflow_propagation.
- forge-core/runtime/workflow_propagation.go: new
  WorkflowPropagationMatcher (exact + wildcard, port-stripped,
  case-insensitive — same shape as security.DomainMatcher but
  independent so runtime doesn't grow a security dep), plus a
  workflowPropagationTransport that wraps an http.RoundTripper. The
  wrapper clones the request before stamping headers
  (RoundTripper contract) and short-circuits to the underlying
  transport identity-equal when the matcher is empty (zero-overhead
  default-deploy path).
- forge-cli/runtime/runner.go: builds the matcher from
  cfg.WorkflowPropagation.AllowedHosts and wraps the egress
  client's transport at construction time, so http_request,
  webhook_call, and web_search_* all inherit auto-apply.

Pinned by TestWorkflowPropagationMatcher_{Matches,IsEmptyAndNilGuard},
TestWorkflowPropagationTransport_{AppliesHeadersOnAllowlistedHost,
OmitsHeadersOnUnlistedHost,NoOpWhenContextIsZero,
DoesNotMutateOriginalRequest,EndToEnd,PropagatesUnderlyingError},
TestWrapTransportForWorkflowPropagation_EmptyMatcherShortCircuits,
TestNewWorkflowPropagationMatcher_RejectsBadInputCleanly.

Subprocess egress proxy path is not covered — that's a separate
concern (proxy-side header injection) and the issue scopes the
in-process Go HTTP tools. Filing a follow-up if downstream agents
running their HTTP via the subprocess proxy need the same
auto-apply.
@initializ-mk initializ-mk merged commit 581aed6 into main Jun 26, 2026
10 checks passed
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.

1 participant