FeatureAgent Security & Trust-Boundary Model
Status: shipped Audience: operator, security reviewer, prospective adopter Verified against merged commit: a2b7b4505b0e3b2891b5c82039184709cc7d516b
This is the single authoritative description of FeatureAgent's (FA's) security and trust-boundary model, written from the merged tree at the commit above — not from spec prose, not from a PR description, not from a design doc describing what is planned. Every enforcement claim below cites a real repo path. If a claim can't be backed by a citation, it lives in Known Limitations and Not-Merged Work, not in the body — that is the whole discipline this page exists to enforce (see The PR #173 Incident below).
This page documents FA the platform. No enrolled project's name, convention, or file format appears anywhere in it — see the project-agnostic-platform law below.
The Two Core Laws, Restated as Enforced Mechanisms
CLAUDE.md states two non-negotiable laws. Both are backed by code, not just prose.
Law 1 — Project-agnostic platform: no coupling
FA must never bake a specific project's conventions, tool names, or file formats into its own code. Project-specific behavior is expressed only through declarative, project-agnostic config: runtime_image, data_dirs, services, env, setup_command, test_command, verify_command, auth_mode, spec_path, spec_content.
- The adversarial platform Security Reviewer's own prompt builder is deliberately project-agnostic:
src/services/review/security-prompt-builder.tscontains no FA-specific or project-specific content by construction (constitution §VII). Where FA needs to sharpen review of its own PRs, that context is carried as declarative, admin-only project data (security_contexton the project row, gated byADMIN_ONLY_SECURITY_FIELDSinsrc/routes/project-self.ts) — never as a code branch. - The credential broker is provider-agnostic by adapter, not by hardcoded name:
src/services/secret-material/index.tsroutes a credential ref tosrc/services/secret-material/adapters/broker.tsorsrc/services/secret-material/adapters/aws-sm.tspurely by ref shape (fa-broker:…vs. an ARN), never by project identity. - Engine identity is a declared profile, not a hardcoded vendor check:
isNonDefaultEngine(src/services/runtime/docker-runtime.ts) treats every engine other than the platform default (claude-code) as an isolated, declared vendor profile with its ownauthEnv— no project-specific branch.
Law 2 — The trust boundary: FA is multi-tenant
A project API key (fa_…) is a tenant credential — the lowest-privilege thing FA issues. Everything a project supplies — repo contents, .fa/environment.yml, spec_content, declared commands, request bodies — is untrusted input. Six rules are absolute; each is enforced in code, not merely stated:
- Execution happens in
DockerRuntime, never on the host. The only call sites that spawn agent or deploy work aresrc/services/runtime/docker-runtime.tsandsrc/tools/sandbox-exec.ts;src/services/runtime/local-runtime.tsis the explicit, documented, non-default opt-out (see Execution Containment). - Never spread FA's environment into a subprocess. The container's env is built from an explicit
-e VARallowlist only —buildDockerBaseArgsinsrc/services/runtime/docker-runtime.tsnever spreadsprocess.envinto the args list it hands to the container. (ThedockerCLI subprocess itself does inheritprocess.envinrunDocker,src/services/runtime/docker-runtime.ts— needed so the CLI can find its ownPATH/HOME— but Docker only forwards named-eflags into the container, so FA's secrets, which are never named, never cross that boundary.src/tools/sandbox-exec.tsbuilds its subprocess env from an explicit allowlist the same way.)runDeployCommandindocker-runtime.tsdocuments the same invariant inline for the deploy path. - Caller input is not configuration.
src/routes/deploy.tsreturns400ifreq.body.recipeis present at all — the executed recipe is always read fromproject.deploy_recipe, never the request body (Invariant 3, documented in the file header and enforced in thePOST /runshandler). - "Dry-run"/"plan" is not a safety property.
POST /api/projects/:id/deploy/runs(src/routes/deploy.ts) only ever creates a run inpending_approval; execution happens nowhere in that handler.POST /runs/:runId/approveis the sole execution trigger (src/services/deploy.ts,executeDeployRun). - Secrets never appear in argv, logs, or provenance.
src/utils/secrets.ts(redactSecrets) redacts both known secret shapes (GitHub/GitLab/Anthropic/FA key patterns) and the instance's actual configured values — covering the operator-default token of all three VCS providers FA ships (GitHub, GitLab, Bitbucket;configuredSecretValues(), spec 264) — so a secret whose shape isn't recognized still can't leak. A project-configured credential (spec 215) is in neither static pool by construction, so it is scrubbed at the throw site instead of by each call site — wherever the call runs insidewithVcsProvider(src/services/vcs/index.ts), which removes the credential that call authenticated with from the error'smessageandstackbefore it is rethrown. That is every function in thesrc/services/github-pr.tsshim, but not every VCS API call in the tree: the directresolveVcsProvidercall sites named at the end of this item bypass it and have no throw-site scrub. This is the same posturerunSandboxGit(src/services/runtime/sandbox-git.ts) already took for the git path. Two consequences worth knowing: (i) redaction performs no credential resolution of its own — it reuses the resolution the operation needed anyway, so it cannot be made to depend on a secret store being reachable, and the value scrubbed cannot diverge from the value authenticated with (RM-035); (ii) the cover is scoped to what those functions throw — text a caller assembles from other sources still needsredactSecrets'extraSecrets, which a caller that already holds a token supplies viavcsRedactionSecretsFromToken(src/services/vcs/index.ts, e.g.src/services/flows/weftra-merge.ts, which resolves one for its own git push). A path that reaches a provider WITHOUT going through thegithub-pr.tsshim —resolveVcsProvideris still exported and used directly bysrc/services/builder.ts,src/routes/builder.ts,src/services/flows/implement.tsandsrc/services/flows/implement-spec-kit.ts— does not get the throw-site scrub, and there the credential is covered only if its shape is one of the patterns above, which for a Bitbucket app password is deliberately none (src/utils/secrets.ts:26-30).src/utils/redaction.ts(redactProject) masks secret-shaped fields on API responses.src/services/audit-export.ts(redactPayload) recursively redacts secret-keyed fields before a payload is hashed into the tamper-evident audit chain — but a secret value embedded mid-string in a non-secret-named field (or as a top-level string payload) sails through key-based redaction untouched, which is the exact export-boundary gap value- based masking closes:src/services/audit-export.ts(redactValuesDeep) runsredactSecretsover every string in the payload, at any depth, using only FA's own configured secrets (configuredSecretValues()plusredactSecrets's built-in shape patterns) — applied uniformly to every row, regardless of project.assertSecretPoolNotSilentlyEmptyin the same file is law 5's fail-loud guard for this path: if the instance has resolvable configured secrets but the collector resolves an empty pool, it raises rather than exporting unmasked; a genuinely secret-less instance exports normally (key-based redaction and the shape-pattern pass insideredactSecretsstill run regardless). The mask pool deliberately contains no tenant-writable input — not a project's declaredenv, notservices[].env, nothing reachable viaPATCH /api/project(spec 194 §0, 2026-07-29 security review round 2, blocking). An earlier version additionally masked each project's own declaredenv/services[].envvalues, scoped per row so one tenant's declared config couldn't mask a different tenant's records (round 1, finding 2 — real fix, but insufficient). That version was still a trust-boundary inversion: because value-masking runs beforecomputeRecordHash, the tamper-evidentrecord_hash/chain_headwere computed over a view a tenant could shape by editing its ownenv(no content validation) — and because the same project key can read its own records too, a tenant could look up an incriminating event's exact text, declare it as a fake "secret," and have it erased from its own subsequent exports whileverifyAuditChainkept returningok: true. There is no admin-only per-project secret store to substitute, so the fix is exclusion, not narrower scoping: a tenant-declared value is never part of the mask set. The audit routes (src/routes/audit.ts) still accept a project's own scoped key, not just an admin key, and everything a project supplies (repo contents, declared config) remains threat-modelled as hostile — seedocs/OPERATIONS.md's audit-export section for the full history and the residual, accepted limitation (declared secrets are simply never value-masked; key-based redaction on a matching field name is unaffected). - Authn ≠ authz.
src/middleware/auth.tsdefines distinct guards (requireProjectAuth,requireUserAuth,requireAdminAuth,requireExplicitAdmin,requireProjectOrAdminAuth,requireAdminOrUserAuth,requireAdminOrApproverForProject) with different blast radii;tests/authz-surface.test.tsenforces two floor rules mechanically: Rule A (any route withexecutionorsecretsblast radius must sit behind an elevated tier —admin/explicit-adminonly, plus a small named, individually-reviewed exception list) and Rule B (the bareprojecttier — exactly the guard PR #173 shipped with — may never carryexecution,secrets, orcredentialsblast radius, zero exceptions). See Route Authorization.
Trust Zones
Trusted: the FA host process itself, the self-build loop's Governor (bash, runs on the host per docs/OPERATIONS.md §1), and the operator/admin credential (ADMIN_API_KEY, resolved by requireAdminAuth in src/middleware/auth.ts).
Untrusted: everything a project supplies. That includes the project's repo contents (parsed by git — see Repository Clone Placement), its .fa/environment.yml/declared setup_command/test_command/verify_command, spec_content submitted with a feature, and every HTTP request body a project-key holder can send.
A project API key (fa_…) is a tenant credential — the lowest-privilege thing FA issues. requireProjectAuth (src/middleware/auth.ts) resolves a bearer token to a project and nothing more; it carries no admin or execution authority by itself. Floor Rule B (tests/authz-surface.test.ts) makes this structural: no route classified at the bare project tier may ever be assigned execution, secrets, or credentials blast radius in docs/security/authz-surface.json — the test fails the build if one is.
Execution Containment
Agent and deploy work runs inside DockerRuntime (src/services/runtime/docker-runtime.ts), selected by createRuntime (src/services/runtime/index.ts) when config.runtime === 'docker' — the default (process.env.RUNTIME || 'docker', src/config.ts).
- The container's environment is built from nothing.
buildDockerBaseArgs(src/services/runtime/docker-runtime.ts) assembles the container's-eflags only fromauthArgs/engineAuthArgs/tenantAuthArgs(identity), caller-suppliedenv, and namedrepoCredentialEnvkeys — never aprocess.envspread.runDeployCommandin the same file documents the identical invariant for the deploy path: FA's own secrets (ADMIN_API_KEY,GITHUB_TOKEN,ANTHROPIC_API_KEY,SESSION_SECRET) are never added as-eflags. src/tools/sandbox-exec.tsis the project-agnostic sandboxed-execution CLI/helper; it builds its own subprocess environment from an explicit allowlist rather thanprocess.env, mirroring the same rule.- Seccomp, capability-dropping, and a read-only rootfs are wired for BOTH the git bootstrap container and the main job container (Spec 405 inc-1, RM-194). Previously only
src/services/runtime/seccomp-profiles/bootstrap-git.jsonwas applied, and only tosandbox-git.ts's bootstrap clone/fetch container. Both argv builders now share one hardening ceiling —sandboxHardeningArgs(profile)(src/services/runtime/ docker-runtime.ts), consumed bybuildDockerBaseArgs(the job container) and bybuildSandboxGitArgs(sandbox-git.ts, always atmode: 'strict', unconditionally) — so neither builder can hand-copy or silently drift from the other (a dedicated drift test intests/sandbox-git.test.tsdiffs their output). By default (FA_SANDBOX_ HARDENING=strict) the job container gets--cap-drop ALL(+ an operator-declared--cap-addlist, empty by default),--security-opt no-new-privileges,--security-opt seccomp=<profile>(FA's owncontainers/seccomp/fa-default.jsonunlessFA_SECCOMP_PROFILEoverrides it — a path, orunconfinedto disable filtering, operator config only, never tenant input), a--read-onlyrootfs with--tmpfs /tmp(HOME lands there) and the/workbind mount as the only other writable path, and a declared--ulimit nofile.FA_SANDBOX_HARDENING=compatis an explicit, RECORDED operator opt-out (today's pre-405 argv, byte-identical) — shown on the provenance doc and folded into the spec-360 capability-snapshot hash, never a silent fallback; it governs the JOB container only — the bootstrap container has carried this hardening unconditionally since spec 161 and has no such opt-out. Seedocs/OPERATIONS.md§4h-13 anddocs/SETUP.md'sFA_SANDBOX_HARDENINGentry. - The agent job container's DEFAULT egress is now a computed allowlist, not the open bridge (Spec 405 inc-2, RM-194 — closing the gap inc-1 left).
config.runtimeNetwork(src/config.ts,FA_RUNTIME_NETWORK || 'bridge') still names the RAW docker network a run attaches to when nothing scopes its egress, butcreateRuntime(src/services/runtime/index.ts) no longer stops there by default: when no operator-armed globalFA_RUNTIME_EGRESS=allowlist, project override, or caller-supplied network already resolved the policy, and no explicit opt-out is set (FA_RUNTIME_NETWORKset to anything, orFA_RUNTIME_EGRESS=open—config.runtimeEgressDefaultPosture), the job container is attached to the SAME--internalforwarder-scoped network the bootstrap git container uses, restricted to a per-run COMPUTED set: the resolved model-endpoint host, this run's own VCS host, and the project's own declaredruntime_egress_allowlist.open/bridge is the explicit, RECORDED opt-out (visible on the provenance artifact's "Runtime egress" row), never a silent fallback. Seedocs/OPERATIONS.md§4h-8a. - The sandbox reaper (
src/services/runtime/sandbox-reaper.ts) is a periodic backstop, not the primary teardown path: it removes only containers stamped with FA's own labels (featureagent.managed=trueplus this instance's id) that have exceeded a TTL with no live job claim, plus a narrower orphan pass for containers whose instance label is missing/unresolved. It never usesdocker pruneor matches by image. - Instance tagging (
src/services/runtime/instance-id.ts,resolveInstanceId) derives a stable id from the resolvedDATABASE_PATHso the reaper can distinguish this FA instance's containers from a sibling instance sharing the same Docker host. local-runtime.ts— the explicit, non-default opt-out.src/services/runtime/local-runtime.tsruns commands directly on the FA host in the workspace directory, with no container, no seccomp, no network isolation. It is selected only when an operator setsRUNTIME=local— a deliberate dev/test convenience, not a production posture. UnderRUNTIME=localthe trust boundary reduces to "the operator trusts their own machine"; every containment guarantee described in this section does not apply.
Repository Clone Placement and Git Egress
The implement flow's git operations against a project's — completely untrusted — repo run inside a purpose-built, hardened bootstrap container, not host git:
src/services/runtime/sandbox-git.ts(runSandboxGit) runsgitfor the implement flow's clone, fetch, checkout, add, commit,git lfs pull, and the commit/push phase inside a container with--cap-drop ALL,--security-opt no-new-privileges, the bundled seccomp profile above, and a read-only rootfs (only/workand a/tmptmpfs are writable). Its environment is built from nothing: the only credential it can hold isGITHUB_TOKEN, delivered name-only, and only when the clone target's host is exactlygithub.com(isTrustedGitHost).src/services/runtime/git-egress.ts(provisionGitEgress) closes the network side: underRUNTIME=dockerthe bootstrap container is attached to an FA-owned,--internalDocker network with no external route; the only way out is a per-invocation CONNECT forwarder FA itself runs, which permits exactly one destination — the current run's own repo host on port 443. Provisioning fails closed (no fallback to the open default network) and is opt-out only via the explicit operator settingFA_BOOTSTRAP_GIT_EGRESS=open.- Honest scope — this closes the implement, revise, Security Fixer, and spec-kit enrollment/check flows, not the whole platform. Per
docs/OPERATIONS.md§4h-2/§4h-3/ §4h-4c, three flows still run their own git operations directly on the FA host viaexecGitHardened(env-hardened, but still host-side parsing of tenant.gitobjects):src/services/flows/implement-spec-kit.ts,src/services/eval/replay/git.ts, andsrc/services/environment-scaffold-runner.ts. No claim in this page should be read as "the class is closed platform-wide" — only the implement, revise, security-fixer and spec-kit-enrollment paths are closed. RUNTIME=localposture is unchanged for git too —runSandboxGitfalls back toexecGitHardenedwith the original host-SSH clone args, byte-identical to pre-sandboxing behavior. Same trade-off as every other sandboxed flow underRUNTIME=local.
Credential Flow
In-container agent auth (auth_mode). The in-container Claude CLI authenticates one of two ways, selected by config.defaultAgentAuth (FA_AGENT_AUTH, default api) or a per-run auth_mode override:
api—authArgs('api')(src/services/runtime/docker-runtime.ts) passesANTHROPIC_API_KEYinto the container by name only (-e ANTHROPIC_API_KEY); the value travels via thedockerCLI's own spawn environment, never in argv.oauth—authArgs('oauth')mounts the host's Claude credentials directory read-only at a seed path and setsHOME=/tmp; the credentials actually staged into the writable containerHOMEhave theirrefreshTokenstripped first bysrc/services/runtime/oauth-broker.ts(stripRefreshToken) — the container gets a short-livedaccessTokenonly, so it cannot rotate the operator's long-lived credential even if it tries.oauth-broker.tsalso preflight-checks remaining token lifetime (checkTokenLifetime) against the run's max duration before the container starts.
The credential broker (spec 011 increment 2d, merged).src/services/secret-material/index.ts (resolveSecretMaterial) is the single point through which a tenant's own credential (as opposed to the operator's) enters a sandboxed run — used by the governed-deploy apply path (src/services/deploy.ts). Its own header names the failure mode this prevents by name: dereferencing a tenant-controlled ref with FA's own credentials before checking it belongs to that tenant would be a cross-tenant secret read — "the worse-than-#173 failure mode" (src/services/secret-material/index.ts, lines 6-8). src/services/secret-material/entitlement.ts (assertRefEntitlement) is where that check is actually implemented, and it runs before any backend I/O: the entitled namespace is derived from tenantId + adapter kind rather than stored as a separate allowlist column, specifically so a bad actor can't set their own entitlement (deriveEntitlementNamespace). Backend adapters are selected by ref shape, not by project identity (src/services/secret-material/adapters/broker.ts, src/services/secret-material/adapters/aws-sm.ts). The resolved InjectedCredential is never serialized or logged and is dropped when the run ends.
Redaction boundaries. src/utils/secrets.ts redacts known secret shapes plus the instance's actual configured values from any string (used to scrub log lines); src/utils/redaction.ts masks secret-shaped fields on API responses (redactProject); src/services/audit-export.ts (redactPayload) redacts secret-keyed fields recursively before a payload is hashed into the tamper-evident audit chain, and (redactValuesDeep) additionally masks known secret values anywhere in that payload — top-level string or any depth — regardless of the field's key name, so a secret riding in a non-secret-named field (e.g. a message string) doesn't sail through the key-based pass. The value pool is FA's own configured secrets ONLY — no project-declared config (env/services[].env) ever feeds it, applied uniformly to every row regardless of project — and assertSecretPoolNotSilentlyEmpty fails the export loud rather than silently emitting unmasked output if the instance has resolvable configured secrets but the collector resolves an empty pool. This masking is export-time only: it runs inside buildAuditExport, never mutates run_events, and other egress surfaces were swept for the same gap — see docs/OPERATIONS.md's audit-export section. The rule stated in the Trust Boundary law holds throughout: secrets never appear in argv, logs, or provenance.
Route Authorization
docs/security/authz-surface.json is a machine-checked inventory of every FA HTTP route: 8 guard tiers (public, session, project, user, admin-or-user, project-or-admin, admin, explicit-admin) and 6 blast radii (none, read, write, execution, secrets, credentials), one row per route with its guard, tier, blast radius, and a written rationale.
tests/authz-surface.test.ts enforces this mechanically against the live Express router built by src/app.ts — it is drift detection, not a static lint:
- Guard-match regression: every live route's actual middleware (from
src/middleware/auth.ts) must match the manifest's declared guard, and the tier the guard actually implies must match the manifest's declared tier — "a misdeclared tier can defeat the floor rules without ever touching the guard." - Floor Rule A: a route with
executionorsecretsblast radius must sit behind an elevated tier (admin/explicit-admin) — with a small, individually-named, individually-reviewed exception list recorded directly in the manifest (each exception row must carry afindingexplaining why, e.g.POST /api/projects/:id/deploy/runs/:runId/approve, which layerscheckApproverAuthorizedand a dev-mode-open-pass closer on top of a non-elevated tier). - Floor Rule B, zero exceptions: the bare
projecttier — a lonefa_…key — may never carryexecution,secrets, orcredentialsblast radius. This is the exact guard class PR #173 shipped with.
Authn ≠ authz: requireProjectAuth/requireUserAuth/requireAdminAuth (and the composite guards requireProjectOrAdminAuth, requireAdminOrUserAuth, requireAdminOrApproverForProject) in src/middleware/auth.ts establish identity; the tier/blast-radius pairing in docs/security/authz-surface.json, enforced by the floor rules above, is what actually bounds what that identity may do. Execution is never a bare project-key action — see Trust Boundary rule 6.
Review, Gate and Merge Controls
FA's self-build loop layers several independent controls before a change merges. This section summarizes; the authoritative detail lives in docs/OPERATIONS.md, cross-referenced below rather than duplicated here.
- Risk tiering — two independent signals (
selfbuild/config.sh):HIGH_RISK_PATH_RE(trust-boundary file paths — auth, sandbox/runtime, deploy,selfbuild/, migrations, secrets) andHIGH_RISK_DIFF_RE(dangerous constructs —child_process,spawn(,...process.env,dangerously-skip,eval(, and more — matched against added lines). Either signal firing tiers a changehigh; tiering only ever raises risk. Seedocs/OPERATIONS.md§4a. - The independent conformance Reviewer and the adversarial Security Reviewer (
src/services/review/security-run-review.ts, prompt built bysrc/services/review/security-prompt-builder.ts) run on high-tier diffs; the Security Reviewer can escalate or flagrejectbut cannot approve or lower risk. Seedocs/OPERATIONS.md§4b. - Gate independence: the writer of a change never approves it — see
docs/OPERATIONS.md§4d ("Gate independence (non-negotiable)"). - Merge-preflight serialization (
src/tools/merge-preflight.ts) re-verifies a PR against currentmainimmediately before merge when its files overlap with what has landed since its base commit, fails closed on any preflight error, and holds the PR (held-serialize) rather than merging on an unclear result. Seedocs/OPERATIONS.md§4c.
Containment Tiers
This is a design concept, not yet implemented or enforced in the merged tree.docs/design/fa-roadmap.md's spec 161 entry defines two containment tiers a governed run could carry — Tier 1 fa-hosted-sandbox (the engine runs inside FA's own DockerRuntime; FA contains execution) and Tier 2 remote-engine (FA governs gates/ledger/provenance/tenant/merge for a remote hosted agent server but does not contain its execution; containment is delegated) — and states the tier must be recorded on every run and exposed to any consumer, so a Tier-2 run is never presented as if FA sandboxed it. Searching the merged src/ tree for fa-hosted-sandbox, remote-engine, "containment tier", or a Tier 1/Tier 2 field on any run record returns no matches: today FA's containment posture is the binary RUNTIME=docker/RUNTIME=local switch described in Execution Containment, not a per-run recorded tier. Do not read this page (or any other) as claiming containment tiers are enforced today — the concept itself (spec 161, "the FA Governance SDK") is explicitly gated by the roadmap as not buildable yet, design-only, pending an approved MVP spec.
The PR #173 Incident and the Standing Rule It Produced
On 2026-07-17, PR #173 (spec 045 increment 1, "governed app deployment") shipped POST /:id/deploy guarded by requireProjectAuth — no approval — reading its command straight from the request body, and executing it with spawn('bash', ['-lc', cmd])on the FA host with {...process.env}. Any project API key holder could POST {"recipe":{"command":"env"}} and read every FA secret. It broke all six Trust Boundary rules above. Its spec and PR description claimed "runs in the existing sandbox" and "injected read-only into the sandbox env" — both false — and it still passed the conformance Reviewer and CI, because the risk classifier's path list (HIGH_RISK_PATH_RE at the time) named neither the new deploy-providers.ts file nor routes/features.ts where the route landed, so the change tiered low — auto-merge eligible. It was caught by the operator asking, not by any automated gate. The branch was closed, not merged.
The standing rule this produced: a safety claim in a spec or comment is not evidence. When a spec, PR description, or code comment says "sandboxed", "read-only", "redacted", "approval required", or "tenant-scoped", that sentence proves nothing by itself — find the line that makes it true. This is the discipline this page is built under: every claim above cites the code that enforces it, and anything that couldn't be verified against the merged tree was moved to the section below instead of asserted. selfbuild/config.sh's HIGH_RISK_PATH_RE/HIGH_RISK_DIFF_RE were both widened after this incident (see the "2026-07-17" comment inline in that file), and the adversarial Security Reviewer (src/services/review/security-run-review.ts) was purpose-built afterward to optimize for exactly this lens. See docs/OPERATIONS.md §4a for the full incident writeup and the "why the content signal exists" rationale.
Known Limitations and Not-Merged Work
- The Governance SDK / Agent Access Mandate program (spec 161's SDK track) is design-only.
docs/design/fa-roadmap.mdmarks it explicitly not buildable yet, blocked until an operator-approved MVP spec exists — no code insrc/implements a governed API contract, an Agent Access Mandate, or a policy-enforcement point of the kind that design describes. Do not confuse this with spec 161's git-sandboxing increments (1, 2b, 2c, 3), which are merged and described above. - Containment tiers (
fa-hosted-sandbox/remote-engine) are not recorded on any run. See Containment Tiers above — this is a design concept from the same not-yet-buildable SDK track, not a shipped property of today's runs. - Seccomp, capability-dropping and read-only rootfs are no longer asymmetric between the two containers; the default network posture (Spec 405 inc-2, RM-194) is allowlist-by-default on both, but the job container's default SET is project-declared where the bootstrap clone's is operator-enforced — that asymmetry remains (inc-2 security round 1, finding 4). The main, long-lived agent job container built by
src/services/runtime/docker-runtime.tsnow carries the same--cap-drop ALL/--security-opt no-new-privileges/seccomp/ read-only-rootfs hardening as the git bootstrap container by default (FA_SANDBOX_HARDENING=strict), via the sharedsandboxHardeningArgs()helper — see the Execution Containment section above. As of inc-2,services/runtime/index.ts'screateRuntimealso defaults the job container's egress toallowlist— a per-run COMPUTED set (the run's model-endpoint host, its own VCS host, and the project's own declaredruntime_egress_allowlist) — whenever nothing more specific already resolved the policy (no operator-armed globalFA_RUNTIME_EGRESS=allowlist, no project override, no declared-services network) and no explicit opt-out is set (a non-emptyFA_RUNTIME_NETWORK/FA_RUNTIME_EGRESS=open→config.runtimeEgressDefaultPosture, or the project's ownruntime_egress: 'open'). Every input to that computed set is declared by the project (its model endpoint, its repo host, itsruntime_egress_allowlist), so it confines a run against UNDECLARED egress; the bound on what a project may declare is the operator's tighten-onlyFA_RUNTIME_EGRESS=allowlistlist, which resolves the run before the default is consulted (OPERATIONS §4h-8a).bridge/openis the explicit, RECORDED opt-out (shown on the provenance artifact's "Runtime egress" row), not a silent fallback. Seedocs/OPERATIONS.md§4h-8a. Known gap this does not close: a project with declared ephemeralservices(spec 003) stays on the pre-inc-2openpath by design — see §4h-8a's own "Known limitation" for why dual-homing onto the services network would defeat the allowlist rather than fix it. - The host-git parse surface is closed for the implement, revise, Security Fixer, and spec-kit enrollment/check flows — not platform-wide. Three flows still run git directly on the FA host via
execGitHardened(env-hardened, not sandboxed):src/services/flows/implement-spec-kit.ts,src/services/eval/replay/git.ts, andsrc/services/environment-scaffold-runner.ts. Seedocs/OPERATIONS.md§4h-2/§4h-3/§4h-4c. - Deploy execution is not yet time-bounded. Spec 106 increment 1 (governed deploy —
src/routes/deploy.ts,src/services/deploy.ts) is merged and approval-gated, but increment 2 (bounded execution: timeout, cancel, restart recovery) is queued, not yet built perdocs/design/fa-roadmap.md— an approved deploy run executes without an enforced upper time bound today. RUNTIME=localis a full, unsandboxed opt-out, intended for local development only. Under it there is no container, no seccomp, no network isolation, and no sandboxed git — every containment and git-egress guarantee described above is absent, and the trust boundary reduces to "the operator trusts their own machine." It must never be set on a deployment that runs untrusted project code.- Private repos on non-GitHub SSH hosts lose SSH-key cloning under sandboxed git. FA's only in-container git credential is
GITHUB_TOKEN; the sandbox is never handed the FA host's SSH key. A private repo on e.g. a self-hosted GitLab, previously cloneable via the host's own SSH agent, now fails the sandboxed clone with an authentication error. Public repos on any host are unaffected. Seedocs/OPERATIONS.md§4h. git-lfsis not bundled in the bootstrap git image. UnderRUNTIME=docker, a project whose repo needs Git LFS content will typically see thegit lfs pullstep fail inside the sandboxed container (caught, logged, non-fatal — same handling as a repo with no LFS at all) rather than actually fetching LFS objects. Seedocs/OPERATIONS.md§4h-2.- This page supersedes stale prior snapshots.
CLAUDE.md's 2026-07-17 "Recent Work" note describes the spec-106 deploy rebuild as blocked on the spec-011 credential broker. As of the commit this page was verified against, both are merged (src/services/secret-material/index.ts;src/routes/deploy.ts,src/services/deploy.ts) — that note had gone stale, which is precisely the drift problem this page exists to close. Trust this page (and, ultimately, the code) over any prior snapshot that disagrees with it.