Skip to content

feat: secrets S1 — what codeoid stores never holds a secret's value - #467

Open
saucam wants to merge 4 commits into
mainfrom
feat/secrets-s1
Open

saucam wants to merge 4 commits into
mainfrom
feat/secrets-s1

Conversation

@saucam

@saucam saucam commented Nov 13, 2026

Copy link
Copy Markdown
Collaborator

Codeoid stores everything a session does: its transcript, the history other backends are seeded from, the scrollback clients replay, memory, share bundles. Until now a token printed by gh auth token, a cat .env, or a key pasted into a prompt ended up in all of them in plain text. After this change, none of those hold a secret's value.

Design for this and the next three phases: docs/secrets-and-memory-design.md. The user guide is in docs/CONFIGURATION.md, under "Secrets in what codeoid stores".

What changes for a user

  • A value codeoid knows is replaced by its name. That covers a declared credential, a backend account's key, an MCP sign-in, and the daemon's own keys. They appear as ‹credential:gh-highflame›, and the value stays where it already lives.
  • Anything else that looks like a credential is replaced by a fingerprint. That covers a vendor token, a JWT, a private key, a URL password, and a value under a name like DB_PASSWORD. It appears as ‹secret:github-token:3f9a2c1b›, and the same secret gets the same reference everywhere. A distinct token format is also remembered for the rest of the daemon's run, per tenant, so a bare repeat is caught.
  • Some calls are stored without their secret part.
    • A command that exists to print a credential (gh auth token, printenv, op read, cat .env, kubectl get secret, …) is stored without its output.
    • A write to a credential file is stored without its content, on every backend.
  • Detected values are dropped by default. A workstream can keep them instead with captureSecrets: "vault": encrypted, kept to that workstream and tenant, and revealed only to the owner's own token, with an audit entry.
  • New commands: codeoid secret ls | reveal | forget | promote | scrub. scrub rewrites data stored before this change, and refuses to run while the daemon is up.
  • MCP results are scrubbed before the model sees them, through the hub and the relay (secrets.beforeModel, on by default).

Limits (documented)

  • The backend has already seen a value by the time codeoid stores the result, and a CLI backend keeps its own copy (for example Claude Code's ~/.claude/projects).
  • Claude's Bash output isn't scrubbed before the model sees it.
  • Stateless backends (OpenAI-compatible, Gemini, llama.cpp) see earlier secrets as references from the next turn on. The current prompt goes as written.
  • A password found only by its name isn't remembered, so a bare repeat isn't caught.
  • A secret with no known shape that codeoid doesn't know still gets through.

Verification

  • Reviews: two audit rounds (four auditor passes in total), plus a verification pass on round two's fixes. Everything they found is fixed, with a regression test for each.
  • Checks: typecheck and lint are clean.
  • Tests:
    • Secrets tests: 133 pass under both Bun 1.4.4 and 1.3.13 (the version CI pins).
    • Full unit suite: 6279 pass and 1 fails, the bubblewrap test that also fails on main on this machine.
    • The new e2e journey (e2e/specs/secrets.spec.ts) passes on a real daemon. It covers the web UI, transcripts on disk, a reload, and codeoid secret reveal and forget.
  • Performance: detection is linear on hostile input. 2 MB of ordinary source code takes about 140 ms on Bun 1.3.13 against a 250 ms budget. Text that can't be checked in time isn't stored.
  • No UI changes, so there are no screenshots.
For agents: where things are
  • src/daemon/secrets/:
    • detect.ts: patterns after atuin and gitleaks (MIT). Boundaries are checked in code; private keys and JWTs are found in linear passes; the env value is a sticky second match.
    • known.ts: Aho-Corasick KnownSecrets, and LearnedSecrets indexed by prefix. Both match whole tokens and never inside a reference.
    • scrub.ts: Scrubber and StreamScrubber.
    • verdict.ts and files.ts: what of a tool call is stored.
    • vault.ts: AES-256-GCM, bound to tenant, workstream and fingerprint.
    • service.ts: SecretService and addDaemonKnownSources.
    • scrub-stored.ts: the offline scrub.
  • session.ts:
    • #scrubMessage in #persistAndBuffer and #commitStreamed.
    • #recordedToolInput by verdict, at tool_start, approvals and hook-rewritten inputs.
    • tool_complete is scrubbed before hooks run.
    • #historyForTurn sends the raw current prompt.
    • The canonical history uses scrubTurn (partial) and scrubSeed (whole).
  • providers/canonical.ts, memory/engine.ts (scrubText), share/pack.ts, mcp/hub.ts (setResultScrub, scrubRelayed), mcp/proxy.ts.
  • store.ts: captured_secrets, knownSecretValues, scrubText for settled dispatch tasks, events and audit.
  • routines/store.ts, server.ts and cli.ts. Only codeoid start creates the service (secretsConfigDir).
  • Protocol: secret.list, secret.reveal, secret.forget. Config: secrets.* and workstreams.items[].captureSecrets.

🤖 Generated with Claude Code

saucam and others added 4 commits November 13, 2026 08:31
…known values by name, detected ones by fingerprint, kept only where a workstream says so

- secrets/: a detector after atuin's and gitleaks' patterns, exact matching of
  every value codeoid knows (credentials, accounts, endpoints, OAuth tokens,
  the daemon's keys), HMAC fingerprints, per-tool-call verdicts (a command
  that prints credentials keeps no output, a credential file's write no
  content), a streaming scrubber for replies, values found by shape matched
  exactly from then on (memory only)
- applied to every message a session stores or sends, the canonical history,
  memory, share bundles, and MCP results through the hub
- captureSecrets: "vault" keeps detected values encrypted per workstream;
  secret.list / reveal / forget (owner-only, audited); codeoid secret ls |
  reveal | forget | promote | scrub
- docs/secrets-and-memory-design.md (S1–S4), CONFIGURATION.md, an e2e journey

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ackends as written, code isn't taken for a secret, and every write path is scrubbed

- a stateless backend's current prompt is the user's own (Session#historyForTurn)
- detection: identifiers, dotted paths and calls are code; env-style values stop
  at the next word; a private key only with armour's body, found linearly, as
  is curl -u; .netrc; codeoid's own token formats; the budget checked inside
  each pattern
- exact matching in one Aho-Corasick pass, never inside a reference (scrubbing
  is idempotent); only distinct formats are remembered, per tenant, as whole
  tokens, apart from the known set
- streaming holds from where a credential may start, judged with the line so
  far, never inside a spaced known value, and flushes before a tool call
- scrubbed now: a hook-rewritten input, the turn index, a fork's seeded
  history, pending approvals by verdict, a session's last error, settled
  dispatch tasks, routine run outputs, MCP results through the relay; pinned
  credential files and withheld outputs' spill files are dropped
- verdicts: source / . / cp and *.pem no longer withhold; $HOME paths, git
  show/diff, dd if= and the unparsed fallback do
- codeoid secret scrub: no budget, nothing remembered, unchecked text kept and
  counted, rotated segments, databases rebuilt so old text is gone
- key files written whole and linked into place, permissions checked; GCM tag
  length pinned; the canonical scrub fails closed

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…es found, every backend's credential-file writes withheld

- detection: boundaries checked in code instead of leading lookbehinds (a
  0.6 MB file: 726 ms to 30 ms on Bun 1.3.13), keyword-first name patterns,
  a linear YAML value
- found now: unquoted credential headers, YAML values before a comment and
  with '=', URL query tokens, mysql -p / sshpass -p / docker login -p, and
  snake_case or camelCase values of env-style assignments (spaced code
  assignments stay code); values stop at '&' and a literal \n
- remembered values indexed by prefix (no rebuild when one is learned);
  reference overlap by binary search; deadlines inside both exact passes
- known values only as whole tokens; environment values only when they look
  like a secret
- verdicts: export -p / declare -x, env with options only, echo $SECRET,
  .env globs and braces, commands run through ssh / docker exec / kubectl
  exec; Codex, pi and apply_patch writes of credential files; drop covers argv
- structured MCP output scrubbed string by string; dispatch events, audit
  details and reclaimed tasks scrubbed; offline scrub leaves live tasks alone
- the stream judges a piece with the last 512 characters of its line

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…on every engine, fewer false positives

- a value stops at & only before another name=, at \\ only at an escape
- the env pattern finds its name and separator, then its value with a
  sticky regex: the combined one ran uncompiled on Bun 1.3 (216 ms to 1 ms
  over 2 MB); JWTs found in one linear pass (repeated eyJ: 3.2 s to 47 ms)
- known and remembered values match after a JSON escape or a %XX
- unquoted header and query values only when they look like a key; mysql's
  -p only as mysql's own; sshpass -p and login -p right after the program
- docker exec / compose exec / kubectl exec commands judged with their
  quoting; ssh host -- CMD

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@saucam
saucam requested a review from rsharath November 13, 2026 01:40
@saucam

saucam commented Nov 13, 2026

Copy link
Copy Markdown
Collaborator Author

pr-shepherd: CI is green on a1a810e (daemon-check, e2e, web-check, Socket x2). I made no changes: no failures, the branch is level with main (0 behind), and every commit subject passes the prefix check. Oracle hasn't reviewed yet. Asked for review in #javelin-pr.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant