Skip to content

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.ts contains 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_context on the project row, gated by ADMIN_ONLY_SECURITY_FIELDS in src/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.ts routes a credential ref to src/services/secret-material/adapters/broker.ts or src/services/secret-material/adapters/aws-sm.ts purely 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 own authEnv — 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:

  1. Execution happens in DockerRuntime, never on the host. The only call sites that spawn agent or deploy work are src/services/runtime/docker-runtime.ts and src/tools/sandbox-exec.ts; src/services/runtime/local-runtime.ts is the explicit, documented, non-default opt-out (see Execution Containment).
  2. Never spread FA's environment into a subprocess. The container's env is built from an explicit -e VAR allowlist only — buildDockerBaseArgs in src/services/runtime/docker-runtime.ts never spreads process.env into the args list it hands to the container. (The docker CLI subprocess itself does inherit process.env in runDocker, src/services/runtime/docker-runtime.ts — needed so the CLI can find its own PATH/HOME — but Docker only forwards named -e flags into the container, so FA's secrets, which are never named, never cross that boundary. src/tools/sandbox-exec.ts builds its subprocess env from an explicit allowlist the same way.) runDeployCommand in docker-runtime.ts documents the same invariant inline for the deploy path.
  3. Caller input is not configuration. src/routes/deploy.ts returns 400 if req.body.recipe is present at all — the executed recipe is always read from project.deploy_recipe, never the request body (Invariant 3, documented in the file header and enforced in the POST /runs handler).
  4. "Dry-run"/"plan" is not a safety property. POST /api/projects/:id/deploy/runs (src/routes/deploy.ts) only ever creates a run in pending_approval; execution happens nowhere in that handler. POST /runs/:runId/approve is the sole execution trigger (src/services/deploy.ts, executeDeployRun).
  5. 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 inside withVcsProvider (src/services/vcs/index.ts), which removes the credential that call authenticated with from the error's message and stack before it is rethrown. That is every function in the src/services/github-pr.ts shim, but not every VCS API call in the tree: the direct resolveVcsProvider call sites named at the end of this item bypass it and have no throw-site scrub. This is the same posture runSandboxGit (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 needs redactSecrets' extraSecrets, which a caller that already holds a token supplies via vcsRedactionSecretsFromToken (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 the github-pr.ts shim — resolveVcsProvider is still exported and used directly by src/services/builder.ts, src/routes/builder.ts, src/services/flows/implement.ts and src/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) runs redactSecrets over every string in the payload, at any depth, using only FA's own configured secrets (configuredSecretValues() plus redactSecrets's built-in shape patterns) — applied uniformly to every row, regardless of project. assertSecretPoolNotSilentlyEmpty in 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 inside redactSecrets still run regardless). The mask pool deliberately contains no tenant-writable input — not a project's declared env, not services[].env, nothing reachable via PATCH /api/project (spec 194 §0, 2026-07-29 security review round 2, blocking). An earlier version additionally masked each project's own declared env/services[].env values, 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 before computeRecordHash, the tamper-evident record_hash/chain_head were computed over a view a tenant could shape by editing its own env (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 while verifyAuditChain kept returning ok: 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 — see docs/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).
  6. Authn ≠ authz. src/middleware/auth.ts defines distinct guards (requireProjectAuth, requireUserAuth, requireAdminAuth, requireExplicitAdmin, requireProjectOrAdminAuth, requireAdminOrUserAuth, requireAdminOrApproverForProject) with different blast radii; tests/authz-surface.test.ts enforces two floor rules mechanically: Rule A (any route with execution or secrets blast radius must sit behind an elevated tier — admin/explicit-admin only, plus a small named, individually-reviewed exception list) and Rule B (the bare project tier — exactly the guard PR #173 shipped with — may never carry execution, secrets, or credentials blast 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 -e flags only from authArgs/engineAuthArgs/tenantAuthArgs (identity), caller-supplied env, and named repoCredentialEnv keys — never a process.env spread. runDeployCommand in 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 -e flags.
  • src/tools/sandbox-exec.ts is the project-agnostic sandboxed-execution CLI/helper; it builds its own subprocess environment from an explicit allowlist rather than process.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.json was applied, and only to sandbox-git.ts's bootstrap clone/fetch container. Both argv builders now share one hardening ceiling — sandboxHardeningArgs(profile) (src/services/runtime/ docker-runtime.ts), consumed by buildDockerBaseArgs (the job container) and by buildSandboxGitArgs (sandbox-git.ts, always at mode: 'strict', unconditionally) — so neither builder can hand-copy or silently drift from the other (a dedicated drift test in tests/sandbox-git.test.ts diffs their output). By default (FA_SANDBOX_ HARDENING=strict) the job container gets --cap-drop ALL (+ an operator-declared --cap-add list, empty by default), --security-opt no-new-privileges, --security-opt seccomp=<profile> (FA's own containers/seccomp/fa-default.json unless FA_SECCOMP_PROFILE overrides it — a path, or unconfined to disable filtering, operator config only, never tenant input), a --read-only rootfs with --tmpfs /tmp (HOME lands there) and the /work bind mount as the only other writable path, and a declared --ulimit nofile. FA_SANDBOX_HARDENING=compat is 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. See docs/OPERATIONS.md §4h-13 and docs/SETUP.md's FA_SANDBOX_HARDENING entry.
  • 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, but createRuntime (src/services/runtime/index.ts) no longer stops there by default: when no operator-armed global FA_RUNTIME_EGRESS=allowlist, project override, or caller-supplied network already resolved the policy, and no explicit opt-out is set (FA_RUNTIME_NETWORK set to anything, or FA_RUNTIME_EGRESS=openconfig.runtimeEgressDefaultPosture), the job container is attached to the SAME --internal forwarder-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 declared runtime_egress_allowlist. open/bridge is the explicit, RECORDED opt-out (visible on the provenance artifact's "Runtime egress" row), never a silent fallback. See docs/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=true plus 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 uses docker prune or matches by image.
  • Instance tagging (src/services/runtime/instance-id.ts, resolveInstanceId) derives a stable id from the resolved DATABASE_PATH so 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.ts runs 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 sets RUNTIME=local — a deliberate dev/test convenience, not a production posture. Under RUNTIME=local the 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) runs git for 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 /work and a /tmp tmpfs are writable). Its environment is built from nothing: the only credential it can hold is GITHUB_TOKEN, delivered name-only, and only when the clone target's host is exactly github.com (isTrustedGitHost).
  • src/services/runtime/git-egress.ts (provisionGitEgress) closes the network side: under RUNTIME=docker the bootstrap container is attached to an FA-owned, --internal Docker 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 setting FA_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 via execGitHardened (env-hardened, but still host-side parsing of tenant .git objects): src/services/flows/implement-spec-kit.ts, src/services/eval/replay/git.ts, and src/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=local posture is unchanged for git toorunSandboxGit falls back to execGitHardened with the original host-SSH clone args, byte-identical to pre-sandboxing behavior. Same trade-off as every other sandboxed flow under RUNTIME=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:

  • apiauthArgs('api') (src/services/runtime/docker-runtime.ts) passes ANTHROPIC_API_KEY into the container by name only (-e ANTHROPIC_API_KEY); the value travels via the docker CLI's own spawn environment, never in argv.
  • oauthauthArgs('oauth') mounts the host's Claude credentials directory read-only at a seed path and sets HOME=/tmp; the credentials actually staged into the writable container HOME have their refreshToken stripped first by src/services/runtime/oauth-broker.ts (stripRefreshToken) — the container gets a short-lived accessToken only, so it cannot rotate the operator's long-lived credential even if it tries. oauth-broker.ts also 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 execution or secrets blast 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 a finding explaining why, e.g. POST /api/projects/:id/deploy/runs/:runId/approve, which layers checkApproverAuthorized and a dev-mode-open-pass closer on top of a non-elevated tier).
  • Floor Rule B, zero exceptions: the bare project tier — a lone fa_… key — may never carry execution, secrets, or credentials blast 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) and HIGH_RISK_DIFF_RE (dangerous constructs — child_process, spawn(, ...process.env, dangerously-skip, eval(, and more — matched against added lines). Either signal firing tiers a change high; tiering only ever raises risk. See docs/OPERATIONS.md §4a.
  • The independent conformance Reviewer and the adversarial Security Reviewer (src/services/review/security-run-review.ts, prompt built by src/services/review/security-prompt-builder.ts) run on high-tier diffs; the Security Reviewer can escalate or flag reject but cannot approve or lower risk. See docs/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 current main immediately 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. See docs/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.md marks it explicitly not buildable yet, blocked until an operator-approved MVP spec exists — no code in src/ 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.ts now 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 shared sandboxHardeningArgs() helper — see the Execution Containment section above. As of inc-2, services/runtime/index.ts's createRuntime also defaults the job container's egress to allowlist — a per-run COMPUTED set (the run's model-endpoint host, its own VCS host, and the project's own declared runtime_egress_allowlist) — whenever nothing more specific already resolved the policy (no operator-armed global FA_RUNTIME_EGRESS=allowlist, no project override, no declared-services network) and no explicit opt-out is set (a non-empty FA_RUNTIME_NETWORK/FA_RUNTIME_EGRESS=openconfig.runtimeEgressDefaultPosture, or the project's own runtime_egress: 'open'). Every input to that computed set is declared by the project (its model endpoint, its repo host, its runtime_egress_allowlist), so it confines a run against UNDECLARED egress; the bound on what a project may declare is the operator's tighten-only FA_RUNTIME_EGRESS=allowlist list, which resolves the run before the default is consulted (OPERATIONS §4h-8a). bridge/open is the explicit, RECORDED opt-out (shown on the provenance artifact's "Runtime egress" row), not a silent fallback. See docs/OPERATIONS.md §4h-8a. Known gap this does not close: a project with declared ephemeral services (spec 003) stays on the pre-inc-2 open path 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, and src/services/environment-scaffold-runner.ts. See docs/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 per docs/design/fa-roadmap.md — an approved deploy run executes without an enforced upper time bound today.
  • RUNTIME=local is 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. See docs/OPERATIONS.md §4h.
  • git-lfs is not bundled in the bootstrap git image. Under RUNTIME=docker, a project whose repo needs Git LFS content will typically see the git lfs pull step 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. See docs/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.

Released under the MIT License.