Skip to content

Live vault access from a job container, for the logins nothing cheaper can serve #226

Description

@edgehero

What

A job container reaches one 1Password vault, live, for the duration of its run, so an agent can fetch a fresh one-time code at the moment it needs one and look up an item the trigger did not name in advance.

This is the half of vault access that reverses a written rule, and the issue leads with that rather than working around it. docs/secrets.md:49-58 says, normatively:

Your manager's credential is a host credential, and it never goes into a job container. … Do not put INFISICAL_TOKEN, VAULT_TOKEN or their kin in there. … adding a credential that can read every secret in your project is strictly worse than the trade this project already discloses, and it converts one bounded exposure into an unbounded one.

The manager's job stops at the worker process. Nothing below crosses the container boundary.

A 1Password service-account or Connect token is the kin that sentence names. The rule's statement is categorical; its reason is about breadth ("every secret in your project"), and a token scoped to one vault is narrower than the object the reason argues about. That gap is the only opening this proposal has, and it has to be argued in the open. Shipping on the reason while the statement says otherwise is precisely the constraint-that-ships-unenforced pattern this repo has refused three times (OQ-004:117-118, OQ-011, OQ-012).

Why nothing cheaper serves it

Filed alongside #225, the cheap half, where the worker resolves references before the container starts. That half serves named secrets and username/password pairs and needs no doctrine change. It provably cannot serve a login with a second factor, and the reason is structural rather than a gap to close:

  • The container env is built once, synchronously, before spawn (worker/src/run-container.mjs:52worker/src/docker-run.mjs:104-107). There is no host→container channel afterwards: /outbox is read only after the container exits (worker/src/processor.mjs:377-383), and nothing polls /workspace or /session mid-run. A code injected at t=0 is dead long before the agent reaches a form, and jobs run up to thirty minutes.
  • The image ships no persistent browser profile, deliberately (image/Dockerfile:134-137, per DES-PLAYWRIGHT-CLI-NOT-CHROME-DEVTOOLS at specs/design.md:1995-2013). No cookie survives a --rm container, so every job logs in from scratch and therefore needs a live second factor every time.

So this is not the same feature with a wider grant. It is a different capability, and it should be accepted or declined on its own terms.

The shape to build, if it is built

A broker on the job network, not a direct-to-vendor token. Stated vendor-neutrally because #206 and #209 both refused blessing a manager, and stated honestly because this half cannot be as vendor-neutral as #225 is: the container needs an actual client binary, and a binary is a vendor. That asymmetry is a cost of this feature, not a detail, and it is one more reason the two halves are filed apart.

With 1Password as the worked example, the broker is Connect rather than a service account, and the difference is not cosmetic:

Direct token (service account) Broker on the job network (Connect)
Container egress to 1Password needs a *.1password.com entry on the deployment-global allowlist, reachable by every job on the host none — the container reaches only the Connect server
Agent enumeration full op item list over the vault refused: op item list/op vault list do not work in Connect mode, fetch-by-reference only
Rate limits daily cap is account-wide, so one runaway agent starves every other service account in the organisation none after initial sync
Expiry --expires-in is optional, so tokens are non-expiring by default op connect token create --expires-in
Deployment secret n/a 1password-credentials.json stays on the host and never enters a container

Connect also fits the topology that already exists. createJobNetwork (worker/src/egress.mjs:173-182) creates a per-job --internal network and attaches the proxy by name, with rollback if the attach fails. A Connect server joins with one more network connect.

What it costs, stated up front

Five ratifications and a spec amendment, which is itself an argument for filing this separately:

  1. docs/secrets.md:49-58 reversed, and SECURITY.md:401-404, which repeats it operator-facing.
  2. CONST-TOKEN-SCOPED-PER-JOB gains a third credential class. Its acceptance clause (specs/constitution.md:695-696) is "no container environment holds a credential that is broad-scope or long-lived." A vault-scoped token is not broad-scope, so this reduces to long-lived — and since expiry is optional at creation, the entry must carry an operator obligation in the style it already uses for GitLab and Forgejo, not a mention.
  3. CONST-ISOLATION-CONTAINER-PER-JOB:199-204 amended. It currently asserts the job network's "only other member is the allowlist proxy." A Connect server is a third member. :81-83 explicitly pre-refuses borrowing prior reasoning for this kind of change: "This one adds a mount and cannot borrow that argument."
  4. OQ-012 stops being opt-in. There is no secrets-manager client of any kind in the job image — I checked the whole Dockerfile: git, jq, ca-certificates, wget, fonts, gh, glab, tea, node, pi, Chromium. So every vault job needs run.image pointing at an operator-built image, which inverts that row's own bound from "the risk exists only for deployments that opted in" to mandatory for anyone using the feature. The alternative — adding op to the shipped image — puts a vault client in every container on every deployment and is a separate argument.
  5. A WORKER_ONLY_SECRET_VARS entry (worker/src/config.mjs:100-109). If the token has a fixed variable name, an operator can put the worker's own token in PI_FORWARD_ENV and hand it to every job on the deployment, unscoped, with nothing failing and nothing logged. PR Accept the App private key as a value, not only a path #213 set the standard: a change that makes a new credential env-reachable ships its container refusal in the same commit.

