Tools & platform-governance point release: a web_fetch builtin (read a
URL as clean markdown, #266), the previously-dormant general file
file_read/file_write/file_edit/file_patch builtins wired for
general agents (#268), an operator-authored org-wide command denylist
platform policy — denied_command_patterns (OWASP ASI02, #238), and an
apikey_header LLM auth scheme for Kong AI Gateway / fixed-header API
gateways (#302). All additive; two new default-on tools (web_fetch +
file write/edit/patch). Full notes:
https://github.com/initializ/forge/releases/tag/v0.17.1
Implements AARM (Autonomous Action Runtime Management) — the R-series runtime agent-governance controls (R3 intent alignment, R4a–R4c guardrails / step-up / defer, R5/R6 audit hash chain + Ed25519 signing, R7 intent drift, R9 JIT credentials) — plus reversible context compression (ctxzip), an opt-in headless-browser tool family, a pluggable remote session store, and a platform guardrails overlay. Full notes: https://github.com/initializ/forge/releases/tag/v0.17.0
The entries below accumulated since the last CHANGELOG rollover; a few also shipped in v0.16.0 (their rollover was missed) — the authoritative per-version scope lives in the GitHub Release notes.
- Guardrail MODIFY / DENY now apply to every tool, not just
cli_execute(#209 / governance R4a). Pre-#209 theSkillGuardrailEngineshort-circuited on any tool whose name wasn'tcli_execute, sodeny_commands/deny_outputpatterns silently no-op'd forweb_search,http_request, MCP calls, and every custom tool. The short-circuit is removed.- Match-target asymmetry to be aware of: for
cli_executethe match target is still the reconstructed shell command line (binary arg1 arg2 …) so existing shell-style patterns keep working. For every other tool the match target is the raw tool-input JSON as the LLM produced it. Seedocs/security/policy-decisions.mdfor migration guidance. GuardrailChecker.CheckInbound/CheckOutboundsignatures change to return(PolicyResult, error)— the newPolicyDecisionenum (Allow<Modify<StepUp<Defer<Deny, ordered by restrictiveness) is the R4 taxonomy from the governance framework.StepUp/Deferare reserved for R4b (#210) / R4c (#211) and not yet emitted; callers today still read only Allow / Modify / Deny.- Every
guardrail_checkaudit event already carried afields.decisionstring; nothing on the audit wire shape changes.
- Match-target asymmetry to be aware of: for
-
Pluggable session store with an opt-in
remotebackend (issue #243).memory.session_storeselectsfile(default — today's local.forge/sessions/*.json) orremote, which pushes per-task session snapshots to a platform session service over HTTP so stateless pods resume any task on any replica without a PVC. The remote backend does a conditional GET before each turn (If-None-Match→ 304/200), commits with anIf-MatchCAS, and on a 412 conflict yields (the newer state wins, the model is never re-run) rather than clobbering the concurrent writer. Enable withmemory.session_store: remote+session_store_url(orFORGE_SESSION_STORE/FORGE_SESSION_STORE_URL); auth reuses the admission client'sFORGE_PLATFORM_TOKEN+FORGE_ORG_ID/FORGE_WORKSPACE_ID. Aremoteselection missing its URL or token warns and falls back tofile. The file backend and its tests are unchanged. Seedocs/core-concepts/memory-system.md. -
Opt-in browser tool family (issue #94). Agents can drive a real headless Chromium (via
chromedp) to navigate, read, click, fill, and screenshot web pages — registered as a conditional tool family (browser_navigate,browser_state,browser_click,browser_fill,browser_extract,browser_screenshot) only when an active skill declaresrequires.capabilities: [browser]and a Chromium binary is present. Token-optimized: the LLM drives the page through a compact indexed digest (not raw HTML) and acts by element index, so each observation is ~1–3 KB and every action returns the new state. All traffic is forced through the existingEgressProxy(same allowlist / SSRF / DNS-rebinding protection);browser_fillrefuses password and payment fields unless the skill opts in viaguardrails.browser.allow_sensitive_fill; the analyzer scores the capability high-risk and flags abrowser+trust_hints.network: falsecontradiction as a Critical violation.forge buildinstalls Chromium into the image only for browser agents. New env varsFORGE_BROWSER_BINandFORGE_BROWSER_HEADLESS; bundledweb-browsereference skill. Seedocs/reference/browser-tools.md. -
Anthropic-format custom URLs + AWS Bedrock SigV4 outbound auth (issue #202). Two-phase rollout:
- Phase 1:
forge init's "Custom" provider option now asks whether the endpoint speaks OpenAI Chat Completions or Anthropic Messages wire format and writes the matching provider into the generatedforge.yaml(openaioranthropic). Both flows accept the same Base URL + API key inputs; the shape picker is the only branch. Underlying plumbing already supportedprovider: anthropic + ANTHROPIC_BASE_URLsymmetric with the OpenAI path (#137 / #139); this exposes it through the wizard. - Phase 2: new
model.auth_scheme: aws_sigv4+model.aws_regionfields onModelRef(with matchingClientConfig.AuthScheme/AWSRegion) wrap the LLM client'shttp.Transportwith an AWS SigV4 signer. Credentials resolve via the standard env vars (AWS_ACCESS_KEY_ID/_SECRET_ACCESS_KEY/_SESSION_TOKEN). Works symmetrically across the openai and anthropic providers so an operator can point either at AWS Bedrock or any other SigV4-fronted gateway:provider: anthropic+auth_scheme: aws_sigv4→ calls Anthropic-shaped endpoints with SigV4 signing instead ofx-api-key; theanthropic-versionheader still rides.provider: openai+auth_scheme: aws_sigv4→ calls OpenAI-shaped endpoints with SigV4 signing instead ofAuthorization: Bearer; theOpenAI-Organizationheader still rides.
- SigV4 signer is hand-rolled (~250 LOC, stdlib only) matching
the existing
forge-core/auth/providers/aws_sigv4inbound-auth posture — keeps the binary footprint flat (no aws-sdk-go-v2 + ~5 MB). AWS_REGIONenv safety-net for the SigV4 path symmetric withOPENAI_BASE_URL/ANTHROPIC_BASE_URLenv safety-nets — set once at the platform layer instead of in everyforge.yaml.- Bedrock hostnames (
bedrock-runtime.<region>.amazonaws.com) auto-extend the egress allowlist via the existingLLMProviderDomainsparsing — no separateegress.allowed_domainsentry required whenmodel.base_urlpoints there. - The empty
AuthSchemedefault preserves the pre-#202 contract byte-for-byte. Existing Anthropic and OpenAI deployments see zero behavior change. - Web identity tokens / IRSA / EC2 instance metadata resolution is
NOT yet supported — the credential getter reads env only. Most
Bedrock deployments today set the AWS env vars at the platform
layer (via IRSA's env injection, sidecar credential fetcher, or
AWS_PROFILEresolution). STS-based credential refresh is tracked as a follow-up. - Out of scope for this PR: Bedrock-specific URL / body
rewriting (
/model/<id>/invokepath +anthropic_versionbody field). Operators today either point Forge at a Bedrock-compat proxy that handles the translation (litellm, OpenRouter) or wait for a Phase 2.5 PR that adds native Bedrock InvokeModel passthrough. - Pinned by
TestSigV4Transport_{StampsAuthorizationAndAmzDate, StampsSecurityTokenWhenTemporary, PreservesBodyAndContentLength, DoesNotMutateCallerRequest, PropagatesUnderlyingError, MissingCredentialsErrors, RequiresRegionAndService, CanonicalQueryOrdering, EndToEndAgainstHTTPTestServer},TestSigV4CredentialsFromEnv_ParsesStandardVars,TestAnthropicClient_{DefaultAuthSchemeKeepsXAPIKey, SigV4AuthSchemeOmitsXAPIKey, SigV4WrapsTransport, DefaultTransportNotWrapped},TestOpenAIClient_{DefaultAuthSchemeKeepsBearer, SigV4AuthSchemeOmitsBearer, SigV4WrapsTransport, OrgIDStillSetUnderSigV4},TestLLMProviderDomains_BedrockHostExtracted,TestNormalizeCustomProvider_{AnthropicShapeRewritesToAnthropic, DefaultShapeStaysOpenAI}.
- Phase 1:
-
Platform admission hook for per-agent quota / cost-limit gating (issue #201). A new pre-dispatch middleware lets the platform tell an agent process to stop accepting new
tasks/sendinvocations when an agent / workspace / org is over budget — distinct from auth (HTTP 401 on bad credentials) and from the per-IP rate limiter (HTTP 429 on burst).- Two env vars to engage:
FORGE_ADMISSION_URL(the platform's admission endpoint) andFORGE_PLATFORM_TOKEN(bearer token). Both must be set; partial config logs one warn at startup and runs without admission. ExistingFORGE_ORG_ID/FORGE_WORKSPACE_IDenv vars from #157 forward as outboundOrg-Id/Workspace-Idheaders when set (empty value → header omitted entirely). - Baked defaults: 2s HTTP timeout, 5s decision cache TTL,
GETmethod. Not env-overridable — keeps the operator surface flat. - Wire shape:
GET /v1/admission?agent_id=<id>with bearer + tenancy headers returns{decision, reason, scope, window, reset_at}.decisionisadmitordeny; everything else is platform-defined. - Fail-open everywhere: any failure (timeout, 4xx, 5xx, parse
error, unknown decision value) produces a logged warn line and
a cached fail-open admit for the TTL. No
REQUIREDknob — the cascade of "platform degraded → all agents stop serving" is a worse production failure than a 5-second quota leak. - Caller-facing on deny: HTTP 402 Payment Required with
Retry-After(derived fromreset_at, clamped non-negative) and a structured body carrying reason / scope / window. - Pipeline placement: middleware fires after
auth_middleware, before the dispatcher. Auth runs first so the platform call never burns on unauthenticated traffic; admission runs before the executor so a denied invocation never reaches the LLM / tool stack. - New audit event
task_admission_deniedcarryingfields.reason/scope/window/reset_at/cached.cacheddistinguishes "platform actively denied" from "serving a few-second-old cached deny" when debugging propagation lag. - New OTel span
admission.check, sibling ofauth.verify(issue #187). Attributes:forge.admission.decision/.reason/.scope/.window/.cached/.fallback. Status=Error on deny. The HTTP call nests under it ashttp.clientso total + platform latency surface cleanly. - Pinned by
TestPlatformAdmissionChecker_{AdmitFromPlatform, DenyFromPlatform,TenancyHeadersSentAndOmitted,CachesWithinTTL, CacheExpires,FailsOpenOnNetworkError,FailsOpenOnPlatform5xx, FailsOpenOnAuth4xx,FailsOpenOnMalformedJSON, FailsOpenOnUnknownDecision,AppendsAgentIDToExistingQuery, TimeoutHonored},TestBuildAdmissionChecker_{BothEnvSetReturnsPlatformChecker, NeitherEnvSetSilentNoop,PartialConfigWarnsButReturnsNoop},TestAdmissionMiddleware_{AdmitPassesThrough, DenyReturns402WithStructuredBody, DenyClampsNegativeRetryAfter,EmitsAuditEventOnDeny, NoopShortCircuits,NilCheckerPasses}.
- Two env vars to engage:
-
Three runtime spans for previously-invisible latency / causality surfaces:
auth.verify,channel.<adapter>.deliver,schedule.fire(issue #187). All three use the existing globalTracer()(no new install) and respect the off-by-default tracing posture — when tracing is disabled the no-op tracer makes them zero-allocation. Status=Error on the failure path keeps error-rate dashboards consistent across span types.auth.verifywraps theProvider.Chain.Verifycall inforge-core/auth/middleware.go. Provider outbound HTTP calls (JWKS / STS / IAP / Graph) now nest under it instead of appearing as orphan roots. Attributes:forge.auth.provider,forge.auth.token_kind,forge.auth.decision,forge.auth.user_id/org_id(success),forge.auth.fail_reason(failure). Theauth.FailReason(err) stringhelper is exported fromforge-core/authso the span and the auditauth_failevent share one reason vocabulary; the forge-cli runtime's localauthFailReasonnow delegates to it.channel.<adapter>.deliverwraps the per-message handler in every channel adapter (Slack / Telegram / Teams) via a newchannels.StartDeliverSpanhelper. The internal A2A POST inforge-cli/channels/router.gonow injects the W3Ctraceparentvia the global propagator, so the downstreama2a.tasks/sendspan nests under the deliver span. Attributes:forge.channel.adapter,forge.channel.target,forge.channel.message_id,forge.channel.user_id. Highest user-visible payoff of the three — operators can now answer "Slack→agent latency" from the flame graph alone.schedule.firewrapsScheduler.fireinforge-core/scheduler/scheduler.go(file backend only). Attributes:forge.schedule.id,forge.schedule.cron,forge.schedule.source(yaml/llm). K8s-backend dispatch is out of scope for v1 — the trigger Pod is a separate curl-based Pod and needstraceparentinjected into the rendered CronJob YAML atforge packagetime (follow-up).- Pinned by
TestAuthVerifySpan_{SuccessRecordsProviderTokenKindDecision, FailureSetsErrorStatusAndFailReason, MissingBearerOpensZeroDurationSpan, ParentsProviderHTTPClientSpans},TestStartDeliverSpan_{StampsAdapterAndEventAttributes, ErrorSetsStatus,AdapterNameDrivesSpanName, ChildContextCarriesActiveSpan,NilEventDoesNotCrash},TestScheduleFireSpan_{StampsAttributesAndParentsDispatch, ErrorSetsStatusError,SourceSurfacesLLMOriginatedSchedules}.
-
FORGE-1: opt-in auto-propagation of workflow correlation headers on outbound HTTP tool calls (issue #186). Adds a
workflow_propagation.allowed_hostsblock toforge.yaml. Hosts matching the allow-list automatically receive theX-Workflow-Id/X-Workflow-Execution-Id/X-Workflow-Stage-Id/X-Workflow-Step-Id/X-Invocation-Callerheaders from the current request context when invoked from any built-in HTTP tool — no per-tool code change needed. Hosts not on the list keep the pre-#186 opt-in behavior; the headers stay off so workflow identity never leaks to third-party APIs.- New
WorkflowPropagationMatchermirrors the egress allow-list wildcard semantics (exact +*.suffix.com, port-stripped, lowercase-normalized). Wildcards match strictly-deeper subdomains and refuse the apex —*.agents.internaldoes NOT match the bareagents.internal. - New
WrapTransportForWorkflowPropagationwraps the egresshttp.Transportonce at runner startup so every HTTP tool (http_request,webhook_call,web_search_*, future tools) inherits the auto-apply viasecurity.EgressTransportFromContext. Empty config = the wrapper short-circuits and returns the underlying transport identity-equal, zero overhead per request on the default-deploy path. - The wrapper clones the request before mutating headers (the
http.RoundTrippercontract), so a caller'sreq.Headeris never modified across retries. - Builds on FORGE-2 (#185) — the allow-listed propagation set
includes the new
X-Workflow-Execution-Idheader so downstream agents' audit events join 1:1 on per-run timelines. - Pinned by
TestWorkflowPropagationMatcher_{Matches,IsEmptyAndNilGuard},TestWorkflowPropagationTransport_{AppliesHeadersOnAllowlistedHost, OmitsHeadersOnUnlistedHost,NoOpWhenContextIsZero, DoesNotMutateOriginalRequest,EndToEnd, PropagatesUnderlyingError},TestWrapTransportForWorkflowPropagation_EmptyMatcherShortCircuits,TestNewWorkflowPropagationMatcher_RejectsBadInputCleanly.
- New
-
FORGE-2: split workflow definition from per-run execution (issue #185). The previously-overloaded
X-Workflow-IDheader now carries the workflow DEFINITION id (stable across every run); a newX-Workflow-Execution-IDheader carries the per-run instance id. Both surface as top-level fields on every audit event under a workflow run (workflow_id,workflow_execution_id), so SIEM consumers can answer "show me every event in this specific run" (join onworkflow_execution_id) AND "top failing workflows" / "latency by workflow definition" (group byworkflow_id) without joining on opaque ids. Industry precedent for the split: GitHub Actions (workflow + workflow_run_id), Tekton (Pipeline + PipelineRun), Argo (Workflow + WorkflowRun).WorkflowContextgainsWorkflowExecutionID;WorkflowContextFromHTTPHeadersreads both headers,ApplyToHTTPHeaderswrites both for outbound A2A propagation.AuditEventgainsWorkflowExecutionIDwithjson:"workflow_execution_id,omitempty".EmitFromContextstamps both fields from the request context.- New OTel span attribute
forge.workflow.execution.idstamped onagent.executeand dispatcher spans, alongside the existingforge.workflow.id. - Both fields use
omitempty— direct A2A invocations without orchestrator headers continue to emit byte-identical JSON to pre-FWS-2 consumers.schema_versionstays1.0(additive schema-compatible change). - Clean break — no backward-compatibility alias (per the issue;
the contract is pre-production). Orchestrators sending only the
old
X-Workflow-Idcontinue to populateworkflow_idwith run semantics; they should switch to the split contract before cutting workflow timelines on the new field. - Pinned by
TestWorkflowContextFromHTTPHeaders_{DefinitionAndExecutionAreIndependent,ExtractsAllFour},TestApplyToHTTPHeaders_PopulatesExecutionID,TestRoundTripHTTPHeaders,TestEmitFromContext_{TagsWorkflowFieldsWhenContextHasThem,TagsBothWorkflowDefinitionAndExecution,OmitsWorkflowFieldsWhenContextEmpty}.
-
Guardrails DB mode hardening: fail-loud + seed helpers + exclusivity warning (issue #166). Three quiet behaviors in the
BuildGuardrailCheckerresolution ladder that mismatched what operators expect from a "production-grade guardrails" deploy are now addressable from the operator surface:- New
FORGE_GUARDRAILS_DB_REQUIRED=trueenv var: when DB mode is selected (FORGE_GUARDRAILS_DBset) and the Mongo connect fails, the runner logs an Error and returns a non-nil startup error instead of silently downgrading to file mode or defaults. Off by default for back-compat; recommended ON for platform deployments where DB-mode guardrails are security-critical.BuildGuardrailCheckernow returns(GuardrailChecker, error)so the runner can propagate the failure as a non-zero exit. - New
forge guardrails seed-defaultssubcommand: printsDefaultStructuredGuardrailsas JSON suitable for piping into MongoDB. Round-trips throughmodels.StructuredGuardrailsso the output is library-consumable verbatim. Closes the "DB mode bypasses built-in defaults" footgun — operators have a one-line baseline seed. - New
forge guardrails validate-dbsubcommand: connects toFORGE_GUARDRAILS_DB, fetches the agent'sAgentConfigdocument, and reports on baseline coverage (PII config, jailbreak / prompt- injection / command-injection thresholds, secret-pattern rule count, core gate enablement). Warns when fewer than 5 secret-pattern rules are present or PII config is missing. Exits non-zero on missing document so CI / deployment hooks can fail rollout. - One-shot startup warning when both
FORGE_GUARDRAILS_DBis set AND aguardrails.jsonis present in the workdir. Repo readers previously saw the file and assumed it was active; in DB-mode deploys it was dead config that drifted. The warning fires through the ops logger (not the audit stream) exactly once per process, pointing at the specific path being ignored. - DB connect timeout in
NewDBGuardrailEnginetrimmed from 10s to 3s — short enough that a misconfigured URI surfaces during startup, long enough to absorb DNS jitter + TLS on a healthy cluster. - Pinned by
TestBuildGuardrailChecker_{DBRequired_FailsLoudOnUnreachable, DBUnreachable_FallsBackByDefault,DBRequiredAcceptsForgivingParse, DBAndFile_WarnsOnce,DBOnly_NoFile_NoExclusivityWarn, FileOnly_NoWarn,HonorsCustomGuardrailsPath},TestGuardrailsSeedDefaults_RoundTripsThroughLibraryModel,TestScoreAgentConfig_{FullDefaultsHaveNoWarnings,SnakeCaseCompat, EmptyDocFlagsEverything,FewerThan5SecretRulesWarns},TestExtractCustomRules_DefensiveOnShape.
- New
-
Audit payload capture: operator surface + consolidated redact pass (issue #163). FWS-8 raw-payload capture (
AuditPayloadCapture) is now configurable fromforge.yamlandFORGE_AUDIT_CAPTURE_*env vars in addition to the existing programmaticRunnerConfigpath — closing the gap that previously made the feature unusable from a container deployment without a code change. Capture stays off by default; flipping any flag on emits rawargs,result,prompt_messages, orcompletion_textfields on the correspondingtool_exec/llm_callevents.- New env vars:
FORGE_AUDIT_CAPTURE_TOOL_ARGS,FORGE_AUDIT_CAPTURE_TOOL_RESULT,FORGE_AUDIT_CAPTURE_LLM_MESSAGES,FORGE_AUDIT_CAPTURE_LLM_RESPONSE,FORGE_AUDIT_CAPTURE_REDACT(defaulttrue),FORGE_AUDIT_CAPTURE_MAX_BYTES(single-knob 16 KiB default). - New
forge.yamlblock underaudit.capture:with per-field*boolsemantics (nil = fall through to env). Precedence:forge.yaml> env > default. - New
Redactfield oncoreruntime.AuditPayloadCapture. ON by default. When on, captured fields run through the shared vendor-secret regex scrub (PrepareCapturedContent) BEFORE truncation so a token an LLM glued into acli_executecommand surfaces in audit as[REDACTED], not verbatim. Setfalseonly when a downstream sink runs its own scrubber. - New shared helper
coreruntime.PrepareCapturedContent(s, redact, maxBytes). The FWS-8 capture hooks (registerAuditHooks), guardrail evidence pipeline (prepareEvidence), and OTel span content pipeline (PrepareSpanContent) now ALL delegate to this one helper so a fix to the regex set propagates to every content-capture path. Removes 3 independent copies of the vendor-secret regex set. - Pinned by
TestAuditPayloadCaptureFromEnv_{Defaults,FlagsParsed, RedactEscapeHatch,MaxBytesIsSingleKnob,MaxBytesIgnoresInvalid, RejectsZeroAndNegativeMaxBytes},TestPrepareCapturedContent_{RedactScrubsVendorTokens, NoRedactKeepsSecretsVerbatim,TruncatesAtCap,RedactBeforeTruncate, EmptyFastPath,DefaultCap},TestPrepareSpanContent_StillDelegatesAndUsesItsOwnDefault,TestResolveAuditPayloadCapture_{EnvOnly,YAMLWinsOverEnv, YAMLNilDoesNotClobberEnv,MaxBytesUniform,AllSurfacesOff},TestRegisterAuditHooks_{DefaultPostureOmitsCaptureFields, CaptureToolArgs_OnlyStartEventCarries,CaptureRedactsVendorTokens, CaptureRedactFalseLeavesSecretsVerbatim, CaptureToolResult_Truncates}.
- New env vars:
-
Skill Builder edit mode (issue #193). The dashboard's Skill Builder can now iterate on an already-attached custom skill instead of only creating new ones. A new Skills attached to this agent panel lists each
skills/<name>/SKILL.mddiscovered on disk; clicking Edit loads its current SKILL.md and helper scripts into the editor, primes the chat with the existing content, and switches the LLM call to an edit-mode prompt that instructs it to preserve## Tool: <name>headings, default to minimal patches, and emit a**Changed:**summary. A Preview changes Monaco diff modal shows editor-state vs disk side-by-side before save. Confirm save overwrites the existing skill directory in place; helper scripts dropped during the edit are removed from disk so the runtime stops discovering them. A Restart agent banner appears after a successful edit-mode save because the running agent's tool registry is captured at startup and not live-mutated.- New endpoints:
GET /api/agents/{id}/skill-builder/skills([]CustomSkillSummary) andGET /api/agents/{id}/skill-builder/skills/{name}(CustomSkillContent). Both reject path-traversal, slashes, backslashes, and non-kebab-case names with400. POST /api/agents/{id}/skill-builder/chatacceptsmode: "edit"andediting_name. The server loads the on-disk SKILL.md itself (single source of truth — never trusts UI-provided baseline) and appends an## Edit Modetrailer to the system prompt.POST /api/agents/{id}/skill-builder/saveacceptsoverwrite: truewithediting_namematchingskill_name. Mismatched editing_name is rejected at the handler boundary as defense in depth; the writer additionally guards against wiping scripts when names don't match.- The script loader rejects symlinks whose resolved target escapes
the skill's own directory so a malicious link to
/etc/passwdnever reaches the editor or LLM context. - Pinned by
TestListCustomSkills_ReturnsAllForms,TestGetCustomSkill_{Subdir,Flat,Errors},TestReadCustomSkill_SymlinkEscapeRejected,TestChat_EditMode_PrimesPromptWithExistingSkill,TestChat_CreateMode_OmitsEditTrailer,TestSave_OverwriteMismatchedEditingName_Rejected,TestValidateSkillMD_DuplicateNameSuppression,TestSaveSkillToDisk_Overwrite_DropsStaleScripts,TestSaveSkillToDisk_Overwrite_NameMismatch_DoesNotWipeScripts.
- New endpoints:
-
Subprocess W3C trace-context propagation + binary-runtime skills (issue #182). Skill / tool subprocesses now receive
TRACEPARENT,TRACESTATE, andBAGGAGEenv vars derived from the parent agent's active span, plus a curatedOTEL_*SDK config subset so the child exports to the same collector with consistent sampling. OTel-instrumented binaries (infil, an LLM CLI, a Python service) now nest their spans under the agent'stool.<name>span instead of starting a fresh root.OTEL_EXPORTER_OTLP_HEADERSis deliberately excluded from the passthrough — collector auth tokens are treated as secrets and must be declared via SKILL.mdenv.optionallike every other credential.- Adds
metadata.forge.runtime: binaryto the SKILL.md schema. Binary skills exec the firstmetadata.forge.requires.binsentry directly (resolved viaexec.LookPath) — no bash fork, no script file required.runtime: script(or empty) keeps the legacy materialized-bash-script path. Pinned byTestSkillCommandExecutor_TraceparentInjectedWhenCtxHasSpan,TestSkillCommandExecutor_TraceparentAbsentWhenNoSpan,TestSkillCommandExecutor_OTelSubsetPassedThrough,TestNewBinarySkillTool_RunsBinaryDirectly. - When tracing is off the global propagator is a no-op composite — subprocess env is byte-identical to pre-#182 deploys.
- Adds
- K8s scheduler backend no longer hard-errors when
scheduler.kubernetes.service_urlis unset (issue #179). Pre-fix, an agent deployed in-cluster with a defaultscheduler.backend: autoand no explicitservice_urlaborted startup withkubernetes scheduler backend: scheduler.kubernetes.service_url is required— even though the build-timeschedule_manifest_stagealready knew how to default the same field tohttp://<agent_id>.<namespace>.svc:<port>/. Runtime now mirrors the build-time default: whenServiceURLis empty, the constructor derives the in-cluster Service DNS usingagent_id+ resolved namespace + the runner's listen port (default 8080). Explicitservice_urloverrides still pass through untouched, so operators behind an Ingress / Gateway are unaffected. Pinned byTestKubernetesBackend_ServiceURLDefaultDerivation,TestKubernetesBackend_ServiceURLDefaultPortFallback, andTestKubernetesBackend_ServiceURLExplicitOverride.
forge buildno longer fails the bundledcode-reviewskill on a fresh agent (issue #145, PR TBD). The build'ssecurity-analysisstage evaluated skills againstanalyzer.DefaultPolicy(MaxRiskScore=75), and the bundledcode_review_diff/code_review_fileskills routinely scored 100 — three of their declared egress domains (chatgpt.com,patch-diff.githubusercontent.com,raw.githubusercontent.com) were not in the builtintrustedDomainsmap and racked up 30 points; the 9 config-knob env vars added another 45. The operator saw onlysecurity policy check failed: 2 error(s), 0 warning(s)— no rule detail, no path to the audit JSON, no override knob (onlyforge skills auditaccepted--policy). Three-layer fix:- Extended
trustedDomainswith the GitHub-owned content endpoints (raw.githubusercontent.com,patch-diff.githubusercontent.com,gist.githubusercontent.com,objects.githubusercontent.com) andchatgpt.com(OpenAI product redirect). - Capped the env category at 25 points so multi-purpose skills declaring many config knobs aren't penalized linearly. Per-item factors are still emitted; only the points contribution to the aggregate is bounded.
- Raised
DefaultPolicy.MaxRiskScorefrom 75 → 90 so vetted bundled skills clear the default. Operators wanting a stricter posture can lower the ceiling in a policy YAML. - Added
security.policy_pathtoforge.yamland--policytoforge build, mirroringforge skills audit --policy. The flag wins over the yaml field; both point at the sameanalyzer.SecurityPolicyschema. A missing file is a hard load error (no silent fallback). - Rewrote the
security-analysisfailure path: per-skill rule + message- recommendations + audit JSON path + policy source + remediation hint print to stderr; the returned error still includes the violation count for programmatic consumers.
- Extended
-
Session persistence no longer poisons followup turns when the LLM returns empty content after
finish_reason: length(issue #131, PR #132). Long-running Slack threads (and any session-persistent channel) succeeded on the first message and then failed every subsequent followup with"something went wrong while processing your request, please try again". The executor wrote the LLM's assistant message to memory unconditionally before checking content. When the provider hitfinish_reason: lengthand returned an assistant turn with empty content AND no tool_calls, that invalid-per-OpenAI-spec shape landed in mem. The in-loop empty-response recovery papered over it for the current task, butpersistSessionwrote the polluted memory to.forge/sessions/<task_id>.json. The next request recovered it and strict OpenAI-spec providers (Moonshot, hosted OpenRouter, OpenAI strict mode) returned HTTP 400. Fix substitutes a placeholder content string ("(continuing — previous response was truncated by output token limit)") when the LLM returns the bad shape, AND extendssanitizeMessagesonLoadFromStorewith a newstripEmptyAssistantTurnspass to rescue sessions already on disk without anrmmigration. -
Duplicate user message at the start of every fresh session no longer trips strict-mode providers like
gpt-5-nano(issue #143, PR #144). Same symptom as #131 —"something went wrong"on followup — different root cause. The runner pre-appendsparams.Messagetotask.Historybefore callingExecuteso SSE clients see the inbound message in the in-flight task. The executor's!recoveredfirst-interaction path then iteratedtask.HistoryAND appended*msgseparately, producing two consecutive identical user turns at the start of every fresh conversation. OpenAI reasoning models (gpt-5-nano,o1,o3) and strict OpenAI-compatible gateways (Together's Kimi) reject consecutive same-role messages with HTTP 400. Fix strips the trailingtask.Historyentry when it equals*msg(newa2aMessagesEqualhelper) AND extendssanitizeMessageswith acollapseConsecutiveDuplicatespass to rescue sessions already on disk. The collapse is surgical: only EXACT same-role same-content tool-call-free pairs collapse; workflow nudges and tool-bearing turns are preserved. -
The
code-reviewskill no longer routes Anthropic-first when both API keys are set (issue #133, PR #134). The skill'scode-review-diff.shandcode-review-file.shscripts picked Anthropic wheneverANTHROPIC_API_KEYwas non-empty, even when the operator'sforge.yamlpointed at an OpenAI-compatible provider (Together.ai, OpenRouter, Groq, Fireworks, Anyscale, vLLM, llama.cpp's server) viaOPENAI_BASE_URLandREVIEW_MODELwas clearly a non-Anthropic model. Operators with a staleANTHROPIC_API_KEYco-resident with a liveOPENAI_API_KEYin.forge/secrets.encgotAnthropic API returned status 401and assumed the skill was broken. Fix adds an explicitREVIEW_PROVIDERenv var (valuesanthropicoropenai) that wins always. When unset, auto-detected fromREVIEW_MODELprefix (claude-*oranthropic/*→ Anthropic; anything else → OpenAI), then by sole API key, then defaults to OpenAI when both are set with no other signal. TheOPENAI_BASE_URL-as-Responses-API-toggle was also wrong (Together, OpenRouter, Groq, etc. only implement/chat/completions; the OpenAI Responses API is proprietary); decoupled into a separateOPENAI_USE_RESPONSES_API=1opt-in. -
The
code-reviewskill now usesmax_completion_tokensinstead of the deprecatedmax_tokenson the OpenAI Chat Completions branch (issue #141, PR #142). OpenAI deprecatedmax_tokensin favor ofmax_completion_tokens; reasoning models (o1,o1-preview,o3,gpt-5) and strict OpenAI-compatible providers (Together.ai's Kimi-K2.6 series, Moonshot) reject the legacy field with HTTP 400"Unsupported parameter: 'max_tokens' is not supported with this model. Use 'max_completion_tokens' instead.". The Anthropic branch keepsmax_tokens(correct field for Anthropic's API). -
Skill subprocesses now inherit
OPENAI_BASE_URL,ANTHROPIC_BASE_URL,OLLAMA_BASE_URL, andGEMINI_BASE_URLfrom the parent env even when the SKILL.md doesn't declare them (issue #137, PR #138). Pre-fixSkillCommandExecutorbuilt a whitelist-only env whereOPENAI_ORG_IDwas always-passed but the standard SDK base-URL pointers were not — unless eachSKILL.mdauthor remembered to declare each variable inenv.optional. Every LLM-calling skill that forgot silently broke for OpenAI-compatible deployments. Fix special-cases the four standard SDK variables alongsideOPENAI_ORG_ID, so every skill that uses the industry-standard env conventions just works. -
The CLI wizard now shows
(secrets) — okforone_ofenv keys encrypted in.forge/secrets.encAND no longer pre-writes a misleadingANTHROPIC_API_KEY=placeholder when no key is provided (issue #135, PR #136).forge skills adddidn't validateone_ofgroups at all — operators got no confirmation that their encrypted key was detected.forge init's fallback wroteopts.EnvVars[OneOfEnv[0]] = ""(the first key in the list,ANTHROPIC_API_KEYforcode-review), producing an empty.envline that misled operators about which provider was expected. Fix mirrorsRequiredEnv's three-source check (os.Getenv/.env/loadSecretPlaceholders) forone_ofgroups and drops the placeholder pollution. -
forge packageandforge runnow auto-add the LLM provider's custom base URL host to the egress allowlist + generatedNetworkPolicy(issue #139, PR #140). Operators configuring an OpenAI-compatible provider viaOPENAI_BASE_URL=https://api.together.ai/v1shipped aNetworkPolicythat blocked the provider's hostname — deployed agents 401d or timed out depending on which side noticed first. Same trap Phase 6 of OTel Tracing v1 fixed for the OTLP collector (issue #107), but for the LLM provider. Fix addsModelRef.BaseURLandModelFallback.BaseURLfields to the schema and two new helpers —security.LLMProviderDomains(cfg-driven, used by build + runtime) andsecurity.LLMProviderEnvDomains(env-driven runtime safety net forOPENAI_BASE_URL/ANTHROPIC_BASE_URL/OLLAMA_BASE_URL/GEMINI_BASE_URL). Both wired intoegress_stage.goandrunner.goalongside the existingAuthDomains/MCPDomains/OTelDomainmerges.
-
OpenTelemetry tracing v1 — full end-to-end distributed tracing (initiative #108, PRs #122–#128). Off-by-default OTLP export covering A2A dispatch (
a2a.<method>SpanKindServer), the executor loop (agent.execute), every LLM completion (llm.completionwith GenAI semconvgen_ai.usage.*_tokens), every tool call (tool.<name>), and every outbound HTTP request (auto viaotelhttpon the egress-enforced transport). Newobservability.tracingblock inforge.yaml, nine--otel-*CLI flags, and all 10 standardOTEL_*env vars (OTEL_EXPORTER_OTLP_*,OTEL_TRACES_SAMPLER,OTEL_SERVICE_NAME, ...) honored with full precedence (CLI > env > yaml > defaults). Audit events emitted viaEmitFromContextcarrytrace_id+span_idso operators pivot audit row ↔ trace tree with a single copy-paste (omitempty— tracing-off audit JSON is byte-identical to pre-Phase-4). The composite W3Ctracecontext + baggagepropagator is installed at startup; the dispatcher extracts inboundtraceparentand outbound HTTP re-injects it, so multi-hop A2A flows display as one connected trace. The OTLP HTTP exporter rides through the same egress enforcer as every other in-process client — a misconfigured collector URL cannot exfiltrate spans.forge packageandforge runauto-add the collector hostname toegress_allowlist.jsonso the generated NetworkPolicy admits OTLP traffic with no second egress edit. Phase 3 ships metadata-only; content capture (thecapture_contentknob) is a follow-up that will reuse the FWS-8 audit redactor. Seedocs/core-concepts/observability-tracing.mdfor the full reference. Issue breakdown: #101 (Phase 0, seam, PR #122), #102 (Phase 1, OTLP provider, PR #123), #103 (Phase 2, config wiring, PR #124), #104 (Phase 3, span instrumentation, PR #125), #105 (Phase 4, audit cross-link, PR #126), #106 (Phase 5, inbound propagation, PR #127), #107 (Phase 6, build-time egress, PR #128). -
Per-IP rate-limit configurability —
server.rate_limitblock + CLI flags + env vars (issue #110, FWS-10). The A2A server's per-IP rate limiter (originally from issue #31) is now configurable end-to-end via a new top-levelserver.rate_limit:block inforge.yaml, matching--rate-limit-*CLI flags on bothforge runandforge serve start, andFORGE_RATE_LIMIT_*env vars. Resolution order is per-field: CLI > env > yaml > defaults. Five fields exposed:read_rps,read_burst,write_rps,write_burst,cancel_exempt. Seedocs/reference/forge-yaml-schema.md#serverrate_limit--per-ip-a2a-rate-limits-fws-10.
tasks/sendnow rejects malformed messages at the entry point with a clear diagnostic instead of letting the executor produce a confused reply (issue #119). The most common failure was a client sending the pre-0.3.0"type": "text"discriminator instead of the A2A 0.3.0 spec-correct"kind": "text".encoding/jsonsilently dropped the unknowntypefield, leavingPart.Kindempty; the executor then produced a reply like "It looks like your message didn't come through" — confusing the caller about what actually went wrong. NewPart.Validate()andMessage.Validate()(inforge-core/a2a) catch this case along with emptyKindon a populated part, unknownKindvalues, missingrole, and emptypartsarrays. The validator is invoked on all fourtasks/sendentry points (JSON-RPC sync + SSE, REST sync + SSE) and returnsJSON-RPC -32602 InvalidParams/HTTP 400with the spec divergence named in the message text — e.g. "parts[0]: part kind is required (A2A 0.3.0); got empty kind with non-empty content — did you send\"type\"instead of\"kind\"?\"type\"is from the pre-0.3.0 dialect and is silently ignored by the decoder". Operators see a structuredWarnlog (tasks/send rejected: invalid message shapewithtask_id,reason, andremote_addr) so they can grep for clients still emitting the legacy shape. Sentinel errors (ErrPartKindMissing,ErrPartKindUnknown,ErrMessageRoleMissing,ErrMessagePartsEmpty) are exposed for callers that want to branch on the specific cause without parsing strings.
-
A2A server rate-limit defaults bumped for orchestrated workloads (issue #110, FWS-10). Write limits raised from
10/min+ burst3to60/min+ burst20. The old defaults predated parallel workflow execution and cron bursts — a 10-step parallel stage was getting serialized after the 3rd dispatch.tasks/cancelis now exempt from the write bucket by default (configurable viacancel_exempt: false). The cost-ceiling cancel-burst case (orchestrator firing N parallel cancels when a workflow budget trips) was hitting-32603: rate limit exceededat exactly the moment cancellation matters most — that's the failure mode FWS-4's manual test surfaced. Read defaults (60/min, burst10) unchanged. Operators with stricter threat models can lock down via the new config surface (example for a public-facing agent in the schema docs). -
Hardened audit emission — sequence numbers + schema version + opt-in payload capture (issue #91, FWS-8). Every audit event now carries
schema_version: "1.0"(the audit schema is documented as a stable, additive-by-default contract — version only bumps on removals or semantic changes). Every event emitted on behalf of an A2A invocation also carries a monotonically increasingseqfield starting at1, so consumers detect gaps and reordering by grouping(correlation_id, task_id). Sequences are scoped per-invocation; startup events (policy_loaded,agent_card_published,audit_export_status) omitseq. The default audit posture remains metadata-only: token counts, sizes, durations, tool names — never raw prompt text, completion text, or tool args / results. A newAuditPayloadCaptureconfig (off by default; opt-in field by field viaLLMMessages/LLMResponse/ToolArgs/ToolResult) lets customers who need raw payloads in audit (debug, supervised-learning corpora, compliance replay) capture them, with per-field byte caps and…[truncated:N]markers so a runaway prompt or gigabyte tool output cannot bloat one event. A regression test (TestNoPayloadByDefault_LLMCall) pins the metadata-only invariant — any future caller that smuggles raw user content into a default audit event will fail it. Audit-event signing is deferred per the issue's architectural recommendation ("ship if a customer asks") — sequence numbers cover gap detection in the meantime. Seedocs/security/audit-logging.md#schema-contract-fws-8. -
Audit event export capability — Unix Domain Socket sink + HTTP fallback (issue #95, FWS-7). Audit events can now be exported to a local Unix Domain Socket (preferred) or localhost HTTP endpoint in addition to the existing NDJSON-to-stderr stream — letting an in-pod sidecar (e.g. the initializ platform receiver) consume audit with low latency while preserving stderr as the safety-net fallback. Configure via
--audit-socket=/path/to/audit.sock,--audit-http-endpoint=http://127.0.0.1:9097/v1/audit, or the matchingFORGE_AUDIT_SOCKET/FORGE_AUDIT_HTTP_ENDPOINT/FORGE_AUDIT_WRITE_TIMEOUTenv vars (works on bothforge runandforge serve start; flag wins over env). The default zero config is unchanged from pre-FWS-7 — stderr only — so existing deployments are unaffected. Newcoreruntime.Sinkinterface with three implementations:writerSink(the safety net),socketSink(UDS with lazy reconnect + 50ms per-write timeout + exponential backoff, drops on timeout without back-pressuring the emitter), andhttpSink(localhost POST fallback). Per-sink stats counters (writes_ok,drops_timeout,drops_dial,connected) feed a newaudit_export_statusaudit event emitted every 60s so operators can tail the audit stream itself to confirm export health. Sinks are fire-and-forget: buffering is the sidecar's concern. Events leaving each sink are byte-identical; no sink transforms the payload. The audit event schema, the event types, and theAuditLogger.Emit()API are unchanged — this is purely an additive transport layer. Seedocs/security/audit-logging.md. -
Three-layer platform policy + channel scope (issue #90, FWS-6). Forge now reads platform policy from three layers at startup (
/etc/forge/policy.yaml,~/.forge/policy.yaml, and the path atFORGE_PLATFORM_POLICY— system, user, and workspace respectively). The schema is unchanged from FWS-5 and applies identically at every layer; resolution unions deny lists and takes the smallest non-zero max-bound across layers ("most restrictive wins"). For audit attribution, the first layer (in load order: system → user → workspace) to contain an offending value takes credit so operators greppinglayer=systemsee every sysadmin-enforced violation without false positives from per-user overrides. Every audit event the policy subsystem emits (policy_loaded,policy_violation_at_build_time,channel_denied_by_policy) now carriesfields.layer(system/user/workspace) andfields.source(the on-disk path). Channel deny is now first-class:denied_channelsin any layer skips the named adapter at startup with achannel_denied_by_policyevent;forge run --withfilters andforge channel serverefuses to start a denied target outright. Channel skip is non-fatal — the agent runs with the remaining channels. Newforge channel disable <name>andforge channel enable <name>CLI subcommands edit~/.forge/policy.yamlby default (the user layer); pass--systemto edit/etc/forge/policy.yamlinstead (warns when not root). Both are idempotent and remove the policy file entirely when the resulting document is empty. NewGET /api/user-policyandPUT /api/user-policyendpoints inforge uisurface all three layers (user editable, system + workspace read-only); the agent card renders denied channels as locked / dimmed chips and clicking an editable chip flips the entry in the user layer. Migration from FWS-6's first cut: thedisabled_channels:field that briefly shipped inforge.yamlwas rejected on review — channel disable is laptop-level or workspace-level, never agent declaration. Move anydisabled_channels:block fromforge.yamlinto~/.forge/policy.yaml'sdenied_channels:(developer scope),/etc/forge/policy.yaml(laptop-wide), or the workspace ConfigMap (deployed-agent).forge channel disable <name>does this automatically. Thechannel_disabled_by_configaudit event was retired in the same pass;channel_denied_by_policy(with layer attribution) carries every skip. Seedocs/security/platform-policy.mdandexamples/platform-policy.yaml. -
Platform policy enforcement at runtime (issue #89, FWS-5). Forge agents now accept a deploy-time policy file defining workspace-level upper bounds on egress destinations, registered tools, allowed models, and configuration sizes. The agent's
forge.yamlis what it claims to do; the platform policy is the ceiling — the agent refuses to start when its declaration exceeds the bound. Read viaFORGE_PLATFORM_POLICYenv var at startup; absence (or missing file) maps to no constraints, fully backward compatible. Two audit events:policy_loadedonce at startup when a non-zero policy is active, andpolicy_violation_at_build_timeone-per-violation whenforge.yamlconflicts (carryingviolation_kind,offending_value,forge_yaml_field). Egress allowlist is the set-difference offorge.yaml's declaration minus the policy deny list; denied tools is the union; user-selected builtins surviveforge.yamldenies but NOT platform-policy denies.forge packageDeployment manifests are now policy-ready by default — every generated deployment.yaml has theFORGE_PLATFORM_POLICYenv, the/etc/forge/policyvolumeMount, and anoptional: trueConfigMap volume referencingforge-platform-policy. Operators (or platform deployers like initializ Command, custom controllers, GitOps tooling) just create the ConfigMap to apply bounds; absence preserves today's behavior. The ConfigMap itself is not generated byforge package— policy is an operator concern, not a developer concern. Newforge validate --platform-policy=PATHstandalone linter for CI gating. Schema reserves adenied_channelsslot for FWS-6 (#90). Seedocs/security/platform-policy.mdandexamples/platform-policy.yaml. -
Cancellation signal handling (issue #88, FWS-4). The A2A
tasks/cancelJSON-RPC method now actually cancels in-flight invocations instead of merely flipping the stored task state. A per-RunnerCancellationRegistrytracks every active invocation by task ID; the cancel handler signals the registeredcontext.CancelCauseFuncwith a typed reason (workflow_failure/cost_limit_exceeded/timeout/external_signal), which propagates through the executor's ctx. The agent loop honors cancellation at the iteration boundary and between tool calls within an iteration, so cancellation latency is bounded by the current LLM call or tool exec. A newinvocation_cancelledaudit event closes every cancelled invocation with the classified reason,duration_msup to cancellation, and partial token totals consumed before the signal (from the FWS-3LLMUsageAccumulator). The A2A response carries statecanceledplus acancelled: <reason>message so the orchestrator can react. Cancel-after-complete is idempotent — a cancel for a task that already finished returns the stored state unchanged rather than corrupting it.CancelTaskParamsgains an optionalreasonfield (unknown values are forwarded verbatim to audit). The grace-period / hard-cancel concept maps to bounded cancellation latency: Go's runtime can't kill a goroutine, so Forge honors the signal at the next safe checkpoint and the orchestrator-side timeout is its own concern. Seedocs/security/audit-logging.md#cancellation. -
Token usage and execution duration emission (issue #87, FWS-3). Every
llm_callaudit event now carriesinput_tokens,output_tokens,model,provider,duration_ms, andrequest_idcaptured directly from provider response metadata (Anthropic, OpenAI, Ollama via the OpenAI-compatible path, OpenAI Responses). Field naming aligns with OTel GenAI semantic conventions (gen_ai.usage.input_tokens/gen_ai.usage.output_tokens) so audit consumers can correlate to OTel traces without a translation table. When a provider returns no usage (some self-hosted Ollama setups), the event flagstokens_unavailable: truerather than silent zeros. Eachtool_execevent gainsduration_msplus structured arg-shape metadata (args_size,result_size) — raw arg values are not emitted (payload stripping is FWS-8's concern). A newinvocation_completeevent closes every A2A invocation with total wall-clock duration and aggregatedinput_tokens_total/output_tokens_total/llm_call_count. A2A responses now carry the same totals inline asX-Forge-Tokens-In,X-Forge-Tokens-Out,X-Forge-Duration-Ms,X-Forge-Model,X-Forge-Providerheaders so orchestrators can enforce cost ceilings during parallel workflow execution without subscribing to the audit stream. Headers populate regardless of OTel-tracing state. Cost calculation is deliberately not in Forge — Forge emits tokens, the platform applies price tables. The new emitters route throughAuditLogger.EmitFromContextso workflow-correlation fields (FWS-2) auto-tag everyllm_call/tool_exec/invocation_completeevent when the inbound request carried orchestrator headers. Schema additivity: existing audit consumers reading the pre-FWS-3 shape continue to work unchanged. Seedocs/security/audit-logging.md#token-usage-and-execution-duration.Internal API change as part of this work:
llm.UsageInfofield names were renamedPromptTokens→InputTokensandCompletionTokens→OutputTokens(JSON tags too) to align with OTel GenAI semconv. The type is internal toforge-core/llmand not consumed outside that package, so no external callers are affected. -
Workflow correlation ID threading (issue #86, FWS-2). Forge agents now extract orchestration headers —
X-Workflow-ID,X-Workflow-Stage-ID,X-Workflow-Step-ID,X-Invocation-Caller— at the A2A dispatch boundary (JSON-RPC + REST handlers) and inject them intocontext.Contextas aWorkflowContextvalue. Every audit event emitted during the invocation is then auto-tagged via a newAuditLogger.EmitFromContextwith the matchingworkflow_id/stage_id/step_id/invocation_callerfields, letting audit consumers correlate events across multiple agents participating in one workflow run. Direct A2A invocations (no orchestrator headers) leave the fields unset — emitted JSON is byte-for-byte identical to the pre-FWS-2 shape, so existing audit consumers keep working. AWorkflowContext.ApplyToHTTPHeadershelper is exposed for tools that want to propagate the headers onto outbound agent-to-agent A2A calls; auto-propagation is deliberately off by default to prevent leaking workflow identity to third-party APIs. Seedocs/security/workflow-correlation.md. -
A2A 0.3.0 Agent Card conformance (issue #85, FWS-1). Forge now serves a spec-conformant Agent Card at the A2A 0.3.0 canonical path
/.well-known/agent-card.json. The card carries every required A2A 0.3.0 field —version,protocolVersion(pinned to0.3.0),defaultInputModes,defaultOutputModes— plussecuritySchemesderived from the configured auth chain (static_token→ HTTP bearer,oidc→ openIdConnect with discovery URL,gcp_iap→ apiKey in header,aws_sigv4→ custom bearer format, etc.), and emits anagent_card_publishedaudit event on startup carrying the card's identity + size + a sha256 hash so downstream consumers can detect config drift. Identical card shape acrossforge devand deployed modes. Seedocs/reference/a2a-agent-card.md. -
Workspace-level skill-builder LLM config (issue #92). The
forge uiskill builder now reads its LLM configuration from<workspace>/.forge/ui.yaml(or~/.forge/ui.yamlas a machine-wide fallback) instead of borrowing credentials from whichever agent the operator picked. The skill-builder LLM is decoupled from any agent's runtime LLM, so the same configuration works across every agent in the workspace and is usable before any agent has been scaffolded.- New
GET/PUTendpoints at/api/settings/skill-builderplus a Settings modal in the skill-builder UI. - New
GET /api/skill-builder/provider(path-less) for first-run detection in an empty workspace. - Status banner surfaces the resolution source (
workspace/user/agent_fallback/unset) and a deprecation warning when the agent-fallback compat shim resolves. - The Settings modal accepts the API key value inline (password field)
and persists it to
<workspace>/.forge/.envwith mode 0600. An auto-generated<workspace>/.forge/.gitignoreprotects the file from accidental commits. The key value never appears inui.yamland is never echoed back by the GET endpoint. - See
docs/ui/skill-builder-llm.mdfor the configuration reference.
- New
forge run/forge serveops logs now write to stdout (was stderr) — stream separation from audit (issue #100, FWS-9). The structuredJSONLogger(r.logger.Info/Warn/Error: startup banner, request lines, runtime errors) now writes to stdout. Audit NDJSON continues to write to stderr (and to the dedicated FWS-7 sink when configured). Container log collectors and SIEM pipelines can now split ops from audit at the stream level — no payload parsing needed. Operator migration: if you previously captured ops logs viaforge run 2> ops.log, switch toforge run > ops.log(and2> audit.logfor audit). Container deployments that capture both streams via the runtime's standard log collector are unaffected. Interactive CLI commands (forge init,forge build,forge channel) still write user-facing warnings + errors to stderr — those are UX messages, not server ops logs, and the stream-split policy doesn't apply. Seedocs/security/audit-logging.md#streams-fws-9.SkillBuilderCodegenModelno longer overrides the operator's model (issue #92). The function previously forcedgpt-4.1for openai andclaude-opus-4-6for anthropic regardless of what the agent (or workspace) had configured. The override is removed; the operator's chosen model is used verbatim. This unblocks agents pointed at custom OpenAI-compatible endpoints (OpenRouter, vLLM, litellm, self-hosted Kimi/Llama) where the hardcoded "stronger" model isn't hosted.- Skill-builder handlers no longer call
os.Setenv(issue #92). The pre-#92 handlers leaked the picked agent's.envinto theforge uiprocess's environment viaos.Setenvcalls, which caused cross-agent credential stomping when switching agents in the UI. Credentials are now threaded as request-scoped values.
- Legacy Agent Card path
/.well-known/agent.json(issue #85). Still served and returns the same body as the canonical/.well-known/agent-card.json, but now emits aDeprecation: trueresponse header per RFC 8594 plus aLinkheader pointing at the successor path. Scheduled for removal in the release after next.
forge initCustom provider now produces a runnable agent (issue #83). Picking the Custom provider inforge init(or the Web UI wizard) previously wroteprovider: customtoforge.yamlplusMODEL_BASE_URL/MODEL_API_KEYenv vars, neither of which the runtime understood — agents fell back toStubExecutorand every task failed withagent execution not configured for framework "forge". Scaffold now normalizes Custom →provider: openai+OPENAI_BASE_URL/OPENAI_API_KEY, matching the OpenAI-compatible code path the runtime resolver already supports. Affects both TUI and Web UI flows.- OAuth-credentials path no longer silently overrides
OPENAI_BASE_URL(issue #83). When the runtime or skill builder found stored ChatGPT OAuth credentials AND noOPENAI_API_KEY, it ignored an explicitly-setOPENAI_BASE_URLand routed traffic tochatgpt.com/backend-api/codex— manifesting as a 400 from ChatGPT rejecting the operator's model name. Bothforge runandforge uinow refuse this combination with a clear error explaining what to set.
- If you have
provider: customin a checked-inforge.yamlfrom an earlierforge initrun, change it toprovider: openaiand rename the.envkeys fromMODEL_BASE_URL/MODEL_API_KEYtoOPENAI_BASE_URL/OPENAI_API_KEY. No newforge initis required.
- Model Context Protocol (MCP) HTTP client support. Configure servers
under a new
mcp:block inforge.yaml; discovered tools are registered as namespaced<server>__<tool>first-class tools that flow through the existing LLM executor. forge mcpsubcommands:forge mcp list— show every configured server, its state, and the number of tools it exposes after filtering.forge mcp test <name>— connect, list tools, optionally call one with--call <tool> --args '<json>'.forge mcp login <name>— laptop-time OAuth 2.1 PKCE flow.forge mcp logout <name>— remove stored OAuth tokens.
- OAuth 2.1 PKCE for hosted MCP servers (Linear, Notion, Atlassian,
GitHub hosted MCP, etc.). Tokens persist via the existing
AES-256-GCM keyring at
~/.forge/credentials/mcp_<name>.json(encrypted whenFORGE_PASSPHRASEis set). - Audit events (NDJSON to stderr, no byte payload ever):
mcp_server_started,mcp_server_failed,mcp_server_degraded,mcp_tool_call,mcp_tool_result,mcp_tool_conflict,mcp_token_refresh. - Egress integration. MCP server hosts auto-merged into the egress
allowlist (mirroring
auth_domains) so an HTTP MCP call cannot silently be blocked at runtime. - Tool namespacing.
tools.Registry.Registerrejects names containing__unless the tool implements the newtools.MCPSourcemarker interface, preventing builtins from shadowing MCP-namespaced tools.
mcp_calladapter tool removed. Superseded by the newmcp:configuration block inforge.yaml, which exposes each MCP server's tools as first-class namespaced tools — strictly better UX for the LLM than a single meta-tool. Seedocs/mcp/index.mdfor the migration path.
- Phase 1 supports HTTP transport only. Stdio MCP servers (Notion,
Linear community, Atlassian, the modelcontextprotocol/servers
reference set) are on the roadmap.
transport: stdiois rejected atforge validatetime with the message"stdio is on the roadmap; Phase 1 supports HTTP transport only". - MCP protocol version pinned to
2025-06-18. Handshake hard-fails on mismatch — version negotiation is intentionally absent. - OAuth callback runs on a
127.0.0.1loopback listener; it is a laptop-time operation. For K8s deployments, runforge mcp login <name>locally, then mount the resulting credentials file as a Secret and pointMCP_TOKEN_STORE_PATHat it. - No new top-level dependencies — JSON Schema validation reuses
the existing
xeipuuv/gojsonschemaalready ingo.mod.
aws_sigv4auth provider. Authenticate AWS-IAM callers by reflecting their Sigv4 signature to AWS STSGetCallerIdentity. Noaws-sdk-go-v2dependency.gcp_iapauth provider. Verify the JWT IAP forwards asX-Goog-Iap-Jwt-Assertionwhen Forge sits behind a GCP HTTPS Load Balancer with IAP enabled.azure_adauth provider. Verify Microsoft Entra ID Bearer tokens with tenant lock-in and optional Microsoft Graph group enrichment.- Non-interactive
forge initflags for the three new providers:--auth-aws-region,--auth-aws-allowed-principal(repeatable),--auth-gcp-iap-audience,--auth-azure-tenant,--auth-azure-multi-tenant,--auth-azure-groups-mode. - Web UI exposes the three new types via the
/api/wizard-metaendpoint; server-side validation rejects malformed payloads before scaffold. egress_hostsautomatically extended for each new provider (sts.<region>.amazonaws.com,www.gstatic.com,login.microsoftonline.com,graph.microsoft.comwhen applicable).
- Middleware now consults the auth chain even when no Bearer token is
extracted, so non-Bearer formats (Sigv4
Authorization, IAPX-Goog-Iap-Jwt-Assertion) can be recognized. Existing Bearer + JWT flows are unchanged. auth.HeadersFromRequestwidened withX-Goog-Iap-Jwt-Assertionforgcp_iap. Providers that don't consume this header are unaffected.auth.TokenKindrecognizes theforge-aws-v1.Bearer prefix and returns"sigv4". The audittoken_kindfield now has five possible values:empty,opaque,jwt,sigv4,iap_jwt.validate.ValidateAuthConfigadmits the three new provider types and enforces their per-type required keys (aws_sigv4.region,gcp_iap.audience,azure_ad.audience,azure_ad.tenant_id-unless- multi-tenant,azure_ad.groups_modewhitelist).
- No forge.yaml changes are required for callers continuing to use
Phase 1 providers (
static_token,oidc,http_verifier). Phase 1 test suite passes without modification. - If you wrote a custom provider that inspects headers, the
Headersmap now contains additional keys. Existing keys are unchanged. - The
oidcpackage gained an internalSkipIssuerCheckfield carryingyaml:"-"— it cannot be set viaforge.yamland is reachable only from Go callers (currently onlyazure_admulti-tenant). Operators see no change.
For "any IAM principal in these AWS accounts" without writing glob patterns:
auth:
providers:
- type: aws_sigv4
settings:
region: us-east-1
allowed_accounts: ["412664885516", "109887654321"]Internally expands to the canonical glob set covering all identity
shapes (IAM users, IAM roles, STS assumed-roles, federated users)
for each account. Composes with allowed_principals — you can list
specific roles AND whole accounts in the same provider entry.
For AWS-Org-wide trust without enumerating accounts, use AWS IAM
Identity Center (SSO) — SSO permission sets gate Org membership at
sign-in, and you can match Identity Center-assumed roles with the
existing allowed_principals globs.
auth:
providers:
- type: azure_ad
settings:
audience: api://forge
allow_multi_tenant: true
allowed_tenants:
- "00000000-1111-2222-3333-444444444444" # partner A
- "55555555-6666-7777-8888-999999999999" # partner BWhen allow_multi_tenant: true, the tid claim must be in
allowed_tenants (case-insensitive GUID match). Empty list +
multi-tenant remains the documented "any tenant globally" mode for
back-compat, but forge validate now emits a warning when the list
is empty to make the trade-off explicit. Non-interactive flag:
--auth-azure-allowed-tenant (repeatable).
forge init's TUI picker now includes AWS Sigv4 (IAM),
GCP Identity-Aware Proxy, and Azure AD / Entra ID entries with
step-by-step input flows. AAD is single-tenant in the TUI;
multi-tenant remains a deliberate YAML edit (security default).
The client side is a Bearer token with a 3-line mint:
import boto3, base64
url = boto3.client('sts', region_name='us-east-1').generate_presigned_url(
'get_caller_identity', ExpiresIn=900)
token = 'forge-aws-v1.' + base64.urlsafe_b64encode(url.encode()).rstrip(b'=').decode()
requests.post(forge_url, headers={'Authorization': f'Bearer {token}'}, data=msg)Pattern is identical to aws-iam-authenticator for EKS. Reference client
in scripts/forge-aws-sign.py — use it directly or as a template for
Go / Java / Node clients. Wire format is documented in the package
docstring of forge-core/auth/providers/aws_sigv4/provider.go.
- (none for Phase 2)