Compass agent container runtime — design
Status: Active
Design for how a per-agent Compass container gets its toolchain, its
credentials, and stays current over a long-lived session, on the Go
stack (go). Companion to compass.md §5.3/§5.5 and
the v0.6 record compass-0.6/design.md. All grounding
is against the working tree at 6b192e731 (branch
compass-sea-1327-container-runtime-design); oss/ is byte-identical to
origin/main at c1146579a (verified this run:
git diff --stat origin/main...HEAD -- oss/ → empty). The Rust
compass-daemon crate is cut from the tree (verified this run:
git ls-tree -r origin/main --name-only | grep -c compass-daemon → 0) and is
never cited as live below.
Re-authored (SEA-1327 reopen). A prior version of this record built a cred-avoidance sandbox around Nix evaluation (host-store overlay, a dedicated eval uid, capture-env-once, eval-phase egress narrowing). Matt’s directives invert that premise — see Threat model below — so Decisions 1–3 are rewritten from scratch. Decisions 4–5 and the distribution spine carry over.
Five core decisions are made and are not open here:
- Image = one self-contained base image shipping its own Nix + base store; the agent activates and rebuilds its devenv itself, in-container, as itself (no per-repo image builds, no host Nix dependency).
- Secrets store = SecretSpec + keyring, Server-side — with no
repo-committed
secretspec.toml; the declared set is what’s in the store, and the MVP injects the whole store into every agent. - Distribution + rotation ride the v0.6 config spine (Runner fetches;
Sessionsstream signals; stdin-execinjection; the file-vs-env delivery split) — with provider (LLM) credentials riding the OMP SDK AuthStorage/getApiKeysurface, not a secret-file channel — a distinction of consumption (resolved per LLM call viagetApiKey), not delivery: the credential still lands as a 0600 seed file. - No in-container supervisor for MVP; the Runner drives from outside. An in-container Go supervisor is a named FUTURE seam, explicitly unbuilt.
- The Setup agent guides secrets onboarding (store population — it authors no in-repo secrets file).
This record grounds those decisions in the current code, fixes the concrete
mechanisms and interfaces, and decomposes the work. It supersedes by
citation (never by rewriting the frozen records): compass.md §5.5’s
per-repo devenv container build image model and build-sandbox posture
(compass.md:157-168), §5.3/§6.7’s default-deny-egress-as-MVP claims
(compass.md:143, :252), and the v0.6 OCI-image config-distribution bits
(compass-0.6/design.md:862-888), as detailed under Spec impact below.
Problem / Intent
Section titled “Problem / Intent”A launched agent container today gets exactly one toolchain source and one
secret: the image is built per-repo from its devenv
(go/internal/runtime/image.go:10-13: “devenv container build <name> emits a nix2container image spec”), and provisioning installs only the
scoped git credential (agent.go:232-242: provision = firewall → credentials
→ clone). There is no provider (LLM) credential, no gh credential, no secret
store on the Server (internal/store/ holds accounts/tokens/channels/messages
only — store/migrations/0001_init.sql:1-5 names the whole schema: “accounts,
channel groups, channels + membership, agent workspaces, conversation
messages, and subject-typed token hashes”), and no way to rotate anything into
a long-lived container short of tearing it down. Per-repo image builds also
couple agent-runtime updates to repo rebuilds, and the Runner-side clone step
(agent.go:278-289) bakes in a one-repo-per-agent assumption the product
model contradicts (multi-repo workspaces, compass.md:147).
This record designs the container runtime that fixes all of it: a single self-contained base image whose devenv the agent manages itself, a Server-side SecretSpec+keyring secret store, Runner-driven secret distribution over the v0.6 spine (provider creds through the OMP SDK auth surface), and a rotation path split by delivery shape (file vs env).
Approach
Section titled “Approach”Threat model — the agent is trusted
Section titled “Threat model — the agent is trusted”The container exists for blast-radius containment, not credential avoidance. The agent is handed its credentials on purpose — the whole point is a capable coding environment — so there is nothing to gain by gating the agent’s access to secrets it is entitled to. What the per-agent rootless container still buys, and what this record preserves:
- Host isolation — a rootless podman container with its own namespaces;
the agent user is unprivileged and capability-less inside it
(
egress.go:6-10: the agent “cannot flush or edit the ruleset even though the container nominally holds the capability”). - Inter-agent isolation — scoped
$HOMEper container, 0600 credentials, no shared writable state; “one agent’s token never leaks into another’s tree” (workspace.go:1-9). - Auditability — every secret fetch through the Server is audited
(SecretSpec audit log, D14 redaction). Under inject-all this is
fetch-level audit: each
FetchSecretsrecords one whole-store resolution with one reason string, not a per-secret access trail; per-secret granularity arrives with the future grants seam.
What this record deliberately does not build: any mechanism whose only purpose is to keep the agent away from credentials it will be handed anyway — no separate eval uid for Nix evaluation, no install-secrets-after-activation ordering, no captured-env indirection, no eval-phase egress narrowing. Repo Nix evaluates as the agent, with the agent’s credentials present and the agent’s (default-open, Decision 1) egress — the same code the agent would run by hand a moment later.
The built substrate this composes with
Section titled “The built substrate this composes with”All file+line references are into go/internal/runtime/ unless
otherwise pathed. Everything below extends the merged Go runtime package, not
a parallel path:
- Lifecycle façade —
AgentRuntime.Launch=createAndStartthenprovision(agent.go:147-172);provisionruns, in fixed order,armEgress(root) →installCredentials(agent user) →cloneRepo(agent user) (agent.go:232-242). New provisioning is new steps in this function; this record also deletes one (the clone, Decision 1). - The canonical secret posture —
installCredentialsfeeds the credential script “over stdin tosh -s, neversh -c <script>: the token is in the script body, and argv is visible in the container’s process list while stdin is not” (agent.go:264-266); the script installs git’sstorehelper against a 0600$HOME/.git-credentials(workspace.go:143-150). Every secret channel in this record copies this posture. - The container is exec-driven, not self-hosting —
createAndStartsetsCommand: []string{"sleep", "infinity"}with the comment “Keep the container alive so the Runner can exec into it; the agent is driven via exec, not as the container’s main process” (agent.go:213-215). Decision 4 keeps this. - Egress — nftables armed as root at provision; the container holds
NET_ADMINonly so the root arm step can install the ruleset, and the agent user “cannot flush or edit the ruleset even though the container nominally holds the capability” (egress.go:6-10;EgressPolicyategress.go:29-34). The mechanism stays; the MVP policy becomes default-open (Decision 1). - Scoped credentials type —
Credentials{Host, Username, Token}withString/GoStringredaction so “a struct dump can’t leak it” (workspace.go:60-67). New secret types copy the redaction. - The in-container agent + control stream — the agent is the first-party
OMP-SDK process:
CompassAgent“wrapsAgentfrom@oh-my-pi/pi-agent-core, subscribes its SDK event stream … consumes decodedAgentControlops from the ControlSource and drives the SDK (prompt/steer/setTools/setSystemPrompt)” (packages/compass-agent/src/agent.ts:3-7). The frozen control oneof is{prompt; steer; ask_answer; config; replay; replay_complete}(src/control.ts:6-12); the typed domain union lives atcontrol.ts:30-53; outbound frames rideFrameSink(src/frame.ts:50-52). This is the re-read-notify seam rotation rides. The SDKAgentis caller-constructed: “Constructed by the caller (container entrypoint) with its model/tools/system-prompt” (agent.ts:27-31;opts.agent ?? new Agent(),agent.ts:54) — the seam Decision 3’s provider-credential wiring plugs into. - The SDK auth surface —
AgentOptions.getApiKey?: (model: Model) => Promise<ApiKey | undefined> | ApiKey | undefined— “Resolves an API key or resolver dynamically for each LLM call. Useful for expiring tokens and model-scoped credential routing” (@oh-my-pi/pi-agent-coredist/types/agent.d.ts:66-70; vendored atnode_modules/.bun/@oh-my-pi+pi-agent-core@16.4.8). Behind it,@oh-my-pi/pi-aidefines the persistence abstraction:AuthCredential = ApiKeyCredential | OAuthCredential(dist/types/auth-storage.d.ts:16-24), theAuthCredentialStoreinterface (auth-storage.d.ts:234), theAuthStoragemanager over it (constructor(store: AuthCredentialStore, …),auth-storage.d.ts:596-604), and the SQLite-backedSqliteAuthCredentialStore(auth-storage.d.ts:1064-1067). - Transport spine — Runner↔Server is the frozen internal
RunnerService: “container lifecycle commands and Server→Runner config-version signals ride theSessionsbidi stream (the only Server→Runner path); agent events ridePublishEvents(Runner→Server only)” (compass-0.6/design.md:960-966). Note:RunnerServiceis designed, not yet in the Go tree (grep ofgoforRunnerService|FetchSecrets|Sessions|Enrollthis run: no Go handler matches; the wire shape is frozen indocs/designs/platform/go-toolchain-default.md:905-981, T8). The secret-fetch surface below is a delta on that held contract, not on live code.
Decision 1 — self-contained base image; the agent manages its own devenv
Section titled “Decision 1 — self-contained base image; the agent manages its own devenv”One self-contained base image ships its own Nix installation and base
Nix store, plus devenv + direnv, bun + the bundled compass-agent, git, gh,
and the egress toolchain the arm script requires in-image (“Requires nft,
getent, and awk in the image”, egress.go:76-77). There is no host
/nix/store mount, no overlay store, and no assumption the host has Nix at
all — Compass must run on hosts without Nix. The image is a normal OCI
artifact the Runner pulls, versioned like the v0.6 restart path expects (“The
agent binary and base image ride versioned OCI pulls”,
compass-0.6/design.md:886-888).
The agent activates and rebuilds its devenv itself, as itself. The agent
works across multiple repos (compass.md:147), cloning them itself (see No
clone step below), so no single environment can be baked or pre-activated for
it. Instead the in-container Nix is set up for the agent uid — single-user
mode, /nix owned by the agent user — so devenv, direnv allow, and
direnv exec work normally, unprivileged, with write access to the store. The
agent modifies devenv.nix, re-evaluates, and rebuilds mid-session the same
way a developer does. Cache misses substitute from the public Nix binary cache
over the default-open egress (below) — no special-cased egress phase.
Runner-driven execs pick the environment up via direnv at the workspace
root. ExecAsAgent builds a bare argv exec — NewExecSpec(command...)
with HOME = scoped home (agent.go:176-181) — which fires no shell hook.
The Runner therefore wraps agent execs as direnv exec <WorkDir> -- <argv>
(T2). direnv resolves the .envrc governing the given directory, and
repos are subdirectories of WorkDir — so the wrapper resolves a root
.envrc at WorkDir if the agent has authored and direnv allowed one,
and degrades to the base image’s tools otherwise. Per-repo devenvs are the
agent’s own business: the agent activates and uses them from inside each
repo (its own shell / direnv exec <repo> invocations), exactly as a
developer does — the Runner-side wrapper makes no per-repo claim.
Re-evaluation under the wrapper runs as the agent uid with the agent’s
credentials and egress — exactly the trusted posture above, so no
captured-env indirection is needed.
Egress: mechanism kept, MVP ruleset default-open. armEgress and
egress.go stay in the tree and in the provision sequence — root-armed
nftables the capability-less agent cannot edit (egress.go:6-10) — but the
MVP policy is allow-all: the user configures zero domains, and Compass is
as easy to reach the network from as any coding agent on a laptop. T2 adds an
allow-all EgressPolicy mode whose ruleset still installs the table (so the
arming step, the NET_ADMIN posture, and the agent-can’t-edit property all keep
working) with an accept default instead of the drop default + allowlist
(egress.go:109-131 baseRuleset, “policy drop”). Per-agent egress
restriction is a future opt-in: the validated-allowlist path
(AllowEgress, egress.go:40; dual-stack resolve, egress.go:16-19) stays
available behind config, not default-on.
No clone provision step. cloneRepo (agent.go:278-289) is deleted from
the launch path: the agent has git credentials installed
(installCredentials, agent.go:256-276) and clones its own repos — one
agent, N repos, its own choice of layout. Launch becomes create+start →
armEgress (default-open) → installCredentials, nothing else. The
Workspace clone surface (Source, Branch, CloneCommand,
workspace.go:69-97) retires with it; Workspace keeps HomeDir/UID/
Credentials and gains WorkDir — a pre-created, agent-owned workspace root
replacing CheckoutDir (“the absolute path inside the container where the
repo is cloned”, workspace.go:74-76). Checkout-dir assumptions this
disturbs: ExecAsAgent sets the exec workdir to the checkout
(agent.go:176-181) and AgentHandle.CheckoutDir documents it as the
session’s cwd (agent.go:63-65) — both re-point at WorkDir (T2
Interfaces); the OMP-SDK session’s cwd is WorkDir, and repo paths under it
are the agent’s own business.
The Setup agent still authors devenv.nix when a repo lacks one
(compass.md:164); its output now feeds the agent’s own in-container
activation instead of an image build. Container-readiness is no longer a
Runner-side pre-launch gate — there is no Runner-side checkout to probe —
so IsContainerReady (image.go:82-89) and ImageBuilder retire from the
hot path (flag for deletion in the impl PR if nothing else references them).
Prebuilt per-repo images are an explicit FUTURE feature (a warm cache in
front of in-container activation), not MVP.
Accepted MVP launch cost: with no prebuilt repo image and no host store,
every container cold-realizes its devenv from the public Nix binary cache —
N agents on a host each pull the same multi-GB closure, and first activation
takes minutes per launch. This is a deliberate MVP trade (boring, portable);
the named mitigations are the FUTURE prebuilt image above and the per-agent
/nix named volume recorded under Alternatives considered.
Decision 2 — secrets store: SecretSpec + keyring, Server-side; no repo manifest; inject-all
Section titled “Decision 2 — secrets store: SecretSpec + keyring, Server-side; no repo manifest; inject-all”The Server owns secrets through SecretSpec (cachix/secretspec, Apache-2.0), verified against the upstream docs this run:
- Declaration/storage split + provider set: README — “separating secret declaration from secret storage … the actual values live in a secure provider like your system keyring, 1Password, or any other backend”.
- Keyring layout: one entry per
secretspec/{project}/{profile}/{key}, current OS user as account (docsproviders/keyring.md, “Storage model”). - Provider fallback chains: per-secret
providers = [...]lists tried left to right for reads; writes go only to the first provider (docsconcepts/providers.md, “How SecretSpec selects a provider”). - Provider-connecting credentials (0.15+) pass in memory: “SecretSpec
passes a retrieved credential to the destination provider in memory. It
does not export the credential or include it in the environment of a
process started by
secretspec run” (docsconcepts/providers.md, “Provider credentials”). - Audit logging (0.12+): “Every secret access recorded locally (who, when, why, outcome) — on by default, secret values never logged” (README) — feeding the D14 redaction/audit requirement.
- Go SDK (0.13+):
github.com/cachix/secretspec/secretspec-go— “thesecretspec-ffiC ABI loaded at runtime via purego (no cgo)”, resolving “through the same providers, profiles, fallback chains, and generators as the CLI”, with a typedMissingRequiredError(docs blogsecretspec-0-13-sdks.md). Recommended Server integration surface (OQ2).
The keyring provider is the default (encryption at rest per the provider
matrix), pluggable to 1Password/pass/gopass/Vault later with zero Compass code
change. This satisfies the v0.5 D14 boundary
(compass-0.5/design.md:528-546) without a bespoke Compass encrypted store:
encryption at rest, rotation/revocation (provider-native writes), redaction
(the SecretSpec audit log never logs values, plus Compass-side redaction
below). SecretSpec runs only on the Server — never inside a container;
containers receive resolved values, not provider access.
No repo-committed secretspec.toml. One agent works across multiple
repos, so a single in-repo declaration file doesn’t fit — and the store, not a
repo, is the source of truth. The user adds secrets through a Compass
surface (the Compass CLI and/or the Bridge UI, T7) that writes the
provider directly; the declared set is what’s in the store. Mechanically
(the SecretSpec resolver is manifest-driven and exposes no enumeration —
the SDK “resolves the exact secrets your manifest declares”, 0.13 blog), the
Server keeps a names-only registry — a secrets table in the existing
Postgres store (internal/store/, today accounts/tokens/channels/messages
only per store/migrations/0001_init.sql:1-5): name, delivery kind, provider
flag, timestamps, never values — and generates the manifest SecretSpec
resolves against from that registry (T3). This is bookkeeping, not
authorization.
Inject-all for MVP; per-agent grants are a named FUTURE seam. There is
no per-agent authorization table: every launched agent receives the whole
store (non-provider secrets as files/env over the stdin-exec channel;
provider creds via Decision 3’s AuthStorage path). This follows directly from
the threat model — the agent is trusted with the user’s working credentials,
and one user’s agents share one store. A single-user Server is an explicit
MVP constraint (OQ7, ruled by Matt in this review; user scoping is the named
post-MVP path). Per-agent scoping (which agent sees which secret) is
explicitly unbuilt: the seam is the FetchSecrets
request/response (T4), which today carries no filter and would gain one — plus
a grants store and a Server-side check — without reshaping any other
interface. The redaction/audit posture is kept in full regardless.
Decision 3 — distribution + rotation on the v0.6 config spine
Section titled “Decision 3 — distribution + rotation on the v0.6 config spine”Secrets move exactly like config, on the same hops
(compass-0.6/design.md:960-966):
- Runner fetches from Server over the existing
RunnerServicegRPC connection — a new unaryFetchSecretsRPC (held-for-review interface, T4), returning the whole store’s resolved values (inject-all, no grants filter). The Server resolves via SecretSpec at fetch time; resolved values are never persisted outside the provider and never logged. - Server signals rotation over the
Sessionsbidi stream — the only Server→Runner path — as a secret-version signal shaped like the config-version signal (“the Server signals ‘config version N for agent X’ over theSessionsbidi stream”,compass-0.6/design.md:874-875). The Runner re-fetches on signal; values never ride the signal itself. - Runner injects into the container via the built stdin-
execchannel: a setup script fed tosh -sover stdin (agent.go:264-270), writing 0600 files under the agent’s scoped$HOME(theworkspace.go:143-150pattern) or staging env values for the agent’s exec env. Never argv.
Provider (LLM) credentials ride the OMP SDK auth surface — not a
secret-file channel. The in-container agent is constructed by the container
entrypoint (agent.ts:27-31: “Constructed by the caller (container
entrypoint) with its model/tools/system-prompt”; agent.ts:54), and the SDK
accepts a per-call credential resolver: AgentOptions.getApiKey?: (model: Model) => Promise<ApiKey | undefined> | ApiKey | undefined — “Resolves an API
key or resolver dynamically for each LLM call. Useful for expiring tokens and
model-scoped credential routing” (pi-agent-core agent.d.ts:66-70). So:
- The Runner materializes fetched provider credentials as a 0600 seed
file under the agent’s scoped
$HOME(same stdin-execposture), keyed by provider, shaped as the SDK’sAuthCredentialunion (ApiKeyCredential | OAuthCredential,pi-aiauth-storage.d.ts:16-24). - The entrypoint constructs the SDK
Agentwith agetApiKeyresolver backed by that seed (loaded into an in-memoryAuthStorageover anAuthCredentialStore,auth-storage.d.ts:234,:596-604— or read directly for the api-key-only MVP), and hands it toCompassAgentviaCompassAgentOptions.agent. - Because
getApiKeyresolves per LLM call, provider-credential rotation is a seed-file rewrite + theSecretsChangedre-read notify (below) — no agent restart, no env involvement. Provider creds never appear insecretspec-style env/file delivery, in exec env maps, or in argv.
This re-expresses SEA-1115’s Rust-era ProviderCredentials intent
(docs/designs/agents/sea-1115-agent-provisioning-cotal.md:113, :151) on
the Go stack, with the SDK auth surface replacing the harness-supplied
env-var/file fork — the first-party agent has a first-party credential API,
so Compass uses it.
The file-vs-env delivery split for non-provider secrets (load-bearing). Where a secret lands determines how it rotates:
- File-shaped secrets rotate in place. The Runner rewrites the file over
the same stdin-
execchannel (write-temp + atomicmvwithin$HOME), then notifies the running agent via a control-stream re-read op — a newAgentControlvariant, working nameSecretsChanged(TS domain kindsecretsChanged, carrying the rotated secret names, never values). The control seam is built for exactly this: the agent consumes decodedAgentControlops from stdin and drives the SDK (agent.ts:3-7; union atcontrol.ts:30-53). The frozen oneof has six variants (control.ts:6-12), so a seventh is an additive contract delta held for review —AgentControlis deliberately not yet in the generated proto (proto/compass/v1/agent.proto:73-85: “It is deliberately NOT defined in this PR”), so the variant lands with the decoder. Secrets a tool re-reads per use (git credentials — read by git’sstorehelper on every network op; the provider seed — read per LLM call viagetApiKey) rotate with no notify at all once rewritten. Caveat (git token write side): the forge token arrives viaAgentSpec.Workspace.Credentialsat provision (agent.go:238,workspace.go:113-151) — it is not aResolvedSecretand no rotation path reaches it. MVP scope: git-token rotation is OUT — a stale forge token is refreshed by the throwaway-container restart path (re-provisioned with freshCredentials); folding the forge credential into the declared store is the post-MVP path. - Env-shaped secrets rotate for subsequent execs; only captured env needs
a restart. Env delivery is itself file-backed: values live in the 0600
aggregate env file each wrapped exec consumes at spawn time (T5’s
--env-file), so rewriting that file rotates env secrets for every subsequent exec with no restart. What cannot be mutated is the environment a process already captured at its own exec time — for a long-lived process holding a stale env value, rotation falls back to the throwaway-container restart path the v0.6 record already defines for image updates: “stop, restart on the new image, transcript replayed” (compass-0.6/design.md:886-888) — the replay barrier (control.ts:10-12) makes the restart loss-free. (The agent process itself consumes no declared env secret — provider creds ride the seed — so in practice the restart case is a user tool the agent left running.) - Consequence: prefer file delivery for anything expected to rotate; env delivery still trails file delivery for long-lived consumers, which capture values at spawn.
Placement set for MVP (folded into Plan tasks):
- git — BUILT:
installCredentials→Workspace.CredentialSetupScript()(agent.go:256-276,workspace.go:113-151). The canonical channel; unchanged. - provider (LLM) — the AuthStorage seed above (T5), rotated live via
seed rewrite + per-call
getApiKeyresolution. - gh — a declared secret in the store with its own kind,
SecretGH(T3): the registry row carrieskind = SecretGHplus ahostcolumn (defaultgithub.com), so the T5 materializer routes it toGHCredentials.SetupScript—~/.config/gh/hosts.yml(0600, under scoped$HOME) for github.com and custom forges (withGH_HOSTfor the latter) — never to the generic$HOME/.compass/secrets/<NAME>path. File form is the default because it is the rotatable one and it avoids the env path’s argv exposure (env values ride-e KEY=VALUEinto podman argv today,podman.go:594-596/:365-367— see T5, the--env-filedelta);GH_TOKENenv stays only for tools that can consume gh from env alone. - generic declared secrets (DB URLs, API tokens) — file under
$HOME/.compass/secrets/<NAME>or env, per the store-registeredDeliveryKind(T3/T5).
Decision 4 — no in-container supervisor for MVP
Section titled “Decision 4 — no in-container supervisor for MVP”The container keeps sleep infinity as its main process and the Runner keeps
driving the agent via exec from outside (“the agent is driven via exec, not
as the container’s main process”, agent.go:213-215). The Runner (Go,
outside) owns lifecycle and secret placement; the TS agent
(packages/compass-agent) stays a pure OMP-SDK/frame process, speaking typed
AgentFrame/AgentControl over the built streaming-exec stdio pipes
(podman.go:289-296).
Named FUTURE seam — the in-container Go supervisor (explicitly unbuilt): a small Go binary as PID 1 that owns secret-watch (inotify on the secret files + originating the re-read notify) and agent lifecycle (spawns the TS agent as a child, restarts it on crash), to cut the Runner out of the per-agent hot path at scale, post-dogfood. Recorded rationale:
- A supervisor cannot be the process it restarts — restart authority must sit outside the restarted process: today the Runner restarts the agent; later the supervisor restarts the agent and the Runner restarts the supervisor.
- Privileged secret placement must not share a process with model-driven agent code — placement stays in the Runner (MVP) or the supervisor (future), never in the TS agent the model steers.
The MVP interfaces are shaped so the supervisor slots in without redesign:
secret files land at stable paths under $HOME (the SecretMaterializer
contract, T5), and the notify is a control-stream frame the supervisor could
equally originate.
Decision 5 — Setup agent guides secrets onboarding
Section titled “Decision 5 — Setup agent guides secrets onboarding”Devtools onboarding is already the Setup agent’s job (compass.md:164; the
naming table entry: “authors a repo’s devenv config so Compass can build
per-agent container images (§5.5)”, compass.md:353 — the image half of that
sentence is superseded by Decision 1; the authoring role stays). Secrets are
the remaining high-friction surface, so the Setup agent additionally walks the
user through populating the store — it authors no in-repo secrets
file (there is none, Decision 2):
- Reports which expected secrets are set/unset (the Server’s value-free
status surface, T7
ListSecrets) and proposes names for what the repo’s tooling appears to need. - Directs the user to the Compass CLI / Bridge UI entry flow (
SetSecret, T7) for each missing value. - Detects an existing 1Password/pass/gopass store and suggests wiring it as
the SecretSpec provider instead — provider
reffields can read credentials other applications already stored (docsproviders/keyring.md, “Use existing secrets”).
The Setup agent never sees secret values in its transcript or
conversation: it drives the Server’s value-free status RPC, and value
entry happens in the Client UI / CLI directly against the Server — values
stay out of every transcript by construction (D14 redaction,
compass-0.5/design.md:540-542). (As a launched agent it does receive the
inject-all store as 0600 files like any other agent — the claim is
transcript-scoped, not container-scoped.)
Spec impact — supersession, by citation
Section titled “Spec impact — supersession, by citation”Per the sealed frozen-record convention, merged records are never edited;
this record supersedes specific claims by citing them, and the impl PR’s
living-spec update carries the replacement into
docs/specs/product/compass.md:
docs/designs/product/compass.md§5.5 (compass.md:157-168) — the per-repo image model is superseded: “the container image is built from adevenvenvironment” (:159) and “the image is the composeddevenv container buildoutput” (:163) give way to the single self-contained base image + agent-managed in-container activation. The build-sandbox posture (:166: “no ambient credentials, egress limited to the Nix substituter cache”) is also superseded: there is no image build from repo devenv anymore, and under the trusted-agent threat model the agent evaluates repo Nix as itself, with its credentials and its (default-open) egress. Kept intact: the Setup-agent authoring flow (:164).compass.md§5.3/§6.2/§6.7 default-deny egress (:143,:201,:252) — “a default-deny egress allowlist at the container layer” is superseded as the MVP default: the mechanism remains built and armed, but the shipped ruleset is allow-all; default-deny/per-agent allowlists become the future opt-in restriction tier (consistent with the hardening tiercompass.md:378already names for stricter egress). Accepted cost (explicit): the frozen spec leans on the closed allowlist as the structural floor beneath the hook gates — §6.2: “the per-agent container’s default-deny egress allowlist (§5.3) bounds every outbound connection, hook-mediated or not … the container’s allowlist is the structural floor beneath them” (compass.md:201), and §6.7: “exfiltration to an arbitrary host is blocked even when no hook fires” (compass.md:252). Under default-open MVP egress that floor is absent: egress synthesized inside an already-approved subprocess is uncovered until the opt-in restriction tier is enabled. The hook-level gates themselves keep working; what they lose is the container backstop beneath them.compass.md§5.3 clone-per-container (:145) — “created inside the container at startup” is adjusted, not replaced: clones are still per-container and in-container, but the agent creates them, not the Runner’s provision step; multi-repo workspaces (:147) follow naturally.compass-0.6/design.md:862-888— the config-distribution mechanics are extended, not replaced: Runner-mediated pull,Sessionsversion signal, and read-only-mount swap stay for config; this record adds the secret fetch + placement channel beside them. Superseded within that range: “The agent binary and base image ride versioned OCI pulls” (:886-887) now applies to the single base image only — there is no per-repo image to pull.- v0.5 D14 (
compass-0.5/design.md:528-546) — “per-user / per-agent authorization on secret access” is deferred in both halves, not dropped: MVP inject-all applies no per-agent grants (the named-future seam, Decision 2) and no per-user scoping — the T3 registry andFetchSecretscarry no user dimension, so on a multi-user Server the whole store would reach every agent regardless of owner — hence the ratified MVP constraint of a single-user Server (OQ7, ruled by Matt; user scoping is the named post-MVP path). Encryption at rest, rotation/revocation, per-container isolation of delivered secrets, and redaction are delivered as specified. - Living spec —
docs/specs/product/compass.md’s “Not yet specified” tail (compass.md:611in the spec) does not yet name the container runtime; the impl PR adds agent-container-runtime requirements (base image, agent-managed activation, secret store, distribution/rotation, default-open egress) as new spec sections, leaving frozen design prose untouched.
Alternatives considered
Section titled “Alternatives considered”Alternatives to the five decisions themselves are settled and not re-argued; recorded here are the rejected shapes inside each mechanism:
- Per-repo prebuilt images (status quo,
image.go:10-13) — rejected for MVP: couples agent updates to repo rebuilds, costs a fulldevenv container build+ skopeo load per repo per change, and the build product is node-local anyway. Named FUTURE: a prebuilt image as a warm cache in front of in-container activation, same activation contract. - Shared host Nix store (read-only lower + per-container overlay upper) —
rejected: it assumes Nix on the host (Compass must run without it), drags in
SELinux relabel hazards on the store mount (
mountArgunconditionally appends:Z,podman.go:605-609), uid-mapping reconciliation under--userns=keep-id(podman.go:357), and Nix-DB validity coupling — all complexity purchased for a warm-cache latency win the public binary cache already approximates. A self-contained image is boring and portable. - Per-agent podman named volume for
/nix(warm cache) — acknowledged, not chosen for MVP: a named volume persisted across containers would keep each agent’s realized closure warm (cutting the cold-realize launch cost under Decision 1) with no host-Nix dependency — materially different from both the rejected host-store overlay and the FUTURE prebuilt image. Deferred because it adds per-agent volume lifecycle (GC, versioning against base-image Nix upgrades) the MVP doesn’t need; named as the first knob if launch latency hurts in dogfood. podman secretfor secret delivery — rejected: podman secrets mount at container create time only — no path adds or rotates one on a running container, forfeiting the live-rotation contract (T6).- Eval-uid sandbox + captured-env indirection (the prior version of this record) — rejected on the threat model: the agent is trusted with its credentials, so isolating Nix evaluation from the agent’s own secrets bought no security and cost an in-image second user, cross-uid ownership dances, a capture-env file contract, and a two-phase egress re-arm.
- Bespoke Compass encrypted secret store — rejected: v0.5 D14 needs
encryption at rest, rotation, redaction (
compass-0.5/design.md:533-542); SecretSpec + provider gives them off the shelf, with pluggable backends Compass would otherwise re-implement one by one. - Running SecretSpec inside the container — rejected: it would hand every agent provider access (keyring/1Password reachability + provider credentials) — a store-level blast radius, beyond the agent’s own resolved values. Trusted-agent does not mean store-admin. Containers get resolved values only.
- Provider creds as a declared env/file secret — rejected: the SDK has a
first-party per-call credential surface (
getApiKey,agent.d.ts:66-70) that gives live rotation and model-scoped routing for free; an env var can’t live-rotate and a bespoke file contract would reinventAuthCredential(auth-storage.d.ts:16-24). - Secret values riding the
Sessionssignal — rejected: the signal is a version notification; carrying values would put secrets on a broadcast-ish control path and into any stream logging. Fetch stays a dedicated authenticated unary pull. - Read-only mount swap for rotatable secret files — considered (it is the
config mechanism,
compass-0.6/design.md:870-877) and not chosen for secrets: mount swap requires Runner-host files (secrets at rest on the host disk) and mount churn; stdin-rewrite keeps secrets container-side and reuses the exact built posture on a running container. - Supervisor in the MVP — rejected per Decision 4; the rationale pair (restart authority, privilege separation) is recorded there.
Tasks are ordered by dependency; each carries its own test cycle. Exact signatures are the contract; field names may be refined in review but arity and placement may not.
T1 — Base image: one self-contained agent base
Section titled “T1 — Base image: one self-contained agent base”Define and build the single base image: its own Nix (flakes enabled,
single-user, /nix owned by the agent uid), devenv, direnv, bun + the bundled
compass-agent, git, gh, and nft/getent/awk (required in-image by the egress
script, egress.go:76-77). Published as the one versioned OCI artifact the
Runner pulls (compass-0.6/design.md:886-888 restart path). No host mounts,
no host-Nix dependency. ImageBuilder + IsContainerReady
(image.go:82-89) retire from the launch path (kept only if the FUTURE
prebuilt cache revives them — flag for deletion in the impl PR if nothing
else references them).
- Interfaces:
- Nix: a
compass-agent-basepackage/image attribute in the repo flake (exact attr path fixed in the impl PR; nix is declarative config, not a Go surface). The image bakes the agent uid’s passwd entry, its$HOMEskeleton, and agent-uid ownership of/nix. - Uid mapping (load-bearing): the baked agent uid must be the uid the
container actually runs as. Today’s create path uses bare
--userns=keep-id(podman.go:355-357: “Rootless uid mapping: files the agent writes in a bind-mount map back to the invoking user” /"--userns=keep-id"), which maps the host Runner uid into the container — if that differs from the baked uid, the agent does not own/nixand rebuild-own-devenv fails. Fix: T2 switches create to--userns=keep-id:uid=<agent-uid>,gid=<agent-gid>so the host Runner uid appears in-container as the baked uid; the invariant is baked-uid ==Workspace.UID== the mapped in-container uid, pinned in one config constant and asserted by the T8 agent-owns-/nixtest. - Go:
AgentSpec.Image(agent.go:35-36) now always carries the base image ref supplied by the Runner’s config, never anImageBuilder.Buildresult.
- Nix: a
- Consumes: nothing. Produces: the image every container starts from.
T2 — Launch path: no clone, default-open egress, direnv exec wrapper
Section titled “T2 — Launch path: no clone, default-open egress, direnv exec wrapper”Simplify provision (agent.go:232-242) to exactly: armEgress
(default-open) → installCredentials → new secret-materialization steps
(T5). Delete cloneRepo (agent.go:278-289) and the Workspace clone
surface; add a root pre-step that creates the agent-owned workspace root.
Wrap Runner-driven agent execs in direnv exec <WorkDir> so they pick up the
workspace-root .envrc when the agent has authored + allowed one (Decision 1).
- Interfaces:
Workspace(workspace.go:69-85) dropsSource,Branch, andCloneCommand()(workspace.go:87-97);CheckoutDirbecomesWorkDir string— the agent-owned workspace root the root pre-stepmkdir -p+chowns at provision, the exec workdir (agent.go:176-181), and the OMP-SDK session’s cwd (agent.go:63-65re-pointed).RepoSourceand its tests retire.func AllowAllEgress() EgressPolicy— the default-open policy; itsNftScript()still installs thecompass_egresstable and chain (arming stays fail-closed and root-only,egress.go:76-79) with an accept default instead of the drop policy + allowlist sets (egress.go:109-131).AllowEgress(egress.go:40) stays as the future opt-in restriction path; no user network config exists in MVP.Create(podman.go:347-357) switches--userns=keep-idto--userns=keep-id:uid=<agent-uid>,gid=<agent-gid>— the T1 uid-mapping invariant (baked uid ==Workspace.UID== mapped in-container uid).func (r *AgentRuntime) ExecAsAgent(ctx, handle, command ...string)— unchanged signature; the built argv becomesdirenv exec <WorkDir> -- <command...>(bare argv today,agent.go:176-181).direnvships in the image (T1). The wrapper resolves the.envrcatWorkDir(the root convention, Decision 1) when the agent has authored +direnv allowed one, else the base toolchain — it makes no per-repo claim; per-repo devenvs are the agent’s own in-repo invocations. The exec additionally passes the T5 aggregate env file via--env-file(a new exec-spec env-file field,podman.godelta) so declared env secrets reach the process without riding-eargv. The streaming exec that starts the agent itself (podman.go:289-296) takes the same wrapper so the agent process sees its activated env.
- Consumes: T1. Produces: a launched container the agent owns end to end — clones, envs, and rebuilds are the agent’s.
T3 — Server secret store: internal/secrets + names registry
Section titled “T3 — Server secret store: internal/secrets + names registry”New Server package wrapping SecretSpec resolution behind an interface, plus
the names-only registry that defines the declared set (Decision 2 — no
repo manifest, no grants table). All types redact like Credentials
(workspace.go:60-67).
- Interfaces:
type ResolvedSecret struct { Name string; Value string; Version string; Delivery DeliveryKind; Kind SecretKind; Host string }withfunc (s ResolvedSecret) String() string/func (s ResolvedSecret) GoString() stringredactingValue.Hostis set only forSecretGHrows (empty otherwise).Versionis produced by the Resolver as a content hash (SHA-256, hex) of the resolved value — the registry stores no values and SecretSpec resolve returns values, not versions, so the hash is the only deterministic producer. Consequence (explicit): re-setting a secret to the same value yields the sameVersion, so T6’s diff sees no change and triggers no rotation — correct, since nothing the container holds is stale.type DeliveryKind uint8—DeliveryFile/DeliveryEnv(the load-bearing split), stamped from the registry row at declaration time.type SecretKind uint8—SecretGeneric/SecretProvider/SecretGH; provider rows additionally carryProvider string(the SDK provider id) so T5 routes them to the AuthStorage seed, never the generic channels;SecretGHrows carryHost string(defaultgithub.com) so T5 routes them toGHCredentials.SetupScript(Decision 3’s gh placement), never the generic file path.type Resolver interface { Resolve(ctx context.Context, reason string) ([]ResolvedSecret, error); Set(ctx context.Context, name, value string) error; Delete(ctx context.Context, name string) error }—Resolveresolves the whole registry (inject-all; anames []stringparameter returns with the future grants seam);Set/Deleteare the provider write path T7’sSetSecret/DeleteSecretrequire — the Go SDK is read-shaped (builder →Load()→Get()/SetAsEnv(); upstream writes are CLI-only,secretspec set), so the write half shells to the pinnedsecretspecCLI with the value over stdin, never argv (OQ2).reasonfeeds the SecretSpec audit log. The resolver generates the SecretSpec manifest from the registry (one Compass project, one profile) and writes it under the Server’s own state directory — the SDK builder takes provider/profile/reason, not a manifest path, so the resolver runs with that directory as its manifest root. Server state, never repo state.- Store:
func (s *Store) DeclareSecret(ctx context.Context, actor AccountID, name string, delivery DeliveryKind, kind SecretKind, provider, host string) error,func (s *Store) DeleteSecretDeclaration(ctx context.Context, actor AccountID, name string) error,func (s *Store) DeclaredSecrets(ctx context.Context) ([]SecretDeclaration, error); migrationmigrations/0002_secrets.sql(name UNIQUE, delivery, kind, provider, host, declared_by, timestamps — no value column). - Name validation (load-bearing): a declared name is validated against
^[A-Za-z_][A-Za-z0-9_]*$(SecretSpec’s env-var-name grammar) atDeclareSecrettime — it later becomes a path under$HOME/.compass/secrets/and a line in a root-adjacent script (T5), so it is validated at the door, not escaped downstream.
- Consumes:
internal/store(store.go:36-38), SecretSpec Go SDK. Produces: the Server-side resolve surface T4 serves from.
T4 — Secret fetch + rotation signal on RunnerService (held-for-review contract delta)
Section titled “T4 — Secret fetch + rotation signal on RunnerService (held-for-review contract delta)”The .proto delta is named here, not written. RunnerService now exists
in the Go tree — proto/compass/v1/runner.proto:42 defines it with
Enroll, Sessions, and PublishEvents (:50, :61, :70). This record
adds FetchSecrets + a SecretsVersion signal to that existing service as a
contract delta: it rides its implementation PR, and the schema edit lands only
after compass.v1-owner + maintainer review:
- Interfaces (held for review):
rpc FetchSecrets(FetchSecretsRequest) returns (FetchSecretsResponse)onRunnerService— request:session_idonly (no names filter, no grants — inject-all; the filter parameter is the named future per-agent-scoping seam). Response: resolved name/value/version/delivery/kind tuples for the whole store. Authenticated by the per-Runner token (RunnerServicerejects account tokens,go-toolchain-default.md:970-971); the Server verifies the requestedsession_idis bound to this authenticated Runner via the platform record’s T9 session registry (“session id → owning Runner”,go-toolchain-default.md:1007-1011) and rejects a foreign session withCodePermissionDenied. With inject-all there is no per-agent secret differentiation to protect beyond that Runner-binding check; the stricter agent-identity plumbing returns with the grants seam.- A
SecretsVersion { session_id, version }server→runner message added to the existingSessionsstream’sSessionsResponsecommand oneof (runner.proto:115-117), sibling to the config-version signal (compass-0.6/design.md:874-875) — signal only, never values. The Runner re-fetches on signal and re-materializes (T6). - Server handler:
func (s *RunnerServiceHandler) FetchSecrets(ctx context.Context, req *connect.Request[runnerv1.FetchSecretsRequest]) (*connect.Response[runnerv1.FetchSecretsResponse], error)delegating tosecrets.Resolver. - No-log posture:
FetchSecretsResponseis marked no-log (adebug_redactfield option or an interceptor skip) so a connect/gRPC logging interceptor can never dump resolved values.
- Consumes: T3; the T8
RunnerServiceseam from the platform record. Produces: the wire surface T5/T6 pull through.
T5 — Runner secret materializer: AuthStorage seed + gh + declared secrets
Section titled “T5 — Runner secret materializer: AuthStorage seed + gh + declared secrets”The Runner-side half: turn fetched secrets into container state, copying the
git posture exactly (stdin sh -s, 0600, scoped $HOME, never argv —
agent.go:264-270, workspace.go:143-150). git stays as built. New
placements: the provider-credential seed (Decision 3), gh, and generic
declared secrets (file under $HOME/.compass/secrets/<NAME> or env, per
DeliveryKind).
- Interfaces:
type ProviderSeed struct { Entries map[string]ProviderSeedEntry }— provider id → credential, serialized to a 0600$HOME/.compass/auth-seed.json;type ProviderSeedEntry struct { Type string; Key string }(api-key MVP; the field set mirrors the SDK’sApiKeyCredential,auth-storage.d.ts:16-20, so an OAuth extension is additive). Both redact viaString/GoString.func ProviderSeedScript(homeDir string, seed ProviderSeed) (string, error)— write-temp + atomicmv, umask 077; the serialized seed JSON is base64-embedded and decoded in-container (the same delimiter-independent transport used for file-secret values), never shell-interpolated.- TS (entrypoint,
packages/compass-agentconsumer): the container entrypoint constructsnew Agent({ getApiKey })wheregetApiKeyresolves the model’s provider from the seed file (mtime-cached re-read, so a rewritten seed is picked up on the next LLM call —agent.d.ts:66-70semantics), and passes it asCompassAgentOptions.agent(agent.ts:27-31). type GHCredentials struct { Host string; Token string }(redacting);func (g GHCredentials) SetupScript(homeDir string) (string, error)—~/.config/gh/hosts.yml+GH_HOSTfor custom forges.type SecretFile struct { Name string; Path string; Value string }(redacting);func SecretSetupScript(homeDir string, files []SecretFile) (string, error)— one script, write-temp +mv, umask 077. Script-safety invariants (load-bearing): names were validated at declaration (T3) and are re-checked here (defense in depth) with a cleaned-prefix check that the resolvedPathlies under$HOME/.compass/secrets/; values are never interpolated into shell source — a file value is base64-encoded on the host (Go) and embedded in the setup script as a single-quoted literal, decoded in-container to its 0600 file (printf %s '<b64>' | base64 -d). The base64 alphabet ([A-Za-z0-9+/=]) contains no single quote and no newline, so inside the single-quoted literal the value cannot break out, terminate the script framing, or become shell source — the transport is delimiter-independent, so a value whose own bytes form a delimiter line is inert. Env values are written asKEY=VALUElines into a 0600 aggregate env file at$HOME/.compass/env(a sibling of thesecrets/dir, so no secret namedenvcan collide with it), rejecting values with newline/NUL (the env-file line grammar).type SecretMaterializer struct { runtime ContainerRuntime };func (m *SecretMaterializer) Install(ctx context.Context, handle *AgentHandle, secrets []secrets.ResolvedSecret) error— routes byKind: provider →ProviderSeedScript; gh (SecretGH) →GHCredentials.SetupScript(usingResolvedSecret.Host); generic file → per-secret 0600 files; generic env → the aggregate env file. Env values do not ride execEnvmaps — that emits-e KEY=VALUEinto podman argv (podman.go:594-596,Create:365-367), host-process-list visible. Env delivery has exactly one path: wrapped execs pass the aggregate file viapodman exec --env-file <0600-path>(a new exec-spec env-file field,podman.godelta; T2), never-e— there is no source-in-wrapper variant (“source the file” is not expressible in T2’s bare argv exec, and two delivery paths would fork the rotation semantics).provision(agent.go:232-242) gainsinstallSecrets(the materializer) afterinstallCredentials.
- Consumes: T2 (order in
provision), T4 (fetch). Produces: a container whose agent can reach its model and forge tooling.
T6 — Rotation: file rewrite + SecretsChanged control op; env regen + captured-env restart
Section titled “T6 — Rotation: file rewrite + SecretsChanged control op; env regen + captured-env restart”Wire the rotation loop end to end: Sessions SecretsVersion signal →
Runner re-fetch (T4) → diff by Version (the T3 content hash; a same-value
re-set hashes identically and is a no-op) → provider: seed rewrite (picked
up per LLM call via getApiKey, no notify strictly needed, one sent anyway
for observability); file-shaped: rewrite via SecretSetupScript + emit the
control-stream re-read op; env-shaped: regenerate the aggregate env file
(write-temp + mv), which rotates the value for every subsequent exec
(T5’s --env-file is read at spawn) — the throwaway-container restart
(stop → relaunch → transcript replay, compass-0.6/design.md:886-888,
reusing the replay barrier, control.ts:10-12) remains only for a
long-lived in-container process that already captured the stale env value;
the agent process itself consumes no declared env secret (provider creds
ride the seed), so the restart is a per-case call, not an automatic step.
- Interfaces:
- TS:
AgentControlunion (control.ts:30-53) gains{ readonly kind: "secretsChanged"; readonly names: readonly string[] };CompassAgent.#applyControlre-reads/announces per name (names only, never values). - Proto (held for review, additive): a seventh
AgentControloneof variantSecretsChanged secrets_changed = 7— an amendment to the frozen six-variant oneof (control.ts:6-12— authoritative; theagent.proto:73-85comment is stale, listing five variants — OQ4). - Go (Runner):
func (m *SecretMaterializer) Rotate(ctx context.Context, handle *AgentHandle, fresh []secrets.ResolvedSecret) (changedFiles, removedFiles []string, needsRestart bool, err error). Deletion cleanup (load-bearing): a secret deleted from the store must not leave its stale 0600 file usable in a running container. The removal set is computed against ground truth, not an in-memory manifest: the installed set is what is physically present in the container’s secret dir (ls $HOME/.compass/secrets/over the exec seam), and the should-exist set is the freshFetchSecretsresult.Rotatedeletes (truncate thenrm) every per-secret file whose name is not in the fresh file-delivery set; the aggregate env file, the provider seed, and the ghhosts.ymlare fully regenerated fromfreshon every rotation (write-temp +mv), so a deleted env secret, provider credential, orSecretGHentry is gone because the file is rebuilt from the current grant set, not edited. gh credentials are the one delivery target outside$HOME/.compass/secrets/(they land at~/.config/gh/hosts.ymlviaGHCredentials.SetupScript, T5), so the dir-scan removal never reaches them:hosts.ymlis reconciled by this full-regeneration path — every rotation rewrites it from the freshSecretGHrows and removes it when none remain, so a revoked or host-changed token cannot survive in the running container. Reading the container’s own dir makes deletion correct across a Runner restart + reattach (an in-memory manifest would rebuild empty and never delete).
- TS:
- Consumes: T4, T5, the built control seam — and, cross-record, the
parked
AgentControlstdin decoder: “AgentControlis NOT in ./gen and its concrete stdin decoder is a parked follow-up (stacked PR once the payload shape is ruled)” (control.ts:17-21;agent.proto:73-85). Until that stacked PR lands, nosecretsChangedframe reaches a running agent — T6’s file rewrite still lands (per-use re-readers pick it up), but the notify half is blocked on the decoder. Produces: live rotation for provider + file-shaped secrets (install, rewrite, and delete); a defined restart path for env-captured consumers.
T7 — Secrets entry + status surface (CLI / Bridge UI / Setup agent)
Section titled “T7 — Secrets entry + status surface (CLI / Bridge UI / Setup agent)”The Server RPC surface behind the Compass CLI and Bridge UI (the store’s only write path) and the Setup agent’s value-free status view (Decision 5):
- Interfaces (held for review where proto):
rpc ListSecrets(ListSecretsRequest) returns (ListSecretsResponse)— the registry’s names + per-secret set/unset status, never values (via SecretSpec’s value-free report path if the Go SDK exposes one — only the Rust-sideSecrets::report()is grounded upstream; fallback: the pinned CLI’ssecretspec check, which reports set/unset without printing values). Callable by user and agent tokens (the Setup agent drives this).rpc SetSecret(SetSecretRequest) returns (SetSecretResponse)— declares (registry row, T3) and writes the value through the T3Resolver.Setprovider write path (CLI-backed, value over stdin, never argv — T3/OQ2); value redacted from logs/audit by construction.rpc DeleteSecret(DeleteSecretRequest) returns (DeleteSecretResponse)— removes the registry row + provider entry (Resolver.Delete) and bumps the secrets version (triggering T6 deletion cleanup in live containers). Both enforced user-only (load-bearing): “Client-only” is not enforceable by a generic authenticated-account check — a user token and an agent token are both account subjects, so the Setup agent’s own token would pass.SetSecret/DeleteSecretMUST explicitly require the caller’s account kind to be User and reject an agent account withCodePermissionDenied(the same fail-closed posture as the admin-gatedIssueToken, whose non-admin caller getspermission_denied—gen/compass/v1/compassv1connect/compass.connect.go:93). A regression test asserts an agent-tokenSetSecretisCodePermissionDenied.- Go:
func (s *SecretsService) ListSecrets(...)/func (s *SecretsService) SetSecret(...)/func (s *SecretsService) DeleteSecret(...)(connect handlers over T3); acompass secrets set/list/deleteCLI verb over the same RPCs.
- Consumes: T3. Produces: the onboarding loop Decision 5 describes and the store’s sole write path.
T8 — Red-first integration tests
Section titled “T8 — Red-first integration tests”Per rule red-green: BDD/integration tests written first, watched failing,
then implementation. Gated behind podman-available skips like the existing
lifecycle tests (internal/runtime/lifecycle_test.go).
- Interfaces: test names, not code —
- launch shape:
Launchagainst the base image performs no clone; the container has an empty agent-ownedWorkDir, an armedcompass_egresstable, and installed git credentials; anExecAsAgentreaches an arbitrary external host (default-open egress). - agent-managed devenv: as the agent uid, clone a fixture repo with a
trivial
devenv.nixintoWorkDir,direnv allowit, and assert a devenv-provided tool resolves through the wrappedExecAsAgent— and that the agent uid can rebuild after editingdevenv.nix(write access to/nix). - provider seed: materialize a seed, boot the entrypoint’s
getApiKeyresolver against it (bun test), assert per-call resolution; rewrite the seed and assert the next call picks up the new key with no restart. - secrets placement: fetch → install; probe 0600 + ownership + values via
exec; assert no value ever appears in
podmanargv (process-list probe) nor in any log line (redaction test onResolvedSecret,ProviderSeed,GHCredentials,SecretFile). - script safety: a declared name with
../, a separator, or a newline is rejected at declaration and again at materialization; a secret value containing arbitrary bytes — a lone delimiter-style line, a newline,$(...),;, or a single quote — is written verbatim to its 0600 file with no command execution (base64 transport) and no secondKEY=line in the aggregate env file; an env value with newline/NUL is rejected. - fetch authz: a Runner requesting a
session_idbound to a different Runner getsCodePermissionDenied. - rotation: rewrite a file secret, assert the
secretsChangedframe reaches a stub ControlSource consumer; env rotation regenerates the aggregate env file and a subsequent wrapped exec sees the new value; a same-value re-set produces no rotation (identical content-hashVersion). - deletion: delete a secret from the store →
Rotateremoves the stale 0600 file (post-rotate exec cannot read it) and returns it inremovedFiles; the same holds after discarding the in-memoryAgentHandle(simulated Runner restart + reattach) — proving the removal set reads the container dir, not a cached manifest; a rotation that deletes a file secret while an env secret remains regenerates the aggregate env file (sibling ofsecrets/, never scanned) with the still-declared env value intact; deleting the lastSecretGHrow regenerates~/.config/gh/hosts.ymlto empty (the stale host/token is gone from the running container) while a still-declared file secret stays readable — proving the gh file is reconciled by full regeneration, not thesecrets/dir-scan. - write-authz: an agent-token
SetSecret/DeleteSecretisCodePermissionDenied; a user-token call succeeds.
- launch shape:
- T1 — Self-contained base image (own Nix + store, agent-uid-owned; devenv/direnv/bun/compass-agent/git/gh/nft in-image; Runner config carries the ref)
- T2 — Launch path: delete
cloneRepo+Workspaceclone surface,WorkDir,AllowAllEgressdefault-open policy,direnv execwrapper - T3 —
internal/secretsResolver over SecretSpec +secretsnames registry migration (no values, no grants) - T4 —
FetchSecretsRPC (inject-all, no filter) +SecretsVersionSessions signal (held-for-reviewRunnerServicedelta) - T5 —
SecretMaterializer: AuthStorage seed +getApiKeyentrypoint wiring, gh + declared-secret placement, stdin-exec posture - T6 — Rotation loop: seed/file rewrite +
secretsChangedcontrol op (TS + held proto variant); deletion cleanup; env regen → restart only for captured env - T7 — Secrets entry/status RPCs (
ListSecrets/SetSecret/DeleteSecret, user-only writes, held for review) + CLI verbs - T8 — Red-first integration tests green; redaction + process-list probes pass
- Impl PR updates the living spec (
docs/specs/product/compass.md) per Spec impact; frozen records untouched
Global Constraints
Section titled “Global Constraints”- Go stack (
go), idioms perdocs/designs/platform/go-idioms-and-libraries.md; edit-over-create per compassAGENTS.md. - The agent is trusted with its credentials. The container is per-agent blast-radius containment (host + inter-agent isolation) — never a mechanism to keep the agent from secrets it is handed. No task may reintroduce cred-avoidance machinery (separate eval identities, install-after-eval ordering, captured-env indirection, eval-phase egress narrowing).
- Reuse the built stdin-
execcredential posture for EVERY secret channel (agent.go:264-270); a secret never appears in argv, an image layer, or a log line. Every secret-bearing Go type redacts under%s,%v, and%#v(theworkspace.go:60-67pattern). - Secret redaction from transcripts + the audit log is mandatory (v0.5
D14,
compass-0.5/design.md:540-542) — enforced by construction (values never enter agent-visible streams; SecretSpec’s audit log never records values) and by test (T8). - Provider (LLM) credentials ride the OMP SDK auth surface only
(
getApiKey,pi-agent-coreagent.d.ts:66-70) — never the generic env/file secret channels, exec env maps, or argv. - Egress is default-open in MVP, mechanism retained:
armEgressstays in the provision sequence and the agent stays capability-less against the ruleset (egress.go:6-10); restriction is future opt-in config, never a user setup requirement. - Supersede by citation; never rewrite the frozen
compass.md§5.3/§5.5 or the v0.6 record in place. The impl PR carries the living-spec update. - Design only for schema: every
.protodelta here (FetchSecrets,SecretsVersion, theSecretsChangedcontrol variant, the secrets-entry RPCs) is a held-for-review interface — the schema edit lands in the impl PR aftercompass.v1-owner + maintainer review, additive, buf-breaking-gated. - SecretSpec runs Server-side only; containers receive resolved values, never provider access or provider credentials. The store’s write path is user-only (T7).
- Red → Green: BDD + unit tests first (T8 written before T2/T5/T6 land).
Open Questions
Section titled “Open Questions”Each carries a recommendation; load-bearing = a merge-blocker an executor hits if unresolved. Forks the directives settle are folded into the record above, not re-opened here.
- In-container Nix mode (load-bearing for T1): single-user Nix with
/nixowned by the agent uid vs a multi-user in-containernix-daemon. Recommend: single-user, agent-owned — one uid does all Nix work (matching agent-rebuilds-own-env), no daemon to supervise inside an exec-driven container (Decision 4 has no PID-1 to own it), and rootless podman maps one uid in — via--userns=keep-id:uid=<agent-uid>per T1’s uid-mapping invariant (barekeep-id,podman.go:355-357, maps the host Runner uid in, so agent ownership of/nixonly holds when the baked uid is the mapped one). Fallback if devenv proves daemon-dependent: a daemon socket-activated by the root arm step. - SecretSpec integration surface on the Server (load-bearing — T3’s
dependency choice): shell out to the
secretspecCLI vs the Go SDK. Recommend: the Go SDK (github.com/cachix/secretspec/secretspec-go, 0.13+ — thesecretspec-ffiC ABI via purego, no cgo): same resolver, providers, and fallback chains as the CLI; in-process values (no secret transits a subprocess stdout); typedMissingRequiredError; no PATH/version-skew dependency. Two caveats shape the call: (a) the write path is CLI-only regardless — the SDK is read-shaped (builder →Load()); upstream writes aresecretspec set— so the pinned CLI is in the Server’s closure either way (T3Resolver.Set/Delete), and “SDK” here decides only the read path; (b) thesecretspec-fficdylib is not on the Go module proxy — the nix-built Server must stagelibsecretspec_ffiin its closure or setSECRETSPEC_FFI_LIB, so FFI packaging fighting nix is the likely case to engineer for, not an edge. Fallback: the CLI for reads too — pinned in the Server’s closure, values over a pipe, never argv. - Provider-seed handoff shape (load-bearing for T5’s TS half): a JSON
seed file the entrypoint’s
getApiKeyresolver reads (recommended above) vs the Runner writing the SDK’s SQLite store (SqliteAuthCredentialStore.open,auth-storage.d.ts:1064-1067) directly. Recommend: the seed file + in-process resolver — writing sqlite from a Go-driven shell script is fragile, the seed is atomic-rewritable over the built stdin-execchannel, andgetApiKey’s per-call semantics (agent.d.ts:66-70) give live rotation either way. The sqlite store remains the natural upgrade when OAuth credentials (refresh state) arrive. SecretsChangedamends a frozen oneof (load-bearing for T6): theAgentControlcontract is frozen at six variants (control.ts:6-12— the authoritative statement; theagent.proto:73-85comment lists only five variants, missingask_answer, and is stale — flag it for correction in the impl PR); this record needs a seventh. The proto message is deliberately not yet generated, so the addition is cheap now and expensive after the decoder lands. Recommend: ratify the seventh variant with this record’s review, so the decoder PR implements the seven-variant oneof once. Regardless of timing, require the TS decoder’s unknown-variant path to surface (count/log) an unrecognized control op rather than silently drop it — the codebase already has this discipline (the stagedaskAnswerarm is “never silently dropped”,control.ts:37-41).- Session-cwd contract under agent-managed clones (not load-bearing —
T2 picks a default): with no Runner-side clone, the OMP-SDK session’s cwd
is the workspace root
WorkDir, not a repo checkout. Recommend: fixedWorkDir(e.g./work) as the session cwd, with repo layout under it the agent’s own convention; anything downstream that assumed cwd-is-a-git-repo (none found ingothis run beyond theagent.go:63-65doc comment) must treat the cwd as a plain directory. - Keyring reachability + the headless-deploy provider — RESOLVED by
Matt in this design-PR review. The concern (load-bearing for deployment
defaults, not for code): the keyring provider needs an OS credential
service reachable and unlocked from the Server process (Linux: Secret
Service — gnome-keyring/KWallet, per the provider docs). A desktop can
fail too (a pre-login systemd unit with no session bus, a locked keyring)
and a headless box can succeed with a session keyring. Ruling: keyring
stays the default for the single-user desktop-local Server (the dogfood
shape), accepting its interactive keychain unlock; container/headless
deploys wire SecretSpec to a machine-authenticated provider — 1Password
(service account), Vault/OpenBao (machine token), or a cloud secret
manager (AWS Secrets Manager, GCP Secret Manager, Azure Key Vault) — a
config-time provider choice, no code fork.
pass/gopassare not the headless answer: both are GPG-agent-based, so they carry the same interactive-unlock ceremony as keyring (its peers on the unlock problem, per the SecretSpec provider matrix), not a solution to the container case. State the keyring precondition and the first-resolve error surface, so an install fails loudly at first resolve rather than mysteriously. - Inject-all has no user scope on a multi-user Server — RESOLVED by
Matt in this design-PR review: option (a). The concern: the T3 secrets
table has no user dimension (name UNIQUE, delivery, kind, provider, host,
declared_by — one flat store), and
FetchSecretsreturns the whole store on asession_idwith only the Runner-binding check (T4), while the account schema is multi-user (go/internal/store/migrations/0001_init.sql:27-38—user_accounts, andagent_accounts.owner_user_id NOT NULL). So on a two-user Server, user A’s secrets would reach user B’s agents — beyond the trusted-agent premise, which trusts an agent with its own user’s credentials, not every user’s. Ruling: (a) — a single-user Server is an explicit MVP constraint. Dogfood and early user testing are all single-user / multi-agent workflows, so every agent receiving the whole store is accepted for the MVP. (b) is the named post-MVP path: anowner_user_idcolumn on the secrets table, resolve session → agent →owner_user_id(agent_accounts.owner_user_id), and filterFetchSecretsto that owner’s rows — addable without reshaping any other interface once multi-user support lands.