What this does not buy, and must not claim

  • Within the vault the agent has everything. Scoping bounds which vault, not what the agent does inside it.
  • op run masking is not a confidentiality boundary. It is exact-substring replacement: echo "$SECRET" is concealed, but ${SECRET:0:10}, base64, rev and fold -w1 all defeat it trivially. Against a process running attacker-influenced instructions it provides nothing. It is hygiene against accidental logging.
  • Exfiltration paths are the ones already disclosed. The forge is on the allowlist by necessity and the agent holds a write-capable token; the value enters the model provider's context on the first turn after the read, automatically and with no adversary. What changes versus the cheap half is the kind of loss: an exfiltrated token is a capability that composes with future vault contents and with the attacker's own infrastructure, where an exfiltrated value is data.
  • The deployment has no record of what was read. The egress proxy never terminates TLS and has logging deliberately off (deploy/egress-proxy.conf:12-18), and 1Password's own audit names the service account rather than the job, so "which job read this, and was that read legitimate" is unanswerable from either side. Item-level usage reporting also effectively requires 1Password Business.
  • The host-argv and journald exposures described in the sibling issue apply here too, and to the token rather than to one value.

Constraints to preserve

  • Refused beside run.resume. worker/src/get-token.mjs:207-222 already refuses the broad gh source for resumed jobs, because the credential is durable-and-replayed and unbounded by expiry. Nothing redacts a transcript.
  • Refused beside run.replicas. Three concurrent browser logins to one account from one address is a lockout flow at most providers, and logins are the entire reason this feature exists.
  • Not model-authorable, and not inherited by a chained child, on the same grounds as the sibling issue.
  • A vault name is the unit of grant here, and that is the weakness. The reviewed triggers.json says "this trigger may reach vault X"; what is in vault X changes after review with no signal. Say so rather than implying the scoping is static.
  • The dev.pi-dispatch.capabilities inclusion list is worth adding as a pre-spend ergonomics gate (a stale image would otherwise fail mid-run in a paid container on every delivery). It is not a security control, and must not be described as one: OQ-012 already records that a label proves intent, not conformance, and here the party who sets the label is the party who benefits from it lying.

Explicitly NOT proposed

  • Adding any manager's client to the shipped job image. That would put a vault client in every container on every deployment, and it is the change that would make this half vendor-neutral, which is precisely why it is a separate and larger argument.
  • Blessing a vendor in code. The broker is an address and a token variable; docs/secrets.md carries the 1Password Connect worked example the way it already carries the others. What cannot be kept neutral is the client binary in the image, and that is stated above rather than hidden.
  • A per-trigger egress relaxation (specs/design.md:2196-2198 refused run.network; Connect removes the need).
  • Any model-callable path, settings-overlay key, or flow-declared or repo-declared binding. A serviced repo naming which secrets its jobs get is DES-AI-TRIGGER-FLOW-GATE's refusal one layer over.
  • Relying on HARD_RULES.md rule 4 as a control. It is prompt text, and an operator-built image may not carry it at all.

Acceptance

Before this is worth building, three things need answering rather than assuming: whether --expires-in can be made mandatory by convention that doctor can actually check; whether a Connect server on the per-job network is acceptable against CONST-ISOLATION-CONTAINER-PER-JOB's two-endpoint clause, or whether that clause should hold and this feature should not ship; and whether OQ-012 becoming a precondition is acceptable or is itself disqualifying. If those three land, the acceptance is: a vault-armed trigger refuses pre-spend on a non-declaring image; the token variable is refused in PI_FORWARD_ENV; run.resume and run.replicas refuse beside it at load; a resurrected sandbox carries exactly TERM and TMOUT; refusal messages name the field and never the reference, the vault or the item; and op run -- sh -c 'exit 2' is pinned in CI as returning 2, because that behaviour is undocumented and load-bearing for the exit-code policy.

One verified note for whoever builds it: op run does preserve child exit codes exactly (0/1/2/3/7/42/77/127/128/200/254/255 all pass through on CLI 2.39.0 and 2.31.1), so it does not share the Infisical defect that collapses every nonzero exit to 1. Two caveats: op's own failures also exit 1, so 1 is ambiguous while 2 is clean; and a signal-killed child reports 255, not 128+N.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions