From 348203918031635c06938b7afcbabaaaecf5b09d Mon Sep 17 00:00:00 2001 From: Sergey Eroshenkov Date: Sat, 10 Oct 2026 10:29:39 +0200 Subject: [PATCH 01/84] chore(brainstorm): write-side-trust --- .../write-side-trust/cli-output/claude.md | 47 ++++ .../write-side-trust/cli-output/prompt.md | 44 ++++ docs/brainstorm/write-side-trust/design.md | 78 ++++++ docs/brainstorm/write-side-trust/plan.md | 244 ++++++++++++++++++ docs/brainstorm/write-side-trust/variants.md | 49 ++++ 5 files changed, 462 insertions(+) create mode 100644 docs/brainstorm/write-side-trust/cli-output/claude.md create mode 100644 docs/brainstorm/write-side-trust/cli-output/prompt.md create mode 100644 docs/brainstorm/write-side-trust/design.md create mode 100644 docs/brainstorm/write-side-trust/plan.md create mode 100644 docs/brainstorm/write-side-trust/variants.md diff --git a/docs/brainstorm/write-side-trust/cli-output/claude.md b/docs/brainstorm/write-side-trust/cli-output/claude.md new file mode 100644 index 00000000..ed990370 --- /dev/null +++ b/docs/brainstorm/write-side-trust/cli-output/claude.md @@ -0,0 +1,47 @@ +# Consultant output - write-side trust + +Produced in-session by the design orchestrator (host mode: the consultant run and the orchestrator are the same session; no external process was spawned). The prompt used is at `cli-output/prompt.md`; the analysis below is the verbatim variant output, also mirrored in `variants.md`. + +### Variant 1: Substrate-first spine - one permissions document, one chokepoint, gates as consumers + +- **Approach**: Land identity and authorization as pure leaf modules first: a token store that maps hash-at-rest credentials to agent names, and a permissions document (`Brain/_permissions.yaml`) with a pure resolver returning allow/ask/deny per (subject, action, target). Every action gate - the staged-review lanes, the owner-write gate, the force-confirmed rule - becomes a consumer of one disposition function that consults the document, or the legacy per-feature keys when no document exists. Ask stages into a generalized multi-lane pending queue (the A3 precedent: staging is a change of directory), deny refuses with a named token, and every non-allow verdict appends one row to a decision ledger that records the rule that decided. Ambient capture ships last, strictly behind the gates. Landing order: document + resolver + ledger + token store (pure, disjoint) -> transport auth, recall exclusion, signal chokepoint, owner-write preference lane, multi-lane staging, open decisions, ambient consent (parallel, disjoint files) -> document-backed dispositions, note-lane owner guard, bootstrap (integration) -> reconciliation. +- **Trade-offs**: + - Pro: every card inherits the same substrate, so "who was allowed, asked, or denied what, by which rule" has one answer and one record. The t_29798f41 requirement (a queryable ledger replacing scattered point checks) is satisfied by construction rather than by a later reporting pass over heterogeneous gates. + - Pro: default-identity is trivially auditable: with no document, no tokens, and no keys, every consumer short-circuits to today's behavior, so the existing suites are the byte-identity proof. + - Pro: the chokepoints already exist and are proven - `writeSignal` for signals (the `targetDir` staging seam), `createNote`/`applyWriteBatch` for notes, `resolvedOwnerFor` for preferences, `admitToIndex` for recall. The wave adds predicates beside them, it does not reroute traffic. + - Pro: the ask verdict reuses the pending queue's apply/reject semantics, so the human approval door is the existing CLI door with a widened id grammar - one review UX, not one per lane. + - Con: the document resolver must be a true leaf module or the import-cycle ratchet (`tests/core/architecture/import-cycles.test.ts`) blocks the gates from consulting it; this constrains what the substrate may reuse. + - Con: five lanes touching one spine need the contract pinned in the plan (signatures, config keys, shared-append files), or the merge is where the design actually happens. + - Con: two staging sources (document vs `write_approval.*` keys) need an explicit precedence rule or operators get different answers on different days; the design pays this with "document present = document only". +- **Complexity**: medium-high +- **Risk**: low-medium (byte-identity is per-consumer and independently testable; the substrate is pure and small) + +### Variant 2: Gate-local first, document later + +- **Approach**: Ship each card on its own config keys exactly as the existing gates work: staged review extends via `write_approval.*` lane keys, the owner-write gate via a new integrity key, tokens via the transport, bootstrap as orchestration. Defer the permissions document to a later wave that retrofits a policy layer over the now-existing gates, mapping each key into a document entry. +- **Trade-offs**: + - Pro: each task is independently shippable with the smallest possible blast radius; no substrate commit needs to land first, so lanes never wait. + - Pro: no new operator-facing document to design, validate, and fail-close this wave; the `write_approval` and `integrity` key patterns are established and understood. + - Con: the approval door gets built twice - the pending queue generalization and the ledger need a verdict vocabulary now, and a later document has to either subsume the keys (a second migration) or live beside them (two sources of truth for the same question, the exact "scattered point checks" shape the card exists to remove). + - Con: the ledger records gate verdicts that have no rule identity beyond a config key; retrofitting entry/role/default provenance onto rows written by key-driven gates means a schema migration on an append-only store. + - Con: `force_confirmed` and the role-matrix gap stay unanswerable: without a document there is no principal model to hang the "requires allow" rule on, so the one bypass the recon proved ships unchanged with no decision recorded. + - Con: identity does not compose - a token identity with no document gives per-caller `brain_context` attribution and per-caller owner-scope refusal, but no per-caller write policy, so the t_85059d6d and t_29798f41 cards land as strangers. +- **Complexity**: medium (per task) but higher cumulative (double build of the door) +- **Risk**: medium (the deferred document wave re-opens every file this wave touches) + +### Variant 3: Transport middleware - decide at the dispatch boundary + +- **Approach**: Put identity, policy, and staging in one middleware layer at the MCP/CLI boundary: `authenticateRequest` resolves identity, a policy check runs before every tool handler from a table keyed by tool name, and mutating calls are redirected into review by the wrapper rather than by the write primitives. Core modules stay untouched; the permissions document is read only by the wrapper. +- **Trade-offs**: + - Pro: smallest core diff - one wrapper, one policy table, no changes to write primitives; trivially reversible. + - Pro: the dispatch seam already records refusals (the `mapFrozen` pattern at `src/mcp/server.ts:336`), so denial logging has an existing home. + - Con: the boundary is not the write seam, which this project has already learned the hard way: internal writers (dream apply, hygiene, write-session commit, session import, inline scan, capture lifecycle) never cross the dispatch wrapper, so staged review would miss exactly the bulk lanes the card names. The t_107cac80 recon shows the same lesson on the read side: the gate lives at `coerceAgentScope` because that is the one reader, not because the boundary is privileged. + - Con: CLI verbs bypass the wrapper entirely, so the operator's own paths and any script calling core directly would need a parallel enforcement story. + - Con: staging needs the resolved target path, which exists only inside the write primitives (`resolveNoteTarget`, `resolveEffectiveScope`); a wrapper can only see raw arguments, so it would re-implement path resolution or stage the unresolved name - both drift from what publish would actually write. + - Con: per-lane review granularity (stage creates, allow updates of published notes) is a property of the operation, not the tool; a tool-name table cannot express it. +- **Complexity**: small +- **Risk**: high (repeats the `o2b brain protect` failure the wave exists to fix: enforcement at a layer the writers bypass) + +## Recommendation + +Variant 1, the substrate-first spine. The wave's seven cards share one question - "may this principal do this write, and who says so" - and only Variant 1 answers it in one place. The chokepoints the gates need already exist and are census-pinned, so the spine is predicates beside proven seams rather than new plumbing; the pending queue generalization gives the ask verdict a human door that already has apply/reject semantics, tests, and a CLI. Variant 2 is honest about sequencing but builds the approval door and ledger twice and leaves `force_confirmed` unanswerable; Variant 3 is the smallest diff and the wrong layer, missing the internal and CLI writers that make up most of the write surface. diff --git a/docs/brainstorm/write-side-trust/cli-output/prompt.md b/docs/brainstorm/write-side-trust/cli-output/prompt.md new file mode 100644 index 00000000..1c9e9165 --- /dev/null +++ b/docs/brainstorm/write-side-trust/cli-output/prompt.md @@ -0,0 +1,44 @@ +You are brainstorming architectural variants for the following task. Do not write code. Do not write a final design. Only produce variants and a recommendation. You are running inside the project repository and MAY read files to ground your variants; cite file paths you relied on. + +# Task + +This is an EPIC of seven related kanban tasks that ship as ONE feature branch, ONE pull request and ONE release of Open Second Brain. Produce variants for the architecture of the whole suite (how the seven pieces share primitives, in what order they land, where the seams are), not seven separate mini-designs. The wave's working title: write-side trust - identity, permission, review. + +Constraints every variant must honor: + +- Default-off or default-preserving: with zero configuration, every existing single-key install, every existing write path and every current test stays green byte-for-byte. No silent fallbacks; refusals surface by name with a next command. +- Reuse what exists: the staged-review pending queue (`src/core/brain/pending.ts`, toggle `write_approval.enabled`), the credential custody subsystem from v1.78.0 (`src/core/brain/secrets/`), the trigger store lifecycle precedent (`src/core/brain/triggers/store.ts`), the owner-scope refusal gate (`src/mcp/owner-scope-refusal.ts`), the integrity gate-mode pattern (`off|warn|fail`). +- The permissions document and its choke point are the substrate every other card consumes. +- The plan must be executable by 4-5 parallel implementation lanes with disjoint file ownership. + +## Card t_6ff73d61 (priority 4): staged review for writer tools and bulk ingest + +The write-approval gate stages extracted signals into `Brain/pending/` instead of `Brain/inbox/` when `write_approval.enabled` is on (`src/core/brain/pending.ts:37-52`); it is consumed by exactly two extraction callers (`src/core/brain/extract-signals.ts:602`, `src/core/brain/fact-extract.ts:350-362`). Every MCP writer tool and every bulk-ingest lane publishes straight to its final lane: `brain_feedback` writes its signal with no targetDir (`src/mcp/brain/feedback-tools.ts:199`), all note tools route through `createNote`/`applyWriteBatch` with no staging concept, and session import, inline scan and capture/checkpoint write to the inbox ungated. Correction verified in source: staged documents are NOT out of recall today - the search walker admits every in-scope `.md` and `admitToIndex` excludes only `Brain/state` and `Brain/.payloads` (`src/core/vault-scope/index-admission.ts:39-52`), so `brain_search` and recall-inject can surface an unapproved staged note. Extending the gate without closing that hole would stage notes recall still sees. + +## Card t_29798f41 (priority 3): unified permissions document, approval door, decision ledger + +Trust checks are scattered point checks across at least six layers with no shared policy or queryable record. Corrections verified: the role matrix is nearly dead (one enforced call site, `src/core/brain/apply-evidence.ts:197`; `force_confirmed` at `src/mcp/brain/feedback-tools.ts:268-306` writes a confirmed preference past both the matrix and the dream trial window), and five partial trails exist (Brain log, pref-audit, idempotency ledger, trigger ledger, ephemeral retrieval receipts) but no queryable allow/ask/deny ledger. Decide: document format and home (vault file vs `Brain/_brain.yaml` block vs flat device config), the principal vocabulary (identity is currently process-global, `src/mcp/server.ts:249-251`), precedence when document and config disagree, and how ask surfaces to a human. + +## Card t_8913c934 (priority 3): durable pending-decision record with enumerated options + +`recordDecision` requires `chosen` (`src/core/brain/decisions/record.ts:400-401`); no open-decision artifact exists anywhere. The trigger store supplies the lifecycle pattern to copy: one Markdown record per item, open/terminal status sets, read-time TTL, terminal records stay in place, named-unreadable partitioned reads, directory locks (`src/core/brain/triggers/store.ts`). Resolution should mint a real decision page via `recordDecision` and land in the existing decision-change receipt trail. + +## Card t_85059d6d (priority 3): named per-agent MCP auth tokens + +`authorized()` compares ONE shared key (`src/mcp/http.ts:367-371`); caller identity is the process config name (`resolveAgentName`, `src/mcp/server.ts:249-251`) plus a caller-supplied `agent_scope` argument read only by `coerceAgentScope` (`src/mcp/coerce.ts:128-146`). Even under `integrity.owner_scope_delivery: fail` the identity is process-global - one identity per endpoint (`src/mcp/http.ts:355-361` says so). The v1.78.0 custody store (`src/core/brain/secrets/store.ts`) can host credentials but a locked passphrase envelope must not become an authentication outage. Decide: storage shape, token format, per-request identity threading into `MCPServer` (one instance serves concurrent requests), revocation and rotation semantics, shared-key coexistence. + +## Card t_89e1f601 (priority 3): idempotent machine bootstrap + +No machine-facing bootstrap exists; install is human-driven per harness (`src/core/install/`, adapters for ten targets). Corrections verified: the token primitive does not exist (t_85059d6d must land first), and Claude Code and ZCode are deliberately not adapter targets (`src/core/runtime/host-facts.ts:170-173`), so bootstrap spans three install models: adapter-driven, plugin-driven (verify only), print-and-paste (generic). Idempotency contract: same input, byte-identical output (`src/core/install/payload.ts:6-8`); receipt-driven teardown via `install.lock.json`. No plaintext secrets in harness configs - the reference form is `$secret:NAME` and names allow no dashes. + +## Card t_107cac80 (priority 2): operator opt-in gate for cross-owner and global scope writes + +Correction verified: the global/wildcard half is refuted as a live surface - no wildcard token exists in the scope model (`src/core/graph/agent-scope.ts:130-147`), and the one genuinely global write (the `shared_namespace` mirror) is already operator opt-in (`src/core/config.ts:462-470`). The real surface is cross-owner writes: an explicit caller `owner` wins unconditionally in `resolvedOwnerFor` (`src/core/brain/preference.ts:545-546`, pinned by test), and on the note lane `owner:` is not in the reserved frontmatter keys (`src/core/brain/write-batch.ts:699-704`), so a caller can stamp or strip another agent's ownership. Decide: gate placement (the two chokepoints exist), mode shape (the repo's `off|warn|fail` `GATE_MODE`), and what explicit-owner stays legal for. + +## Card t_af5e252f (priority 3): opt-in ambient writeback with TTL and consent + +Corrections verified: the managed-instruction-block convention exists and is audited but has no installer by design (`src/core/brain/writeback-contract.ts:29-33`); session capture already runs on Stop (`hooks/hooks.json:112-121`) and the extraction lane ALREADY writes whenever a session classifies as capture, with no opt-in flag - so a default-off flag on the existing lane would be default-changing, not default-preserving. TTL machinery exists end to end (`expiration_date`, read-time filtering, `brain_feedback expires` chokepoint). Decide: what opt-in means on a lane that already writes, consent storage (the `guardrails.*` boolean block is the precedent for a memory-mutating lane), and whether a Stop-hook backstop has a nameable delta over session-capture-on-Stop. + +# Output format + +Three variants, each with: Approach (where the seams are, what shares primitives, landing order), Trade-offs (pros and cons grounded in the repo), Complexity, Risk. Then a recommendation with rationale. Be specific about module paths, and design for parallel lanes with disjoint file ownership. diff --git a/docs/brainstorm/write-side-trust/design.md b/docs/brainstorm/write-side-trust/design.md new file mode 100644 index 00000000..3255cff1 --- /dev/null +++ b/docs/brainstorm/write-side-trust/design.md @@ -0,0 +1,78 @@ +# Write-side trust: identity, permission, review - named agent tokens, one permissions document with an approval door and a decision ledger, staged review for every writer, an owner-write gate, open decisions, opt-in ambient capture + +**Status:** draft +**Author:** design orchestrator (feature-release-playbook, phases 0-1) +**Audience:** implementation + +## Problem statement + +An operator who lets agents write into an Open Second Brain vault today cannot say, for any given write, who made it, what rule allowed it, or how to stop the next one. The gaps stack in order. Identity: the HTTP transport compares one shared bearer key (`src/mcp/http.ts:367-371`), and the only "who is calling" behind the endpoint is the process's configured agent name (`src/mcp/server.ts:249-251` resolves `resolveAgentName` per access) - two agents behind one endpoint are indistinguishable, and the code says so itself (`src/mcp/http.ts:355-361`, "identity and scope are process-global"). Authorization: trust checks are scattered point checks with no shared policy - one enforced role check (`apply-evidence.ts:197`), a `force_confirmed` argument that writes a confirmed preference past both the role matrix and the dream trial window (`src/mcp/brain/feedback-tools.ts:268-306`), and per-feature toggles living in three different config homes. Review: the staged-review gate covers exactly two extraction callers (`extract-signals.ts:602-603`, `fact-extract.ts:350-362`); every MCP writer tool and every bulk-ingest lane publishes straight to its final lane, and - a correction this design treats as load-bearing - a staged document is *not* out of recall today, because the search index admits every in-scope `.md` including `Brain/pending/` (`src/core/vault-scope/index-admission.ts:39-52` has no pending exclusion). Accountability: five partial trails exist (Brain log, pref-audit, idempotency ledger, trigger ledger, ephemeral retrieval receipts) but no queryable record of which rule allowed, asked, or denied which operation. Judgment: a decision can only be recorded after it is made (`recordDecision` requires `chosen`, `record.ts:400-401`); there is no artifact that parks an open question with enumerated options. Ambient capture: the extraction lane writes whenever a session classifies as capture, with no operator consent handle and no TTL. + +## Scope + +Seven cards, one spine, one PR on `feat/write-side-trust`: + +- **Identity (t_85059d6d)** - named per-agent MCP auth tokens: an operator-minted token per agent, caller identity resolved from the credential at the transport, threaded per request into the server. Identity comes from the credential, never from a caller-supplied argument. The shared `--api-key` stays valid as the operator master credential. +- **Authorization (t_29798f41)** - one permissions document (`Brain/_permissions.yaml`, allow/ask/deny, operator-defined roles, per-agent entries, target-scoped exceptions), resolved at one chokepoint every gate consumes, plus a queryable decision ledger (`Brain/logs/decisions/`, JSONL shards) that records ask/deny verdicts with the rule that produced them. Document absent: every check behaves exactly as today. +- **Review (t_6ff73d61)** - the staged-review gate extends from the two extract callers to all writer tools and bulk ingest: signals at the `writeSignal` chokepoint, note creates and the ingest summary page into a multi-lane pending queue, recall closed behind the gate by an index-admission exclusion. Unreviewed agent-generated documents stay in `Brain/pending/` until an operator applies or rejects them. +- **Owner writes (t_107cac80)** - an opt-in `integrity.owner_scope_writes` gate (off/warn/fail) that refuses a caller-named owner different from the resolved identity on the preference and note lanes, with warn rows landing in the decision ledger. +- **Bootstrap (t_89e1f601)** - `o2b bootstrap --target `: one idempotent command that provisions a named token, MCP registration through the existing adapter apply, and a receipt, with rotation that takes effect on the next request. +- **Open decisions (t_8913c934)** - a durable open-decision artifact at `Brain/decisions/open-.md` with enumerated options, modeled on the trigger store lifecycle, resolved into a real `type: decision` page. +- **Ambient capture (t_af5e252f, reshaped)** - operator consent and TTL for the existing ambient extraction lane (`guardrails.ambient_writeback`, `guardrails.ambient_ttl_days`), riding the staged-review gate. The managed-instruction-block installer and the Stop-hook backstop are deferred with a recorded verdict (see Out of scope). + +## Out of scope + +- **Stop-hook backstop (deferred from t_af5e252f).** Session capture already runs on Stop (`hooks/hooks.json:112-121`) and banks the turn's durable artifacts; the recon could not name a state a new deterministic backstop would bank that per-prompt capture misses, and the runtimes where a Stop nudge is visible are the ones where `decision: "block"` buys a forced continuation turn, which `hooks/README.md:33` forbids for opt-in hooks. Revisit only with a named delta over session-capture-on-Stop. +- **Managed-instruction-block installer (deferred from t_af5e252f).** `writeback-contract.ts:29-33` states "NO INSTALLER LIVES HERE" deliberately; the natural home is bootstrap provisioning after token identity has shipped and operated for a cycle. The check-only contract already audits the files; writing them is the deferred half. +- **Staging the remaining generator lanes** (design notes, decisions, research reports, distillations, derived facts, session summaries, lifecycle moves). The multi-lane pending store is built so a generator lane is one registry row plus one call at its write site; wave 1 stages the three lanes the card names (signals, notes, ingest summary) and the registry is the documented extension point. +- **An MCP surface for the pending queue.** The human approval door stays CLI-only (`o2b brain pending list|apply|reject`), matching today's shape; agents see staged receipts with a `pending_id` and the next command. +- **Subsuming the read-side gates** (owner-scope delivery, visibility, reach, retrieval trust) into the document. They keep their modules and modes; the document composes most-restrictive-wins with them. Mechanical cutover is a later wave, per the migration order in `docs/brainstorm/who-wrote-what/design.md`. +- **Per-call consent records.** Nothing in the tree models one; the closest shape (v1.78's digest-sealed import approval) needs an MCP transport story callers cannot hold. Consent this wave is standing configuration. +- **Scope metadata on tokens.** A token mints identity only; policy lives in the permissions document. Token-borne tool profiles or reach ceilings would duplicate `mcp_tool_profile` and the transport-reach rule ("a token must mint identity, never reach", `src/mcp/http.ts:106-110`). +- **stdio tokens.** One caller per process that already owns the process tree; identity stays config-derived on stdio. Token authentication is an HTTP-transport feature. +- **Chaining the decision ledger.** The idempotency-ledger precedent (`src/core/brain/idempotency-ledger.ts`) is unchained; tamper evidence exists where the repo chose to pay for it (the Brain log chain). Revisit only if the ledger becomes a security boundary rather than an accountability record. +- **Pre-approval policy writes into harness configs** (Claude Code `permissions.allow` entries, Codex approval tables). `o2b brain protect` owns that surface; bootstrap does not grow a second policy writer this wave. + +## Chosen approach + +One spine, landed substrate-first: identity (tokens) feeds authorization (the permissions document and its resolver) which feeds the action gates (staged review per lane, the owner-write gate) which feed accountability (the decision ledger, open decisions), with ambient capture strictly behind all of it. The permissions document is the substrate: it is a vault file (`Brain/_permissions.yaml`) so it rides Syncthing like the freeze marker and the operator hand-edits it as trust state, with a strict loader modeled on the `_brain.yaml` block machinery (field-named errors, unknown-key warnings, a `version` key with a hard-refuse gate). Its resolver is a pure leaf module; every gate consults it through one disposition function, so "which rule decided this" has exactly one answer and the ledger can record it. + +Default posture is byte-identity: no document, no tokens, no gate keys, no flags - every existing write path and every existing test stays green with zero config. A document present is a closed world (its `default_action` is a required, explicit key); deny wins ties; the document composes most-restrictive-wins with the built-in gates. + +The lanes are ordered so the substrate lane commits pure modules first; the gate lanes build against the pinned signatures (tests first) and rebase when the substrate commit lands. No new MCP tool is added in this wave - every surface change is an action on an existing tool, a field on an existing receipt, or a CLI verb - so the tool-count pins do not move and the lanes stay mergeable. Refusals are never silent and never prose-only: typed errors and named refusal tokens (`owner-write-refused`, `force-confirmed-requires-allow`, the admission reason `review-pending`) join the existing closed vocabulary, each with a next command, recorded in the decision ledger by the gate that produced them (the ledger is a leaf module, so the guard can record without the cycle that forced frozen refusals to the dispatcher). + +## Design decisions + +- **Tokens are hash-at-rest, not custody ciphertext.** The store keeps `sha256(token)` beside a non-secret prefix for listings; verification hashes the presented credential and compares in constant time. A plaintext-equivalent token never exists after mint (the material is shown exactly once, on stdout, with a shown-once notice), so rotation and verification do not depend on the passphrase envelope - locking the custody store must not be an authentication outage, which rule (a) in the t_85059d6d recon (tokens as custody entries) cannot satisfy. The store file sits beside `secrets.json` under `.open-second-brain/secrets/mcp-tokens.json` (0600, `icacls` owner ACL on Windows via the custody `custodyTargets` machinery, writes under `withSecretsLock`), and every mint, rotation and revocation appends a no-values custody audit record. Names use underscores (`mcp_token_`) so the `$secret:NAME` reference grammar (no dashes, `src/core/secret-ref.ts:33`) can address them. +- **Identity threads as a parameter, never as instance state.** One `MCPServer` serves concurrent HTTP requests, so a request-scoped identity cannot live on the instance. `handleRequest(request, identity?)` threads through `handleToolsCall` and `invokeToolHandler` into a `contextFor(identity)` build; `agentName` resolves to `identity.agent` when a credential matched, else `resolveAgentName(configPath)` exactly as today. Authentication order at `src/mcp/http.ts:306`: token-map hash lookup first (identity = the token's agent, `via: "token"`), then the shared key (identity = the process config name, `via: "shared-key"`), else anonymous and byte-identical behavior. The generic 401 body is unchanged for missing and wrong credentials - no oracle distinguishes revoked from unknown. Under `integrity.owner_scope_delivery: fail` a token identity makes that gate per-caller real for the first time: `refuseOwnerScopeRequest` keeps its code and gets a real per-request identity input. +- **Token-map presence keeps the shared key valid; enforcement is a separate explicit key.** The shared key stays the operator master credential during and after migration. A new device key `mcp_tokens_required` (default false, env twin `OPEN_SECOND_BRAIN_MCP_TOKENS_REQUIRED`) makes the endpoint refuse credential-less requests when a non-empty map exists; default-off keeps every existing posture (loopback keyless, non-loopback keyed) byte-identical. The non-loopback bind rule (`startHttp`, `http.ts:99-104` and the CLI pre-check `main.ts:870-875`) learns "key OR non-empty token map". The token store is read per request behind an mtime cache, so rotation and revocation take effect on the next request with no restart - the shared key keeps its launch-time capture, documented. +- **The permissions document is a vault file, not a `_brain.yaml` block.** Trust policy is state the operator hand-edits and syncs to every device, exactly like the freeze marker; `_brain.yaml` is kernel config with its own strict machinery the document borrows by pattern only (`policy/` blocks, field-named `PermissionsDocumentError`, unknown-key warn, `version: 1` hard-refuse on anything else). Absent file: `{ document: null }` and every consumer proceeds as today - the repo's absent-is-inert convention (`integrity.ts:53-66`, `write-binding/index.ts:45-52`). Present but unreadable: fail closed with a named error and a doctor finding, never a silent fallback to permissive - the same asymmetry as `BRAIN_INTEGRITY_STRICT_FALLBACK` and "an unreadable config is not consent to fold" (`vault-path-field.ts:251`). +- **Resolution order and composition.** Within the document: a target-scoped entry beats the agent's per-action override, which beats the agent's role, which beats `default_action`; among entries of equal specificity deny beats ask beats allow. Across systems, most-restrictive-wins: the document can narrow anything, but a `fail` gate (`owner_scope_delivery`, `owner_scope_writes`) still refuses what the document would allow - the document may restore only what an `off` mode permits. When a document exists it is the only disposition source (the `write_approval.*` lane keys apply only when no document exists), so one write has exactly one deciding rule and the ledger records it. +- **Roles in the document are principal bundles, not BrainRoles.** The built-in `writer|dreamer|applier` matrix (`trust/role.ts`) stays a tool-side nomination matrix; document roles are operator-defined named bundles of per-action verdicts (`reviewer`, `autonomous`, ...). The one enforcement gap the recon proved - `force_confirmed` writing a confirmed preference past the matrix - closes only under a document: when a document exists, `force_confirmed` requires the caller's `write` verdict to be `allow`, else the named refusal. Document-absent behavior is unchanged and the hole is documented rather than silently patched. +- **The decision ledger records decisions, not writes.** JSONL month/device shards under `Brain/logs/decisions/` on the idempotency-ledger model (`ledger-shards.ts` grammar, per-shard `proper-lockfile`, deterministic merge), one row per non-allow verdict plus resolution rows for applied/rejected staged documents; allow rows only under the document's explicit `ledger.record_allows`. Row shape: `ts, actor, via, action, target, verdict, source` (entry id / role / default / gate key), `reason` token, `tool`, `correlation_id`. Absent document and off gates produce no rows - default-silent like every ledger. The existing trails (Brain log, pref-audit, idempotency) stay; the ledger joins them via `correlation_id` the way `event-trace.ts` already joins log events. +- **Staging is a change of directory, generalized per lane.** The A3 invariant holds unchanged: the staged document is byte-for-byte what the publish target would receive, and apply moves it verbatim. Signals keep `Brain/pending/sig-*.md` exactly as today. Notes stage at `Brain/pending/notes/.md` where the id is `note--` and `` is a reversible percent-encoding of the caller-named vault-relative target without its final `.md` (Windows-legal characters only, round-trip tested against CJK, spaces and dots; a target that encodes past the 255-character filename bound refuses at stage time by name). The ingest summary page stages at `Brain/pending/ingest/.md` with the deterministic publish basename - the publish path is deterministic from the source identity (`ingest.ts:219-223`), so the basename suffices. Apply is the existing exclusive-create-then-unlink with `PendingApplyConflictError` on an occupied target; reject renders into `Brain/retired/` with the retire-shaped frontmatter, adding a `osb_pending_lane` stamp at reject time only (reject transforms, publish never does). Every generalized move-then-unlink gets a `DESTRUCTIVE_SITES` declaration. +- **The note-lane review boundary is entry, not mutation.** Creates stage; updates and appends to already-published notes stay direct. The thing under review is a document no operator has admitted yet; once admitted, editing it is the ordinary write path with its own record. In the batch kernel this is per-operation: create ops stage and report receipt status `staged` with the pending id, update/append ops proceed - the validate-all-then-commit kernel is untouched because a staged create never touches the real path. `brain_feedback` and every other `writeSignal` caller inherits the gate at the chokepoint (see next decision), so the signals lane needs no per-tool wiring. +- **The gate resolves inside `writeSignal`, below the pending module.** When `targetDir` is absent and the signals lane gate resolves on, `writeSignal` stages into `Brain/pending/` and reports `staged: true` on its result; `stagePendingSignal` keeps passing an explicit `targetDir` and can never re-enter the gate. The toggle resolution lives in a new leaf module `src/core/brain/write-gate.ts` (signal.ts cannot import pending.ts - pending imports signal). The two extract callers keep their current injected resolution and are behavior-identical; the six ungated `writeSignal` call sites (MCP and CLI feedback, inline scan, session import, session lifecycle, session checkpoint) gain the gate with no code change of their own. +- **Per-lane toggles under one master.** `write_approval.notes` and `write_approval.ingest` (flat device keys with env twins, matching `write_approval.enabled`'s resolver) default to the master key when absent, so the existing key keeps its meaning ("stage everything") and an operator can gate bulk ingest without gating interactive feedback. Resolution order: lane key, then master, then off. +- **The owner-write gate is a third behaviour family on a sibling integrity key, not a mode on `owner_scope_delivery`.** `integrity.owner_scope_writes: off|warn|fail` (closed `GATE_MODE` union, default off, strict fallback fail on unreadable config) keeps read isolation and write trust separable - their `warn` semantics already differ (stamp-and-allow vs allow-and-log). The check is a pure predicate in `src/core/brain/trust/owner-write-gate.ts`: refuse when a caller-named owner differs from the resolved identity, on the preference lane at `resolvedOwnerFor`'s explicit-owner arm (`preference.ts:545-546`) and on the note lane where `owner` joins the reserved frontmatter keys under `fail` (`write-batch.ts:699-704`). Warn allows and logs a ledger row. Global-scope writes need no new gate: the recon refuted the wildcard surface (no wildcard token exists; `agent_scope: "*"` narrows rather than widens, `agent-scope.ts:130-147`) and the one genuinely global write, the `shared_namespace` mirror, is already operator opt-in (`config.ts:462-470`). +- **Open decisions follow the trigger store, not the pending queue.** One Markdown record per question at `Brain/decisions/open-.md` (the `decision-` prefix filter already coexists with design notes in that directory), a frozen key table, JSON-quoted free text, a body of `## Question` / `## Options` / `## Context` sections, named-unreadable partitioned reads, and a directory lock around transitions. Statuses are `open | resolved | discarded` with no read-time expiry: a parked judgment question is exactly the thing that must not silently expire. Resolution takes the chosen option, mints the real `type: decision` page through `recordDecision`, stamps the open record `resolved` with a `[[decision-]]` pointer that stays in place (history is a status filter), and appends one `open_resolved` receipt to the existing decision-change trail. Creating an open decision is exempt from the write gate by design: accountability lanes stay writable when content lanes are gated, the same principle that keeps the audit lane appending under freeze. +- **Ambient capture gets a consent handle and a TTL, not a new lane.** `guardrails.ambient_writeback: false`, when explicitly set, suppresses the ambient extraction lane with a counted, logged `ambient-withheld` event - absent keeps today's behavior byte-identically (the lane already writes on capture, so a default-off flag would be default-changing, not default-preserving). `guardrails.ambient_ttl_days`, when set, stamps `expiration_date` at creation on ambient-extracted signals through the validated chokepoint (`feedback-tools.ts:152-162` precedent); absent stamps nothing. Both keys live in the `guardrails` block beside `marker_writeback`, the direct precedent for a memory-mutating lane. Suppressed-by-consent capture still flows through the capture boundary first, and the staging gate still applies to what is written. +- **Bootstrap composes existing install machinery; it does not duplicate it.** `o2b bootstrap --target ` runs the adapter's existing idempotent `apply` (MCP registration, and hooks for the one adapter that writes them), mints and prints the harness token once when `--token` is passed (never on argv, never in a harness config - the payload env block stays credential-free per `payload.ts:119-124`), and writes a receipt at `/.open-second-brain/bootstrap.lock.json` (schema 1, owned entries, token name and non-secret prefix, `applied_at`) modeled on `protect.lock.json` and the install manifest. Re-apply is a byte-identical no-op; `--rotate` re-mints under the same name (`replaced: true` audit row) and reprints once; `--check` verifies drift from `InstallEnv` alone. Wave-1 targets: the config-writing adapters (codex, grok, opencode), `generic` print-and-paste, and verify-only for plugin runtimes - Claude Code and ZCode are deliberately not adapter targets (`host-facts.ts:170-173`, `install/zcode.md:3-4`). +- **File ownership keeps the lanes disjoint; a small set of shared-append files absorbs registration.** New modules are self-contained (path helpers live in their owning modules - the secrets-store precedent - so `paths.ts` gains no rows). The dispatcher switches, verb barrels, help text, command manifest and CLI reference are declared shared-append: each lane appends only its own entries, and the reconciliation task owns the count pins. No new MCP tool means no tool-count pin moves at all. + +## File changes + +New core: `src/core/brain/permissions/document.ts` (loader, schema, `PermissionsDocumentError`), `src/core/brain/permissions/resolve.ts` (`resolvePermission`, subject/decision types), `src/core/brain/permissions/ledger.ts` (`appendDecisionLedger`, `queryDecisionLedger`, shard handling), `src/core/brain/write-gate.ts` (lane toggle resolver, leaf module), `src/core/brain/pending/pending-lanes.ts` (lane registry, staging primitives, encode/decode, generalized list/apply/reject), `src/core/brain/trust/owner-write-gate.ts` (`refuseCrossOwnerWrite` predicate), `src/core/brain/secrets/token-store.ts` (mint/rotate/revoke/list/resolve, hash-at-rest), `src/core/brain/decisions/open-store.ts` (open-decision records, transitions), `src/core/brain/decisions/brief.ts` (brief section renderer), `src/cli/bootstrap/` (command module, receipt). +Extended core: `src/core/brain/signal.ts` (gate resolution in `writeSignal`, `staged` on the result), `src/core/brain/pending.ts` (delegate to the lanes module for listing and resolution), `src/core/vault-scope/index-admission.ts` (`Brain/pending` exclusion), `src/core/brain/notes/create-note.ts` (staging seam, owner-frontmatter guard call site), `src/core/brain/write-batch.ts` (create-op staging, reserved `owner` key under the gate), `src/core/brain/ingest/ingest.ts` + `source-cleanup.ts` (summary-page staging, staged cleanup), `src/core/brain/preference.ts` (explicit-owner gate arm), `src/core/brain/policy/blocks/integrity.ts` + guardrails.ts (new keys, resolvers, defaults), `src/core/brain/fact-extract.ts` (ambient consent + TTL), `src/core/brain/types.ts` (open-decision log kinds), `src/core/brain/decisions/receipts.ts` (`open_resolved` reason), `src/core/brain/destructive-sites.ts` (generalized queue entries), `src/core/state/surfaces.ts` (decisions ledger row), MCP: `src/mcp/http.ts` (`authenticateRequest`, token map, bind rule), `src/mcp/server.ts` (identity parameter threading), `src/mcp/brain/notes-tools.ts`, `write-batch-tools.ts`, `feedback-tools.ts`, `ingest-tools.ts` (staged receipt fields, force_confirmed check), `src/mcp/brain/decisions-tools.ts` (open/list_open/show_open/resolve/discard actions), `src/mcp/brain/brief-tools.ts` (open-decisions section); CLI: `o2b brain pending` widening (`src/cli/brain/verbs/pending.ts`), `o2b brain decision` actions (`decision.ts`), new `o2b brain permissions` verb, new `o2b bootstrap` + `o2b mcp token` arms in `src/cli/main.ts`; doctor: unreadable-permissions finding; morning brief: open-decisions section. +Tests: `tests/core/brain/permissions/{document,resolve,ledger}.test.ts`, `tests/core/brain/write-gate.test.ts`, `tests/core/brain/pending-lanes.test.ts` (+ existing pending suites stay green), `tests/core/brain/trust/owner-write-gate.test.ts`, `tests/core/brain/secrets/token-store.test.ts`, `tests/core/brain/decisions/open-store.test.ts`, `tests/core/brain/fact-extract.ambient.test.ts`, `tests/core/search/index-admission.test.ts` extension, `tests/mcp/http-token-auth.test.ts`, `tests/cli/{brain-permissions,bootstrap,brain-pending-lanes}.test.ts`, plus the extensions the plan names per task. +Docs: `docs/cli-reference.md`, `docs/mcp.md`, `docs/observability.md` (ledger), README control section, CHANGELOG `[1.79.0]`. + +## Risks and open questions + +- **Identity threading touches the hottest dispatch path.** Parameter threading through `handleRequest` is mechanical but wide (tools/call, resources, `brain_context`'s no-argument contract). The agent-scope matrix and one-reader censuses (`tests/mcp/owner-scope-refusal.test.ts:299-309`, `agent-scope-matrix.test.ts:237`) must pass unmodified except for new identity-positive cases; any census that must change is a design error and stops the lane. +- **`default_action: deny` is a foot-gun.** The validator requires it explicitly (no silent default), `o2b brain permissions show` prints the effective decisions for a dry-run set of subjects, and the doctor emits a finding when a document denies the locally configured agent. An operator who writes a one-agent document denies everything else by construction; the show output makes that visible before the first refusal does. +- **Encoded pending filenames inherit filesystem bounds.** The 255-character filename bound and Windows reserved names are handled at stage time with named refusals; the round-trip suite covers CJK, spaces, dots, and the percent-encoding of separators. A target that cannot stage refuses the write by name rather than truncating into a collision. +- **Two staging sources could disagree.** They cannot: the document, when present, is the only disposition source; the `write_approval.*` keys apply only when it is absent. One write, one deciding rule, one ledger row. +- **Pin reconciliation across parallel lanes.** The shared-append files (dispatcher, barrels, help text, manifest, CLI reference) and the census counts (verdict vocabulary, state surfaces, destructive sites) are append-only per lane and reconciled by measurement in the final task, per the standing note from the who-wrote-what wave. +- **Open question - ledger retention.** Month/device shards grow without bound like every ledger in the vault; no prune verb ships this wave. If the ledger becomes operator-visible noise, retention follows the write-image prune precedent in a later wave. +- **Open question - bootstrap token delivery for non-print hosts.** The token is printed once for the operator to place (env or `$secret:` reference). A host that can take a file drop securely would prefer a written pointer; deferred until a host asks. diff --git a/docs/brainstorm/write-side-trust/plan.md b/docs/brainstorm/write-side-trust/plan.md new file mode 100644 index 00000000..502c6878 --- /dev/null +++ b/docs/brainstorm/write-side-trust/plan.md @@ -0,0 +1,244 @@ +# Write-side trust: identity, permission, review - implementation plan + +Feature branch: `feat/write-side-trust` (already checked out from `origin/main` at v1.78.0). TDD per task, one atomic conventional commit per task, `bun run fmt` + `bun run lint` green before every commit. Work is organized as five implementation lanes in separate worktrees plus one reconciliation task. Lane A commits its pure substrate modules first; the gate lanes start on tests against the pinned signatures below and land after the substrate commit reaches the branch. + +Registration checklist for a new `o2b brain` verb (six places): handler `src/cli/brain/verbs/.ts`; barrel `src/cli/brain/verbs/index.ts`; dispatcher import + `case` in `src/cli/brain.ts` (switch terminated by `default:`); `src/cli/brain/help-text.ts` summary line in `BRAIN_HELP` AND a `VERB_HELP` key; `src/cli/command-manifest.ts` brain group; `docs/cli-reference.md`. Pinned by `tests/cli/manifest-completeness.test.ts` and `tests/cli/help-surface-parity.test.ts`. + +Top-level command registration (`o2b bootstrap`, `o2b mcp token`): a `case` in `dispatchCommand` (`src/cli/main.ts:1272`) plus a `CLI_COMMAND_MANIFEST` entry (`src/cli/command-manifest.ts`); the manifest-completeness census reads the switch, so the case must be a literal. Lane B owns `main.ts` outright; no other lane touches it. + +Registration checklist for a new closed vocabulary (statuses, verdicts, refusal-token sets): frozen object + companion member list + type guard, registered in `tests/core/architecture/verdict-vocabulary-census.test.ts`. New destructive move-then-unlink sites: `src/core/brain/destructive-sites.ts` declaration + census. New durable Brain directory: `src/core/state/surfaces.ts` row + `tests/core/architecture/state-surface-census.test.ts` count. + +This wave adds NO new MCP tool. Every MCP change is an action on an existing tool enum, a field on an existing receipt, or a new CLI verb, so the tool-count pins (`tests/mcp/mcp.test.ts`, `brain-tools-parity.test.ts`, `agent-scope-matrix.test.ts`, `tests/core/install/tool-ceiling.test.ts`, `docs/mcp.md` prose) do not move. + +## Cross-lane contract (pinned) + +Module paths and the ONE owning lane per file during phases 0-1. Files may change owner across phase boundaries (the plan names each handoff); never within a phase. + +| Owner | Files | +|---|---| +| Lane A | `src/core/brain/permissions/document.ts`, `src/core/brain/permissions/resolve.ts`, `src/core/brain/permissions/ledger.ts`; `tests/core/brain/permissions/**`; `tests/cli/brain-permissions.test.ts`; doctor finding module | +| Lane B | `src/core/brain/secrets/token-store.ts`; `src/mcp/http.ts`; `src/mcp/server.ts`; `src/cli/main.ts`; `src/cli/bootstrap/**`; `tests/core/brain/secrets/token-store.test.ts`; `tests/mcp/http-token-auth.test.ts`; `tests/cli/bootstrap.test.ts` | +| Lane C | `src/core/brain/write-gate.ts`; `src/core/brain/signal.ts`; `src/core/brain/pending.ts`; `src/core/brain/pending/pending-lanes.ts`; `src/core/vault-scope/index-admission.ts`; `src/core/brain/notes/create-note.ts`; `src/core/brain/write-batch.ts`; `src/core/brain/ingest/ingest.ts`; `src/core/brain/ingest/source-cleanup.ts`; `src/mcp/brain/notes-tools.ts`; `src/mcp/brain/write-batch-tools.ts`; `src/mcp/brain/feedback-tools.ts`; `src/mcp/brain/ingest-tools.ts`; `src/cli/brain/verbs/pending.ts`; their test files | +| Lane D | `src/core/brain/policy/blocks/integrity.ts` (+ `resolve.ts`/`types.ts` integrity fields); `src/core/brain/trust/owner-write-gate.ts`; `src/core/brain/preference.ts`; their test files | +| Lane E | `src/core/brain/decisions/open-store.ts`; `src/core/brain/decisions/brief.ts`; `src/mcp/brain/decisions-tools.ts`; `src/cli/brain/verbs/decision.ts`; `src/mcp/brain/brief-tools.ts`; `src/cli/brain/verbs/morning-brief.ts`; `src/core/brain/policy/blocks/guardrails.ts`; `src/core/brain/fact-extract.ts`; `src/core/brain/types.ts`; `src/core/brain/decisions/receipts.ts`; their test files | + +Shared-append files (every lane appends only its own entries; the reconciliation task owns the result): `src/cli/brain.ts`, `src/cli/brain/verbs/index.ts`, `src/cli/brain/help-text.ts`, `src/cli/command-manifest.ts`, `docs/cli-reference.md`, `docs/mcp.md`, `docs/observability.md`, and the census pins listed above. No lane edits `src/core/brain/paths.ts` - new path helpers live in their owning modules (the `secrets/store.ts` precedent). + +### Exported signatures each lane provides or consumes + +Lane A provides (`src/core/brain/permissions/`, leaf modules - import nothing from `core/brain` except `types.ts`-level constants; must satisfy `tests/core/architecture/import-cycles.test.ts`): + +```ts +// document.ts +export const PERMISSIONS_DOCUMENT_REL = "Brain/_permissions.yaml"; +export const PERMISSIONS_SCHEMA_VERSION = 1; +export type PermissionVerdict = "allow" | "ask" | "deny"; +export type PermissionAction = "write" | "ingest" | "owner_write"; +export interface PermissionEntry { id: string; agent?: string; role?: string; action: PermissionAction; target?: string; verdict: PermissionVerdict } +export interface PermissionsDocument { + version: 1; + default_action: PermissionVerdict; // required - no silent default + roles: Record>>; + agents: Record; + entries: PermissionEntry[]; + ledger?: { record_allows?: boolean }; +} +export class PermissionsDocumentError extends Error {} // message names file + field +export function loadPermissionsDocument(vault: string): { document: PermissionsDocument | null; path: string }; +// absent file -> { document: null }; present-but-unreadable -> throws PermissionsDocumentError (fail closed) + +// resolve.ts +export interface PermissionSubject { agent: string; via: "token" | "config" | "operator" } +export interface PermissionDecision { verdict: PermissionVerdict; source: string; reason: string } +// source: "entry:" | "agent:" | "role:" | "default" +export function resolvePermission(doc: PermissionsDocument, subject: PermissionSubject, action: PermissionAction, target?: string): PermissionDecision; +// precedence: target entry > agent override > agent role > default_action; deny > ask > allow at equal specificity + +// ledger.ts +export interface DecisionLedgerRow { ts: string; actor: string; via: string; action: PermissionAction | "resolution"; target: string; verdict: string; source: string; reason: string; tool?: string; correlation_id?: string } +export function appendDecisionLedger(vault: string, row: DecisionLedgerRow): { logged: boolean; audit_reason?: string }; +// append-only month/device JSONL under Brain/logs/decisions/, per-shard proper-lockfile; a failed append never throws - it returns audit_reason +export function queryDecisionLedger(vault: string, filter: { actor?: string; action?: string; verdict?: string; target?: string; since?: string; until?: string; limit?: number }): DecisionLedgerRow[]; +``` + +Lane B provides (`src/core/brain/secrets/token-store.ts`, `src/mcp/http.ts`, `src/mcp/server.ts`): + +```ts +// token-store.ts +export interface McpTokenRecord { name: string; agent: string; status: "active" | "revoked"; token_hash: string; token_prefix: string; created_at: string; rotated_at?: string } +export function mintAgentToken(vault: string, name: string, agent: string): { tokenMaterial: string; record: McpTokenRecord }; // tokenMaterial shown once, never stored +export function rotateAgentToken(vault: string, name: string): { tokenMaterial: string; record: McpTokenRecord }; +export function revokeAgentToken(vault: string, name: string): boolean; +export function listAgentTokens(vault: string): McpTokenRecord[]; // sorted by name; never contains material +export function resolveAgentForToken(vault: string, presented: string): { agent: string; name: string } | null; +// sha256(presented) compared against stored hashes (timingSafeEqual); store read behind an mtime cache + +// http.ts +export interface RequestIdentity { agent: string; via: "token" | "shared-key" } +export function authenticateRequest(req: IncomingMessage, opts: { apiKey: string | null; resolveToken: (presented: string) => { agent: string } | null; tokensRequired: boolean }): RequestIdentity | null; +// token map first, then shared key (identity = process config name), else null; invalid/missing credential with tokensRequired && map non-empty -> 401 (same generic body) + +// server.ts +async handleRequest(request: JsonRpcRequest, identity?: RequestIdentity): Promise; +// identity threads as a PARAMETER through handleToolsCall -> invokeToolHandler -> contextFor(identity); +// contextFor().agentName === identity?.agent ?? resolveAgentName(configPath). No instance field (concurrent requests). +``` + +Lane C provides (`src/core/brain/write-gate.ts`, `src/core/brain/pending/pending-lanes.ts`, `src/core/brain/signal.ts`): + +```ts +// write-gate.ts (leaf module: imports only config/fs - signal.ts imports it, pending-lanes.ts imports it) +export const WRITE_APPROVAL_NOTES_CONFIG_KEY = "write_approval.notes"; +export const WRITE_APPROVAL_INGEST_CONFIG_KEY = "write_approval.ingest"; +export const WRITE_APPROVAL_NOTES_ENV_KEY = "OPEN_SECOND_BRAIN_WRITE_APPROVAL_NOTES_ENABLED"; +export const WRITE_APPROVAL_INGEST_ENV_KEY = "OPEN_SECOND_BRAIN_WRITE_APPROVAL_INGEST_ENABLED"; +export type ReviewLane = "signals" | "notes" | "ingest"; +export function resolveWriteApprovalLane(lane: ReviewLane, configPath?: string): boolean; // lane key ?? master write_approval.enabled ?? false; env wins per key + +// pending-lanes.ts +export interface WriteDisposition { verdict: "publish" | "stage" | "refuse"; source: string; reason?: string; pendingId?: string } +export function resolveWriteDisposition(vault: string, lane: ReviewLane, subject?: PermissionSubject): WriteDisposition; +// no document -> write_approval lane keys (on = stage, off = publish); document present -> resolvePermission (deny = refuse, ask = stage, allow = publish) +export function stageForReview(vault: string, lane: ReviewLane, publishTarget: string, render: () => string): { pendingId: string; path: string }; +// writes the rendered bytes verbatim into Brain/pending//.md under the vault-identity write guard +export function encodePendingTargetPath(relPath: string): string; // reversible percent-encoding, Windows-legal, 255-bound refusal by name +export function decodePendingTargetPath(encoded: string): string; +export function listPendingLane(vault: string, lane: ReviewLane | "all"): PendingLaneEntry[]; // sorted; unreadable entries partitioned and named +export function applyPendingLane(vault: string, id: string, opts?: { dryRun?: boolean }): PendingApplyResult; // exclusive-create at the decoded target, then unlink; occupied -> PendingApplyConflictError +export function rejectPendingLane(vault: string, id: string, reason: string, opts?: { dryRun?: boolean }): PendingRejectResult; +// ids: ^(sig|note|ing)-\d{4}-\d{2}-\d{2}-[A-Za-z0-9][A-Za-z0-9._%-]*$ - the sig- shape stays byte-compatible +// with the existing queue; the note- suffix is the encoded publish target without its final .md +// (note-2026-10-10-notes%2Ffoo%2Fbar), the ing- suffix is the deterministic publish basename without .md + +// signal.ts (extension) +// WriteSignalOptions unchanged; writeSignal resolves resolveWriteApprovalLane("signals") when targetDir is absent, +// stages into Brain/pending/, and adds `staged: boolean` to its result. stagePendingSignal passes targetDir explicitly and bypasses the gate. +``` + +Lane D provides (`src/core/brain/trust/owner-write-gate.ts`, consumed by Lane C in Task 13): + +```ts +export const OWNER_SCOPE_WRITES_KEY = "integrity.owner_scope_writes"; // in INTEGRITY_GATE_KEYS; strict fallback = fail +export interface CrossOwnerWriteInput { explicitOwner?: string; frontmatterOwner?: string; resolvedIdentity: string; gateMode: "off" | "warn" | "fail"; document?: PermissionsDocument | null; subject?: PermissionSubject } +export type CrossOwnerWriteVerdict = { refused: false } | { refused: true; token: "owner-write-refused"; reason: string }; +export function refuseCrossOwnerWrite(input: CrossOwnerWriteInput): CrossOwnerWriteVerdict; +// composition: document verdict (most restrictive) with gate mode; warn -> { refused: false } + caller logs one ledger row; off with no document -> { refused: false } byte-identically +``` + +Lane E provides (`src/core/brain/decisions/open-store.ts`, consumed by nobody this wave; and the ambient keys consumed only by its own fact-extract wiring): + +```ts +export const OPEN_DECISION_STATUS = Object.freeze({ open: "open", resolved: "resolved", discarded: "discarded" } as const); +export const OPEN_DECISION_STATUSES: ReadonlyArray; // frozen trio: object + list + guard, census-registered +export function openDecision(vault: string, input: { title: string; question: string; options: string[]; context?: string; agent?: string }): OpenDecisionRecord; +// Brain/decisions/open-.md; dedup on sha16(normalized question) -> typed duplicate refusal naming the existing id; directory lock; vault-identity guard +export function listOpenDecisions(vault: string, opts?: { status?: OpenDecisionStatus; readable?: (relPath: string) => boolean }): { records: OpenDecisionRecord[]; unreadable: { path: string; reason: string }[] }; +export function resolveOpenDecision(vault: string, id: string, input: { choice: string; actor?: string; rationale?: string }): { decision: string }; // mints recordDecision page, stamps resolved + [[decision-]], appends "open_resolved" receipt +export function discardOpenDecision(vault: string, id: string, input: { reason: string; actor?: string }): void; +``` + +### Config keys inventory (pin - one vocabulary per key, no second reader) + +| Key | Home | Default | Owner | +|---|---|---|---| +| `write_approval.enabled` / `OPEN_SECOND_BRAIN_WRITE_APPROVAL_ENABLED` | device flat (existing) | off | Lane C (unchanged semantics; master fallback) | +| `write_approval.notes` / `OPEN_SECOND_BRAIN_WRITE_APPROVAL_NOTES_ENABLED` | device flat | falls back to master | Lane C | +| `write_approval.ingest` / `OPEN_SECOND_BRAIN_WRITE_APPROVAL_INGEST_ENABLED` | device flat | falls back to master | Lane C | +| `Brain/_permissions.yaml` | vault document | absent = no document | Lane A | +| `integrity.owner_scope_writes` | `Brain/_brain.yaml` integrity block | `off` (unreadable config: `fail`) | Lane D | +| `guardrails.ambient_writeback` | `Brain/_brain.yaml` guardrails block | absent (= today's behavior); explicit `false` suppresses | Lane E | +| `guardrails.ambient_ttl_days` | `Brain/_brain.yaml` guardrails block | absent (no stamp) | Lane E | +| `mcp_tokens_required` / `OPEN_SECOND_BRAIN_MCP_TOKENS_REQUIRED` | device flat | false | Lane B | +| `.open-second-brain/secrets/mcp-tokens.json` | device custody dir | absent until first mint | Lane B | +| `/.open-second-brain/bootstrap.lock.json` | device receipt | absent until first bootstrap | Lane B | +| `Brain/logs/decisions/` | vault ledger dir | empty until first gate fires | Lane A | + +### Dependency-wait rule + +A task whose **Depends on** names an unlanded commit proceeds as follows: write the failing tests against the pinned signatures above first; then poll every 30 seconds with `git fetch origin feat/write-side-trust && git log origin/feat/write-side-trust --oneline -- ` until the upstream commit subject appears; rebase onto it and run the suite. After 30 minutes of waiting, stop and report to the orchestrator instead of stubbing the dependency. Never copy a substrate module into your lane to unblock yourself. + +### Security-review and portability rules for every lane + +- No identifier containing `secret` before a quoted value anywhere in src or tests; name credential variables `tokenMaterial`, `presentedCredential`, `PRIVATE_PATH`. The plugin scanner flags `const secret = "..."` forms. +- Every credential-shaped literal in tests comes from `tests/helpers/fake-credentials.ts` (`fakeCredential(...parts)`), the established pattern in `tests/core/brain/secrets/store.test.ts:158`. A raw `osbt_...` or bearer-shaped literal in a test is a review failure. +- Tests that assert permission denial via file modes use `test.skipIf(CHMOD_CANNOT_DENY)` (`tests/helpers/platform.ts:31`); no POSIX-only assumption in new tests; all new path handling round-trips Windows-invalid characters. +- Tests never depend on directory iteration order: every listing sorts deterministically (the `listPending` precedent). +- Tests that spawn subprocesses (bootstrap adapter probes, hook invocations) set an explicit per-test timeout of 20000 ms and never rely on wall-clock sleeps. +- Ledger and store writers serialize under `proper-lockfile` directory locks with deterministic merge order `(timestamp, shardId)`. + +## Tasks + +### Task 1: Permissions document loader and resolver (Lane A, substrate) +- **Lane**: A. **Files**: new `src/core/brain/permissions/document.ts`, `src/core/brain/permissions/resolve.ts`; `tests/core/brain/permissions/document.test.ts`, `tests/core/brain/permissions/resolve.test.ts`. +- **Acceptance**: absent `Brain/_permissions.yaml` yields `{ document: null }`; a valid minimal document loads with `default_action` required (missing key is a field-named `PermissionsDocumentError`); unknown keys warn, `version` other than 1 hard-refuses naming the file; an unreadable file (bad YAML, wrong types) throws with the field named, never returns a document; resolution order (target entry > agent override > role > default; deny > ask > allow at equal specificity) is pinned table-style; the modules import nothing from `core/brain` beyond type-level constants and `tests/core/architecture/import-cycles.test.ts` passes. This task commits FIRST and its commit sha is the substrate anchor for Tasks 8, 12, 13. +- **Depends on**: none + +### Task 2: Decision ledger store (Lane A, substrate) +- **Lane**: A. **Files**: new `src/core/brain/permissions/ledger.ts`; `tests/core/brain/permissions/ledger.test.ts`; `src/core/state/surfaces.ts` + `tests/core/architecture/state-surface-census.test.ts` (decisions-ledger row, count +1). +- **Acceptance**: rows append to `Brain/logs/decisions/[.].jsonl` under the shard grammar with per-shard lock; concurrent appends from two simulated devices never lose a row; a failed append returns `{ logged: false, audit_reason }` and never throws; `queryDecisionLedger` filters by actor/action/verdict/target/time with deterministic merge order; an empty vault yields zero rows and no directory. +- **Depends on**: none + +### Task 3: Named per-agent token store (Lane B, substrate) +- **Lane**: B. **Files**: new `src/core/brain/secrets/token-store.ts`; `tests/core/brain/secrets/token-store.test.ts`. +- **Acceptance**: `mintAgentToken` returns material exactly once and persists only the sha256 hash plus a non-secret prefix; the store file is `.open-second-brain/secrets/mcp-tokens.json` created 0600 (Windows: `custodyTargets` owner ACL; denial-assertions `test.skipIf(CHMOD_CANNOT_DENY)`); writes serialize under `withSecretsLock`; every mint/rotate/revoke appends a no-values custody audit record (`mcp_token_minted|rotated|revoked`); `resolveAgentForToken` matches the presented material by hash in constant time, returns null for unknown or revoked, and reflects a rotation on the next call without any restart; names validate `mcp_token_` (underscores, `$secret:`-compatible); every credential literal in tests comes from `fakeCredential`. +- **Depends on**: none + +### Task 4: Permissions CLI verb and doctor finding (Lane A) +- **Lane**: A. **Files**: new `src/cli/brain/verbs/permissions.ts` (`show` prints the effective document plus a dry-run decision table over configured agents and roles; `ledger` lists rows with `--actor --action --verdict --since --until --json`); doctor finding `permissions-unreadable` (`nextCommand` names the fix); shared-append registration (six places); `tests/cli/brain-permissions.test.ts`; doctor-exit census. +- **Acceptance**: with no document, `show` says so and prints no table; with a document, `show` renders the resolved decision for each declared agent; `ledger` returns rows in deterministic order; a deliberately corrupt document makes `show` fail with the field-named error and the doctor emit exactly one `permissions-unreadable` finding. +- **Depends on**: Tasks 1, 2 + +### Task 5: Recall exclusion for the review lane (Lane C) +- **Lane**: C. **Files**: `src/core/vault-scope/index-admission.ts` (`BRAIN_PENDING_REL` covered-lane exclusion, reason `review-pending`); `tests/core/search/index-admission.test.ts` extension; a walker-level case proving `Brain/pending/**` is not indexed while `Brain/pendingfoo/x.md` still is (the `pathCovers` boundary rule). +- **Acceptance**: a document staged under `Brain/pending/` never enters the search index (so `brain_search` and recall-inject cannot surface it) while gate-off vaults are unaffected (no pending directory, no behavior change); the exact-state-lane and payload-store verdicts are unchanged. +- **Depends on**: none + +### Task 6: Signal-lane chokepoint gate (Lane C) +- **Lane**: C. **Files**: new `src/core/brain/write-gate.ts` (lane resolver leaf module); `src/core/brain/signal.ts` (resolve the signals lane when `targetDir` is absent; `staged: boolean` on the result); `tests/core/brain/write-gate.test.ts` (new) plus staged-path cases in the `writeSignal` suites (`tests/core/brain/vault-identity.test.ts` extension and the MCP feedback suite covering the staged receipt). +- **Acceptance**: with every toggle absent, `writeSignal` is byte-identical (the existing suites pass unmodified); with `write_approval.enabled: true`, every ungated `writeSignal` caller (MCP feedback, CLI feedback, inline scan, session import, session lifecycle, session checkpoint) stages into `Brain/pending/` with `staged: true` on the result and the identical dedup/idempotency consumption; `stagePendingSignal` (explicit `targetDir`) never re-enters the gate; the two extract callers are behavior-identical with their injected resolution; lane keys fall back to the master key per the resolver table. +- **Depends on**: none + +### Task 7: Transport authentication and request-scoped identity (Lane B) +- **Lane**: B. **Files**: `src/mcp/http.ts` (`authenticateRequest`, token map wiring, non-loopback rule accepts key OR non-empty map, `mcp_tokens_required` enforcement with the unchanged generic 401 body); `src/mcp/server.ts` (`handleRequest(request, identity?)` parameter threading through `handleToolsCall`/`invokeToolHandler`/`contextFor`); `tests/mcp/http-token-auth.test.ts`; extensions of `tests/mcp/http-transport.test.ts` and `tests/mcp/owner-scope-refusal.test.ts` (identity-positive cases only - the one-reader census must pass unmodified). +- **Acceptance**: no tokens configured - every existing HTTP test passes unmodified (byte-identical auth); a valid token yields per-caller identity so `brain_context` reports the token's agent and `refuseOwnerScopeRequest` under `fail` refuses a foreign scope per caller; the shared key still authenticates with the process identity; a revoked token gets the same generic 401 as an unknown one; `mcp_tokens_required: true` with a non-empty map refuses credential-less requests, and with an empty map only warns at startup; non-loopback bind accepts key-or-map; concurrent requests with different tokens never observe each other's identity (parameter threading, no instance field); stdio is untouched. +- **Depends on**: Task 3 + +### Task 8: Owner-write gate and the preference lane (Lane D) +- **Lane**: D. **Files**: `src/core/brain/policy/blocks/integrity.ts` (+ resolver/types: `owner_scope_writes` in `INTEGRITY_GATE_KEYS`, default `off`, strict fallback `fail`); new `src/core/brain/trust/owner-write-gate.ts` (the pinned `refuseCrossOwnerWrite` predicate); `src/core/brain/preference.ts` (the explicit-owner arm consults the predicate; `warn` appends one ledger row via the Task 2 recorder); `tests/core/brain/trust/owner-write-gate.test.ts`; extensions of `tests/core/brain/owner-stamp.test.ts` (gate-off cases unchanged) and a new gate-matrix suite. +- **Acceptance**: gate off or document absent - `owner-stamp.test.ts` passes unmodified (explicit caller owner still wins, pinned behavior preserved); under `fail`, an explicit owner differing from the resolved identity refuses with `owner-write-refused` naming both tokens; under `warn` it is allowed with one decision-ledger row carrying the gate key as source; an unreadable `_brain.yaml` fails closed (named error); a document `owner_write: deny` refuses even when the gate mode is `off`, and a document `allow` cannot override gate `fail` (most-restrictive-wins pinned both ways); the restore/import paths (explicit = undefined) are unaffected. +- **Depends on**: Tasks 1, 2 + +### Task 9: Multi-lane pending queue and the notes/ingest staging seams (Lane C) +- **Lane**: C. **Files**: new `src/core/brain/pending/pending-lanes.ts` (lane registry, `resolveWriteDisposition` over the write-approval keys only at this task, `stageForReview`, encode/decode, generalized list/apply/reject with named-unreadable partitioning); `src/core/brain/pending.ts` (delegate listing/resolution to the lanes module; the existing exports and id grammar stay); `src/core/brain/notes/create-note.ts` (stage creates under the notes lane disposition; update/append untouched); `src/core/brain/write-batch.ts` (create ops stage per-op, receipt status `staged` with the pending id); `src/core/brain/ingest/ingest.ts` + `source-cleanup.ts` (summary page stages under the ingest lane; cleanup removes a matching staged page); `src/core/brain/destructive-sites.ts` (generalized move-then-unlink entries); `src/cli/brain/verbs/pending.ts` (lane column, `--lane`, widened id grammar, `--dry-run` honest previews); `src/mcp/brain/notes-tools.ts`, `write-batch-tools.ts`, `feedback-tools.ts`, `ingest-tools.ts` (staged receipt fields with `pending_id` and next command); `tests/core/brain/pending-lanes.test.ts`, extensions of the pending/dry-run/CLI suites and the batch suites. +- **Acceptance**: toggles absent - every existing pending, note, batch, ingest and feedback suite passes unmodified (byte-identical published paths); `write_approval.notes: true` stages `brain_create_note` and batch create ops as byte-for-byte documents whose apply reproduces the published bytes exactly (round-trip encode tests over CJK, spaces, dots, nested paths; over-long targets refuse by name); apply moves into an occupied target refusing with `PendingApplyConflictError`; update/append against published notes stay direct under the gate; `write_approval.ingest: true` stages the summary page while registration completes and cleanup removes the staged page; `o2b brain pending list` shows all lanes sorted with `--lane` filtering and named-unreadable entries; dry runs run every check and write nothing. +- **Depends on**: Task 6 (the `write-gate.ts` leaf and its config keys) + +### Task 10: Open-decision vault (Lane E) +- **Lane**: E. **Files**: new `src/core/brain/decisions/open-store.ts` (frozen key table, JSON-quoted values, `## Question`/`## Options`/`## Context` body sections, named-unreadable reads, directory lock, dedup, transitions); new `src/core/brain/decisions/brief.ts` (render-only `## Open decisions` section, cap 5); `src/core/brain/decisions/receipts.ts` (`open_resolved` reason); `src/core/brain/types.ts` (log kinds `decision-open`, `decision-resolved`, `decision-discarded`); `src/mcp/brain/decisions-tools.ts` (actions `open | list_open | show_open | resolve | discard` on the existing tool, reach `readable` predicate as today); `src/cli/brain/verbs/decision.ts` (action mirror); `src/mcp/brain/brief-tools.ts` + `src/cli/brain/verbs/morning-brief.ts` (section wiring); shared-append registration; `tests/core/brain/decisions/open-store.test.ts`, extensions of `tests/mcp/decision-tool.test.ts` and `tests/cli/brain-decision.test.ts`, brief suite, verdict-vocabulary census registration. +- **Acceptance**: an open record with two enumerated options round-trips through hand-edit-tolerant parsing; a duplicate question (same normalized hash) refuses naming the existing id; resolve mints a real `decision-` page via `recordDecision` with the chosen option, stamps the open record `resolved` with the `[[decision-]]` pointer, and lands exactly one `open_resolved` receipt; discard records the reason; terminal records stay in place and `list` partitions by status with unreadable entries named; resolving or discarding a missing id is a typed error; the morning brief renders at most five open records plus the unreadable block and never mutates them; every writer carries the vault-identity guard and rewrites under the directory lock. +- **Depends on**: none + +### Task 11: Ambient consent and TTL (Lane E) +- **Lane**: E. **Files**: `src/core/brain/policy/blocks/guardrails.ts` (`ambient_writeback` boolean, `ambient_ttl_days` non-negative integer in `KNOWN_KEYS` with resolver + defaults); `src/core/brain/fact-extract.ts` (explicit `false` suppresses ambient extraction with a counted, logged `ambient-withheld` event; `ambient_ttl_days` stamps `expiration_date` at creation through the validated chokepoint); `tests/core/brain/fact-extract.ambient.test.ts`; extensions of `tests/core/brain/fact-extract.durability.test.ts` and the expiration suites. +- **Acceptance**: keys absent - the extraction lane and its tests are byte-identical; explicit `false` suppresses with one counted event per capture and no signal written; `ambient_ttl_days: N` stamps `expiration_date = created + N` on ambient-extracted signals which `filterExpired` then drops at read; a non-boolean or negative value is a hard field-named config error; staging composes (a TTL-stamped signal still stages when the signals gate is on, expiration preserved verbatim through apply). +- **Depends on**: none + +### Task 12: Document-backed dispositions and the force-confirmed rule (Lane C) +- **Lane**: C. **Files**: `src/core/brain/pending/pending-lanes.ts` (`resolveWriteDisposition` consults `loadPermissionsDocument` + `resolvePermission` when a document exists; deny -> typed `WriteRefusedError` naming principal, action, rule and next command; ask -> stage; ledger rows via the Task 2 recorder for stage and refuse); `src/mcp/brain/feedback-tools.ts` (under a document, `force_confirmed` requires the caller's `write` verdict to be `allow`, else the named refusal); `tests/core/brain/pending-lanes.test.ts` document cases; an MCP-level refusal-shape test. +- **Acceptance**: no document - dispositions come from the write-approval keys exactly as in Task 9 (all Task 9 assertions still hold); with a document, one write produces exactly one deciding rule and (for stage/refuse) exactly one ledger row with `source` naming the entry, role, or default; `deny` on `ingest` refuses `brain_ingest_source` before any write; a document cannot be bypassed by the lane keys (document present = document only); `force_confirmed` under a non-allow verdict refuses with `force-confirmed-requires-allow` and document-absent behavior is unchanged; the refusal token vocabulary is census-registered. +- **Depends on**: Tasks 1, 2, 9 + +### Task 13: Note-lane owner-frontmatter guard (Lane D, after the Lane C handoff) +- **Lane**: D (receives file ownership of the guard regions of `src/core/brain/write-batch.ts` and `src/core/brain/notes/create-note.ts` from Lane C at this boundary; the staging code landed in Task 9 is untouched). **Files**: `write-batch.ts` (`owner` joins the refused update-frontmatter keys under the gate via `refuseCrossOwnerWrite`), `create-note.ts` (create-time frontmatter owner guard), `tests/core/brain/owner-write-notes.test.ts` (two-state probe: a `CROSS_OWNER_MARKER` page is not writable by the wrong identity under `fail`, is writable with a ledger row under `warn`, untouched under `off`). +- **Acceptance**: gate off - the note suites from Task 9 pass unmodified; under `fail`, an update naming a foreign `owner:` refuses with `owner-write-refused` and a create carrying one refuses before any byte; matching-identity owners always pass; warn logs exactly one decision-ledger row per allowed write; the refusal answers as a named error, never as an existence leak. +- **Depends on**: Tasks 8, 9 + +### Task 14: Bootstrap command and rotation (Lane B) +- **Lane**: B. **Files**: new `src/cli/bootstrap/` (command module, receipt writer); `src/cli/main.ts` (`bootstrap` arm, `mcp token` sub-dispatcher with `mint|rotate|revoke|list`); `src/cli/command-manifest.ts` (Lane B entries); `tests/cli/bootstrap.test.ts`; `tests/cli/mcp-token.test.ts`. +- **Acceptance**: `o2b bootstrap --target codex --agent codex --token` runs the adapter's existing idempotent apply, mints `mcp_token_codex`, prints the material exactly once with a shown-once notice (never on argv, never in any harness config - the payload env block stays credential-free), and writes `.open-second-brain/bootstrap.lock.json` (schema 1, owned entries, token name and non-secret prefix, `applied_at`); a second identical run is a byte-identical no-op (exit 0, no receipt churn); `--rotate` re-mints under the same name with a `replaced: true` audit row and reprints once, and the new material authenticates on the next request with no server restart; `--check` verifies drift from `InstallEnv` alone; `--target generic` prints the payload and the manual steps; unsupported targets are refused with the available list; exit codes follow the `INSTALL_EXIT` table style; adapter-probing tests use explicit 20000 ms timeouts; Windows uses the existing launcher machinery untouched. +- **Depends on**: Tasks 3, 7 + +### Task 15: Reconciliation, docs, version (orchestrator) +- **Files**: every shared-append registration file (dispatcher, barrels, help text, manifest, `docs/cli-reference.md`, `docs/mcp.md`, `docs/observability.md` - decision ledger section, README control section); census pins reconciled by measurement (verdict vocabulary, state surfaces, destructive sites, doctor exits); `CHANGELOG.md` `[1.79.0]` with link reference; `package.json` 1.79.0 + `bun run scripts/sync-version.ts`. +- **Acceptance**: `bun run typecheck`, `bun run lint`, `bun run fmt:check`, `bun run test`, `bun run sync-version:check`, `python -m unittest discover -s tests/python` all green on the merged branch; every gate documented as default-off with its key, and every refusal token documented with its next command. +- **Depends on**: Tasks 1-14 diff --git a/docs/brainstorm/write-side-trust/variants.md b/docs/brainstorm/write-side-trust/variants.md new file mode 100644 index 00000000..8fc01879 --- /dev/null +++ b/docs/brainstorm/write-side-trust/variants.md @@ -0,0 +1,49 @@ +# Write-side trust: identity, permission, review - brainstorm audit trail + +Consultant: in-session variant analysis produced by the design orchestrator (host mode: no external consultant process was spawned; the variants below are the consultant output for this wave, mirrored verbatim in `cli-output/claude.md`). Prompt at `cli-output/prompt.md`. + +## Variants as returned + +### Variant 1: Substrate-first spine - one permissions document, one chokepoint, gates as consumers + +- **Approach**: Land identity and authorization as pure leaf modules first: a token store that maps hash-at-rest credentials to agent names, and a permissions document (`Brain/_permissions.yaml`) with a pure resolver returning allow/ask/deny per (subject, action, target). Every action gate - the staged-review lanes, the owner-write gate, the force-confirmed rule - becomes a consumer of one disposition function that consults the document, or the legacy per-feature keys when no document exists. Ask stages into a generalized multi-lane pending queue (the A3 precedent: staging is a change of directory), deny refuses with a named token, and every non-allow verdict appends one row to a decision ledger that records the rule that decided. Ambient capture ships last, strictly behind the gates. Landing order: document + resolver + ledger + token store (pure, disjoint) -> transport auth, recall exclusion, signal chokepoint, owner-write preference lane, multi-lane staging, open decisions, ambient consent (parallel, disjoint files) -> document-backed dispositions, note-lane owner guard, bootstrap (integration) -> reconciliation. +- **Trade-offs**: + - Pro: every card inherits the same substrate, so "who was allowed, asked, or denied what, by which rule" has one answer and one record. The t_29798f41 requirement (a queryable ledger replacing scattered point checks) is satisfied by construction rather than by a later reporting pass over heterogeneous gates. + - Pro: default-identity is trivially auditable: with no document, no tokens, and no keys, every consumer short-circuits to today's behavior, so the existing suites are the byte-identity proof. + - Pro: the chokepoints already exist and are proven - `writeSignal` for signals (the `targetDir` staging seam), `createNote`/`applyWriteBatch` for notes, `resolvedOwnerFor` for preferences, `admitToIndex` for recall. The wave adds predicates beside them, it does not reroute traffic. + - Pro: the ask verdict reuses the pending queue's apply/reject semantics, so the human approval door is the existing CLI door with a widened id grammar - one review UX, not one per lane. + - Con: the document resolver must be a true leaf module or the import-cycle ratchet (`tests/core/architecture/import-cycles.test.ts`) blocks the gates from consulting it; this constrains what the substrate may reuse. + - Con: five lanes touching one spine need the contract pinned in the plan (signatures, config keys, shared-append files), or the merge is where the design actually happens. + - Con: two staging sources (document vs `write_approval.*` keys) need an explicit precedence rule or operators get different answers on different days; the design pays this with "document present = document only". +- **Complexity**: medium-high +- **Risk**: low-medium (byte-identity is per-consumer and independently testable; the substrate is pure and small) + +### Variant 2: Gate-local first, document later + +- **Approach**: Ship each card on its own config keys exactly as the existing gates work: staged review extends via `write_approval.*` lane keys, the owner-write gate via a new integrity key, tokens via the transport, bootstrap as orchestration. Defer the permissions document to a later wave that retrofits a policy layer over the now-existing gates, mapping each key into a document entry. +- **Trade-offs**: + - Pro: each task is independently shippable with the smallest possible blast radius; no substrate commit needs to land first, so lanes never wait. + - Pro: no new operator-facing document to design, validate, and fail-close this wave; the `write_approval` and `integrity` key patterns are established and understood. + - Con: the approval door gets built twice - the pending queue generalization and the ledger need a verdict vocabulary now, and a later document has to either subsume the keys (a second migration) or live beside them (two sources of truth for the same question, the exact "scattered point checks" shape the card exists to remove). + - Con: the ledger records gate verdicts that have no rule identity beyond a config key; retrofitting entry/role/default provenance onto rows written by key-driven gates means a schema migration on an append-only store. + - Con: `force_confirmed` and the role-matrix gap stay unanswerable: without a document there is no principal model to hang the "requires allow" rule on, so the one bypass the recon proved ships unchanged with no decision recorded. + - Con: identity does not compose - a token identity with no document gives per-caller `brain_context` attribution and per-caller owner-scope refusal, but no per-caller write policy, so the t_85059d6d and t_29798f41 cards land as strangers. +- **Complexity**: medium (per task) but higher cumulative (double build of the door) +- **Risk**: medium (the deferred document wave re-opens every file this wave touches) + +### Variant 3: Transport middleware - decide at the dispatch boundary + +- **Approach**: Put identity, policy, and staging in one middleware layer at the MCP/CLI boundary: `authenticateRequest` resolves identity, a policy check runs before every tool handler from a table keyed by tool name, and mutating calls are redirected into review by the wrapper rather than by the write primitives. Core modules stay untouched; the permissions document is read only by the wrapper. +- **Trade-offs**: + - Pro: smallest core diff - one wrapper, one policy table, no changes to write primitives; trivially reversible. + - Pro: the dispatch seam already records refusals (the `mapFrozen` pattern at `src/mcp/server.ts:336`), so denial logging has an existing home. + - Con: the boundary is not the write seam, which this project has already learned the hard way: internal writers (dream apply, hygiene, write-session commit, session import, inline scan, capture lifecycle) never cross the dispatch wrapper, so staged review would miss exactly the bulk lanes the card names. The t_107cac80 recon shows the same lesson on the read side: the gate lives at `coerceAgentScope` because that is the one reader, not because the boundary is privileged. + - Con: CLI verbs bypass the wrapper entirely, so the operator's own paths and any script calling core directly would need a parallel enforcement story. + - Con: staging needs the resolved target path, which exists only inside the write primitives (`resolveNoteTarget`, `resolveEffectiveScope`); a wrapper can only see raw arguments, so it would re-implement path resolution or stage the unresolved name - both drift from what publish would actually write. + - Con: per-lane review granularity (stage creates, allow updates of published notes) is a property of the operation, not the tool; a tool-name table cannot express it. +- **Complexity**: small +- **Risk**: high (repeats the `o2b brain protect` failure the wave exists to fix: enforcement at a layer the writers bypass) + +## Recommendation + +Variant 1, the substrate-first spine. The wave's seven cards share one question - "may this principal do this write, and who says so" - and only Variant 1 answers it in one place. The chokepoints the gates need already exist and are census-pinned, so the spine is predicates beside proven seams rather than new plumbing; the pending queue generalization gives the ask verdict a human door that already has apply/reject semantics, tests, and a CLI. Variant 2 is honest about sequencing but builds the approval door and ledger twice and leaves `force_confirmed` unanswerable; Variant 3 is the smallest diff and the wrong layer, missing the internal and CLI writers that make up most of the write surface. From 799c986b00b5ad39a1c00ad265b1ad3145a167ee Mon Sep 17 00:00:00 2001 From: Sergey Eroshenkov Date: Sat, 10 Oct 2026 10:37:55 +0200 Subject: [PATCH 02/84] feat(search): keep the write-approval review lane out of the index --- src/core/vault-scope/index-admission.ts | 14 +++++++++- tests/core/search/index-admission.test.ts | 32 +++++++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/src/core/vault-scope/index-admission.ts b/src/core/vault-scope/index-admission.ts index ebfb3237..d43529bc 100644 --- a/src/core/vault-scope/index-admission.ts +++ b/src/core/vault-scope/index-admission.ts @@ -15,7 +15,7 @@ * canonicalises). The predicate is pure and does no I/O. */ -import { BRAIN_PAYLOADS_REL, BRAIN_STATE_REL } from "../brain/paths.ts"; +import { BRAIN_PENDING_REL, BRAIN_PAYLOADS_REL, BRAIN_STATE_REL } from "../brain/paths.ts"; import { pathCovers } from "./defaults.ts"; export interface AdmissionVerdict { @@ -48,5 +48,17 @@ export function admitToIndex(relPath: string): AdmissionVerdict { if (pathCovers(BRAIN_PAYLOADS_REL, relPath)) { return Object.freeze({ admit: false, reason: "payload-store" }); } + // The write-approval review lane: a document staged into `Brain/pending/` + // is exactly the content no operator has admitted yet, so recall must not + // surface it (write-side trust, Task 5). Before this exclusion a staged + // signal or note rode the index straight back into `brain_search` and + // recall-inject, which silently undid the gate - staging is a change of + // directory, and this is the directory the change has to close. The + // boundary is the shared `pathCovers` question: `Brain/pending` and + // `Brain/pending/x.md` are inside, `Brain/pendingfoo/x.md` merely shares + // the name prefix and stays ordinary indexed content. + if (pathCovers(BRAIN_PENDING_REL, relPath)) { + return Object.freeze({ admit: false, reason: "review-pending" }); + } return ADMIT; } diff --git a/tests/core/search/index-admission.test.ts b/tests/core/search/index-admission.test.ts index a7b8d113..c185cdfa 100644 --- a/tests/core/search/index-admission.test.ts +++ b/tests/core/search/index-admission.test.ts @@ -1,6 +1,9 @@ import { test, expect } from "bun:test"; +import { join } from "node:path"; import { admitToIndex } from "../../../src/core/vault-scope/index-admission.ts"; +import { walkVault } from "../../../src/core/search/walker.ts"; +import { createTempVault, writeMd, makeConfig } from "../../helpers/search-fixtures.ts"; test("defaults to admit for ordinary vault content", () => { expect(admitToIndex("notes/a.md").admit).toBe(true); @@ -22,3 +25,32 @@ test("does not exclude siblings that merely share the lane name prefix", () => { expect(admitToIndex("Brain/stateful/x.md").admit).toBe(true); expect(admitToIndex("Brain/state-notes.md").admit).toBe(true); }); + +test("excludes the write-approval review lane (reason review-pending)", () => { + expect(admitToIndex("Brain/pending").admit).toBe(false); + expect(admitToIndex("Brain/pending").reason).toBe("review-pending"); + expect(admitToIndex("Brain/pending/sig-2026-10-10-x.md").admit).toBe(false); + expect(admitToIndex("Brain/pending/notes/note-2026-10-10-a.md").admit).toBe(false); + expect(admitToIndex("Brain/pending/ingest/ing-2026-10-10-a.md").admit).toBe(false); +}); + +test("admits the review lane's name-prefix neighbours (pathCovers boundary)", () => { + expect(admitToIndex("Brain/pendingfoo/x.md").admit).toBe(true); + expect(admitToIndex("Brain/pending-notes.md").admit).toBe(true); +}); + +test("walker does not index Brain/pending but does index Brain/pendingfoo", () => { + const v = createTempVault("pending-admission"); + try { + writeMd(v.vault, "Brain/pending/sig-2026-10-10-staged.md", "staged signal"); + writeMd(v.vault, "Brain/pending/notes/note-2026-10-10-x.md", "staged note"); + writeMd(v.vault, "Brain/pendingfoo/x.md", "ordinary note"); + writeMd(v.vault, "Brain/active.md", "digest"); + const cfg = makeConfig({ vault: v.vault, dbPath: join(v.vault, "x.sqlite") }); + const yielded: string[] = []; + for (const f of walkVault(cfg)) yielded.push(f.relPath); + expect(yielded.toSorted()).toEqual(["Brain/active.md", "Brain/pendingfoo/x.md"]); + } finally { + v.cleanup(); + } +}); From 4bc3764495be2c13f74c59a2aef55f5c9f084419 Mon Sep 17 00:00:00 2001 From: Sergey Eroshenkov Date: Sat, 10 Oct 2026 10:45:38 +0200 Subject: [PATCH 03/84] feat(secrets): named per-agent MCP token store, hash-at-rest (write-side-trust task 3) mint/rotate/revoke/list/resolve over .open-second-brain/secrets/mcp-tokens.json (0600, custody owner ACL beside secrets.json), writes under withSecretsLock, no-values custody audit rows (mcp_token_minted|rotated|revoked), names in the mcp_token_ $secret: grammar, resolves through an mtime cache so a rotation takes effect on the next call without a restart. --- src/core/brain/secrets/store.ts | 15 + src/core/brain/secrets/token-store.ts | 355 +++++++++++++++++++ tests/core/brain/secrets/token-store.test.ts | 301 ++++++++++++++++ 3 files changed, 671 insertions(+) create mode 100644 src/core/brain/secrets/token-store.ts create mode 100644 tests/core/brain/secrets/token-store.test.ts diff --git a/src/core/brain/secrets/store.ts b/src/core/brain/secrets/store.ts index 34847e7b..0cbb767e 100644 --- a/src/core/brain/secrets/store.ts +++ b/src/core/brain/secrets/store.ts @@ -90,6 +90,17 @@ export function keyPath(vault: string): string { return join(secretsDir(vault), "keyfile"); } +/** + * The MCP token store path (write-side-trust, Task 3). Declared here so + * the custody targets below cover it with the same owner-only ACL sweep + * `setSecret` performs, and so `./token-store.ts` - which owns everything + * else about that file - shares one spelling of its location without + * this module importing back. + */ +export function tokenStorePath(vault: string): string { + return join(secretsDir(vault), "mcp-tokens.json"); +} + /** The name rule `set` enforces, shared with the bundle importer. */ export function isValidSecretName(name: string): boolean { return NAME_RE.test(name); @@ -397,6 +408,10 @@ export function custodyTargets( [keyPath(vault), "file"], ]; if (existsSync(storePath(vault))) targets.push([storePath(vault), "file"]); + // Same rule as the ciphertext store: absent until the first mint, and + // a target from then on, so every later custody write re-applies the + // owner-only ACL to both files. + if (existsSync(tokenStorePath(vault))) targets.push([tokenStorePath(vault), "file"]); return targets; } diff --git a/src/core/brain/secrets/token-store.ts b/src/core/brain/secrets/token-store.ts new file mode 100644 index 00000000..e98b74b4 --- /dev/null +++ b/src/core/brain/secrets/token-store.ts @@ -0,0 +1,355 @@ +/** + * Named per-agent MCP token store (write-side-trust, Task 3). + * + * One operator-minted credential per agent, so an HTTP caller's identity + * comes from what it PRESENTS rather than from process config. The + * store is hash-at-rest by design decision (t_85059d6d), not custody + * ciphertext: `sha256(tokenMaterial)` sits beside a non-secret prefix in + * `/.open-second-brain/secrets/mcp-tokens.json` (0600, owner ACL + * on Windows through the custody targets beside `secrets.json`), and a + * plaintext-equivalent token never exists after the mint answer returns + * it exactly once. Verification therefore never needs the passphrase + * envelope - locking the custody store must not be an authentication + * outage, which is the property the ciphertext design could not give. + * + * Every mint, rotation and revocation appends a no-values record to the + * secret-custody audit, the same trail `secrets.json` writes. Names are + * `mcp_token_` - underscores only - so a `$secret:NAME` reference + * (whose grammar admits no dashes) can address them. Reads go through an + * mtime cache, so a rotation or revocation performed by another process + * (the CLI) takes effect on this one's next resolve without a restart. + */ + +import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { createHash, randomBytes, timingSafeEqual } from "node:crypto"; + +import { renameWithRetry } from "../../fs-atomic.ts"; +import { appendAuditRecord } from "../../reliability/audit.ts"; +import { SECRET_CUSTODY_AUDIT_DIR } from "../audit-dirs.ts"; +import { brainDirsForWrite } from "../paths.ts"; +import { isoSecond } from "../time.ts"; +import { assertVaultIdentityForWrite } from "../vault-identity.ts"; +import { restrictToOwner } from "./owner-acl.ts"; +import { tokenStorePath, withSecretsLock } from "./store.ts"; + +export const MCP_TOKENS_SCHEMA_VERSION = 1; + +/** How much of the material the non-secret display prefix keeps. */ +export const TOKEN_PREFIX_LENGTH = 12; + +/** The material prefix: short, recognisable in a config, not a secret. */ +const MATERIAL_PREFIX = "osbt_"; + +/** + * `mcp_token_` - underscores only, lowercase, so the name is a + * legal `$secret:NAME` body (no dashes) and a legal env-var tail. + */ +const TOKEN_NAME_RE = /^mcp_token_[a-z0-9_]+$/; + +export type McpTokenStatus = "active" | "revoked"; + +/** The stored view of one token. No member ever carries the material. */ +export interface McpTokenRecord { + name: string; + agent: string; + status: McpTokenStatus; + /** sha256(tokenMaterial), hex - the at-rest form, verified constant-time. */ + token_hash: string; + /** A non-secret cut of the material front, for listings. */ + token_prefix: string; + created_at: string; + rotated_at?: string; +} + +interface TokenStoreFile { + readonly version: number; + readonly tokens: Record; +} + +export class McpTokenStoreError extends Error { + constructor(message: string) { + super(message); + this.name = "McpTokenStoreError"; + } +} + +const EMPTY_STORE: TokenStoreFile = Object.freeze({ + version: MCP_TOKENS_SCHEMA_VERSION, + tokens: Object.freeze({}), +}); + +/** The name rule mint/rotate/revoke enforce. */ +export function isValidMcpTokenName(name: string): boolean { + return TOKEN_NAME_RE.test(name); +} + +// ----- Writers --------------------------------------------------------------- + +/** + * Mint one named token for `agent`. The material rides the RETURN VALUE + * exactly once - print it, or lose it - and only the hash is persisted. + * An existing name (active or revoked) refuses with a message naming + * rotate: a second mint over a live name would silently orphan a + * credential some agent still holds. + */ +export function mintAgentToken( + vault: string, + name: string, + agent: string, +): { tokenMaterial: string; record: McpTokenRecord } { + assertVaultIdentityForWrite(vault); + const cleanName = validatedName(name); + const cleanAgent = validatedAgent(agent); + const tokenMaterial = mintMaterial(); + const record: McpTokenRecord = { + name: cleanName, + agent: cleanAgent, + status: "active", + token_hash: hashOf(tokenMaterial), + token_prefix: tokenMaterial.slice(0, TOKEN_PREFIX_LENGTH), + created_at: isoSecond(), + }; + withSecretsLock(vault, () => { + const file = readTokenStore(vault); + if (file.tokens[cleanName] !== undefined) { + throw new McpTokenStoreError( + `token ${JSON.stringify(cleanName)} already exists; use rotate to replace its material`, + ); + } + writeTokenStore(vault, { + version: MCP_TOKENS_SCHEMA_VERSION, + tokens: { ...file.tokens, [cleanName]: record }, + }); + }); + auditToken(vault, "mcp_token_minted", cleanName, { agent: cleanAgent }); + return { tokenMaterial, record }; +} + +/** + * Re-mint the material under an existing name: new hash, new prefix, + * `rotated_at` stamped, `created_at` kept. The old material stops + * resolving on the NEXT resolve (the mtime cache re-reads), so a server + * process needs no restart. A revoked record refuses - revocation is + * terminal; mint a new name to start over. + */ +export function rotateAgentToken( + vault: string, + name: string, +): { tokenMaterial: string; record: McpTokenRecord } { + assertVaultIdentityForWrite(vault); + const cleanName = validatedName(name); + const tokenMaterial = mintMaterial(); + let agent = ""; + withSecretsLock(vault, () => { + const file = readTokenStore(vault); + const existing = file.tokens[cleanName]; + if (existing === undefined) { + throw new McpTokenStoreError(`unknown token: ${JSON.stringify(cleanName)}`); + } + if (existing.status === "revoked") { + throw new McpTokenStoreError( + `token ${JSON.stringify(cleanName)} is revoked; mint a new name instead of rotating it`, + ); + } + agent = existing.agent; + writeTokenStore(vault, { + version: MCP_TOKENS_SCHEMA_VERSION, + tokens: { + ...file.tokens, + [cleanName]: { + ...existing, + token_hash: hashOf(tokenMaterial), + token_prefix: tokenMaterial.slice(0, TOKEN_PREFIX_LENGTH), + rotated_at: isoSecond(), + }, + }, + }); + }); + auditToken(vault, "mcp_token_rotated", cleanName, { agent, replaced: true }); + return { + tokenMaterial, + record: readTokenStore(vault).tokens[cleanName]!, + }; +} + +/** Revoke one token: the record stays (history), the material stops resolving. */ +export function revokeAgentToken(vault: string, name: string): boolean { + assertVaultIdentityForWrite(vault); + const cleanName = validatedName(name); + let revoked = false; + let agent = ""; + withSecretsLock(vault, () => { + const file = readTokenStore(vault); + const existing = file.tokens[cleanName]; + if (existing === undefined || existing.status === "revoked") return; + agent = existing.agent; + writeTokenStore(vault, { + version: MCP_TOKENS_SCHEMA_VERSION, + tokens: { ...file.tokens, [cleanName]: { ...existing, status: "revoked" } }, + }); + revoked = true; + }); + if (revoked) auditToken(vault, "mcp_token_revoked", cleanName, { agent }); + return revoked; +} + +// ----- Readers --------------------------------------------------------------- + +/** Every record, sorted by name. Metadata only - never the material. */ +export function listAgentTokens(vault: string): McpTokenRecord[] { + return Object.values(readTokenStore(vault).tokens).toSorted((a, b) => + a.name.localeCompare(b.name), + ); +} + +/** + * Resolve a presented credential to its agent, or null when nothing + * active matches. The presented material is hashed and compared against + * every stored hash with timingSafeEqual (fixed 32-byte digests), so no + * byte of a wrong answer leaks through early exit. The store is read + * behind an mtime cache, which is what lets a CLI-side rotation take + * effect here on the next request without a restart. + */ +export function resolveAgentForToken( + vault: string, + presented: string, +): { agent: string; name: string } | null { + if (typeof presented !== "string" || presented.length === 0) return null; + const index = activeHashIndex(vault); + const digest = hashOf(presented); + for (const [storedHash, entry] of index) { + if (timingSafeEqual(Buffer.from(storedHash, "hex"), Buffer.from(digest, "hex"))) { + return { agent: entry.agent, name: entry.name }; + } + } + return null; +} + +// ----- Store file ------------------------------------------------------------ + +function validatedName(name: string): string { + const trimmed = name.trim(); + if (!TOKEN_NAME_RE.test(trimmed)) { + throw new McpTokenStoreError( + `token name must be mcp_token_ (lowercase [a-z0-9_]): ${JSON.stringify(name)}`, + ); + } + return trimmed; +} + +function validatedAgent(agent: string): string { + const trimmed = agent.trim(); + if (trimmed.length === 0) throw new McpTokenStoreError("token agent must not be empty"); + return trimmed; +} + +function mintMaterial(): string { + return `${MATERIAL_PREFIX}${randomBytes(32).toString("base64url")}`; +} + +function hashOf(material: string): string { + return createHash("sha256").update(material).digest("hex"); +} + +function readTokenStore(vault: string): TokenStoreFile { + const path = tokenStorePath(vault); + if (!existsSync(path)) return EMPTY_STORE; + // Same custody posture as `secrets.json`: a store that arrived by copy + // keeps whatever ACL it came with, and the read re-applies owner-only. + if (process.platform === "win32") restrictToOwner(path, "file"); + const parsed: unknown = JSON.parse(readFileSync(path, "utf8")); + if ( + parsed === null || + typeof parsed !== "object" || + (parsed as { version?: unknown }).version !== MCP_TOKENS_SCHEMA_VERSION + ) { + throw new McpTokenStoreError(`MCP token store is corrupt or from a newer version: ${path}`); + } + const tokens = (parsed as { tokens?: unknown }).tokens; + if (tokens === null || typeof tokens !== "object" || Array.isArray(tokens)) { + throw new McpTokenStoreError(`MCP token store is corrupt: ${path}`); + } + const records: Record = {}; + for (const [key, value] of Object.entries(tokens as Record)) { + const record = value as Partial | null; + if ( + record === null || + typeof record !== "object" || + typeof record.name !== "string" || + typeof record.agent !== "string" || + (record.status !== "active" && record.status !== "revoked") || + typeof record.token_hash !== "string" || + !/^[0-9a-f]{64}$/.test(record.token_hash) + ) { + throw new McpTokenStoreError( + `MCP token store entry ${JSON.stringify(key)} is corrupt: ${path}`, + ); + } + records[key] = record as McpTokenRecord; + } + return { version: MCP_TOKENS_SCHEMA_VERSION, tokens: records }; +} + +function writeTokenStore(vault: string, file: TokenStoreFile): void { + const path = tokenStorePath(vault); + const tmp = `${path}.tmp`; + writeFileSync(tmp, JSON.stringify(file, null, 2) + "\n", { mode: 0o600 }); + renameWithRetry(tmp, path); + // The writer is the one reader whose cache must not survive its own + // write: dropping the entry makes the next resolve re-stat, so an + // in-process rotation takes effect even where the filesystem's mtime + // granularity would have shown the old entry as fresh. + cacheByPath.delete(path); +} + +// ----- mtime cache ----------------------------------------------------------- + +interface CacheEntry { + readonly mtimeMs: number; + readonly size: number; + readonly index: ReadonlyMap; +} + +/** The active-token hash index per store path, refreshed when the file changes. */ +const cacheByPath = new Map(); + +const EMPTY_INDEX: ReadonlyMap = new Map(); + +function activeHashIndex(vault: string): ReadonlyMap { + const path = tokenStorePath(vault); + if (!existsSync(path)) { + cacheByPath.delete(path); + return EMPTY_INDEX; + } + const stats = statSync(path); + const cached = cacheByPath.get(path); + if (cached !== undefined && cached.mtimeMs === stats.mtimeMs && cached.size === stats.size) { + return cached.index; + } + const index = new Map(); + for (const record of Object.values(readTokenStore(vault).tokens)) { + if (record.status !== "active") continue; + index.set(record.token_hash, { agent: record.agent, name: record.name }); + } + cacheByPath.set(path, { mtimeMs: stats.mtimeMs, size: stats.size, index }); + return index; +} + +// ----- Audit ----------------------------------------------------------------- + +function auditToken( + vault: string, + action: "mcp_token_minted" | "mcp_token_rotated" | "mcp_token_revoked", + name: string, + details: Record, +): void { + appendAuditRecord(join(brainDirsForWrite(vault).log, SECRET_CUSTODY_AUDIT_DIR), { + timestamp: new Date().toISOString(), + actor: "cli", + action, + target: name, + ok: true, + details, + }); +} diff --git a/tests/core/brain/secrets/token-store.test.ts b/tests/core/brain/secrets/token-store.test.ts new file mode 100644 index 00000000..c559eea0 --- /dev/null +++ b/tests/core/brain/secrets/token-store.test.ts @@ -0,0 +1,301 @@ +/** + * Named per-agent MCP token store (write-side-trust, Task 3). + * + * Hash-at-rest: the store keeps sha256(tokenMaterial) beside a + * non-secret prefix, and the minted material exists in plaintext only + * for the moment the mint answer carries it - shown once, never + * stored, never audited. Every credential-shaped literal here is + * assembled by `fakeCredential`, so no source line carries a string + * shaped like a real token. + */ + +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; +import { createHash } from "node:crypto"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import lockfile from "proper-lockfile"; + +import { + MCP_TOKENS_SCHEMA_VERSION, + TOKEN_PREFIX_LENGTH, + listAgentTokens, + mintAgentToken, + resolveAgentForToken, + revokeAgentToken, + rotateAgentToken, +} from "../../../../src/core/brain/secrets/token-store.ts"; +import { + secretsDir, + tokenStorePath, + withSecretsLock, +} from "../../../../src/core/brain/secrets/store.ts"; +import { fakeCredential } from "../../../helpers/fake-credentials.ts"; +import { IS_WINDOWS } from "../../../helpers/platform.ts"; + +let tempRoot: string; +let vault: string; + +beforeEach(() => { + // A vault name with spaces and CJK characters: the store round-trips + // vault paths no filesystem grammar should have to care about. + tempRoot = mkdtempSync(join(tmpdir(), "o2b-token-store-")); + vault = join(tempRoot, "brain vault 東"); + mkdirSync(vault, { recursive: true }); +}); + +afterEach(() => { + rmSync(tempRoot, { recursive: true, force: true }); +}); + +/** sha256 hex, the at-rest form the store persists. */ +function hashOf(material: string): string { + return createHash("sha256").update(material).digest("hex"); +} + +function mint(name = "mcp_token_codex", agent = "codex") { + return mintAgentToken(vault, name, agent); +} + +function auditActions(): string[] { + const auditDir = join(vault, "Brain", "log", "secret-custody"); + if (!existsSync(auditDir)) return []; + return readdirSync(auditDir) + .flatMap((f) => readFileSync(join(auditDir, f), "utf8").split("\n")) + .filter((l) => l.trim().length > 0) + .map((l) => (JSON.parse(l) as { action: string }).action); +} + +function auditLines(): string[] { + const auditDir = join(vault, "Brain", "log", "secret-custody"); + return readdirSync(auditDir) + .flatMap((f) => readFileSync(join(auditDir, f), "utf8").split("\n")) + .filter((l) => l.trim().length > 0); +} + +describe("mintAgentToken", () => { + test("returns material exactly once and persists only the hash plus a non-secret prefix", () => { + const { tokenMaterial, record } = mint(); + expect(tokenMaterial).toMatch(/^osbt_/); + expect(record).toMatchObject({ + name: "mcp_token_codex", + agent: "codex", + status: "active", + token_hash: hashOf(tokenMaterial), + created_at: expect.any(String), + }); + // The prefix is a display aid, not a credential: a cut of the front + // of the material, never the whole of it. + expect(record.token_prefix).toBe(tokenMaterial.slice(0, TOKEN_PREFIX_LENGTH)); + expect(record.token_prefix.length).toBeLessThan(tokenMaterial.length); + + const raw = readFileSync(tokenStorePath(vault), "utf8"); + expect(raw).not.toContain(tokenMaterial); + const parsed = JSON.parse(raw) as { + version: number; + tokens: Record; + }; + expect(parsed.version).toBe(MCP_TOKENS_SCHEMA_VERSION); + expect(parsed.tokens["mcp_token_codex"]?.token_hash).toBe(hashOf(tokenMaterial)); + }); + + test("the store file is created 0600 on POSIX", () => { + if (IS_WINDOWS) return; // access there is the owner ACL, pinned via custodyTargets + mint(); + const mode = statSync(tokenStorePath(vault)).mode & 0o777; + expect(mode).toBe(0o600); + }); + + test("a fresh vault keeps the store absent until the first mint", () => { + expect(listAgentTokens(vault)).toEqual([]); + expect(resolveAgentForToken(vault, fakeCredential("osbt_", "nothing-here"))).toBeNull(); + expect(existsSync(secretsDir(vault))).toBe(false); + }); + + test("names validate mcp_token_ in the $secret: grammar", () => { + expect(() => mint("codex")).toThrow(/name/); + expect(() => mint("mcp_token_")).toThrow(/name/); + expect(() => mint("MCP_TOKEN_CODEX")).toThrow(/name/); + expect(() => mint("mcp-token-codex")).toThrow(/name/); + expect(() => mint("mcp_token_codex", " ")).toThrow(/agent/); + }); + + test("an existing name refuses mint and names rotate", () => { + mint(); + expect(() => mint()).toThrow(/rotate/); + }); + + test("writes serialize under the secrets store lock", () => { + // The lock anchors on the secrets directory, which a first write + // creates through the keyfile path; build it here so the held lock + // describes the same directory a real writer would hold. + mkdirSync(secretsDir(vault), { recursive: true }); + const release = lockfile.lockSync(secretsDir(vault), { stale: 10_000, realpath: false }); + try { + expect(() => mint()).toThrow(/secrets store lock/); + } finally { + void release(); + } + mint(); + expect(listAgentTokens(vault)).toHaveLength(1); + }); + + test("two named tokens coexist and land one mint audit record each", () => { + mint("mcp_token_codex", "codex"); + mint("mcp_token_grok", "grok"); + expect(listAgentTokens(vault).map((t) => t.name)).toEqual([ + "mcp_token_codex", + "mcp_token_grok", + ]); + expect(auditActions().filter((a) => a === "mcp_token_minted")).toHaveLength(2); + }); +}); + +describe("listAgentTokens", () => { + test("sorted by name, never carrying the material", () => { + const grok = mint("mcp_token_grok", "grok"); + const codex = mint("mcp_token_codex", "codex"); + const listed = listAgentTokens(vault); + expect(listed.map((t) => t.name)).toEqual(["mcp_token_codex", "mcp_token_grok"]); + expect(JSON.stringify(listed)).not.toContain(codex.tokenMaterial); + expect(JSON.stringify(listed)).not.toContain(grok.tokenMaterial); + }); +}); + +describe("resolveAgentForToken", () => { + test("matches the presented material by hash; unknown material answers null", () => { + const { tokenMaterial } = mint(); + expect(resolveAgentForToken(vault, tokenMaterial)).toEqual({ + agent: "codex", + name: "mcp_token_codex", + }); + expect(resolveAgentForToken(vault, fakeCredential("osbt_", "unknown-material"))).toBeNull(); + expect(resolveAgentForToken(vault, "")).toBeNull(); + expect(resolveAgentForToken(vault, tokenMaterial.slice(0, 8))).toBeNull(); + }); + + test("a revoked token answers null, exactly like an unknown one", () => { + const { tokenMaterial } = mint(); + expect(revokeAgentToken(vault, "mcp_token_codex")).toBe(true); + expect(resolveAgentForToken(vault, tokenMaterial)).toBeNull(); + expect(listAgentTokens(vault)[0]?.status).toBe("revoked"); + }); + + test("a rotation takes effect on the next call without any restart", () => { + const first = mint(); + expect(resolveAgentForToken(vault, first.tokenMaterial)?.agent).toBe("codex"); + const second = rotateAgentToken(vault, "mcp_token_codex"); + expect(second.record.status).toBe("active"); + expect(second.record.rotated_at).toEqual(expect.any(String)); + expect(resolveAgentForToken(vault, first.tokenMaterial)).toBeNull(); + expect(resolveAgentForToken(vault, second.tokenMaterial)).toEqual({ + agent: "codex", + name: "mcp_token_codex", + }); + }); + + test("a store rewritten by another process is picked up through the mtime cache", () => { + // Simulate a rotation performed by a different process (the CLI) + // while a long-lived server holds its read cache: rewrite the store + // file out from under the cache and resolve again. + const stale = mint(); + expect(resolveAgentForToken(vault, stale.tokenMaterial)?.agent).toBe("codex"); + const elsewhere = fakeCredential("osbt_", "rotated-elsewhere-9c22"); + const file = JSON.parse(readFileSync(tokenStorePath(vault), "utf8")) as { + version: number; + tokens: Record; + }; + file.tokens["mcp_token_codex"] = { + name: "mcp_token_codex", + agent: "codex", + status: "active", + token_hash: hashOf(elsewhere), + token_prefix: elsewhere.slice(0, TOKEN_PREFIX_LENGTH), + created_at: "2026-06-05T10:00:00Z", + }; + writeFileSync(tokenStorePath(vault), JSON.stringify(file, null, 2) + "\n", { mode: 0o600 }); + expect(resolveAgentForToken(vault, stale.tokenMaterial)).toBeNull(); + expect(resolveAgentForToken(vault, elsewhere)?.agent).toBe("codex"); + }); +}); + +describe("rotateAgentToken", () => { + test("re-mints under the same name and keeps created_at", () => { + const first = mint(); + const second = rotateAgentToken(vault, "mcp_token_codex"); + expect(second.record.created_at).toBe(first.record.created_at); + expect(second.record.token_hash).not.toBe(first.record.token_hash); + expect(listAgentTokens(vault)).toHaveLength(1); + }); + + test("an unknown name refuses by name; a revoked one stays revoked", () => { + expect(() => rotateAgentToken(vault, "mcp_token_ghost")).toThrow(/unknown token/); + mint(); + revokeAgentToken(vault, "mcp_token_codex"); + expect(() => rotateAgentToken(vault, "mcp_token_codex")).toThrow(/revoked/); + }); + + test("revoke then revoke again answers true then false, one audit record", () => { + mint(); + expect(revokeAgentToken(vault, "mcp_token_codex")).toBe(true); + expect(revokeAgentToken(vault, "mcp_token_codex")).toBe(false); + expect(auditActions().filter((a) => a === "mcp_token_revoked")).toHaveLength(1); + }); +}); + +describe("custody audit", () => { + test("mint, rotate and revoke land no-values records; material never appears", () => { + const minted = mint(); + rotateAgentToken(vault, "mcp_token_codex"); + revokeAgentToken(vault, "mcp_token_codex"); + const actions = auditActions(); + expect(actions).toContain("mcp_token_minted"); + expect(actions).toContain("mcp_token_rotated"); + expect(actions).toContain("mcp_token_revoked"); + const everything = auditLines().join("\n"); + expect(everything).not.toContain(minted.tokenMaterial); + expect(everything).not.toContain(minted.record.token_hash); + }); + + test("the rotation audit row carries replaced: true", () => { + mint(); + rotateAgentToken(vault, "mcp_token_codex"); + const auditDir = join(vault, "Brain", "log", "secret-custody"); + const rows = readdirSync(auditDir) + .flatMap((f) => readFileSync(join(auditDir, f), "utf8").split("\n")) + .filter((l) => l.trim().length > 0) + .map((l) => JSON.parse(l) as { action: string; details?: Record }); + const rotated = rows.filter((r) => r.action === "mcp_token_rotated"); + expect(rotated).toHaveLength(1); + expect(rotated[0]?.details).toMatchObject({ replaced: true, agent: "codex" }); + }); +}); + +describe("the Windows custody ACL covers the token store", () => { + test("an existing mcp-tokens.json joins custodyTargets", () => { + mint(); + const { custodyTargets } = require("../../../../src/core/brain/secrets/store.ts") as { + custodyTargets: (v: string) => ReadonlyArray; + }; + expect(custodyTargets(vault).map(([p]) => p)).toContain(tokenStorePath(vault)); + }); +}); + +describe("the store lock stays with the store module", () => { + test("withSecretsLock serialises a token write the same as a custody write", () => { + let ran = false; + withSecretsLock(vault, () => { + ran = true; + }); + expect(ran); + }); +}); From 17a90756cae03357f3487cc306e5c1b8e9447148 Mon Sep 17 00:00:00 2001 From: Sergey Eroshenkov Date: Sat, 10 Oct 2026 10:47:55 +0200 Subject: [PATCH 04/84] feat(brain): resolve the signal-lane review gate inside writeSignal --- src/core/brain/pending.ts | 31 ++-- src/core/brain/signal.ts | 25 ++- src/core/brain/write-gate.ts | 122 +++++++++++++++ src/mcp/brain/feedback-tools.ts | 7 + .../verdict-vocabulary-census.test.ts | 12 +- tests/core/brain/vault-identity.test.ts | 96 ++++++++++++ tests/core/brain/write-gate.test.ts | 148 ++++++++++++++++++ tests/mcp/brain-feedback-staged.test.ts | 103 ++++++++++++ 8 files changed, 528 insertions(+), 16 deletions(-) create mode 100644 src/core/brain/write-gate.ts create mode 100644 tests/core/brain/write-gate.test.ts create mode 100644 tests/mcp/brain-feedback-staged.test.ts diff --git a/src/core/brain/pending.ts b/src/core/brain/pending.ts index 3f3f4f70..cd6187bb 100644 --- a/src/core/brain/pending.ts +++ b/src/core/brain/pending.ts @@ -26,29 +26,34 @@ import { existsSync, readFileSync, readdirSync, unlinkSync } from "node:fs"; import { join } from "node:path"; import { atomicCreateFileSyncExclusive } from "../fs-atomic.ts"; -import { discoverConfig } from "../config.ts"; import type { FrontmatterMap } from "../types.ts"; import { parseFrontmatter, writeFrontmatterAtomic } from "../vault.ts"; import { brainDirs, brainDirsForWrite, ensureInsideVault } from "./paths.ts"; import { parseSignal, writeSignal, type WriteSignalInput } from "./signal.ts"; import type { BrainSignal } from "./types.ts"; +import { + WRITE_APPROVAL_ENABLED_CONFIG_KEY, + WRITE_APPROVAL_ENABLED_ENV_KEY, + resolveWriteApprovalLane, +} from "./write-gate.ts"; -/** Config key / env twin for the opt-in write-approval queue (default off). */ -export const WRITE_APPROVAL_ENABLED_CONFIG_KEY = "write_approval.enabled"; -export const WRITE_APPROVAL_ENABLED_ENV_KEY = "OPEN_SECOND_BRAIN_WRITE_APPROVAL_ENABLED"; +/** + * Config key / env twin for the opt-in write-approval queue (default off). + * Declared in `write-gate.ts` beside the per-lane keys and re-exported here + * so the historical import path keeps working. + */ +export { WRITE_APPROVAL_ENABLED_CONFIG_KEY, WRITE_APPROVAL_ENABLED_ENV_KEY }; /** - * Resolve the write-approval toggle (env wins over config file), mirroring - * the A1/A2 flat-key resolvers. Default OFF: absent / any non-`true` value - * keeps the direct-to-inbox behaviour byte-for-byte. + * Resolve the write-approval toggle (env wins over config file). + * + * The signals lane of the write-side-trust gate - the master key IS the + * signals key, so this is `resolveWriteApprovalLane("signals")` spelled + * the way every existing caller imports it. Default OFF: absent / any + * non-`true` value keeps the direct-to-inbox behaviour byte-for-byte. */ export function resolveWriteApprovalEnabled(configPath?: string): boolean { - const env = process.env[WRITE_APPROVAL_ENABLED_ENV_KEY]; - const raw = - env !== undefined && env !== "" - ? env - : discoverConfig(configPath).data[WRITE_APPROVAL_ENABLED_CONFIG_KEY]; - return typeof raw === "string" && raw.trim().toLowerCase() === "true"; + return resolveWriteApprovalLane("signals", configPath); } /** A signal basename shape: `sig--` (no path separators). */ diff --git a/src/core/brain/signal.ts b/src/core/brain/signal.ts index 00f0c16e..24bde73b 100644 --- a/src/core/brain/signal.ts +++ b/src/core/brain/signal.ts @@ -45,6 +45,7 @@ import { import { EXPIRATION_DATE_FIELD, normalizeExpirationDate } from "./expiration.ts"; import { writeFrontmatterAtomic, parseFrontmatter, slugify } from "../vault.ts"; import { requireObsidianTagValue } from "./tag-syntax.ts"; +import { resolveWriteApprovalLane, REVIEW_LANE } from "./write-gate.ts"; import { compress, expand, CODEC_VERSION } from "./portability/codec.ts"; import { allocateAndCreate, brainDirsForWrite, validateIsoDate } from "./paths.ts"; import { @@ -238,6 +239,15 @@ export function resolveEffectiveScope( export interface WriteSignalResult { readonly path: string; readonly id: string; + /** + * True when the write-approval review gate resolved ON for the signals + * lane and the signal was staged into `Brain/pending/` instead of + * `Brain/inbox/` (write-side trust, Task 6). The document is + * byte-for-byte identical either way - staging is purely a change of + * directory - and apply moves it verbatim. False on every direct + * write, and false on a deduped no-op (a dedup staged nothing). + */ + readonly staged: boolean; /** * Set to `true` when an `idempotency_key` matched a prior write with an * identical payload, so this call was a deduped no-op (no new file). The @@ -345,6 +355,7 @@ export function writeSignal( return { path: ref.path ? join(vault, ref.path) : "", id: ref.id ?? "", + staged: false, deduped: true, }; } @@ -356,6 +367,16 @@ export function writeSignal( // materializes a mis-resolved root, so it asserts the vault identity // before allocating a filename under it. const dirs = brainDirsForWrite(vault); + // Review gate (write-side trust, Task 6). Resolved ONLY when the caller + // named no targetDir: an explicit target is the staging path itself + // (`stagePendingSignal`), and it must never re-enter the gate it feeds. + // When the signals lane resolves on, the signal stages into + // `Brain/pending/` with byte-identical bytes; every ungated caller + // (MCP and CLI feedback, inline scan, session import, session + // lifecycle, session checkpoint) inherits the gate here at the + // chokepoint with no change of its own. + const gateStaged = + options.targetDir === undefined && resolveWriteApprovalLane(REVIEW_LANE.signals); const prefix = signalPrefix(sanitised.date); // Allocation and creation are one step (#161): a signal write is the @@ -367,7 +388,7 @@ export function writeSignal( allocateAndCreate( { vault, - targetDir: options.targetDir ?? dirs.inbox, + targetDir: options.targetDir ?? (gateStaged ? dirs.pending : dirs.inbox), prefix, slug: sanitised.slug, maxAttempts: options.maxSlugAttempts, @@ -406,7 +427,7 @@ export function writeSignal( ); } - return { path: allocated.path, id }; + return { path: allocated.path, id, staged: gateStaged }; } /** diff --git a/src/core/brain/write-gate.ts b/src/core/brain/write-gate.ts new file mode 100644 index 00000000..b6d31e34 --- /dev/null +++ b/src/core/brain/write-gate.ts @@ -0,0 +1,122 @@ +/** + * Write-approval lane toggle resolver (write-side trust, Task 6). + * + * One leaf module owns every `write_approval.*` key so the gates that + * consume them cannot drift apart. There are three review lanes - + * `signals` (the writeSignal chokepoint), `notes` (note creates) and + * `ingest` (the ingest summary page) - and one master key. Resolution + * order per lane: the lane's own key, then the master + * `write_approval.enabled`, then off. The env twin wins over the config + * value per key, exactly as every other flat-key resolver in this + * project. The signals lane declares no key of its own: the master key + * IS the signals key, which is what keeps the pre-existing toggle's + * meaning ("stage everything") unchanged. + * + * A lane key present with any non-empty value decides for that lane - + * `true` turns the lane on, anything else holds it off even when the + * master is on - so an operator can gate bulk ingest without gating + * interactive feedback. Absent falls through to the master; an empty + * env value counts as unset. + * + * This module imports only the config reader. `signal.ts` consumes it + * BELOW the pending module (pending imports signal, so signal cannot + * import pending), and the disposition layer in `pending-lanes.ts` + * builds on the same resolver. + */ + +import { discoverConfig } from "../config.ts"; + +/** Config key / env twin for the master write-approval toggle (default off). */ +export const WRITE_APPROVAL_ENABLED_CONFIG_KEY = "write_approval.enabled"; +export const WRITE_APPROVAL_ENABLED_ENV_KEY = "OPEN_SECOND_BRAIN_WRITE_APPROVAL_ENABLED"; + +/** Config key / env twin for the notes lane. Absent falls back to the master. */ +export const WRITE_APPROVAL_NOTES_CONFIG_KEY = "write_approval.notes"; +export const WRITE_APPROVAL_NOTES_ENV_KEY = "OPEN_SECOND_BRAIN_WRITE_APPROVAL_NOTES_ENABLED"; + +/** Config key / env twin for the ingest lane. Absent falls back to the master. */ +export const WRITE_APPROVAL_INGEST_CONFIG_KEY = "write_approval.ingest"; +export const WRITE_APPROVAL_INGEST_ENV_KEY = "OPEN_SECOND_BRAIN_WRITE_APPROVAL_INGEST_ENABLED"; + +/** + * The lanes a write can be reviewed through. A closed vocabulary: the + * lane name is both the config-key selector and the `Brain/pending/` + * subdirectory a staged document lands in. + */ +export const REVIEW_LANE = Object.freeze({ + signals: "signals", + notes: "notes", + ingest: "ingest", +} as const); + +/** Review-lane union. */ +export type ReviewLane = (typeof REVIEW_LANE)[keyof typeof REVIEW_LANE]; + +/** Every review lane, in registry order. */ +export const REVIEW_LANES: ReadonlyArray = Object.freeze([ + REVIEW_LANE.signals, + REVIEW_LANE.notes, + REVIEW_LANE.ingest, +]); + +/** Narrow an unvalidated config or argument value to a {@link ReviewLane}. */ +export function isReviewLane(value: unknown): value is ReviewLane { + return typeof value === "string" && (REVIEW_LANES as ReadonlyArray).includes(value); +} + +/** The config key and env twin that decide one lane. */ +const LANE_KEYS: Readonly> = + Object.freeze({ + [REVIEW_LANE.signals]: { + config: WRITE_APPROVAL_ENABLED_CONFIG_KEY, + env: WRITE_APPROVAL_ENABLED_ENV_KEY, + }, + [REVIEW_LANE.notes]: { + config: WRITE_APPROVAL_NOTES_CONFIG_KEY, + env: WRITE_APPROVAL_NOTES_ENV_KEY, + }, + [REVIEW_LANE.ingest]: { + config: WRITE_APPROVAL_INGEST_CONFIG_KEY, + env: WRITE_APPROVAL_INGEST_ENV_KEY, + }, + }); + +/** + * Read one toggle's raw value with the shared precedence: a non-empty env + * value wins; otherwise the config value when it is a non-empty string. + * `undefined` means the key is unset and the next link in the resolution + * chain decides. + */ +function rawToggleValue( + keys: { readonly config: string; readonly env: string }, + data: Readonly>, +): string | undefined { + const env = process.env[keys.env]; + if (env !== undefined && env !== "") return env; + const raw = data[keys.config]; + if (typeof raw !== "string") return undefined; + const trimmed = raw.trim(); + return trimmed === "" ? undefined : trimmed; +} + +/** The repo's one spelling of "on" for a flat toggle key. */ +function isTrue(raw: string): boolean { + return raw.trim().toLowerCase() === "true"; +} + +/** + * Resolve the write-approval toggle for one review lane. + * + * Lane key, then master `write_approval.enabled`, then off - with the + * env twin winning per key. Default OFF everywhere: absent keys and an + * absent config file keep every direct-to-lane write path byte-identical. + */ +export function resolveWriteApprovalLane(lane: ReviewLane, configPath?: string): boolean { + const data = discoverConfig(configPath).data; + if (lane !== REVIEW_LANE.signals) { + const laneRaw = rawToggleValue(LANE_KEYS[lane], data); + if (laneRaw !== undefined) return isTrue(laneRaw); + } + const masterRaw = rawToggleValue(LANE_KEYS[REVIEW_LANE.signals], data); + return masterRaw !== undefined && isTrue(masterRaw); +} diff --git a/src/mcp/brain/feedback-tools.ts b/src/mcp/brain/feedback-tools.ts index cefdd690..0eb85691 100644 --- a/src/mcp/brain/feedback-tools.ts +++ b/src/mcp/brain/feedback-tools.ts @@ -204,6 +204,8 @@ async function toolBrainFeedback( return { kind: "signal", deduped: true, + // A dedup wrote nothing, so it staged nothing. + staged: false, signal_path: vaultRelativeSafe(ctx.vault, sigResult.path), signal_absolute_path: resolve(sigResult.path), signal_id: sigResult.id, @@ -334,6 +336,11 @@ async function toolBrainFeedback( // the one composer: an agent that learns the exit on one surface // reads it on the other. ...captureRoutingHintField(routingHint), + // Write-side trust (Task 6): the gate resolved inside writeSignal, so + // the receipt says which directory the bytes actually landed in. An + // agent that reads `staged: true` knows the signal is awaiting review + // under `Brain/pending/`, not recalled from `Brain/inbox/`. + staged: sigResult.staged, signal_path: vaultRelativeSafe(ctx.vault, sigResult.path), signal_absolute_path: resolve(sigResult.path), signal_id: sigResult.id, diff --git a/tests/core/architecture/verdict-vocabulary-census.test.ts b/tests/core/architecture/verdict-vocabulary-census.test.ts index 0be82e55..69a107c1 100644 --- a/tests/core/architecture/verdict-vocabulary-census.test.ts +++ b/tests/core/architecture/verdict-vocabulary-census.test.ts @@ -147,6 +147,7 @@ import { SNAPSHOT_PRUNE_REFUSALS, } from "../../../src/core/brain/snapshot.ts"; import { GATE_MODE, GATE_MODES, isGateMode } from "../../../src/core/integrity/stamp.ts"; +import { REVIEW_LANE, REVIEW_LANES, isReviewLane } from "../../../src/core/brain/write-gate.ts"; import { isToolScope, TOOL_SCOPE, TOOL_SCOPES } from "../../../src/mcp/tool-contract.ts"; import { isRuntimeTarget, @@ -1511,6 +1512,15 @@ const CENSUS: ReadonlyArray = Object.freeze([ members: HARNESS_IDS, guard: isHarnessId, }, + { + // The review lanes a write can be staged through (write-side trust): + // the lane name selects the `write_approval.*` key and names the + // `Brain/pending/` subdirectory a staged document lands in. + name: "REVIEW_LANE", + values: REVIEW_LANE, + members: REVIEW_LANES, + guard: isReviewLane, + }, ]); // --------------------------------------------------------------------------- @@ -1775,7 +1785,7 @@ const SCANNED = scanVocabularies(SOURCE_TREE); * How many four-piece vocabularies `src/` currently holds. Measured, and * kept as an equality rather than a floor - see the population test. */ -const VOCABULARY_POPULATION = 92; +const VOCABULARY_POPULATION = 93; const REGISTERED = new Map(CENSUS.map((entry) => [entry.name, entry] as const)); describe("verdict vocabulary census", () => { diff --git a/tests/core/brain/vault-identity.test.ts b/tests/core/brain/vault-identity.test.ts index b5fe9416..57bfa790 100644 --- a/tests/core/brain/vault-identity.test.ts +++ b/tests/core/brain/vault-identity.test.ts @@ -1070,3 +1070,99 @@ describe("bootstrap path", () => { expect(notices).toHaveLength(0); }); }); + +/** + * The signal-lane review gate (write-side trust, Task 6). Resolved inside + * `writeSignal` when the caller names no `targetDir`; `stagePendingSignal` + * passes one explicitly and must never re-enter the gate. The gate reads + * the process config path, so these cases drive it through the env twin. + */ +describe("writeSignal review gate", () => { + const GATED_INPUT = { + topic: "gate-topic", + signal: "positive", + agent: "test-agent", + principle: "stage me for review", + created_at: "2026-07-26T00:00:00Z", + date: "2026-07-26", + slug: "gate-topic", + } as const; + + const MASTER_ENV = "OPEN_SECOND_BRAIN_WRITE_APPROVAL_ENABLED"; + let savedMaster: string | undefined; + + function setGate(on: boolean): void { + if (savedMaster === undefined) savedMaster = process.env[MASTER_ENV]; + if (on) process.env[MASTER_ENV] = "true"; + else delete process.env[MASTER_ENV]; + } + + afterEach(() => { + if (savedMaster === undefined) delete process.env[MASTER_ENV]; + else process.env[MASTER_ENV] = savedMaster; + savedMaster = undefined; + }); + + test("gate off writes to the inbox with staged false", () => { + setGate(false); + const res = writeSignal(vault, GATED_INPUT); + expect(res.staged).toBe(false); + expect(res.path.startsWith(brainDirs(vault).inbox)).toBe(true); + expect(existsSync(brainDirs(vault).pending)).toBe(false); + }); + + test("gate on stages into Brain/pending with staged true", () => { + setGate(true); + const res = writeSignal(vault, GATED_INPUT); + expect(res.staged).toBe(true); + expect(res.path.startsWith(brainDirs(vault).pending)).toBe(true); + expect(readdirSync(brainDirs(vault).pending).filter((f) => f.endsWith(".md"))).toEqual([ + `${res.id}.md`, + ]); + expect(existsSync(join(brainDirs(vault).inbox, `${res.id}.md`))).toBe(false); + // The gate lives below the queue: the staged document is listed by the + // ordinary pending listing and apply consumes it unchanged. + expect(listPending(vault).map((entry) => entry.id)).toEqual([res.id]); + const applied = applyPending(vault, res.id); + expect(applied.path.startsWith(brainDirs(vault).inbox)).toBe(true); + }); + + test("the staged document is byte-identical to the inbox document", () => { + setGate(false); + const direct = writeSignal(vault, { ...GATED_INPUT, slug: "byte-compare" }); + setGate(true); + const staged = writeSignal(vault, { ...GATED_INPUT, slug: "byte-compare" }); + expect(staged.id).toBe(direct.id); + expect(readFileSync(staged.path, "utf8")).toBe(readFileSync(direct.path, "utf8")); + }); + + test("a deduped retry under the gate reports staged false and deduped true", () => { + setGate(true); + const first = writeSignal(vault, { + ...GATED_INPUT, + slug: "dedup-under-gate", + idempotency_key: "gate-dedup-1", + }); + expect(first.staged).toBe(true); + const second = writeSignal(vault, { + ...GATED_INPUT, + slug: "dedup-under-gate", + idempotency_key: "gate-dedup-1", + }); + expect(second.deduped).toBe(true); + expect(second.staged).toBe(false); + }); + + test("stagePendingSignal never re-enters the gate", () => { + setGate(true); + const res = stagePendingSignal(vault, { ...GATED_INPUT, slug: "explicit-stage" }); + // Exactly one staged document, written by the explicit path: the gate + // would have routed an ungated write to the same directory, but the + // explicit stage must not depend on (or double-fire through) it. + expect(res.path.startsWith(brainDirs(vault).pending)).toBe(true); + expect(readdirSync(brainDirs(vault).pending).filter((f) => f.endsWith(".md"))).toEqual([ + `${res.id}.md`, + ]); + expect(existsSync(join(brainDirs(vault).inbox, `${res.id}.md`))).toBe(false); + }); +}); diff --git a/tests/core/brain/write-gate.test.ts b/tests/core/brain/write-gate.test.ts new file mode 100644 index 00000000..e2f5f90a --- /dev/null +++ b/tests/core/brain/write-gate.test.ts @@ -0,0 +1,148 @@ +/** + * The write-approval lane resolver (write-side trust, Task 6). + * + * Three lanes sit under one master key. Resolution order per lane: + * lane key, then master `write_approval.enabled`, then off. The env + * twin wins over the config value per key, exactly as every other + * flat-key resolver in this project. The signals lane has no key of + * its own - the master key IS its lane key, which is what keeps the + * pre-existing toggle's meaning unchanged. + */ + +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { + REVIEW_LANE, + REVIEW_LANES, + WRITE_APPROVAL_INGEST_CONFIG_KEY, + WRITE_APPROVAL_INGEST_ENV_KEY, + WRITE_APPROVAL_NOTES_CONFIG_KEY, + WRITE_APPROVAL_NOTES_ENV_KEY, + isReviewLane, + resolveWriteApprovalLane, +} from "../../../src/core/brain/write-gate.ts"; + +const toCleanup: string[] = []; +const envSaved = new Map(); +const ENV_KEYS = [ + "OPEN_SECOND_BRAIN_WRITE_APPROVAL_ENABLED", + WRITE_APPROVAL_NOTES_ENV_KEY, + WRITE_APPROVAL_INGEST_ENV_KEY, + "OPEN_SECOND_BRAIN_CONFIG", +]; + +afterEach(() => { + for (const key of ENV_KEYS) { + const saved = envSaved.get(key); + if (saved === undefined) delete process.env[key]; + else process.env[key] = saved; + } + envSaved.clear(); + for (const p of toCleanup.splice(0)) rmSync(p, { recursive: true, force: true }); +}); + +/** Write a device config with the given flat keys and return its path. */ +function configWith(lines: string[]): string { + const dir = mkdtempSync(join(tmpdir(), "o2b-write-gate-cfg-")); + const configPath = join(dir, "config.yaml"); + writeFileSync(configPath, `${lines.join("\n")}\n`); + toCleanup.push(dir); + return configPath; +} + +function setEnv(key: string, value: string | undefined): void { + if (!envSaved.has(key)) envSaved.set(key, process.env[key]); + if (value === undefined) delete process.env[key]; + else process.env[key] = value; +} + +describe("resolveWriteApprovalLane", () => { + test("every lane resolves off when no key and no env is set", () => { + const configPath = configWith(["vault: /tmp/somewhere"]); + for (const lane of REVIEW_LANES) { + expect(resolveWriteApprovalLane(lane, configPath)).toBe(false); + } + }); + + test("the signals lane is decided by the master key", () => { + const on = configWith(["vault: /tmp/somewhere", "write_approval.enabled: true"]); + expect(resolveWriteApprovalLane("signals", on)).toBe(true); + const off = configWith(["vault: /tmp/somewhere", "write_approval.enabled: false"]); + expect(resolveWriteApprovalLane("signals", off)).toBe(false); + }); + + test("a non-true master value is off, not an error", () => { + const configPath = configWith(["vault: /tmp/somewhere", "write_approval.enabled: yes"]); + expect(resolveWriteApprovalLane("signals", configPath)).toBe(false); + }); + + test("the notes lane falls back to the master key when its own key is absent", () => { + const masterOn = configWith(["vault: /tmp/somewhere", "write_approval.enabled: true"]); + expect(resolveWriteApprovalLane("notes", masterOn)).toBe(true); + const masterOff = configWith(["vault: /tmp/somewhere"]); + expect(resolveWriteApprovalLane("notes", masterOff)).toBe(false); + }); + + test("an explicit notes key wins over the master in both directions", () => { + const laneOnMasterOff = configWith([ + "vault: /tmp/somewhere", + "write_approval.enabled: false", + `${WRITE_APPROVAL_NOTES_CONFIG_KEY}: true`, + ]); + expect(resolveWriteApprovalLane("notes", laneOnMasterOff)).toBe(true); + const laneOffMasterOn = configWith([ + "vault: /tmp/somewhere", + "write_approval.enabled: true", + `${WRITE_APPROVAL_NOTES_CONFIG_KEY}: false`, + ]); + expect(resolveWriteApprovalLane("notes", laneOffMasterOn)).toBe(false); + }); + + test("the ingest lane resolves under the same table", () => { + const both = configWith([ + "vault: /tmp/somewhere", + "write_approval.enabled: true", + `${WRITE_APPROVAL_INGEST_CONFIG_KEY}: true`, + ]); + expect(resolveWriteApprovalLane("ingest", both)).toBe(true); + const only = configWith(["vault: /tmp/somewhere", `${WRITE_APPROVAL_INGEST_CONFIG_KEY}: true`]); + expect(resolveWriteApprovalLane("ingest", only)).toBe(true); + expect(resolveWriteApprovalLane("notes", only)).toBe(false); + }); + + test("the env twin wins per key over the config value", () => { + const configPath = configWith([ + "vault: /tmp/somewhere", + `${WRITE_APPROVAL_NOTES_CONFIG_KEY}: false`, + `${WRITE_APPROVAL_INGEST_CONFIG_KEY}: false`, + ]); + setEnv(WRITE_APPROVAL_NOTES_ENV_KEY, "true"); + setEnv(WRITE_APPROVAL_INGEST_ENV_KEY, "true"); + expect(resolveWriteApprovalLane("notes", configPath)).toBe(true); + expect(resolveWriteApprovalLane("ingest", configPath)).toBe(true); + }); + + test("an empty env value counts as unset and falls through to config", () => { + const configPath = configWith(["vault: /tmp/somewhere", "write_approval.enabled: true"]); + setEnv("OPEN_SECOND_BRAIN_WRITE_APPROVAL_ENABLED", ""); + expect(resolveWriteApprovalLane("signals", configPath)).toBe(true); + }); + + test("an absent config file resolves every lane off", () => { + expect(resolveWriteApprovalLane("signals", "/nonexistent/o2b-gate/config.yaml")).toBe(false); + expect(resolveWriteApprovalLane("notes", "/nonexistent/o2b-gate/config.yaml")).toBe(false); + }); + + test("the lane vocabulary is a closed trio with a guard", () => { + expect(REVIEW_LANES).toEqual(["signals", "notes", "ingest"]); + expect(isReviewLane("notes")).toBe(true); + expect(isReviewLane("signals")).toBe(true); + expect(isReviewLane("ingest")).toBe(true); + expect(isReviewLane("everything")).toBe(false); + expect(isReviewLane(42)).toBe(false); + expect(REVIEW_LANE.notes).toBe("notes"); + }); +}); diff --git a/tests/mcp/brain-feedback-staged.test.ts b/tests/mcp/brain-feedback-staged.test.ts new file mode 100644 index 00000000..70bc6760 --- /dev/null +++ b/tests/mcp/brain-feedback-staged.test.ts @@ -0,0 +1,103 @@ +/** + * `brain_feedback` under the signal-lane review gate (write-side trust, + * Task 6). The tool calls `writeSignal` without a target directory, so the + * gate resolves at the chokepoint and the receipt reports it: `staged: + * true` with `signal_path` under `Brain/pending/` when the signals lane is + * on, `staged: false` and the historical inbox path when it is off. The + * dedup consumption is identical on both sides of the gate - a retried + * idempotency key still dedupes against the staged original. + */ + +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, readdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { bootstrapBrain } from "../../src/core/brain/init.ts"; +import { brainDirs } from "../../src/core/brain/paths.ts"; +import { atomicWriteFileSync } from "../../src/core/fs-atomic.ts"; +import { FEEDBACK_TOOLS } from "../../src/mcp/brain/feedback-tools.ts"; +import type { ServerContext } from "../../src/mcp/tool-contract.ts"; + +const MASTER_ENV = "OPEN_SECOND_BRAIN_WRITE_APPROVAL_ENABLED"; + +let vault: string; +let configHome: string; +let ctx: ServerContext; +let savedMaster: string | undefined; + +beforeEach(() => { + vault = mkdtempSync(join(tmpdir(), "o2b-feedback-staged-vault-")); + configHome = mkdtempSync(join(tmpdir(), "o2b-feedback-staged-cfg-")); + const configPath = join(configHome, "config.yaml"); + atomicWriteFileSync(configPath, `vault: ${vault}\nagent_name: claude\n`); + bootstrapBrain(vault, { configPath }); + ctx = { vault, configPath, repoRoot: null }; +}); + +afterEach(() => { + if (savedMaster === undefined) delete process.env[MASTER_ENV]; + else process.env[MASTER_ENV] = savedMaster; + savedMaster = undefined; + rmSync(vault, { recursive: true, force: true }); + rmSync(configHome, { recursive: true, force: true }); +}); + +function setGate(on: boolean): void { + if (savedMaster === undefined) savedMaster = process.env[MASTER_ENV]; + if (on) process.env[MASTER_ENV] = "true"; + else delete process.env[MASTER_ENV]; +} + +const tool = FEEDBACK_TOOLS.find((t) => t.name === "brain_feedback"); +const handler = tool!.handler; + +const ARGS = { + topic: "staged feedback", + signal: "positive", + principle: "the gate reports staging on the receipt", +}; + +function pendingFiles(): string[] { + return readdirSync(brainDirs(vault).pending).filter((f) => f.endsWith(".md")); +} + +describe("brain_feedback staged receipt", () => { + test("gate off keeps the inbox path with staged false", async () => { + setGate(false); + const res = (await handler(ctx, ARGS)) as Record; + expect(res["staged"]).toBe(false); + expect(String(res["signal_path"]).startsWith("Brain/inbox/")).toBe(true); + // Bootstrap creates the pending directory eagerly; the gate-off call + // leaves it empty and lands the signal in the inbox. + expect(pendingFiles()).toEqual([]); + }); + + test("gate on stages the signal and reports pending_id-ready coordinates", async () => { + setGate(true); + const res = (await handler(ctx, ARGS)) as Record; + expect(res["staged"]).toBe(true); + expect(String(res["signal_path"]).startsWith("Brain/pending/")).toBe(true); + expect(pendingFiles()).toEqual([`${String(res["signal_id"])}.md`]); + expect(String(res["path"]).startsWith("Brain/pending/")).toBe(true); + }); + + test("a retried idempotency key dedupes against the staged original", async () => { + setGate(true); + const first = (await handler(ctx, { ...ARGS, idempotency_key: "staged-retry-1" })) as Record< + string, + unknown + >; + expect(first["deduped"]).toBeUndefined(); + expect(first["staged"]).toBe(true); + const second = (await handler(ctx, { ...ARGS, idempotency_key: "staged-retry-1" })) as Record< + string, + unknown + >; + expect(second["deduped"]).toBe(true); + expect(second["staged"]).toBe(false); + expect(second["signal_id"]).toBe(first["signal_id"]); + // The retry consumed nothing: the queue still holds exactly the original. + expect(pendingFiles()).toEqual([`${String(first["signal_id"])}.md`]); + }); +}); From 4e90a61c740fd2937bac04c18a390d28bd88966c Mon Sep 17 00:00:00 2001 From: Sergey Eroshenkov Date: Sat, 10 Oct 2026 10:52:54 +0200 Subject: [PATCH 05/84] feat(permissions): vault permissions document loader and resolver (write-side-trust task 1) --- src/core/brain/permissions/document.ts | 553 ++++++++++++++++++ src/core/brain/permissions/resolve.ts | 122 ++++ tests/core/brain/permissions/document.test.ts | 290 +++++++++ tests/core/brain/permissions/resolve.test.ts | 245 ++++++++ 4 files changed, 1210 insertions(+) create mode 100644 src/core/brain/permissions/document.ts create mode 100644 src/core/brain/permissions/resolve.ts create mode 100644 tests/core/brain/permissions/document.test.ts create mode 100644 tests/core/brain/permissions/resolve.test.ts diff --git a/src/core/brain/permissions/document.ts b/src/core/brain/permissions/document.ts new file mode 100644 index 00000000..2ccd7a69 --- /dev/null +++ b/src/core/brain/permissions/document.ts @@ -0,0 +1,553 @@ +/** + * The permissions document: `/Brain/_permissions.yaml` (write-side- + * trust, Task 1). + * + * Trust policy is state the operator hand-edits and syncs to every device, + * exactly like the freeze marker - so it is a vault FILE, not a + * `_brain.yaml` block. The strict machinery it borrows from the config + * blocks is pattern, not import: field-named {@link PermissionsDocumentError} + * raises, unknown-key warnings, and a `version` key that hard-refuses + * anything but 1. + * + * The one split every consumer answers through {@link loadPermissionsDocument}: + * + * - ABSENT - `{ document: null }`. The default posture; every gate + * proceeds exactly as it did before this document existed. + * - PRESENT BUT UNREADABLE - malformed YAML, a wrong-typed field, an + * unsupported version, a permission error, a directory in the file's + * place. The operator's policy exists and is NOT in force; answering + * with permissive silence would turn a typo into an open gate, so the + * loader throws and never returns a document. + * + * The YAML subset is the indent-aware one `src/core/brain/yaml-parse.ts` + * established for `_brain.yaml`, extended with the one shape the schema + * needs beyond it: a list of small mappings under `entries:`. Anchors, + * aliases and deeply nested inline structures stay outside the grammar on + * purpose - a policy file an operator cannot eyeball is a policy nobody + * can trust. + * + * LEAF MODULE: imports nothing from the Brain layer. The resolver + * (`./resolve.ts`), the ledger (`./ledger.ts`) and every gate lane build on + * the types and the loader exported here. + */ + +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; + +/** The vault-relative directory the document lives in. */ +const DOCUMENT_DIRECTORY = "Brain"; + +/** The file name, beside `_brain.yaml` and the freeze marker. */ +const DOCUMENT_BASENAME = "_permissions.yaml"; + +/** Vault-relative location of the permissions document. */ +export const PERMISSIONS_DOCUMENT_REL = `${DOCUMENT_DIRECTORY}/${DOCUMENT_BASENAME}`; + +/** The only schema version this build reads. Anything else hard-refuses. */ +export const PERMISSIONS_SCHEMA_VERSION = 1; + +/** What a rule says about one action. Deny wins every tie. */ +export type PermissionVerdict = "allow" | "ask" | "deny"; + +/** What a rule is about: the two write lanes and the owner-stamp lane. */ +export type PermissionAction = "write" | "ingest" | "owner_write"; + +/** The closed vocabularies, in the order equal-specificity ties resolve. */ +const VERDICTS: ReadonlyArray = ["allow", "ask", "deny"]; +const ACTIONS: ReadonlyArray = ["write", "ingest", "owner_write"]; + +/** One target-scoped exception or blanket rule the operator wrote. */ +export interface PermissionEntry { + id: string; + agent?: string; + role?: string; + action: PermissionAction; + target?: string; + verdict: PermissionVerdict; +} + +/** + * The parsed document. `default_action` is REQUIRED - there is no silent + * default - so a document is always a closed world the operator spelled. + */ +export interface PermissionsDocument { + version: 1; + default_action: PermissionVerdict; + roles: Record>>; + agents: Record< + string, + { + role?: string; + write?: PermissionVerdict; + ingest?: PermissionVerdict; + owner_write?: PermissionVerdict; + } + >; + entries: PermissionEntry[]; + ledger?: { record_allows?: boolean }; +} + +/** + * Why the document could not be read. The message always names the file + * and, for every schema-level refusal, the exact field that refused. + */ +export class PermissionsDocumentError extends Error {} + +// ----- YAML subset ---------------------------------------------------------- + +type YamlScalar = string | number | boolean | null; +type YamlValue = YamlScalar | YamlMapping | YamlValue[]; +/** Insertion-ordered mapping; key order drives the deterministic warnings. */ +type YamlMapping = Record; + +interface Line { + readonly indent: number; + readonly content: string; + readonly lineNumber: number; +} + +/** A parse failure, carrying its line like `yaml-parse.ts` does. */ +class YamlSyntaxError extends Error {} + +/** + * The subset's scalar grammar, shared with `yaml-parse.ts`: quoted strings + * are taken verbatim, `true`/`false`/`null`/`~` are literals, plain + * integers and finite decimals parse as numbers, everything else stays a + * string - so `yes` and `no` are honest strings, not booleans. + */ +function parseScalar(text: string): YamlScalar { + if ( + text.length >= 2 && + ((text[0] === '"' && text.at(-1) === '"') || (text[0] === "'" && text.at(-1) === "'")) + ) { + return text.slice(1, -1); + } + if (text === "true") return true; + if (text === "false") return false; + if (text === "null" || text === "~") return null; + if (/^-?\d+$/.test(text)) return parseInt(text, 10); + if (/^-?\d+\.\d+$/.test(text)) return parseFloat(text); + return text; +} + +function splitLines(text: string): Line[] { + const out: Line[] = []; + let lineNumber = 0; + for (const raw of text.split(/\r?\n/)) { + lineNumber++; + const stripped = raw.replace(/\s+$/, ""); + if (stripped.trim() === "" || stripped.trimStart().startsWith("#")) continue; + // Inline comments are honoured only where they cannot be content: a + // line carrying quotes keeps everything, the same rule yaml-parse uses. + let content = stripped; + if (!/['"]/.test(stripped)) { + const hashAt = stripped.indexOf(" #"); + if (hashAt >= 0) content = stripped.slice(0, hashAt).replace(/\s+$/, ""); + } + const indent = content.length - content.trimStart().length; + out.push({ indent, content: content.slice(indent), lineNumber }); + } + return out; +} + +interface KeyValue { + readonly key: string; + readonly value: string; +} + +function splitKeyValue(line: Line): KeyValue { + const at = line.content.indexOf(":"); + if (at <= 0) { + throw new YamlSyntaxError( + `line ${line.lineNumber}: expected 'key: value', got: ${JSON.stringify(line.content)}`, + ); + } + const key = line.content.slice(0, at).trim(); + if (!/^[A-Za-z_][A-Za-z0-9_-]*$/.test(key)) { + throw new YamlSyntaxError(`line ${line.lineNumber}: invalid key name: ${JSON.stringify(key)}`); + } + if (key === "__proto__" || key === "constructor" || key === "prototype") { + throw new YamlSyntaxError(`line ${line.lineNumber}: reserved key name: ${JSON.stringify(key)}`); + } + return { key, value: line.content.slice(at + 1).trim() }; +} + +/** True when the line opens a list item (`- ` prefix). */ +function isListItem(line: Line): boolean { + return line.content === "-" || line.content.startsWith("- "); +} + +/** + * Parse a mapping whose keys sit at exactly `indent`. A key with an empty + * value opens either a nested mapping or a list at a deeper indent; with + * nothing deeper, it stands for an empty mapping. + */ +function parseMapping(lines: Line[], start: number, indent: number): [YamlMapping, number] { + const out: YamlMapping = {}; + let i = start; + while (i < lines.length && lines[i]!.indent === indent) { + const line = lines[i]!; + if (isListItem(line)) break; + const { key, value } = splitKeyValue(line); + if (key in out) { + throw new YamlSyntaxError(`line ${line.lineNumber}: duplicate key '${key}'`); + } + if (value !== "") { + out[key] = parseScalar(value); + i++; + // A scalar line followed by a deeper one is the shape a misdedented + // block takes; refuse rather than guess which line is right. + if (i < lines.length && lines[i]!.indent > indent) { + throw new YamlSyntaxError( + `line ${lines[i]!.lineNumber}: unexpected indentation under '${key}'`, + ); + } + continue; + } + i++; + if (i < lines.length && lines[i]!.indent > indent) { + const childIndent = lines[i]!.indent; + const [child, next] = isListItem(lines[i]!) + ? parseList(lines, i, childIndent) + : parseMapping(lines, i, childIndent); + out[key] = child; + i = next; + } else { + out[key] = {}; + } + } + return [out, i]; +} + +/** + * Parse a list whose `- ` items sit at exactly `indent`. An item whose + * content names a key opens a small mapping: the inline pair is its first + * entry and the siblings align two columns deeper (the `- ` width), which + * is the layout the entries block takes. + */ +function parseList(lines: Line[], start: number, indent: number): [YamlValue[], number] { + const out: YamlValue[] = []; + let i = start; + while (i < lines.length && lines[i]!.indent === indent && isListItem(lines[i]!)) { + const line = lines[i]!; + const item = line.content === "-" ? "" : line.content.slice(2).trim(); + const itemKey = item.indexOf(":"); + if (item === "") { + throw new YamlSyntaxError(`line ${line.lineNumber}: empty list item is not supported`); + } + if (itemKey <= 0) { + out.push(parseScalar(item)); + i++; + continue; + } + // A mapping item: re-parse the inline pair plus the aligned siblings. + const itemIndent = indent + 2; + const synthetic: Line = { + indent: itemIndent, + content: item, + lineNumber: line.lineNumber, + }; + const rest = parseMapping([synthetic, ...lines.slice(i + 1)], 0, itemIndent); + out.push(rest[0]); + // Count how many input lines the item mapping consumed: the synthetic + // first line plus everything after it up to the returned cursor. + const consumed = rest[1] - 1; + i += 1 + consumed; + if (i < lines.length && lines[i]!.indent > indent && !isListItem(lines[i]!)) { + throw new YamlSyntaxError( + `line ${lines[i]!.lineNumber}: inconsistent indentation in list item ` + + `(expected ${itemIndent}, got ${lines[i]!.indent})`, + ); + } + } + return [out, i]; +} + +function parseDocumentYaml(text: string): YamlMapping { + const lines = splitLines(text); + if (lines.length === 0) return {}; + const [out, next] = parseMapping(lines, 0, 0); + if (next < lines.length) { + throw new YamlSyntaxError( + `line ${lines[next]!.lineNumber}: unexpected indentation at the document level`, + ); + } + return out; +} + +// ----- Validation ----------------------------------------------------------- + +function fail(path: string, field: string, message: string): PermissionsDocumentError { + return new PermissionsDocumentError(`${path}: ${field}: ${message}`); +} + +function describeValue(value: unknown): string { + if (value === null) return "null"; + if (Array.isArray(value)) return "a list"; + return typeof value === "string" ? JSON.stringify(value) : `a ${typeof value}`; +} + +function isMapping(value: YamlValue | undefined): value is YamlMapping { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function warn(path: string, field: string): void { + process.stderr.write(`warning: ${path}: ${field}: unknown field ignored (forward-compat)\n`); +} + +function requireVerdict(path: string, field: string, value: YamlValue): PermissionVerdict { + if (typeof value !== "string" || !(VERDICTS as ReadonlyArray).includes(value)) { + throw fail(path, field, `must be one of ${VERDICTS.join(", ")}; got ${describeValue(value)}`); + } + return value; +} + +function requireString( + path: string, + field: string, + value: YamlValue | undefined, + opts: { required: boolean }, +): string | undefined { + if (value === undefined) { + if (opts.required) throw fail(path, field, "is required"); + return undefined; + } + if (typeof value !== "string" || value.trim() === "") { + throw fail(path, field, `must be a non-empty string; got ${describeValue(value)}`); + } + return value; +} + +function parseActionMapping( + path: string, + field: string, + value: YamlValue, + warnings: string[], +): Partial> { + if (!isMapping(value)) { + throw fail(path, field, `must be a mapping; got ${describeValue(value)}`); + } + const out: Partial> = {}; + for (const [key, raw] of Object.entries(value)) { + if (!(ACTIONS as ReadonlyArray).includes(key)) { + warnings.push(`${field}.${key}`); + continue; + } + out[key as PermissionAction] = requireVerdict(path, `${field}.${key}`, raw); + } + return out; +} + +function validateRoles( + path: string, + raw: YamlValue, + warnings: string[], +): Record>> { + if (!isMapping(raw)) throw fail(path, "roles", `must be a mapping; got ${describeValue(raw)}`); + const roles: Record>> = {}; + for (const [name, value] of Object.entries(raw)) { + roles[name] = parseActionMapping(path, `roles.${name}`, value, warnings); + } + return roles; +} + +const AGENT_ACTION_KEYS: ReadonlyArray = ACTIONS; + +function validateAgents( + path: string, + raw: YamlValue, + warnings: string[], +): PermissionsDocument["agents"] { + if (!isMapping(raw)) throw fail(path, "agents", `must be a mapping; got ${describeValue(raw)}`); + const agents: PermissionsDocument["agents"] = {}; + for (const [name, value] of Object.entries(raw)) { + if (!isMapping(value)) { + throw fail(path, `agents.${name}`, `must be a mapping; got ${describeValue(value)}`); + } + const agent: PermissionsDocument["agents"][string] = {}; + for (const [key, inner] of Object.entries(value)) { + if (key === "role") { + agent.role = requireString(path, `agents.${name}.role`, inner, { required: true }); + continue; + } + if ((AGENT_ACTION_KEYS as ReadonlyArray).includes(key)) { + agent[key as PermissionAction] = requireVerdict(path, `agents.${name}.${key}`, inner); + continue; + } + warnings.push(`agents.${name}.${key}`); + } + agents[name] = agent; + } + return agents; +} + +function validateEntries(path: string, raw: YamlValue, warnings: string[]): PermissionEntry[] { + if (!Array.isArray(raw)) throw fail(path, "entries", `must be a list; got ${describeValue(raw)}`); + const entries: PermissionEntry[] = []; + for (const [index, item] of raw.entries()) { + const field = `entries[${index}]`; + if (!isMapping(item)) throw fail(path, field, `must be a mapping; got ${describeValue(item)}`); + const id = requireString(path, `${field}.id`, item["id"], { required: true }); + const actionRaw = item["action"]; + if (actionRaw === undefined) { + throw fail(path, `${field}.action`, "is required"); + } + if (typeof actionRaw !== "string" || !(ACTIONS as ReadonlyArray).includes(actionRaw)) { + throw fail( + path, + `${field}.action`, + `must be one of ${ACTIONS.join(", ")}; got ${describeValue(actionRaw)}`, + ); + } + const verdictRaw = item["verdict"]; + if (verdictRaw === undefined) { + throw fail(path, `${field}.verdict`, "is required"); + } + const verdict = requireVerdict(path, `${field}.verdict`, verdictRaw); + const agent = requireString(path, `${field}.agent`, item["agent"], { required: false }); + const role = requireString(path, `${field}.role`, item["role"], { required: false }); + if (agent !== undefined && role !== undefined) { + throw fail( + path, + field, + "declares both agent and role; an entry names one principal, never two", + ); + } + const target = requireString(path, `${field}.target`, item["target"], { required: false }); + for (const key of Object.keys(item)) { + if ( + !(["id", "action", "verdict", "agent", "role", "target"] as ReadonlyArray).includes( + key, + ) + ) { + warnings.push(`${field}.${key}`); + } + } + entries.push({ + id, + ...(agent !== undefined ? { agent } : {}), + ...(role !== undefined ? { role } : {}), + action: actionRaw as PermissionAction, + ...(target !== undefined ? { target } : {}), + verdict, + }); + } + return entries; +} + +function validateLedger( + path: string, + raw: YamlValue, + warnings: string[], +): { record_allows?: boolean } { + if (!isMapping(raw)) throw fail(path, "ledger", `must be a mapping; got ${describeValue(raw)}`); + const ledger: { record_allows?: boolean } = {}; + for (const [key, value] of Object.entries(raw)) { + if (key === "record_allows") { + if (typeof value !== "boolean") { + throw fail(path, `ledger.${key}`, `must be a boolean; got ${describeValue(value)}`); + } + ledger.record_allows = value; + continue; + } + warnings.push(`ledger.${key}`); + } + return ledger; +} + +/** The absolute path of the document inside `vault`. */ +function documentPath(vault: string): string { + return join(vault, DOCUMENT_DIRECTORY, DOCUMENT_BASENAME); +} + +/** + * Read the permissions document. + * + * ABSENT: `{ document: null }` and every consumer proceeds as today. + * PRESENT: the file is parsed and validated strictly; anything it cannot + * honour raises {@link PermissionsDocumentError} naming the file and the + * field, and UNKNOWN keys warn on stderr while the document still loads. + */ +export function loadPermissionsDocument(vault: string): { + document: PermissionsDocument | null; + path: string; +} { + const path = documentPath(vault); + if (!existsSync(path)) return { document: null, path }; + let text: string; + try { + text = readFileSync(path, "utf8"); + } catch (err) { + throw new PermissionsDocumentError( + `${path}: could not be read: ${err instanceof Error ? err.message : String(err)}`, + ); + } + let raw: YamlMapping; + try { + raw = parseDocumentYaml(text); + } catch (err) { + throw new PermissionsDocumentError( + `${path}: ${err instanceof Error ? err.message : String(err)}`, + ); + } + if (!isMapping(raw)) { + // A top-level list or scalar cannot carry a schema. + throw new PermissionsDocumentError( + `${path}: expected a mapping at the top level; got ${describeValue(raw)}`, + ); + } + + const warnings: string[] = []; + for (const key of Object.keys(raw)) { + if ( + !( + [ + "version", + "default_action", + "roles", + "agents", + "entries", + "ledger", + ] as ReadonlyArray + ).includes(key) + ) { + warnings.push(key); + } + } + + const version = raw["version"]; + if (version !== PERMISSIONS_SCHEMA_VERSION) { + throw fail( + path, + "version", + `must be ${PERMISSIONS_SCHEMA_VERSION}; got ${describeValue(version)}`, + ); + } + const defaultRaw = raw["default_action"]; + if (defaultRaw === undefined) { + throw fail(path, "default_action", "is required - a document is a closed world, not a filter"); + } + const default_action = requireVerdict(path, "default_action", defaultRaw); + const roles = raw["roles"] === undefined ? {} : validateRoles(path, raw["roles"], warnings); + const agents = raw["agents"] === undefined ? {} : validateAgents(path, raw["agents"], warnings); + const entries = + raw["entries"] === undefined ? [] : validateEntries(path, raw["entries"], warnings); + const ledger = + raw["ledger"] === undefined ? undefined : validateLedger(path, raw["ledger"], warnings); + + // Warnings are emitted only once the document is known loadable, so a + // refusing file never drowns its error in forward-compat noise. + for (const field of warnings) warn(path, field); + + return { + document: { + version: 1, + default_action, + roles, + agents, + entries, + ...(ledger !== undefined ? { ledger } : {}), + }, + path, + }; +} diff --git a/src/core/brain/permissions/resolve.ts b/src/core/brain/permissions/resolve.ts new file mode 100644 index 00000000..e2c0c012 --- /dev/null +++ b/src/core/brain/permissions/resolve.ts @@ -0,0 +1,122 @@ +/** + * The one resolver over the permissions document (write-side-trust, + * Task 1). + * + * Every gate consults the document through {@link resolvePermission}, so + * "which rule decided this" has exactly one answer and the decision ledger + * can record it. The precedence table is fixed: + * + * 1. a target-scoped entry (`entry:`) + * 2. any other matching entry, most specific first + * 3. the agent's per-action override (`agent:`) + * 4. the agent's role mapping (`role:`) + * 5. `default_action` (`default`) + * + * and among rules of equal specificity deny beats ask beats allow - deny + * wins every tie, so an operator composing a strict document from several + * angles never has one lenient line open what the rest closed. + * + * PURE LEAF: no I/O, no clock, no config. The `via` half of the subject + * never changes a verdict - it rides the decision into the ledger row the + * calling gate appends. + */ + +import type { + PermissionAction, + PermissionEntry, + PermissionVerdict, + PermissionsDocument, +} from "./document.ts"; + +/** Who is asking. The credential path is carried, never inspected. */ +export interface PermissionSubject { + agent: string; + via: "token" | "config" | "operator"; +} + +/** One resolved answer, ready for a ledger row. */ +export interface PermissionDecision { + verdict: PermissionVerdict; + /** `entry:` | `agent:` | `role:` | `default`. */ + source: string; + reason: string; +} + +/** Deny sorts before ask before allow at equal specificity. */ +const VERDICT_TIGHTNESS: Record = { deny: 0, ask: 1, allow: 2 }; + +function roleOf(doc: PermissionsDocument, agent: string): string | undefined { + return doc.agents[agent]?.role; +} + +/** + * Whether `entry` reaches this subject at this target. An entry naming an + * agent reaches that agent; an entry naming a role reaches every agent + * holding it; an entry naming neither reaches everyone. A target-scoped + * entry reaches only its exact target and never a target-less query. + */ +function entryMatches( + doc: PermissionsDocument, + entry: PermissionEntry, + subject: PermissionSubject, + action: PermissionAction, + target: string | undefined, +): boolean { + if (entry.action !== action) return false; + if (entry.agent !== undefined) { + if (entry.agent !== subject.agent) return false; + } else if (entry.role !== undefined) { + if (entry.role !== roleOf(doc, subject.agent)) return false; + } + if (entry.target !== undefined && (target === undefined || entry.target !== target)) { + return false; + } + return true; +} + +function entryDecision(entry: PermissionEntry): PermissionDecision { + return { + verdict: entry.verdict, + source: `entry:${entry.id}`, + reason: entry.target === undefined ? `entry ${entry.id}` : `target-scoped entry ${entry.id}`, + }; +} + +/** + * Resolve one permission. Never throws on a well-formed document: an + * unmentioned subject and action resolve to `default_action`. + */ +export function resolvePermission( + doc: PermissionsDocument, + subject: PermissionSubject, + action: PermissionAction, + target?: string, +): PermissionDecision { + // Entries first, most specific matching entry wins: target-scoped above + // blanket, then the tightest verdict, then document order for stability. + const matching = doc.entries + .filter((entry) => entryMatches(doc, entry, subject, action, target)) + .map((entry, order) => ({ + entry, + order, + scoped: entry.target !== undefined ? 0 : 1, + tightness: VERDICT_TIGHTNESS[entry.verdict], + })) + .toSorted((a, b) => a.scoped - b.scoped || a.tightness - b.tightness || a.order - b.order); + if (matching.length > 0) return entryDecision(matching[0]!.entry); + + const override = doc.agents[subject.agent]?.[action]; + if (override !== undefined) { + return { verdict: override, source: `agent:${subject.agent}`, reason: "agent override" }; + } + + const role = roleOf(doc, subject.agent); + if (role !== undefined) { + const mapped = doc.roles[role]?.[action]; + if (mapped !== undefined) { + return { verdict: mapped, source: `role:${role}`, reason: "role mapping" }; + } + } + + return { verdict: doc.default_action, source: "default", reason: "default_action" }; +} diff --git a/tests/core/brain/permissions/document.test.ts b/tests/core/brain/permissions/document.test.ts new file mode 100644 index 00000000..3a90e218 --- /dev/null +++ b/tests/core/brain/permissions/document.test.ts @@ -0,0 +1,290 @@ +/** + * `Brain/_permissions.yaml` loader (write-side-trust, Task 1). + * + * Two failure modes, never collapsed: the file ABSENT is the default + * posture (`{ document: null }`, every gate proceeds as today) and the + * file PRESENT BUT UNREADABLE fails closed with a field-named + * {@link PermissionsDocumentError}. The second half is the whole point + * of the loader: a permissions document the machine cannot parse is an + * operator's trust policy that is not in force, and silently proceeding + * would read the silence as consent. + */ + +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { + loadPermissionsDocument, + PERMISSIONS_DOCUMENT_REL, + PERMISSIONS_SCHEMA_VERSION, + PermissionsDocumentError, +} from "../../../../src/core/brain/permissions/document.ts"; +import { CHMOD_CANNOT_DENY } from "../../../helpers/platform.ts"; + +let vault: string; +let docPath: string; + +beforeEach(() => { + vault = mkdtempSync(join(tmpdir(), "o2b-permissions-")); + mkdirSync(join(vault, "Brain"), { recursive: true }); + docPath = join(vault, "Brain", "_permissions.yaml"); +}); + +afterEach(() => { + rmSync(vault, { recursive: true, force: true }); +}); + +function writeDoc(text: string): void { + writeFileSync(docPath, text, "utf8"); +} + +/** One-entry document body; `entry` captures nothing from any test scope. */ +function entryDoc(body: string): string { + return `version: 1\ndefault_action: allow\nentries:\n - ${body}\n`; +} + +/** Capture what the loader warns while `fn` runs. */ +function captureWarnings(fn: () => void): string[] { + const lines: string[] = []; + const original = process.stderr.write.bind(process.stderr); + process.stderr.write = ((chunk: string | Uint8Array): boolean => { + lines.push(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8")); + return true; + }) as typeof process.stderr.write; + try { + fn(); + } finally { + process.stderr.write = original; + } + return lines; +} + +describe("loadPermissionsDocument", () => { + test("an absent document is null and names the path it looked at", () => { + const result = loadPermissionsDocument(vault); + expect(result.document).toBeNull(); + expect(result.path).toBe(docPath); + }); + + test("the constant names the vault-relative document, and the schema version is 1", () => { + expect(PERMISSIONS_DOCUMENT_REL).toBe("Brain/_permissions.yaml"); + expect(PERMISSIONS_SCHEMA_VERSION).toBe(1); + }); + + test("a minimal valid document loads with its explicit default action", () => { + writeDoc("version: 1\ndefault_action: ask\n"); + const { document } = loadPermissionsDocument(vault); + expect(document).not.toBeNull(); + expect(document!.version).toBe(1); + expect(document!.default_action).toBe("ask"); + expect(document!.roles).toEqual({}); + expect(document!.agents).toEqual({}); + expect(document!.entries).toEqual([]); + }); + + test("a full document round-trips roles, agents, entries and the ledger block", () => { + writeDoc( + [ + "version: 1", + "default_action: deny", + "ledger:", + " record_allows: true", + "roles:", + " reviewer:", + " write: ask", + " ingest: deny", + "agents:", + " codex:", + " role: reviewer", + " write: allow", + "entries:", + " - id: freeze-notes", + " agent: codex", + " action: write", + " target: notes/foo.md", + " verdict: deny", + ].join("\n") + "\n", + ); + const { document } = loadPermissionsDocument(vault); + expect(document).not.toBeNull(); + expect(document!.ledger).toEqual({ record_allows: true }); + expect(document!.roles["reviewer"]).toEqual({ write: "ask", ingest: "deny" }); + expect(document!.agents["codex"]).toEqual({ role: "reviewer", write: "allow" }); + expect(document!.entries).toEqual([ + { + id: "freeze-notes", + agent: "codex", + action: "write", + target: "notes/foo.md", + verdict: "deny", + }, + ]); + }); + + test("a missing default_action refuses with the field named, never a silent default", () => { + writeDoc("version: 1\n"); + expect(() => loadPermissionsDocument(vault)).toThrow(PermissionsDocumentError); + expect(() => loadPermissionsDocument(vault)).toThrow(/default_action/); + }); + + test("a missing or unsupported version hard-refuses naming the file and the field", () => { + writeDoc("default_action: allow\n"); + expect(() => loadPermissionsDocument(vault)).toThrow(/version/); + try { + loadPermissionsDocument(vault); + expect.unreachable(); + } catch (err) { + expect(err).toBeInstanceOf(PermissionsDocumentError); + expect((err as Error).message).toContain(docPath); + } + + writeDoc("version: 2\ndefault_action: allow\n"); + expect(() => loadPermissionsDocument(vault)).toThrow(/version/); + expect(() => loadPermissionsDocument(vault)).toThrow(new RegExp("_permissions\\.yaml")); + }); + + test("malformed YAML throws PermissionsDocumentError naming the file", () => { + writeDoc("version: 1\ndefault_action: ask\nroles: [unclosed\n"); + try { + loadPermissionsDocument(vault); + expect.unreachable(); + } catch (err) { + expect(err).toBeInstanceOf(PermissionsDocumentError); + expect((err as Error).message).toContain(docPath); + } + }); + + test("a non-mapping document refuses by name", () => { + writeDoc("- just\n- a\n- list\n"); + expect(() => loadPermissionsDocument(vault)).toThrow(PermissionsDocumentError); + }); + + test("wrong field types refuse with the field named", () => { + writeDoc("version: one\ndefault_action: ask\n"); + expect(() => loadPermissionsDocument(vault)).toThrow(/version/); + + writeDoc("version: 1\ndefault_action: maybe\n"); + expect(() => loadPermissionsDocument(vault)).toThrow(/default_action/); + + writeDoc("version: 1\ndefault_action: 3\n"); + expect(() => loadPermissionsDocument(vault)).toThrow(/default_action/); + + writeDoc("version: 1\ndefault_action: ask\nroles: reviewer\n"); + expect(() => loadPermissionsDocument(vault)).toThrow(/roles/); + + writeDoc("version: 1\ndefault_action: ask\nagents:\n codex: allow\n"); + expect(() => loadPermissionsDocument(vault)).toThrow(/agents\.codex/); + + writeDoc("version: 1\ndefault_action: ask\nledger:\n record_allows: yes-please\n"); + expect(() => loadPermissionsDocument(vault)).toThrow(/ledger\.record_allows/); + }); + + test("entries are validated field by field", () => { + writeDoc(entryDoc("action: write\n verdict: deny")); + expect(() => loadPermissionsDocument(vault)).toThrow(/entries\[0\]\.id/); + + writeDoc(entryDoc("id: e1\n verdict: deny")); + expect(() => loadPermissionsDocument(vault)).toThrow(/entries\[0\]\.action/); + + writeDoc(entryDoc("id: e1\n action: explode\n verdict: deny")); + expect(() => loadPermissionsDocument(vault)).toThrow(/entries\[0\]\.action/); + + writeDoc(entryDoc("id: e1\n action: write")); + expect(() => loadPermissionsDocument(vault)).toThrow(/entries\[0\]\.verdict/); + + writeDoc(entryDoc("id: e1\n action: write\n verdict: perhaps")); + expect(() => loadPermissionsDocument(vault)).toThrow(/entries\[0\]\.verdict/); + + writeDoc( + entryDoc( + "id: e1\n agent: codex\n role: reviewer\n action: write\n verdict: deny", + ), + ); + expect(() => loadPermissionsDocument(vault)).toThrow(/entries\[0\]/); + + // An entry naming neither a principal nor a target would match every + // subject on every action - the document default with extra steps. + // Allowed: it is the "nobody may do X anywhere" form. + writeDoc(entryDoc("id: nobody-writes\n action: write\n verdict: deny")); + const { document } = loadPermissionsDocument(vault); + expect(document!.entries).toHaveLength(1); + }); + + test("entry role and agent values must be strings when present", () => { + writeDoc( + "version: 1\ndefault_action: allow\nentries:\n - id: e1\n agent: 7\n action: write\n verdict: deny\n", + ); + expect(() => loadPermissionsDocument(vault)).toThrow(/entries\[0\]\.agent/); + }); + + test("unknown keys warn with their field path and the document still loads", () => { + writeDoc( + [ + "version: 1", + "default_action: ask", + "future_key: 1", + "roles:", + " reviewer:", + " write: ask", + " review: maybe", + "agents:", + " codex:", + " role: reviewer", + " favourite_colour: blue", + "entries:", + " - id: e1", + " agent: codex", + " action: write", + " verdict: ask", + " note: hello", + ].join("\n") + "\n", + ); + const warnings = captureWarnings(() => { + const { document } = loadPermissionsDocument(vault); + expect(document).not.toBeNull(); + }); + const said = warnings.join(""); + expect(said).toContain("future_key"); + expect(said).toContain("roles.reviewer.review"); + expect(said).toContain("agents.codex.favourite_colour"); + expect(said).toContain("entries[0].note"); + expect(said).toContain("unknown field ignored (forward-compat)"); + }); + + test("duplicate keys in one mapping refuse", () => { + writeDoc("version: 1\ndefault_action: ask\ndefault_action: deny\n"); + expect(() => loadPermissionsDocument(vault)).toThrow(/default_action/); + }); + + test("a directory where the document belongs fails closed", () => { + rmSync(docPath, { force: true }); + mkdirSync(docPath); + expect(() => loadPermissionsDocument(vault)).toThrow(PermissionsDocumentError); + expect(() => loadPermissionsDocument(vault)).toThrow(new RegExp("_permissions\\.yaml")); + }); +}); + +test.skipIf(CHMOD_CANNOT_DENY)( + "a present but unreadable file fails closed with the file named", + () => { + const lockedVault = mkdtempSync(join(tmpdir(), "o2b-permissions-")); + const lockedDocPath = join(lockedVault, "Brain", "_permissions.yaml"); + mkdirSync(join(lockedVault, "Brain"), { recursive: true }); + writeFileSync(lockedDocPath, "version: 1\ndefault_action: ask\n", "utf8"); + chmodSync(lockedDocPath, 0o000); + try { + expect(() => loadPermissionsDocument(lockedVault)).toThrow(PermissionsDocumentError); + try { + loadPermissionsDocument(lockedVault); + expect.unreachable(); + } catch (err) { + expect((err as Error).message).toContain(lockedDocPath); + } + } finally { + chmodSync(lockedDocPath, 0o644); + rmSync(lockedVault, { recursive: true, force: true }); + } + }, +); diff --git a/tests/core/brain/permissions/resolve.test.ts b/tests/core/brain/permissions/resolve.test.ts new file mode 100644 index 00000000..5c3fac9b --- /dev/null +++ b/tests/core/brain/permissions/resolve.test.ts @@ -0,0 +1,245 @@ +/** + * `resolvePermission` precedence (write-side-trust, Task 1). + * + * The resolver is the one answer to "which rule decided this", so the + * precedence table is pinned here row by row: a target-scoped entry + * beats the agent's per-action override, which beats the agent's role, + * which beats `default_action`; among rules of equal specificity deny + * beats ask beats allow. Every row also pins the `source` string a + * ledger row will carry, so the accountability trail and the resolver + * cannot drift apart. + */ + +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { + loadPermissionsDocument, + type PermissionVerdict, + type PermissionsDocument, +} from "../../../../src/core/brain/permissions/document.ts"; +import { + resolvePermission, + type PermissionSubject, +} from "../../../../src/core/brain/permissions/resolve.ts"; + +let vault: string; + +beforeEach(() => { + vault = mkdtempSync(join(tmpdir(), "o2b-resolve-")); +}); + +afterEach(() => { + rmSync(vault, { recursive: true, force: true }); +}); + +const SUBJECT: PermissionSubject = { agent: "codex", via: "token" }; + +function docWith(overrides: Partial = {}): PermissionsDocument { + return { + version: 1, + default_action: "ask", + roles: {}, + agents: {}, + entries: [], + ...overrides, + }; +} + +/** A two-entry document pinning the tie-break rows of the precedence table. */ +function tieDoc(first: PermissionVerdict, second: PermissionVerdict): PermissionsDocument { + return docWith({ + default_action: "allow", + entries: [ + { id: "e1", agent: "codex", action: "write", target: "t.md", verdict: first }, + { id: "e2", agent: "codex", action: "write", target: "t.md", verdict: second }, + ], + }); +} + +describe("resolvePermission", () => { + test("an empty document resolves everything to default_action", () => { + const decision = resolvePermission(docWith({ default_action: "deny" }), SUBJECT, "write"); + expect(decision).toEqual({ verdict: "deny", source: "default", reason: "default_action" }); + }); + + test("the default applies to every action and any target", () => { + const doc = docWith({ default_action: "allow" }); + for (const action of ["write", "ingest", "owner_write"] as const) { + expect(resolvePermission(doc, SUBJECT, action).verdict).toBe("allow"); + expect(resolvePermission(doc, SUBJECT, action, "notes/foo.md").source).toBe("default"); + } + }); + + test("an agent override beats default_action, naming the agent as source", () => { + const doc = docWith({ + default_action: "deny", + agents: { codex: { write: "allow" } }, + }); + expect(resolvePermission(doc, SUBJECT, "write")).toEqual({ + verdict: "allow", + source: "agent:codex", + reason: "agent override", + }); + // Only the action it overrides. + expect(resolvePermission(doc, SUBJECT, "ingest").source).toBe("default"); + }); + + test("the agent's role mapping beats default_action but not the agent override", () => { + const doc = docWith({ + default_action: "deny", + roles: { reviewer: { write: "ask" } }, + agents: { codex: { role: "reviewer" } }, + }); + expect(resolvePermission(doc, SUBJECT, "write")).toEqual({ + verdict: "ask", + source: "role:reviewer", + reason: "role mapping", + }); + + const withOverride: PermissionsDocument = { + ...doc, + agents: { codex: { role: "reviewer", write: "allow" } }, + }; + expect(resolvePermission(withOverride, SUBJECT, "write").source).toBe("agent:codex"); + }); + + test("an agent with no role never reads a role it was not granted", () => { + const doc = docWith({ default_action: "deny", roles: { reviewer: { write: "ask" } } }); + expect(resolvePermission(doc, SUBJECT, "write").source).toBe("default"); + }); + + test("a target-scoped entry beats every blanket rule", () => { + const doc = docWith({ + default_action: "allow", + agents: { codex: { write: "allow" } }, + roles: { reviewer: { write: "allow" } }, + entries: [ + { + id: "freeze-target", + agent: "codex", + action: "write", + target: "notes/foo.md", + verdict: "deny", + }, + ], + }); + expect(resolvePermission(doc, SUBJECT, "write", "notes/foo.md")).toEqual({ + verdict: "deny", + source: "entry:freeze-target", + reason: "target-scoped entry freeze-target", + }); + // The same entry says nothing about any other target. + expect(resolvePermission(doc, SUBJECT, "write", "notes/bar.md").source).toBe("agent:codex"); + }); + + test("an untargeted entry applies everywhere but loses to a target-scoped one", () => { + const doc = docWith({ + default_action: "allow", + entries: [ + { id: "blanket", agent: "codex", action: "write", verdict: "ask" }, + { id: "narrow", agent: "codex", action: "write", target: "notes/foo.md", verdict: "deny" }, + ], + }); + expect(resolvePermission(doc, SUBJECT, "write", "notes/foo.md").source).toBe("entry:narrow"); + expect(resolvePermission(doc, SUBJECT, "write", "notes/bar.md").source).toBe("entry:blanket"); + expect(resolvePermission(doc, SUBJECT, "write", "notes/bar.md").verdict).toBe("ask"); + }); + + test("at equal specificity deny beats ask beats allow, regardless of order", () => { + expect(resolvePermission(tieDoc("ask", "deny"), SUBJECT, "write", "t.md").verdict).toBe("deny"); + expect(resolvePermission(tieDoc("deny", "ask"), SUBJECT, "write", "t.md").verdict).toBe("deny"); + expect(resolvePermission(tieDoc("allow", "ask"), SUBJECT, "write", "t.md").verdict).toBe("ask"); + expect(resolvePermission(tieDoc("ask", "allow"), SUBJECT, "write", "t.md").verdict).toBe("ask"); + expect(resolvePermission(tieDoc("allow", "deny"), SUBJECT, "write", "t.md").verdict).toBe( + "deny", + ); + expect(resolvePermission(tieDoc("deny", "allow"), SUBJECT, "write", "t.md").verdict).toBe( + "deny", + ); + }); + + test("a deny entry beats an allow role at the same tier boundary", () => { + const doc = docWith({ + default_action: "allow", + roles: { reviewer: { write: "allow" } }, + agents: { codex: { role: "reviewer" } }, + entries: [{ id: "stop", role: "reviewer", action: "write", verdict: "deny" }], + }); + expect(resolvePermission(doc, SUBJECT, "write")).toEqual({ + verdict: "deny", + source: "entry:stop", + reason: "entry stop", + }); + }); + + test("entries match by agent, by role, or globally - never by the wrong principal", () => { + const doc = docWith({ + default_action: "allow", + roles: { reviewer: { write: "ask" } }, + agents: { codex: { role: "reviewer" } }, + entries: [ + { id: "for-codex", agent: "codex", action: "write", verdict: "deny" }, + { id: "for-reviewer", role: "reviewer", action: "ingest", verdict: "deny" }, + { id: "for-everyone", action: "owner_write", verdict: "deny" }, + ], + }); + const other: PermissionSubject = { agent: "gemini", via: "token" }; + + expect(resolvePermission(doc, SUBJECT, "write").source).toBe("entry:for-codex"); + expect(resolvePermission(doc, SUBJECT, "ingest").source).toBe("entry:for-reviewer"); + expect(resolvePermission(doc, SUBJECT, "owner_write").source).toBe("entry:for-everyone"); + + // gemini holds no role, so no principal entry reaches it; only the + // global one decides its own action, and the rest fall to the default. + expect(resolvePermission(doc, other, "owner_write").source).toBe("entry:for-everyone"); + expect(resolvePermission(doc, other, "write").source).toBe("default"); + expect(resolvePermission(doc, other, "ingest").source).toBe("default"); + }); + + test("an entry for another agent does not leak onto this one at any tier", () => { + const doc = docWith({ + default_action: "deny", + agents: { gemini: { write: "allow" }, codex: { role: "reviewer" } }, + roles: { reviewer: { write: "ask" } }, + entries: [{ id: "gemini-only", agent: "gemini", action: "write", verdict: "allow" }], + }); + const decision = resolvePermission(doc, SUBJECT, "write"); + expect(decision.verdict).toBe("ask"); + expect(decision.source).toBe("role:reviewer"); + }); + + test("an entry whose action differs never decides this action", () => { + const doc = docWith({ + default_action: "deny", + entries: [{ id: "ingest-only", agent: "codex", action: "ingest", verdict: "allow" }], + }); + expect(resolvePermission(doc, SUBJECT, "write").source).toBe("default"); + }); + + test("the subject's via never changes the verdict", () => { + const doc = docWith({ + default_action: "deny", + agents: { codex: { write: "allow" } }, + }); + for (const via of ["token", "config", "operator"] as const) { + expect(resolvePermission(doc, { agent: "codex", via }, "write").verdict).toBe("allow"); + } + }); + + test("a document that denies by default is visible through the resolver on a real file", () => { + mkdirSync(join(vault, "Brain"), { recursive: true }); + writeFileSync( + join(vault, "Brain", "_permissions.yaml"), + "version: 1\ndefault_action: deny\n", + "utf8", + ); + const { document } = loadPermissionsDocument(vault); + expect(document).not.toBeNull(); + const decision = resolvePermission(document!, SUBJECT, "ingest", "sources/x.md"); + expect(decision.verdict).toBe("deny"); + expect(decision.source).toBe("default"); + }); +}); From 974cd24fefc9d40dd04c2e617f93e8c90d52c25f Mon Sep 17 00:00:00 2001 From: Sergey Eroshenkov Date: Sat, 10 Oct 2026 10:58:33 +0200 Subject: [PATCH 06/84] feat(permissions): decision ledger store with month/device JSONL shards (write-side-trust task 2) --- src/core/brain/permissions/ledger.ts | 260 ++++++++++++++++++ src/core/state/surfaces.ts | 17 ++ .../architecture/state-surface-census.test.ts | 2 +- tests/core/brain/permissions/ledger.test.ts | 257 +++++++++++++++++ tests/core/state/surfaces.test.ts | 2 + 5 files changed, 537 insertions(+), 1 deletion(-) create mode 100644 src/core/brain/permissions/ledger.ts create mode 100644 tests/core/brain/permissions/ledger.test.ts diff --git a/src/core/brain/permissions/ledger.ts b/src/core/brain/permissions/ledger.ts new file mode 100644 index 00000000..0e314208 --- /dev/null +++ b/src/core/brain/permissions/ledger.ts @@ -0,0 +1,260 @@ +/** + * The decision ledger (write-side-trust, Task 2). + * + * The queryable record of which rule allowed, asked or denied which + * operation: JSONL month/device shards under `Brain/logs/decisions/`, on + * the idempotency-ledger model. The per-device shard is the Syncthing + * answer - two machines never append to one file - and the merged read + * orders by (timestamp, shard id, line), so every device replays the same + * sequence no matter the order the shards arrived in. + * + * The append contract is absolute: a failed append NEVER throws. The + * ledger rides behind gates that must refuse or stage a write even when + * accountability cannot be recorded, so every failure - a malformed + * timestamp, an unwritable directory, a lock another writer holds - comes + * back as `{ logged: false, audit_reason }` for the caller to surface. + * Nothing here is silent: the audit reason is the caller's evidence. + * + * LEAF MODULE like its sibling `document.ts`: it imports the shared shard + * grammar and the Brain root name, and nothing that reaches back into the + * gates, so a guard can record a refusal without importing the layer the + * refusal came from. + */ + +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +import lockfile from "proper-lockfile"; + +import { ensureInsideVault } from "../../path-safety.ts"; +import { + JSONL_LEDGER_EXT, + listShardedFiles, + mergeShardedRows, + resolveAppendShardId, + shardedFileName, + type LedgerShardGrammar, + type ShardedRow, +} from "../ledger-shards.ts"; +import { BRAIN_ROOT_REL } from "../path-constants.ts"; +import { assertVaultIdentityForWrite } from "../vault-identity.ts"; +import type { PermissionAction } from "./document.ts"; + +/** Month-sharded JSONL root under `Brain/logs/`, beside the idempotency ledger. */ +const DECISIONS_REL = `${BRAIN_ROOT_REL}/logs/decisions`; + +/** Shard base: one file per UTC month, per device. */ +const MONTH_BASE = "\\d{4}-\\d{2}"; +const MONTH_RE = new RegExp(`^${MONTH_BASE}$`); + +/** The ledger's file-name layout, handed to the shared shard grammar. */ +const DECISIONS_GRAMMAR: LedgerShardGrammar = Object.freeze({ + base: MONTH_BASE, + extensions: Object.freeze([JSONL_LEDGER_EXT]), +}); + +/** + * One durable accountability row. `action` narrows to the permission + * vocabulary plus `resolution` - the row a staged document's apply or + * reject lands. `verdict` stays a plain string so gate modes and refusal + * tokens can appear beside allow/ask/deny; `source` names the deciding + * rule (an entry id, an agent or role, `default`, or a gate key). + */ +export interface DecisionLedgerRow { + ts: string; + actor: string; + via: string; + action: PermissionAction | "resolution"; + target: string; + verdict: string; + source: string; + reason: string; + tool?: string; + correlation_id?: string; +} + +/** The append outcome. `logged: false` always carries `audit_reason`. */ +export interface DecisionLedgerAppendResult { + logged: boolean; + audit_reason?: string; +} + +/** Every field a filter may narrow by; absent fields match everything. */ +export interface DecisionLedgerFilter { + actor?: string; + action?: string; + verdict?: string; + target?: string; + /** Inclusive lower bound on `ts` (ISO-8601 compares as a string). */ + since?: string; + /** Inclusive upper bound on `ts`. */ + until?: string; + /** Applied after the deterministic sort, never during it. */ + limit?: number; +} + +/** + * The directory every decision shard lives in. Exported so the state + * surface inventory and the CLI resolve this ledger's location through + * the module that owns it. + */ +export function decisionLedgerDir(vault: string): string { + return ensureInsideVault(join(vault, DECISIONS_REL), vault); +} + +/** + * One month's shard for one device: `[.].jsonl`. Exported + * for the lock and the doctor probes; the append path derives it itself. + */ +export function decisionLedgerShardPath(vault: string, month: string, shardId: string): string { + if (!MONTH_RE.test(month)) throw new Error(`invalid decision ledger month: ${month}`); + return shardedFileName(month, shardId, JSONL_LEDGER_EXT); +} + +/** + * Append one row to this device's shard for the row's month. + * + * Never throws. The vault-identity guard runs INSIDE the failure net: a + * guard refusal means the row must not be written where it was aimed, and + * refusing-with-a-reason is exactly the append contract - the caller + * still gets its verdict through and carries the reason. + */ +export function appendDecisionLedger( + vault: string, + row: DecisionLedgerRow, +): DecisionLedgerAppendResult { + try { + assertVaultIdentityForWrite(vault); + const ts = requireTimestamp(row.ts); + const month = monthOf(ts); + const shardId = resolveAppendShardId(); + const dir = decisionLedgerDir(vault); + mkdirSync(dir, { recursive: true }); + const shardPath = join(dir, decisionLedgerShardPath(vault, month, shardId)); + withShardLock(shardPath, () => { + writeFileSync(shardPath, `${JSON.stringify(stripEmptyOptionals(row))}\n`, { + encoding: "utf8", + flag: "a", + }); + }); + return { logged: true }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { logged: false, audit_reason: `decision ledger append failed: ${message}` }; + } +} + +/** + * Every row the vault holds, merged across shards into the one order + * every device agrees on - (timestamp, shard id, line) - then narrowed by + * `filter`. An empty vault answers `[]` and creates nothing. + * + * A shard that cannot be READ propagates: an unreadable shard read as an + * empty one would answer "this write was never gated", which is the one + * wrong answer an accountability ledger must never give. A malformed LINE + * is skipped, on the idempotency-ledger precedent: history stays legible + * around a torn line instead of disappearing behind it. + */ +export function queryDecisionLedger( + vault: string, + filter: DecisionLedgerFilter = {}, +): DecisionLedgerRow[] { + const dir = decisionLedgerDir(vault); + const shards = listShardedFiles(dir, DECISIONS_GRAMMAR); + if (shards.length === 0) return []; + const sharded: Array> = []; + for (const shard of shards) { + let text: string; + try { + text = readShardText(vault, shard.path); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "ENOENT") continue; + throw err; + } + for (const [line, content] of text.split("\n").entries()) { + if (content.trim() === "") continue; + try { + sharded.push({ + value: JSON.parse(content) as DecisionLedgerRow, + shardId: shard.shardId, + line, + }); + } catch { + continue; + } + } + } + const merged = mergeShardedRows(sharded, (row) => row.ts); + return merged.filter((row) => matches(row, filter)).slice(0, positiveLimit(filter)); +} + +// ----- internals ------------------------------------------------------------- + +function readShardText(vault: string, path: string): string { + return readFileSync(ensureInsideVault(path, vault), "utf8"); +} + +function requireTimestamp(ts: string): string { + if (typeof ts !== "string" || !MONTH_RE.test(ts.slice(0, 7)) || ts.length < 7) { + throw new Error(`row ts must start with YYYY-MM; got ${JSON.stringify(ts)}`); + } + return ts; +} + +function monthOf(ts: string): string { + return ts.slice(0, 7); +} + +/** + * Serialise the append across processes: proper-lockfile on the shard + * path, with the bounded retry the secrets store's writer lock uses. The + * sync lockfile API has no retry option, so contention spins briefly + * before the caller sees the failure as an audit reason. + */ +function withShardLock(shardPath: string, fn: () => void): void { + const maxAttempts = 20; + let release: (() => void) | null = null; + let lastError: unknown; + for (let attempt = 0; attempt < maxAttempts && release === null; attempt++) { + try { + release = lockfile.lockSync(shardPath, { stale: 10_000, realpath: false }); + } catch (exc) { + if ((exc as NodeJS.ErrnoException).code !== "ELOCKED") throw exc; + lastError = exc; + if (attempt < maxAttempts - 1) Bun.sleepSync(25); + } + } + if (release === null) { + const msg = lastError instanceof Error ? lastError.message : String(lastError); + throw new Error(`another writer holds the decision ledger shard lock: ${msg}`); + } + try { + fn(); + } finally { + void release(); + } +} + +function stripEmptyOptionals(row: DecisionLedgerRow): DecisionLedgerRow { + return { + ...row, + ...(row.tool !== undefined ? { tool: row.tool } : {}), + ...(row.correlation_id !== undefined ? { correlation_id: row.correlation_id } : {}), + }; +} + +function matches(row: DecisionLedgerRow, filter: DecisionLedgerFilter): boolean { + if (filter.actor !== undefined && row.actor !== filter.actor) return false; + if (filter.action !== undefined && row.action !== filter.action) return false; + if (filter.verdict !== undefined && row.verdict !== filter.verdict) return false; + if (filter.target !== undefined && row.target !== filter.target) return false; + if (filter.since !== undefined && row.ts < filter.since) return false; + if (filter.until !== undefined && row.ts > filter.until) return false; + return true; +} + +/** A filter without a limit reads everything; a nonsense limit reads nothing extra. */ +function positiveLimit(filter: DecisionLedgerFilter): number { + if (filter.limit === undefined) return Number.POSITIVE_INFINITY; + return filter.limit > 0 ? filter.limit : 0; +} diff --git a/src/core/state/surfaces.ts b/src/core/state/surfaces.ts index 5bb22679..0bc4ee05 100644 --- a/src/core/state/surfaces.ts +++ b/src/core/state/surfaces.ts @@ -154,6 +154,7 @@ export const STATE_SURFACE_ID = Object.freeze({ rollupLedger: "rollup_ledger", proposalWatermark: "proposal_watermark", captureWatermark: "capture_watermark", + decisionLedger: "decision_ledger", } as const); export type StateSurfaceId = (typeof STATE_SURFACE_ID)[keyof typeof STATE_SURFACE_ID]; @@ -1033,6 +1034,22 @@ export const STATE_SURFACES: ReadonlyArray = Object.freeze([ "walker never indexes it; deleting it replays the acknowledgement, not the captures.", sources: ["src/core/brain/paths.ts"], }, + { + id: STATE_SURFACE_ID.decisionLedger, + label: "decision ledger", + tier: STATE_TIER.vaultContent, + derive: brainTree("logs", "decisions"), + override_env: DEVICE_ID_ENV, + override_config_key: DEVICE_ID_CONFIG_KEY, + carries_memory: false, + reason: + "One JSONL row per gate decision - which rule allowed, asked or denied which operation - " + + "sharded by month and device so two machines syncing one vault never write the same file. " + + "The device id names THIS machine's shard; the reader merges every shard it finds. It is " + + "the only queryable record of why a write was refused, so nothing regenerates a row that " + + "was never appended.", + sources: ["src/core/brain/permissions/ledger.ts"], + }, ]); /** diff --git a/tests/core/architecture/state-surface-census.test.ts b/tests/core/architecture/state-surface-census.test.ts index c4250102..4b1c3221 100644 --- a/tests/core/architecture/state-surface-census.test.ts +++ b/tests/core/architecture/state-surface-census.test.ts @@ -80,7 +80,7 @@ const STORE_DIR_IDENTIFIER = "DERIVED_STORE_DIR"; const SWEPT_POPULATION_SIZE = 23; /** Declared surfaces today. Pinned for the same reason. */ -const DECLARED_SURFACE_COUNT = 47; +const DECLARED_SURFACE_COUNT = 48; /** An exclusion reason has to be an argument, not a label. */ const MIN_REASON_LENGTH = 80; diff --git a/tests/core/brain/permissions/ledger.test.ts b/tests/core/brain/permissions/ledger.test.ts new file mode 100644 index 00000000..8343a18e --- /dev/null +++ b/tests/core/brain/permissions/ledger.test.ts @@ -0,0 +1,257 @@ +/** + * The decision ledger store (write-side-trust, Task 2). + * + * One row per non-allow verdict (plus resolution rows), month/device + * JSONL shards under `Brain/logs/decisions/` on the idempotency-ledger + * model: the per-device shard keeps two Syncthing peers from ever writing + * one file, and the merged read orders by (timestamp, shard id) so every + * device sees the same sequence regardless of arrival order. + * + * The append half has one absolute contract: a failed append NEVER + * throws. The ledger rides behind gates that must refuse or stage a + * write even when accountability cannot be recorded, so every failure + * comes back as `{ logged: false, audit_reason }` instead. + */ + +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import lockfile from "proper-lockfile"; + +import { + appendDecisionLedger, + decisionLedgerDir, + queryDecisionLedger, +} from "../../../../src/core/brain/permissions/ledger.ts"; +import type { DecisionLedgerRow } from "../../../../src/core/brain/permissions/ledger.ts"; +import { CHMOD_CANNOT_DENY } from "../../../helpers/platform.ts"; + +let vault: string; +const savedDeviceId = process.env["O2B_DEVICE_ID"]; + +beforeEach(() => { + vault = mkdtempSync(join(tmpdir(), "o2b-decisions-ledger-")); + // The suite preload pins the empty shard; tests that simulate a device + // set their own and restore this one. + process.env["O2B_DEVICE_ID"] = ""; +}); + +afterEach(() => { + rmSync(vault, { recursive: true, force: true }); + if (savedDeviceId === undefined) delete process.env["O2B_DEVICE_ID"]; + else process.env["O2B_DEVICE_ID"] = savedDeviceId; +}); + +function row(overrides: Partial = {}): DecisionLedgerRow { + return { + ts: "2026-10-10T10:00:00Z", + actor: "codex", + via: "token", + action: "write", + target: "notes/foo.md", + verdict: "deny", + source: "entry:freeze-notes", + reason: "target-scoped entry freeze-notes", + ...overrides, + }; +} + +describe("appendDecisionLedger", () => { + test("a row lands on one line of the month shard of the empty device id", () => { + const result = appendDecisionLedger(vault, row()); + expect(result).toEqual({ logged: true }); + const shard = join(decisionLedgerDir(vault), "2026-10.jsonl"); + expect(existsSync(shard)).toBe(true); + const lines = readFileSync(shard, "utf8").trimEnd().split("\n"); + expect(lines).toHaveLength(1); + expect(JSON.parse(lines[0]!)).toEqual(row()); + }); + + test("the shard month comes from the row's own ts, not the wall clock", () => { + appendDecisionLedger(vault, row({ ts: "2025-01-02T03:04:05Z" })); + expect(existsSync(join(decisionLedgerDir(vault), "2025-01.jsonl"))).toBe(true); + }); + + test("a device id writes its own shard and never the legacy name", () => { + process.env["O2B_DEVICE_ID"] = "lane-a"; + appendDecisionLedger(vault, row({ ts: "2026-10-10T10:00:00Z" })); + expect(existsSync(join(decisionLedgerDir(vault), "2026-10.jsonl"))).toBe(false); + expect(existsSync(join(decisionLedgerDir(vault), "2026-10.lane-a.jsonl"))).toBe(true); + }); + + test("two simulated devices appending in alternation never lose a row", () => { + for (let i = 0; i < 10; i++) { + process.env["O2B_DEVICE_ID"] = i % 2 === 0 ? "device-one" : "device-two"; + const result = appendDecisionLedger( + vault, + row({ ts: `2026-10-10T10:00:${String(i).padStart(2, "0")}Z`, actor: `agent-${i}` }), + ); + expect(result.logged).toBe(true); + } + const rows = queryDecisionLedger(vault, {}); + expect(rows).toHaveLength(10); + expect(rows.map((r) => r.actor)).toEqual([ + "agent-0", + "agent-1", + "agent-2", + "agent-3", + "agent-4", + "agent-5", + "agent-6", + "agent-7", + "agent-8", + "agent-9", + ]); + }); + + test("an invalid row refuses with an audit reason instead of throwing", () => { + const badTs = appendDecisionLedger(vault, row({ ts: "not-a-timestamp" })); + expect(badTs.logged).toBe(false); + expect(badTs.audit_reason).toBeDefined(); + + const emptyTs = appendDecisionLedger(vault, row({ ts: "" })); + expect(emptyTs.logged).toBe(false); + expect(emptyTs.audit_reason).toBeDefined(); + // Nothing was written by either refusal. + expect(existsSync(decisionLedgerDir(vault))).toBe(false); + }); + + test( + "a held shard lock is a failed append with an audit reason, never a throw", + () => { + appendDecisionLedger(vault, row()); + const shard = join(decisionLedgerDir(vault), "2026-10.jsonl"); + const release = lockfile.lockSync(shard, { stale: 10_000, realpath: false }); + try { + const result = appendDecisionLedger(vault, row({ actor: "second" })); + expect(result.logged).toBe(false); + expect(result.audit_reason).toBeDefined(); + } finally { + void release(); + } + // The released lock lets the next append through, and the shard + // keeps the first row plus the retry. + const retried = appendDecisionLedger(vault, row({ actor: "second" })); + expect(retried.logged).toBe(true); + expect(queryDecisionLedger(vault, {})).toHaveLength(2); + }, + { timeout: 20_000 }, + ); +}); + +test.skipIf(CHMOD_CANNOT_DENY)("an unwritable ledger directory fails with an audit reason", () => { + mkdirSync(join(vault, "Brain"), { recursive: true }); + mkdirSync(join(vault, "Brain", "logs")); + // A file where the decisions directory belongs: every append must + // refuse by reason rather than throw. + writeFileSync(join(vault, "Brain", "logs", "decisions"), "occupied", "utf8"); + const result = appendDecisionLedger(vault, row()); + expect(result.logged).toBe(false); + expect(result.audit_reason).toBeDefined(); +}); + +test.skipIf(CHMOD_CANNOT_DENY)("an unreadable shard is never read as an empty one", () => { + appendDecisionLedger(vault, row()); + const shard = join(decisionLedgerDir(vault), "2026-10.jsonl"); + chmodSync(shard, 0o000); + try { + expect(() => queryDecisionLedger(vault, {})).toThrow(); + } finally { + chmodSync(shard, 0o644); + } +}); + +describe("queryDecisionLedger", () => { + test("an empty vault yields zero rows and no directory", () => { + expect(queryDecisionLedger(vault, {})).toEqual([]); + expect(existsSync(decisionLedgerDir(vault))).toBe(false); + }); + + test("the merged read is ordered by (ts, shardId) across device shards", () => { + process.env["O2B_DEVICE_ID"] = "b-device"; + appendDecisionLedger(vault, row({ ts: "2026-10-10T09:00:00Z", actor: "early-on-b" })); + appendDecisionLedger(vault, row({ ts: "2026-10-10T10:00:00Z", actor: "tie-on-b" })); + process.env["O2B_DEVICE_ID"] = "a-device"; + appendDecisionLedger(vault, row({ ts: "2026-10-10T10:00:00Z", actor: "tie-on-a" })); + process.env["O2B_DEVICE_ID"] = ""; + appendDecisionLedger(vault, row({ ts: "2026-10-10T11:00:00Z", actor: "late-legacy" })); + + expect(queryDecisionLedger(vault, {}).map((r) => r.actor)).toEqual([ + "early-on-b", + "tie-on-a", + "tie-on-b", + "late-legacy", + ]); + }); + + test("rows within one shard keep their append order at equal timestamps", () => { + appendDecisionLedger(vault, row({ ts: "2026-10-10T10:00:00Z", actor: "first" })); + appendDecisionLedger(vault, row({ ts: "2026-10-10T10:00:00Z", actor: "second" })); + expect(queryDecisionLedger(vault, {}).map((r) => r.actor)).toEqual(["first", "second"]); + }); + + test("filters narrow by actor, action, verdict and target", () => { + process.env["O2B_DEVICE_ID"] = ""; + appendDecisionLedger(vault, row({ actor: "codex", action: "write", verdict: "deny" })); + appendDecisionLedger(vault, row({ actor: "gemini", action: "ingest", verdict: "ask" })); + appendDecisionLedger( + vault, + row({ actor: "codex", action: "owner_write", verdict: "allow", target: "notes/bar.md" }), + ); + + expect(queryDecisionLedger(vault, { actor: "codex" })).toHaveLength(2); + expect(queryDecisionLedger(vault, { action: "ingest" })).toHaveLength(1); + expect(queryDecisionLedger(vault, { verdict: "deny" })).toHaveLength(1); + expect(queryDecisionLedger(vault, { target: "notes/bar.md" })).toHaveLength(1); + expect(queryDecisionLedger(vault, { actor: "codex", verdict: "deny" })).toHaveLength(1); + expect(queryDecisionLedger(vault, { actor: "nobody" })).toEqual([]); + }); + + test("since and until bound the window inclusively", () => { + appendDecisionLedger(vault, row({ ts: "2026-10-01T00:00:00Z", actor: "first-day" })); + appendDecisionLedger(vault, row({ ts: "2026-10-15T12:00:00Z", actor: "mid-month" })); + appendDecisionLedger(vault, row({ ts: "2026-11-01T00:00:00Z", actor: "next-month" })); + + expect( + queryDecisionLedger(vault, { since: "2026-10-10T00:00:00Z" }).map((r) => r.actor), + ).toEqual(["mid-month", "next-month"]); + expect( + queryDecisionLedger(vault, { until: "2026-10-15T12:00:00Z" }).map((r) => r.actor), + ).toEqual(["first-day", "mid-month"]); + expect( + queryDecisionLedger(vault, { since: "2026-10-15T12:00:00Z", until: "2026-10-15T12:00:00Z" }), + ).toHaveLength(1); + }); + + test("a query spanning months reads every shard, oldest first", () => { + appendDecisionLedger(vault, row({ ts: "2026-09-01T00:00:00Z", actor: "september" })); + appendDecisionLedger(vault, row({ ts: "2026-10-01T00:00:00Z", actor: "october" })); + expect(queryDecisionLedger(vault, {}).map((r) => r.actor)).toEqual(["september", "october"]); + }); + + test("limit caps the merged result after ordering", () => { + for (let i = 0; i < 5; i++) { + appendDecisionLedger(vault, row({ ts: `2026-10-10T10:0${i}:00Z`, actor: `agent-${i}` })); + } + const limited = queryDecisionLedger(vault, { limit: 2 }); + expect(limited.map((r) => r.actor)).toEqual(["agent-0", "agent-1"]); + }); + + test("a malformed line is skipped and the readable rows around it survive", () => { + appendDecisionLedger(vault, row({ actor: "before" })); + const shard = join(decisionLedgerDir(vault), "2026-10.jsonl"); + writeFileSync(shard, "{not json}\n", { encoding: "utf8", flag: "a" }); + appendDecisionLedger(vault, row({ actor: "after" })); + expect(queryDecisionLedger(vault, {}).map((r) => r.actor)).toEqual(["before", "after"]); + }); +}); diff --git a/tests/core/state/surfaces.test.ts b/tests/core/state/surfaces.test.ts index c52b93fa..5ce11382 100644 --- a/tests/core/state/surfaces.test.ts +++ b/tests/core/state/surfaces.test.ts @@ -57,6 +57,7 @@ import { writeImagesDir, } from "../../../src/core/brain/paths.ts"; import { deadLetterDir } from "../../../src/core/brain/dead-letter.ts"; +import { decisionLedgerDir } from "../../../src/core/brain/permissions/ledger.ts"; import { checkpointPath } from "../../../src/core/brain/ingest/checkpoint.ts"; import { sessionCheckpointPath } from "../../../src/core/brain/sessions/checkpoint.ts"; import { manifestPath as ingestManifestPath } from "../../../src/core/brain/ingest/content-manifest.ts"; @@ -297,6 +298,7 @@ const RESOLVER_BINDINGS: ReadonlyArray dirname(skillAcceptJournalPath(v, "probe"))], ["claim_graph", (v) => claimGraphPath(v)], ["rollup_ledger", (v) => rollupLedgerPath(v)], + ["decision_ledger", (v) => decisionLedgerDir(v)], ]); /** From 801fd6f4cb0184c0aacceb89c398aad9a9c5f521 Mon Sep 17 00:00:00 2001 From: Sergey Eroshenkov Date: Sat, 10 Oct 2026 11:08:28 +0200 Subject: [PATCH 07/84] feat(mcp): transport token authentication and request-scoped identity (write-side-trust task 7) authenticateRequest resolves the vault token map first (via: token), then the shared key (via: shared-key, process config identity), with the unchanged generic 401 for missing and invalid credentials; mcp_tokens_required (env twin OPEN_SECOND_BRAIN_MCP_TOKENS_REQUIRED) refuses credential-less requests once a map exists, and the non-loopback bind accepts key or map. Identity threads as a parameter through handleRequest -> handleToolsCall -> invokeToolHandler -> contextFor; no instance field, so concurrent requests never observe each other's identity. No tokens configured is byte-identical: every pre-existing HTTP test passes unmodified. --- src/cli/main.ts | 35 ++- src/core/brain/secrets/token-store.ts | 11 + src/mcp/http.ts | 193 +++++++++++-- src/mcp/server.ts | 77 ++++-- tests/mcp/http-token-auth.test.ts | 373 ++++++++++++++++++++++++++ tests/mcp/http-transport.test.ts | 56 +++- tests/mcp/owner-scope-refusal.test.ts | 63 ++++- 7 files changed, 766 insertions(+), 42 deletions(-) create mode 100644 tests/mcp/http-token-auth.test.ts diff --git a/src/cli/main.ts b/src/cli/main.ts index 9558e88f..db85c848 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -16,6 +16,7 @@ import { defaultConfigPath, discoverConfig, resolveMcpToolProfile, + resolveVault, setConfigValue, validateTimezoneName, } from "../core/config.ts"; @@ -84,7 +85,8 @@ import { CLI_COMMAND_MANIFEST, ROOT_VERSION_FLAG, manifestForJson } from "./comm import { COMPLETION_SHELLS, isCompletionShell, renderCompletions } from "./completions.ts"; import { MCPServer } from "../mcp/server.ts"; import { CLI_TRANSPORT_REACH } from "./transport-reach.ts"; -import { startHttp, isLoopbackHost } from "../mcp/http.ts"; +import { isLoopbackHost, resolveMcpTokensRequired, startHttp } from "../mcp/http.ts"; +import { hasAnyAgentToken } from "../core/brain/secrets/token-store.ts"; import { serveStdio } from "../mcp/stdio.ts"; import { SERVER_VERSION } from "../mcp/protocol.ts"; import { buildToolTable } from "../mcp/tools.ts"; @@ -865,11 +867,23 @@ async function cmdMcp(argv: string[]): Promise { process.env["OPEN_SECOND_BRAIN_MCP_API_KEY"] ?? undefined; // Bearer is optional on a loopback bind (the loopback bind + Host/Origin - // rebinding guard are the baseline defence) but mandatory on a non-loopback - // host, which would otherwise expose the Brain unauthenticated on the network. - if (transport === "http" && !isLoopbackHost(host) && (apiKey === undefined || apiKey === "")) { + // rebinding guard are the baseline defence) but a credential source is + // mandatory on a non-loopback host, which would otherwise expose the Brain + // unauthenticated on the network: the shared key, or a non-empty per-agent + // token map (write-side-trust, Task 7). The vault resolves without + // throwing here so the pre-check keeps its answer (and its exit code) on + // a machine with no vault at all. + const mapVault = (flags["vault"] as string | undefined) ?? resolveVault(config) ?? ""; + const mintedTokens = mapVault !== "" && hasAnyAgentToken(mapVault); + if ( + transport === "http" && + !isLoopbackHost(host) && + (apiKey === undefined || apiKey === "") && + !mintedTokens + ) { process.stderr.write( - "o2b mcp: --api-key (or OPEN_SECOND_BRAIN_MCP_API_KEY) is required when --transport http binds a non-loopback --host\n", + "o2b mcp: --api-key (or OPEN_SECOND_BRAIN_MCP_API_KEY) or at least one minted agent token " + + "is required when --transport http binds a non-loopback --host\n", ); return 2; } @@ -936,6 +950,17 @@ async function cmdMcp(argv: string[]): Promise { } if (transport === "http") { + // Enforcement needs both halves: the operator key AND a map to + // enforce against. With the key on but nothing minted yet the server + // starts and only says so - an outage would be the wrong answer for + // an operator one mint away from the posture they asked for. + if (resolveMcpTokensRequired(config) && !hasAnyAgentToken(vault)) { + process.stderr.write( + "o2b mcp: mcp_tokens_required is on, but no agent token is minted for this vault yet; " + + "credential-less requests are not refused until one exists " + + "(mint one with `o2b mcp token mint `)\n", + ); + } const handle = await startHttp( { vault, configPath: config, repoRoot }, { host, port, apiKey, faultCounts: () => faults.counts() }, diff --git a/src/core/brain/secrets/token-store.ts b/src/core/brain/secrets/token-store.ts index e98b74b4..d02ccb69 100644 --- a/src/core/brain/secrets/token-store.ts +++ b/src/core/brain/secrets/token-store.ts @@ -203,6 +203,17 @@ export function listAgentTokens(vault: string): McpTokenRecord[] { ); } +/** + * Whether any token is minted at all - the non-empty-map half of the + * transport's `mcp_tokens_required` enforcement and of the non-loopback + * bind rule. Read behind the same mtime cache as + * {@link resolveAgentForToken}, so minting the first token tightens a + * running server without a restart. + */ +export function hasAnyAgentToken(vault: string): boolean { + return activeHashIndex(vault).size > 0; +} + /** * Resolve a presented credential to its agent, or null when nothing * active matches. The presented material is hashed and compared against diff --git a/src/mcp/http.ts b/src/mcp/http.ts index b35ecb7c..521eb43c 100644 --- a/src/mcp/http.ts +++ b/src/mcp/http.ts @@ -26,10 +26,34 @@ import type { Writable } from "node:stream"; import { MCPServer, type MCPServerOptions, type MCPServerRuntimeOptions } from "./server.ts"; import { errorResponse, internalErrorResponse, type JsonRpcResponse } from "./server.ts"; +import type { RequestIdentity } from "./server.ts"; import { INVALID_REQUEST, PARSE_ERROR } from "./protocol.ts"; import { DRAIN_STATE, RequestDrain, resolveDrainDeadlineMs, type DrainOutcome } from "./drain.ts"; import { ORIGIN_CHANNEL, setOriginChannel } from "../core/origin-channel.ts"; import { TRANSPORT_REACH, type TransportReach } from "../core/graph/transport-reach.ts"; +import { discoverConfig, resolveAgentName } from "../core/config.ts"; +import { hasAnyAgentToken, resolveAgentForToken } from "../core/brain/secrets/token-store.ts"; + +export type { RequestIdentity }; + +/** + * Device-config switch behind which the endpoint refuses credential-less + * requests once a token map exists (write-side-trust, Task 7). Default + * off, so every posture that predates tokens is byte-identical; with an + * empty map the key only warns at startup (`o2b mcp`). + */ +export const MCP_TOKENS_REQUIRED_CONFIG_KEY = "mcp_tokens_required"; +export const MCP_TOKENS_REQUIRED_ENV_KEY = "OPEN_SECOND_BRAIN_MCP_TOKENS_REQUIRED"; + +/** Flat device key with an env twin, resolved exactly like `write_approval.enabled`. */ +export function resolveMcpTokensRequired(configPath?: string): boolean { + const env = process.env[MCP_TOKENS_REQUIRED_ENV_KEY]; + const raw = + env !== undefined && env !== "" + ? env + : discoverConfig(configPath).data[MCP_TOKENS_REQUIRED_CONFIG_KEY]; + return typeof raw === "string" && raw.trim().toLowerCase() === "true"; +} /** * The process-level fault counts a served transport reports on `/health`. @@ -50,6 +74,13 @@ export interface ServeHttpOptions { readonly host?: string; readonly port?: number; readonly apiKey?: string | null; + /** + * The `mcp_tokens_required` decision for this bind. Absent resolves the + * device config key (env twin included) against the server's own + * config path; injected by `o2b mcp` and by tests so the flag is + * explicit at the surface that warns about it. + */ + readonly tokensRequired?: boolean; readonly stderr?: Writable; /** * How long {@link HttpServerHandle.close} waits for in-flight requests. @@ -95,13 +126,20 @@ export async function startHttp( // Safe by default: on the loopback default a bearer is optional (the // loopback bind + Host/Origin rebinding guard are the baseline defence). // Binding to a NON-loopback interface exposes the Brain on the network, so - // a bearer is mandatory there - no permissive fallback. - if (!isLoopbackHost(host) && (apiKey === null || apiKey === "")) { + // a credential source is mandatory there - the shared key, or a non-empty + // per-agent token map (write-side-trust, Task 7). No permissive fallback. + if (!isLoopbackHost(host) && !hasCredentialSource(apiKey, ctx.vault)) { throw new Error( - "HTTP MCP transport bound to a non-loopback host requires --api-key " + - `(host=${host}); refusing to expose an unauthenticated endpoint on the network`, + "HTTP MCP transport bound to a non-loopback host requires --api-key or at least one " + + `minted agent token (host=${host}); refusing to expose an unauthenticated endpoint on the network`, ); } + // Enforcement is an explicit operator key (default off), resolved once + // per bind like the shared key; the non-empty-map half of the condition + // is re-read per request behind the store's mtime cache, so minting the + // first token tightens a running server without a restart. + const tokensRequired = + opts.tokensRequired ?? resolveMcpTokensRequired(ctx.configPath ?? undefined); const port = opts.port ?? 0; // A transport fact, set after the caller's runtime options for the same // reason `sendNotification` is on stdio: a runtime option must not be @@ -135,7 +173,7 @@ export async function startHttp( // shutdown then waits for a client that is waiting for it. res.on("close", finish); try { - await handleHttpRequest(mcp, apiKey, host, drain, opts.faultCounts, req, res); + await handleHttpRequest(mcp, apiKey, tokensRequired, host, drain, opts.faultCounts, req, res); } catch (exc) { // This promise used to be floated. A throw from the dispatch left // the socket open with no response on it and no record anywhere; @@ -241,6 +279,7 @@ export async function serveHttp( async function handleHttpRequest( mcp: MCPServer, apiKey: string | null, + configTokensRequired: boolean, boundHost: string, drain: RequestDrain, faultCounts: (() => McpFaultCounts) | undefined, @@ -301,9 +340,13 @@ async function handleHttpRequest( return; } - // Bearer is optional on loopback (guards are the baseline) but enforced when - // configured; a non-loopback bind always has a key (see startHttp). - if (apiKey !== null && apiKey !== "" && !authorized(req, apiKey)) { + // Credential resolution (write-side-trust, Task 7): the vault's token + // map first - a match mints the token's agent as the REQUEST identity - + // then the shared key, which keeps the process config identity. The + // generic 401 body is unchanged, and no answer distinguishes a revoked + // token from an unknown one. + const auth = authenticateHttpRequest(mcp, apiKey, configTokensRequired, boundHost, req); + if (auth.refused) { res.writeHead(401, { "content-type": "text/plain; charset=utf-8" }); res.end("Unauthorized\n"); return; @@ -346,7 +389,7 @@ async function handleHttpRequest( } const jsonReq = request as Record; - const response = await mcp.handleRequest(jsonReq); + const response = await mcp.handleRequest(jsonReq, auth.identity); if (response === null) { res.writeHead(204); res.end(); @@ -354,20 +397,134 @@ async function handleHttpRequest( } // No `mcp-session-id`. The header is a promise of per-session state, and // this transport has none: one MCPServer instance serves every request - // (see `startHttp`), identity and scope are process-global, and the id - // that used to be minted here was never read back on any later request. - // A client that received it would be entitled to expect the server to - // recognise it - and to be told 404 once it expired - so advertising one - // was a claim nothing behind it could honour. + // (see `startHttp`) and nothing is keyed by a session. What a request + // carries instead is the credential-minted identity, threaded through + // dispatch as a parameter - so the id that used to be minted here would + // still be advertising state nothing behind it reads. A client that + // received it would be entitled to expect the server to recognise it - + // and to be told 404 once it expired - so advertising one was a claim + // nothing behind it could honour. const accept = String(req.headers.accept ?? ""); if (accept.includes("text/event-stream")) writeSse(res, response); else writeJson(res, response); } -function authorized(req: IncomingMessage, apiKey: string): boolean { - const presented = bearerToken(req.headers.authorization) ?? firstHeader(req.headers["x-api-key"]); - if (presented === undefined) return false; - return constantTimeEqual(presented, apiKey); +/** + * Whether a credential was presented at all, from either header a client + * uses. The shared-key gate's original reader, now also the fast-path + * probe the token wiring uses to keep credential-less anonymous traffic + * off the config and store reads it would never need. + */ +function presentedCredential(req: IncomingMessage): string | undefined { + return bearerToken(req.headers.authorization) ?? firstHeader(req.headers["x-api-key"]); +} + +/** The per-request answer of one HTTP request's credential check. */ +interface HttpAuth { + /** The credential-minted identity, or `null` for an anonymous request. */ + readonly identity: RequestIdentity | null; + /** True when the request must be refused with the generic 401. */ + readonly refused: boolean; +} + +/** + * Resolve one request's credential against the token map and the shared + * key, and decide whether the request may proceed. + * + * The rules, in the order the transport has always applied them: + * + * - a presented credential matching the token map mints the token's + * agent, `via: "token"`; the map is consulted first, so a credential + * that also spells the shared key is a token; + * - a presented credential matching the shared key proceeds with the + * process config identity, `via: "shared-key"` - the operator master + * credential, byte-identical to the pre-token gate except that the + * identity now rides the request; + * - a presented credential matching neither is refused whenever a key is + * configured (the pre-existing rule) or tokens are required, and falls + * through to anonymous otherwise (the loopback posture, unchanged); + * - a credential-less request proceeds anonymous unless tokens are + * required - which is the config key AND a non-empty map, or the + * implicit requirement of a key-less non-loopback bind. + * + * The store probes sit behind the flags that need them: a loopback bind + * with no requirement and no presented credential reads nothing. + */ +function authenticateHttpRequest( + mcp: MCPServer, + apiKey: string | null, + configTokensRequired: boolean, + boundHost: string, + req: IncomingMessage, +): HttpAuth { + const hasKey = apiKey !== null && apiKey !== ""; + const networkBare = !isLoopbackHost(boundHost) && !hasKey; + const mapNonEmpty = configTokensRequired || networkBare ? hasAnyAgentToken(mcp.vault) : false; + const enforced = (configTokensRequired && mapNonEmpty) || (networkBare && mapNonEmpty); + const presented = presentedCredential(req); + const identity = authenticateRequest(req, { + apiKey, + resolveToken: (candidate) => resolveAgentForToken(mcp.vault, candidate), + tokensRequired: enforced, + sharedKeyAgent: resolveAgentName(mcp.configPath ?? undefined), + }); + return { + identity, + refused: identity === null && (presented !== undefined || enforced || hasKey), + }; +} + +export interface AuthenticateRequestOptions { + /** + * The shared operator key, launch-captured. `null` or `""` means none + * is configured, and no key match is possible. + */ + apiKey: string | null; + /** + * The vault's token map, mtime-cached by the store, so a rotation or + * revocation lands on the next request without a restart. + */ + resolveToken: (presented: string) => { agent: string } | null; + /** + * The caller's enforcement decision (`mcp_tokens_required` AND a + * non-empty map, or the implicit network-bind requirement). The + * 401 itself stays at the call site - this function answers identity, + * `null` meaning "no identity", and the caller refuses exactly when + * that null coincides with enforcement or a configured key. + */ + tokensRequired: boolean; + /** + * The process config identity a shared-key match carries. Optional; + * without it the ambient `resolveAgentName()` answers, which is the + * same resolution the process context makes. + */ + sharedKeyAgent?: string; +} + +/** + * Token map first, then the shared key (identity = the process config + * name), else `null`. A presented-but-unmatched credential and an absent + * one both answer `null` - the refusal is the caller's decision, so no + * answer here ever distinguishes a revoked token from an unknown one. + */ +export function authenticateRequest( + req: IncomingMessage, + opts: AuthenticateRequestOptions, +): RequestIdentity | null { + const presented = presentedCredential(req); + if (presented === undefined) return null; + const tokenAgent = opts.resolveToken(presented); + if (tokenAgent !== null) return { agent: tokenAgent.agent, via: "token" }; + if (opts.apiKey !== null && opts.apiKey !== "" && constantTimeEqual(presented, opts.apiKey)) { + return { agent: opts.sharedKeyAgent ?? resolveAgentName(), via: "shared-key" }; + } + return null; +} + +/** Whether the bind would have any credential source to demand. */ +function hasCredentialSource(apiKey: string | null, vault: string): boolean { + if (apiKey !== null && apiKey !== "") return true; + return hasAnyAgentToken(vault); } /** Canonical loopback host names a rebinding guard trusts. */ diff --git a/src/mcp/server.ts b/src/mcp/server.ts index b68834c5..4b4e7511 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -151,6 +151,23 @@ export interface JsonRpcResponse { readonly error?: JsonRpcErrorBody; } +/** + * The per-request identity a transport resolved from a credential + * (write-side-trust, Task 7). The HTTP transport mints it from the + * presented bearer token or shared key; stdio and the CLI bridge pass + * none and keep the config-derived identity. + * + * It threads as a PARAMETER from `handleRequest` through + * `handleToolsCall` and `invokeToolHandler` into `contextFor`, never as + * instance state: one MCPServer serves concurrent HTTP requests, and an + * identity parked on `this` would let two callers read each other's + * scope. + */ +export interface RequestIdentity { + readonly agent: string; + readonly via: "token" | "shared-key"; +} + /** * The `error` member of a JSON-RPC answer. `data` is always present and * always carries the string code, because {@link errorResponse} is the @@ -221,6 +238,17 @@ export class MCPServer { } get context(): ServerContext { + return this.contextFor(); + } + + /** + * The server context for ONE request. `identity` is what the transport + * resolved from the request's credential; when it is absent (stdio, + * the CLI bridge, a probe) the context falls back to the process + * config identity exactly as the plain getter always did - including + * deferring `resolveAgentName`'s refusal to the point of use. + */ + private contextFor(identity?: RequestIdentity): ServerContext { const configPath = this.configPath ?? undefined; return { vault: this.vault, @@ -232,7 +260,8 @@ export class MCPServer { ruleScope: this.ruleScope, // Owner-scope isolation (context-integrity-gates, Unit A): the // only source of identity for `brain_context`, which takes no - // arguments. Resolved per access, like `resolveAgentName`'s other + // arguments. A transport-minted credential wins; otherwise the + // config is resolved per access, like `resolveAgentName`'s other // callers, so a config edit takes effect without a restart. // // A GETTER, not a value, and that is the whole separation this @@ -247,7 +276,7 @@ export class MCPServer { // handlers that need an identity (as a tool-level error carrying the // file name), and the handlers that never ask answer normally. get agentName(): string { - return resolveAgentName(configPath); + return identity?.agent ?? resolveAgentName(configPath); }, }; } @@ -255,7 +284,7 @@ export class MCPServer { /** Public method for CLI tool-call bridge — the legacy code reached into `_tools`. */ async callTool(name: string, args: Record): Promise> { const tool = findTool(this.tools, name); - return toolResult(tool, await this.invokeToolHandler(tool, args)); + return toolResult(tool, await this.invokeToolHandler(tool, args, undefined, undefined)); } /** @@ -284,6 +313,7 @@ export class MCPServer { tool: ToolDefinition, args: Record, onProgress?: ProgressSink, + identity?: RequestIdentity, ): Promise { // Before the unknown-argument gate: a caller naming the visibility // boundary is told about the boundary, not offered a typo suggestion @@ -293,19 +323,21 @@ export class MCPServer { assertKnownArguments(tool, args); if (!this.routeMetricsEnabled) { try { - return await tool.handler(this.context, args, onProgress); + return await tool.handler(this.contextFor(identity), args, onProgress); } catch (exc) { - throw this.mapFrozen(tool, exc); + throw this.mapFrozen(tool, exc, identity); } } const start = performance.now(); let status: McpRouteStatus = "ok"; const routeScope = createRouteScope(); try { - return await routeScope.run(async () => tool.handler(this.context, args, onProgress)); + return await routeScope.run(async () => + tool.handler(this.contextFor(identity), args, onProgress), + ); } catch (exc) { status = "error"; - throw this.mapFrozen(tool, exc); + throw this.mapFrozen(tool, exc, identity); } finally { emitMcpRouteLatency( this.vault, @@ -333,7 +365,7 @@ export class MCPServer { * cover the ones somebody remembered. Every other exception passes * through untouched. */ - private mapFrozen(tool: ToolDefinition, exc: unknown): unknown { + private mapFrozen(tool: ToolDefinition, exc: unknown, identity?: RequestIdentity): unknown { if (!(exc instanceof VaultFrozenError)) return exc; // `agentName` is optional on the context - a transport may supply // none - so an absent identity is recorded as the named absence @@ -342,12 +374,15 @@ export class MCPServer { this.vault, tool.name, exc, - () => this.context.agentName ?? UNRESOLVED_AGENT, + () => this.contextFor(identity).agentName ?? UNRESOLVED_AGENT, ); } /** Process one JSON-RPC request or notification. Returns null for notifications. */ - async handleRequest(request: JsonRpcRequest): Promise { + async handleRequest( + request: JsonRpcRequest, + identity?: RequestIdentity, + ): Promise { if (typeof request !== "object" || request === null) { return errorResponse(null, INVALID_REQUEST, "request must be an object"); } @@ -388,13 +423,13 @@ export class MCPServer { } else if (method === "tools/list") { result = this.handleToolsList(); } else if (method === "tools/call") { - result = await this.handleToolsCall(params); + result = await this.handleToolsCall(params, identity); } else if (method === "resources/list") { result = this.handleResourcesList(); } else if (method === "resources/templates/list") { result = this.handleResourcesTemplatesList(); } else if (method === "resources/read") { - result = this.handleResourcesRead(params); + result = this.handleResourcesRead(params, identity); } else if (method.startsWith("notifications/")) { return null; } else { @@ -470,19 +505,29 @@ export class MCPServer { return { resourceTemplates: listResourceTemplates() }; } - private handleResourcesRead(params: Record): Record { + private handleResourcesRead( + params: Record, + identity?: RequestIdentity, + ): Record { const uri = params["uri"]; if (typeof uri !== "string") { throw new MCPError(INVALID_PARAMS, "resources/read requires a string `uri`"); } const content = readResource( - { vault: this.vault, agentName: this.context.agentName, reach: this.reach }, + { + vault: this.vault, + agentName: this.contextFor(identity).agentName, + reach: this.reach, + }, uri, ); return { contents: [content] }; } - private async handleToolsCall(params: Record): Promise> { + private async handleToolsCall( + params: Record, + identity?: RequestIdentity, + ): Promise> { const name = params["name"]; if (typeof name !== "string") { throw new MCPError(INVALID_PARAMS, "tools/call requires a string name"); @@ -506,7 +551,7 @@ export class MCPServer { ? progressRefusal(token, PROGRESS_REASON.transportSingleResponse) : undefined; try { - const structured = await this.invokeToolHandler(tool, args, onProgress); + const structured = await this.invokeToolHandler(tool, args, onProgress, identity); return withProgressRefusal(buildMcpToolResult(tool, structured, this.artifactStore), refusal); } catch (exc) { if (exc instanceof MCPError) { diff --git a/tests/mcp/http-token-auth.test.ts b/tests/mcp/http-token-auth.test.ts new file mode 100644 index 00000000..6eebb80f --- /dev/null +++ b/tests/mcp/http-token-auth.test.ts @@ -0,0 +1,373 @@ +/** + * Transport authentication and request-scoped identity (write-side-trust, + * Task 7). + * + * The token map is the first credential source, the shared key the + * second (it keeps the process config identity), and a presented + * credential that matches neither is refused with the same generic 401 + * body the shared key has always answered with - no oracle distinguishes + * a revoked token from an unknown one. With no tokens configured every + * existing posture is byte-identical: the suites that predate this one + * pass unmodified. + */ + +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { startHttp, type HttpServerHandle } from "../../src/mcp/index.ts"; +import { + MCP_TOKENS_REQUIRED_CONFIG_KEY, + authenticateRequest, + resolveMcpTokensRequired, + type RequestIdentity, +} from "../../src/mcp/http.ts"; +import type { IncomingMessage } from "node:http"; +import { JSONRPC_VERSION } from "../../src/mcp/protocol.ts"; +import { brainConfigPath } from "../../src/core/brain/paths.ts"; +import { GATE_MODE } from "../../src/core/integrity/stamp.ts"; +import { writePreference } from "../../src/core/brain/preference.ts"; +import { BRAIN_CONFIDENCE, BRAIN_PREFERENCE_STATUS } from "../../src/core/brain/types.ts"; +import { mintAgentToken, rotateAgentToken } from "../../src/core/brain/secrets/token-store.ts"; +import { fakeCredential } from "../helpers/fake-credentials.ts"; + +let vault: string; +let handle: HttpServerHandle | null = null; + +beforeEach(() => { + vault = mkdtempSync(join(tmpdir(), "o2b-http-token-")); +}); + +afterEach(async () => { + if (handle !== null) await handle.close(); + handle = null; + rmSync(vault, { recursive: true, force: true }); +}); + +function rpc(method: string, id: number, params: Record = {}) { + return { jsonrpc: JSONRPC_VERSION, id, method, params }; +} + +async function post( + body: unknown, + opts: { key?: string; header?: "authorization" | "x-api-key" } = {}, +): Promise { + const headers: Record = { + "content-type": "application/json", + accept: "application/json", + }; + if (opts.key !== undefined) { + if (opts.header === "x-api-key") headers["x-api-key"] = opts.key; + else headers.authorization = `Bearer ${opts.key}`; + } + return fetch(handle!.url, { method: "POST", headers, body: JSON.stringify(body) }); +} + +async function postJson(body: unknown, opts: { key?: string } = {}): Promise> { + const res = await post(body, opts); + expect(res.status).toBe(200); + return (await res.json()) as Record; +} + +async function start(opts: Parameters[1] = {}): Promise { + handle = await startHttp({ vault }, { host: "127.0.0.1", port: 0, ...opts }); +} + +/** The identity a token caller named, via a foreign-scope probe under the fail gate. */ +async function resolvedIdentityInRefusal( + tokenMaterial: string, + requestedScope: string, +): Promise { + const body = await postJson( + rpc("tools/call", 1, { + name: "brain_context_pack", + arguments: { max_tokens: 4000, agent_scope: requestedScope }, + }), + { key: tokenMaterial }, + ); + const message = body.error?.message as string | undefined; + if (message === undefined) return null; + const match = /resolved for the caller, "([^"]+)"/.exec(message); + return match?.[1] ?? null; +} + +function setGate(mode: string): void { + mkdirSync(join(vault, "Brain"), { recursive: true }); + writeFileSync( + brainConfigPath(vault), + `schema_version: 1\nintegrity:\n owner_scope_delivery: ${mode}\n`, + ); +} + +function makePref(slug: string, owner?: string): void { + mkdirSync(join(vault, "Brain", "preferences"), { recursive: true }); + writePreference(vault, { + slug, + topic: slug, + principle: `principle for ${slug}`, + created_at: "2026-05-01T00:00:00Z", + unconfirmed_until: "2026-05-08T00:00:00Z", + status: BRAIN_PREFERENCE_STATUS.confirmed, + evidenced_by: [`[[sig-2026-05-01-${slug}]]`], + confirmed_at: "2026-05-02T00:00:00Z", + applied_count: 1, + violated_count: 0, + last_evidence_at: "2026-05-02T00:00:00Z", + confidence: BRAIN_CONFIDENCE.high, + confidence_value: 0.8, + ...(owner !== undefined ? { owner } : {}), + }); +} + +describe("HTTP token authentication", () => { + test("a valid token authenticates with per-caller identity", async () => { + const { tokenMaterial } = mintAgentToken(vault, "mcp_token_edge", "edge-agent"); + // Preferences land before any gate exists, so their owners are the + // ones this fixture states rather than ones a write-time gate stamped. + makePref("shared-pref"); + makePref("edge-owned", "edge-agent"); + makePref("other-owned", "someone-else"); + setGate(GATE_MODE.fail); + await start({}); + // Own scope: answered, and isolated to what this caller may read. + const own = await postJson( + rpc("tools/call", 1, { + name: "brain_context_pack", + arguments: { max_tokens: 4000, agent_scope: "edge-agent" }, + }), + { key: tokenMaterial }, + ); + const payload = JSON.stringify(own.result); + expect(payload).toContain("shared-pref"); + expect(payload).toContain("edge-owned"); + expect(payload).not.toContain("other-owned"); + }); + + test("a foreign scope under the fail gate is refused, naming the token's agent", async () => { + const { tokenMaterial } = mintAgentToken(vault, "mcp_token_edge", "edge-agent"); + setGate(GATE_MODE.fail); + await start({}); + const body = await postJson( + rpc("tools/call", 1, { + name: "brain_context_pack", + arguments: { max_tokens: 4000, agent_scope: "someone-else" }, + }), + { key: tokenMaterial }, + ); + const message = body.error?.message as string; + expect(message).toContain("foreign-owner"); + expect(message).toContain("edge-agent"); + expect(message).toContain("someone-else"); + }); + + test("concurrent requests with different tokens never observe each other's identity", async () => { + const a = mintAgentToken(vault, "mcp_token_caller_a", "caller-a").tokenMaterial; + const b = mintAgentToken(vault, "mcp_token_caller_b", "caller-b").tokenMaterial; + setGate(GATE_MODE.fail); + await start({}); + // Eight rounds of two in-flight requests whose refusal messages would + // name the WRONG token's agent if either request observed the other's + // identity - the failure an instance field would produce. + const rounds = await Promise.all( + Array.from({ length: 8 }, () => + Promise.all([ + resolvedIdentityInRefusal(a, "owner-x"), + resolvedIdentityInRefusal(b, "owner-y"), + ]), + ), + ); + for (const [identityA, identityB] of rounds) { + expect(identityA).toBe("caller-a"); + expect(identityB).toBe("caller-b"); + } + }); + + test("the shared key still authenticates, carrying the process identity", async () => { + const sharedKey = fakeCredential("shared", "-master-", "77f1"); + const savedAgent = process.env["VAULT_AGENT_NAME"]; + process.env["VAULT_AGENT_NAME"] = "operator"; + try { + setGate(GATE_MODE.fail); + await start({ apiKey: sharedKey }); + const own = await postJson( + rpc("tools/call", 1, { + name: "brain_context_pack", + arguments: { max_tokens: 4000, agent_scope: "operator" }, + }), + { key: sharedKey }, + ); + expect(own.error).toBeUndefined(); + } finally { + if (savedAgent === undefined) delete process.env["VAULT_AGENT_NAME"]; + else process.env["VAULT_AGENT_NAME"] = savedAgent; + } + }); + + test("the token map is consulted before the shared key", async () => { + const sharedKey = fakeCredential("shared", "-master-", "77f1"); + const { tokenMaterial } = mintAgentToken(vault, "mcp_token_edge", "edge-agent"); + setGate(GATE_MODE.fail); + await start({ apiKey: sharedKey }); + const identity = await resolvedIdentityInRefusal(tokenMaterial, "someone-else"); + // The refusal names the TOKEN's agent, so the map answered first. + expect(identity).toBe("edge-agent"); + }); + + test("a revoked token answers the same generic 401 as an unknown one", async () => { + const sharedKey = fakeCredential("shared", "-master-", "77f1"); + const minted = mintAgentToken(vault, "mcp_token_edge", "edge-agent"); + await start({ apiKey: sharedKey }); + rotateAgentToken(vault, "mcp_token_edge"); + const revoked = await post(rpc("ping", 1), { key: minted.tokenMaterial }); + const unknown = await post(rpc("ping", 2), { + key: fakeCredential("osbt_", "never-minted"), + }); + expect(revoked.status).toBe(401); + expect(await revoked.text()).toBe("Unauthorized\n"); + expect(await unknown.text()).toBe("Unauthorized\n"); + }); + + test("mcp_tokens_required with a non-empty map refuses credential-less and invalid calls", async () => { + const { tokenMaterial } = mintAgentToken(vault, "mcp_token_edge", "edge-agent"); + await start({ tokensRequired: true }); + const missing = await post(rpc("ping", 1)); + const invalid = await post(rpc("ping", 2), { key: fakeCredential("osbt_", "garbage") }); + const valid = await post(rpc("ping", 3), { key: tokenMaterial }); + expect(missing.status).toBe(401); + expect(await missing.text()).toBe("Unauthorized\n"); + expect(invalid.status).toBe(401); + expect(await invalid.text()).toBe("Unauthorized\n"); + expect(valid.status).toBe(200); + }); + + test("mcp_tokens_required with an empty map does not refuse (warn only)", async () => { + await start({ tokensRequired: true }); + const res = await post(rpc("ping", 1)); + expect(res.status).toBe(200); + }); + + test("a non-loopback bind accepts a token map without an api key", async () => { + const { tokenMaterial } = mintAgentToken(vault, "mcp_token_edge", "edge-agent"); + handle = await startHttp({ vault }, { host: "0.0.0.0", port: 0 }); + const keyless = await post(rpc("ping", 1)); + expect(keyless.status).toBe(401); + const withToken = await post(rpc("ping", 2), { key: tokenMaterial }); + expect(withToken.status).toBe(200); + }); + + test("a rotation takes effect on the next request with no server restart", async () => { + const first = mintAgentToken(vault, "mcp_token_edge", "edge-agent"); + await start({ tokensRequired: true }); + expect((await post(rpc("ping", 1), { key: first.tokenMaterial })).status).toBe(200); + const second = rotateAgentToken(vault, "mcp_token_edge"); + expect((await post(rpc("ping", 2), { key: first.tokenMaterial })).status).toBe(401); + expect((await post(rpc("ping", 3), { key: second.tokenMaterial })).status).toBe(200); + }); + + test("a token is accepted through the x-api-key header too", async () => { + const { tokenMaterial } = mintAgentToken(vault, "mcp_token_edge", "edge-agent"); + await start({ tokensRequired: true }); + const res = await post(rpc("ping", 1), { key: tokenMaterial, header: "x-api-key" }); + expect(res.status).toBe(200); + }); +}); + +/** A minimal request double: authenticateRequest reads only `headers`. */ +const reqWith = (headers: Record) => ({ headers }) as unknown as IncomingMessage; + +const RESOLVED_TOKEN = "token-material-1"; + +const resolveToken = (presented: string) => + presented === RESOLVED_TOKEN ? { agent: "edge-agent" } : null; + +describe("authenticateRequest", () => { + test("the token map answers first, then the shared key, then null", () => { + const base = { + apiKey: "key-material-2", + resolveToken, + tokensRequired: false, + }; + expect( + authenticateRequest(reqWith({ authorization: `Bearer ${RESOLVED_TOKEN}` }), base), + ).toEqual({ + agent: "edge-agent", + via: "token", + }); + expect( + authenticateRequest(reqWith({ authorization: "Bearer key-material-2" }), { + ...base, + sharedKeyAgent: "operator", + }), + ).toEqual({ agent: "operator", via: "shared-key" }); + expect(authenticateRequest(reqWith({ authorization: "Bearer neither" }), base)).toBeNull(); + expect(authenticateRequest(reqWith({}), base)).toBeNull(); + }); + + test("an empty shared key never matches; x-api-key carries a token too", () => { + const base = { apiKey: "", resolveToken, tokensRequired: true }; + expect(authenticateRequest(reqWith({ "x-api-key": RESOLVED_TOKEN }), base)).toEqual({ + agent: "edge-agent", + via: "token", + }); + expect(authenticateRequest(reqWith({ "x-api-key": "" }), base)).toBeNull(); + }); + + test("resolveToken sees exactly the presented credential", () => { + const seen: string[] = []; + authenticateRequest(reqWith({ authorization: `Bearer ${RESOLVED_TOKEN}` }), { + apiKey: null, + resolveToken: (presented) => { + seen.push(presented); + return null; + }, + tokensRequired: false, + }); + expect(seen).toEqual([RESOLVED_TOKEN]); + }); +}); + +describe("resolveMcpTokensRequired", () => { + test("the config key and its env twin resolve, env winning", () => { + const configHome = mkdtempSync(join(tmpdir(), "o2b-http-token-cfg-")); + const configPath = join(configHome, "config.yaml"); + writeFileSync(configPath, `${MCP_TOKENS_REQUIRED_CONFIG_KEY}: "true"\n`); + const savedEnv = process.env["OPEN_SECOND_BRAIN_CONFIG"]; + const savedTwin = process.env["OPEN_SECOND_BRAIN_MCP_TOKENS_REQUIRED"]; + try { + process.env["OPEN_SECOND_BRAIN_CONFIG"] = configPath; + delete process.env["OPEN_SECOND_BRAIN_MCP_TOKENS_REQUIRED"]; + expect(resolveMcpTokensRequired(undefined)).toBe(true); + process.env["OPEN_SECOND_BRAIN_MCP_TOKENS_REQUIRED"] = "false"; + expect(resolveMcpTokensRequired(undefined)).toBe(false); + process.env["OPEN_SECOND_BRAIN_MCP_TOKENS_REQUIRED"] = "true"; + expect(resolveMcpTokensRequired(undefined)).toBe(true); + } finally { + if (savedEnv === undefined) delete process.env["OPEN_SECOND_BRAIN_CONFIG"]; + else process.env["OPEN_SECOND_BRAIN_CONFIG"] = savedEnv; + if (savedTwin === undefined) delete process.env["OPEN_SECOND_BRAIN_MCP_TOKENS_REQUIRED"]; + else process.env["OPEN_SECOND_BRAIN_MCP_TOKENS_REQUIRED"] = savedTwin; + rmSync(configHome, { recursive: true, force: true }); + } + }); + + test("default off with nothing configured", () => { + const savedEnv = process.env["OPEN_SECOND_BRAIN_CONFIG"]; + const savedTwin = process.env["OPEN_SECOND_BRAIN_MCP_TOKENS_REQUIRED"]; + try { + process.env["OPEN_SECOND_BRAIN_CONFIG"] = join(vault, "absent-config.yaml"); + delete process.env["OPEN_SECOND_BRAIN_MCP_TOKENS_REQUIRED"]; + expect(resolveMcpTokensRequired(undefined)).toBe(false); + } finally { + if (savedEnv === undefined) delete process.env["OPEN_SECOND_BRAIN_CONFIG"]; + else process.env["OPEN_SECOND_BRAIN_CONFIG"] = savedEnv; + if (savedTwin === undefined) delete process.env["OPEN_SECOND_BRAIN_MCP_TOKENS_REQUIRED"]; + else process.env["OPEN_SECOND_BRAIN_MCP_TOKENS_REQUIRED"] = savedTwin; + } + }); +}); + +// The identity type is part of the pinned surface; a shape check keeps +// the import honest even where the tests above only build one inline. +const SAMPLE_IDENTITY: RequestIdentity = { agent: "edge-agent", via: "token" }; +if (SAMPLE_IDENTITY.via === "never") throw new Error("unreachable"); diff --git a/tests/mcp/http-transport.test.ts b/tests/mcp/http-transport.test.ts index 7a0ce4eb..b136115c 100644 --- a/tests/mcp/http-transport.test.ts +++ b/tests/mcp/http-transport.test.ts @@ -6,6 +6,8 @@ import { join } from "node:path"; import { JSONRPC_VERSION, PROTOCOL_VERSION, startHttp } from "../../src/mcp/index.ts"; import { MCPServer } from "../../src/mcp/server.ts"; +import { mintAgentToken } from "../../src/core/brain/secrets/token-store.ts"; +import { fakeCredential } from "../helpers/fake-credentials.ts"; interface RawResponse { readonly status: number; @@ -67,13 +69,16 @@ function rpc(method: string, id: number, params: Record = {}) { async function post( url: string, body: unknown, - opts: { key?: string; accept?: string } = {}, + opts: { key?: string; accept?: string; header?: "authorization" | "x-api-key" } = {}, ): Promise { const headers: Record = { "content-type": "application/json", accept: opts.accept ?? "application/json", }; - if (opts.key !== undefined) headers.authorization = `Bearer ${opts.key}`; + if (opts.key !== undefined) { + if (opts.header === "x-api-key") headers["x-api-key"] = opts.key; + else headers.authorization = `Bearer ${opts.key}`; + } return fetch(url, { method: "POST", headers, body: JSON.stringify(body) }); } @@ -317,3 +322,50 @@ describe("Streamable HTTP MCP transport", () => { } }); }); + +// Identity-positive cases only (write-side-trust, Task 7): a minted +// per-agent token authenticates exactly where the shared key does. The +// per-caller identity behaviour lives in `http-token-auth.test.ts` and +// the owner-scope suite; this block pins only the transport acceptance. +describe("HTTP transport with a minted agent token", () => { + test("a minted token round-trips initialize and tools/list like the shared key", async () => { + const { tokenMaterial } = mintAgentToken(vault, "mcp_token_transport", "transport-agent"); + const handle = await startHttp({ vault }, { host: "127.0.0.1", port: 0 }); + try { + const initRes = await post( + handle.url, + rpc("initialize", 1, { protocolVersion: PROTOCOL_VERSION, capabilities: {} }), + { key: tokenMaterial }, + ); + expect(initRes.status).toBe(200); + const init = await responseJson(initRes); + expect(init.result.protocolVersion).toBe(PROTOCOL_VERSION); + const listRes = await post(handle.url, rpc("tools/list", 2), { key: tokenMaterial }); + const list = await responseJson(listRes); + expect(Array.isArray(list.result.tools)).toBe(true); + } finally { + await handle.close(); + } + }); + + test("a token through x-api-key authenticates; an unknown credential does not", async () => { + const { tokenMaterial } = mintAgentToken(vault, "mcp_token_transport", "transport-agent"); + const handle = await startHttp( + { vault }, + { apiKey: fakeCredential("hdr", "-key-", "44b2"), host: "127.0.0.1", port: 0 }, + ); + try { + const viaHeader = await post(handle.url, rpc("ping", 1), { + key: tokenMaterial, + header: "x-api-key", + }); + expect(viaHeader.status).toBe(200); + const unknown = await post(handle.url, rpc("ping", 2), { + key: fakeCredential("osbt_", "unknown-transport"), + }); + expect(unknown.status).toBe(401); + } finally { + await handle.close(); + } + }); +}); diff --git a/tests/mcp/owner-scope-refusal.test.ts b/tests/mcp/owner-scope-refusal.test.ts index ed558d77..fd3995d9 100644 --- a/tests/mcp/owner-scope-refusal.test.ts +++ b/tests/mcp/owner-scope-refusal.test.ts @@ -23,7 +23,7 @@ * exactly that before this unit. */ -import { afterEach, beforeEach, expect, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, relative } from "node:path"; @@ -42,6 +42,7 @@ import { } from "../../src/mcp/owner-scope-refusal.ts"; import { buildToolTable } from "../../src/mcp/tools.ts"; import type { ServerContext, ToolDefinition } from "../../src/mcp/tool-contract.ts"; +import { MCPServer, type JsonRpcResponse } from "../../src/mcp/server.ts"; import { toPosix } from "../../src/core/path-safety.ts"; const OWNER_A = "agent-a"; @@ -337,3 +338,63 @@ test("the tools that stamp a caller-supplied agent are enumerated from the tool const scoped = new Set(toolsDeclaring(AGENT_SCOPE_ARG_NAME)); expect(declaring.filter((name) => scoped.has(name))).toEqual([]); }); + +// ----- identity-positive: the request-scoped token identity (write-side-trust, Task 7) + +/** + * The same refusal, driven through `handleRequest` with a transport-minted + * identity riding the request as a PARAMETER. Nothing here touches the + * enumerations above: those derive from `src/mcp` sources and the census + * they pin must pass unmodified. + */ +describe("a request-scoped identity threads through the dispatcher", () => { + let server: MCPServer; + + beforeEach(() => { + server = new MCPServer({ vault, configPath: null, repoRoot: null }); + }); + + async function rpcCall( + args: Record, + identity?: { agent: string; via: "token" | "shared-key" }, + ): Promise { + return (await server.handleRequest( + { + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { + name: "brain_context_pack", + arguments: { max_tokens: 4000, ...args }, + }, + }, + identity, + )) as JsonRpcResponse; + } + + test("a token identity makes the fail gate per-caller real", async () => { + setGate(GATE_MODE.fail); + const refused = await rpcCall({ agent_scope: OWNER_B }, { agent: OWNER_A, via: "token" }); + const message = (refused.error as { message: string } | undefined)?.message ?? ""; + expect(message).toContain(OWNER_SCOPE_REFUSAL.foreignOwner); + expect(message).toContain(OWNER_A); + expect(message).toContain(OWNER_B); + // And the caller's own scope is answered, isolated to its own pages. + const own = await rpcCall({ agent_scope: OWNER_A }, { agent: OWNER_A, via: "token" }); + const payload = JSON.stringify(own.result); + expect(payload).toContain(SHARED_ID); + expect(payload).toContain(PRIVATE_ID); + }); + + test("no identity on the request falls back to the config-derived context", async () => { + setGate(GATE_MODE.fail); + const refused = await rpcCall({ agent_scope: OWNER_A }); + const message = (refused.error as { message: string } | undefined)?.message ?? ""; + // The fixture vault has no config behind this server (configPath null), + // so the fallback identity is the placeholder the identity chain bottoms + // out at - the unresolved refusal, exactly as the pre-token dispatcher + // answered. Absent identity changes nothing. + expect(message).toContain(OWNER_SCOPE_REFUSAL.unresolvedIdentity); + expect(message).not.toContain(PRIVATE_ID); + }); +}); From 9c75c94277add82093db6a3d5027196f50dd778a Mon Sep 17 00:00:00 2001 From: Sergey Eroshenkov Date: Sat, 10 Oct 2026 11:09:11 +0200 Subject: [PATCH 08/84] feat(permissions): brain permissions verb, unreadable-document doctor finding and registration (write-side-trust task 4) --- docs/cli-reference.md | 1 + src/cli/brain.ts | 3 + src/cli/brain/help-text.ts | 5 + src/cli/brain/verbs/index.ts | 1 + src/cli/brain/verbs/permissions.ts | 243 ++++++++++++++++++++ src/cli/command-manifest.ts | 21 ++ src/core/brain/diagnostics.ts | 13 ++ src/core/brain/doctor.ts | 2 + src/core/brain/doctor/permissions-check.ts | 49 ++++ src/core/brain/permissions/document.ts | 42 ++-- tests/cli/brain-permissions.test.ts | 219 ++++++++++++++++++ tests/core/brain/doctor-exit-census.test.ts | 1 + 12 files changed, 583 insertions(+), 17 deletions(-) create mode 100644 src/cli/brain/verbs/permissions.ts create mode 100644 src/core/brain/doctor/permissions-check.ts create mode 100644 tests/cli/brain-permissions.test.ts diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 71e472be..b0a2eb47 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -429,6 +429,7 @@ o2b brain log verify Walk every JSONL shard of the Brain log and report o2b brain set-primary (CLI-only) Declare or clear primary_agent in Brain/_brain.yaml (--clear) o2b brain protect (CLI-only) Emit / apply native deny rules for Brain/ (--target {claudecode|codex} [--apply]) o2b brain unprotect (CLI-only) Remove the Open-Second-Brain-managed deny rules for the chosen target +o2b brain permissions (CLI-only) Show the vault's trust policy document (Brain/_permissions.yaml) and a dry-run decision table resolving every declared agent against write/ingest/owner_write (`show`, --json adds the resolved rows), or list the decision ledger rows the gates append (`ledger --actor --action --verdict --since --until --limit `, --json). With no document every write is ungated; a document that cannot be read fails closed - `show` names the field and the file, and `o2b brain doctor` reports the same fault as `permissions-unreadable` with this verb as the exit o2b brain snapshot log (CLI-only) Newest-first listing of every recovery point: run id, created_at, typed reason, size, manifest presence, derived-store coverage; --reason filters (unregistered value exits 2), --limit caps, --json o2b brain snapshot diff (CLI-only) Read-only diff between two snapshots, or snapshot vs live Brain/ o2b brain rollback (CLI-only) Restore Brain/ from a snapshot (--dry-run previews; drift abort vs --force-rollback); --list, the prompt and --json name the snapshot reason ('unknown' when the sidecar records none) diff --git a/src/cli/brain.ts b/src/cli/brain.ts index 93aac3a7..e36268f9 100644 --- a/src/cli/brain.ts +++ b/src/cli/brain.ts @@ -162,6 +162,7 @@ import { cmdBrainStale, cmdBrainDaily, cmdBrainWeekly, + cmdBrainPermissions, } from "./brain/verbs/index.ts"; /** @@ -527,6 +528,8 @@ export async function handleBrainSubcommand(argv: ReadonlyArray): Promis return await cmdBrainDaily(rest); case "weekly": return await cmdBrainWeekly(rest); + case "permissions": + return await cmdBrainPermissions(rest); default: process.stderr.write(`error: unknown brain verb: ${verb}\n`); process.stdout.write(BRAIN_HELP); diff --git a/src/cli/brain/help-text.ts b/src/cli/brain/help-text.ts index bb00711d..9339f4b9 100644 --- a/src/cli/brain/help-text.ts +++ b/src/cli/brain/help-text.ts @@ -72,6 +72,7 @@ Brain verbs (observing memory): set-primary Declare or clear primary_agent in _brain.yaml (--clear) protect Emit / apply native deny rules for Brain/ (--target {claudecode|codex} [--apply]) unprotect Remove OSB-managed deny rules for the chosen target (--target) + permissions Show the trust policy document and query the decision ledger (show | ledger) merge Merge two near-duplicate preferences ( ; --dry-run, --force) upgrade Migrate release-owned files forward (--dry-run by default; --apply --yes) export Dump preferences or a transcript dataset @@ -455,6 +456,10 @@ export const VERB_HELP: Record = { unfreeze: "usage: o2b brain unfreeze [--vault ] [--json]\n" + "Remove the freeze marker and reopen the content lane. The unfreeze log event records who lifted it and what the marker said, which is the only place that survives the file. Idempotent.\n", + permissions: + "usage: o2b brain permissions show [--vault ] [--json]\n" + + " o2b brain permissions ledger [--actor ] [--action ] [--verdict ] [--since ] [--until ] [--limit ] [--vault ] [--json]\n" + + "Show the vault's permissions document (Brain/_permissions.yaml) with a dry-run decision table resolving every agent it declares against every action, or query the decision ledger rows the gates append. With no document every write is ungated. A document that cannot be read fails closed: show names the field and the file, and `o2b brain doctor` reports the same fault as permissions-unreadable.\n", pin: "usage: o2b brain pin --id [--vault ] [--json]\n" + "Set pinned: true. Idempotent. Exempts the preference from automatic retire.\n", diff --git a/src/cli/brain/verbs/index.ts b/src/cli/brain/verbs/index.ts index 85bbe491..49f62ed8 100644 --- a/src/cli/brain/verbs/index.ts +++ b/src/cli/brain/verbs/index.ts @@ -150,3 +150,4 @@ export { cmdBrainWeekly } from "./temporal-weekly.ts"; export { cmdBrainHygiene } from "./hygiene.ts"; export { cmdBrainRefresh } from "./refresh.ts"; export { cmdBrainAnticipate } from "./anticipate.ts"; +export { cmdBrainPermissions } from "./permissions.ts"; diff --git a/src/cli/brain/verbs/permissions.ts b/src/cli/brain/verbs/permissions.ts new file mode 100644 index 00000000..0190e0fa --- /dev/null +++ b/src/cli/brain/verbs/permissions.ts @@ -0,0 +1,243 @@ +import { + loadPermissionsDocument, + PermissionsDocumentError, + type PermissionAction, + type PermissionsDocument, +} from "../../../core/brain/permissions/document.ts"; +import { + queryDecisionLedger, + type DecisionLedgerFilter, + type DecisionLedgerRow, +} from "../../../core/brain/permissions/ledger.ts"; +import { resolvePermission } from "../../../core/brain/permissions/resolve.ts"; +import { + brainVerbContext, + fail, + normalizeFlagString, + ok, + okJson, + parse, + usageError, + type BrainVerbFlags, +} from "../helpers.ts"; + +/** + * `o2b brain permissions` - the operator surface over the vault's trust + * policy (write-side-trust, Task 4). + * + * Subcommands: + * show render the effective document plus a dry-run decision table + * over the agents it declares + * ledger list the decision ledger rows the gates appended + * + * `show` exists because `default_action: deny` is a foot-gun: a one-agent + * document denies everything else by construction, and the table makes + * that visible before the first refusal does. Both subcommands are pure + * reads; neither mutates the vault. + * + * A document that cannot be read is never smoothed over: `show` fails + * naming the field the loader refused, and `o2b brain doctor` reports the + * same fault under `permissions-unreadable` - whose exit is this verb. + */ +export async function cmdBrainPermissions(argv: string[]): Promise { + const sub = argv[0]; + const rest = argv.slice(1); + const { flags } = parse(rest, { + vault: { type: "string" }, + json: { type: "boolean" }, + actor: { type: "string" }, + action: { type: "string" }, + verdict: { type: "string" }, + since: { type: "string" }, + until: { type: "string" }, + limit: { type: "string" }, + }); + const json = flags["json"] === true; + + if (sub === "show") return show(flags, json); + if (sub === "ledger") return ledger(flags, json); + return usageError("brain permissions requires a subcommand: show | ledger"); +} + +// ----- show ----------------------------------------------------------------- + +/** The actions one dry-run row resolves, in the document's vocabulary order. */ +const RESOLVED_ACTIONS: ReadonlyArray = ["write", "ingest", "owner_write"]; + +/** One resolved row of the dry-run decision table. */ +interface DecisionTableRow { + agent: string; + role: string; + action: PermissionAction; + /** Empty for the blanket row; a declared target for the scoped rows. */ + target: string; + verdict: string; + source: string; +} + +function show(flags: BrainVerbFlags, json: boolean): number { + const { vault } = brainVerbContext(flags); + try { + const { document, path } = loadPermissionsDocument(vault); + if (document === null) { + if (json) { + okJson({ document: null, path }); + } else { + ok(`no permissions document at ${path} - every write is ungated`); + } + return 0; + } + const decisions = decisionTable(document); + if (json) { + okJson({ document: { ...document }, decisions }); + } else { + for (const line of renderShow(path, document, decisions)) ok(line); + } + return 0; + } catch (exc) { + if (exc instanceof PermissionsDocumentError) return fail(exc.message); + return fail(`failed to read the permissions document: ${(exc as Error).message}`); + } +} + +/** + * Resolve every declared agent against every action - once as a blanket + * query, and once per target the document's entries name for that agent, + * because a target-scoped entry is exactly the rule a blanket row hides. + * Rows are sorted by construction: agents sorted, actions in vocabulary + * order, targets sorted within their action. + */ +function decisionTable(document: PermissionsDocument): DecisionTableRow[] { + const rows: DecisionTableRow[] = []; + for (const agent of Object.keys(document.agents).toSorted()) { + const role = document.agents[agent]?.role ?? ""; + for (const action of RESOLVED_ACTIONS) { + const blanket = resolvePermission(document, { agent, via: "operator" }, action); + rows.push({ + agent, + role, + action, + target: "", + verdict: blanket.verdict, + source: blanket.source, + }); + for (const target of declaredTargets(document, agent, action)) { + const scoped = resolvePermission(document, { agent, via: "operator" }, action, target); + rows.push({ + agent, + role, + action, + target, + verdict: scoped.verdict, + source: scoped.source, + }); + } + } + } + return rows; +} + +/** The distinct targets the document's entries declare for one agent and action. */ +function declaredTargets( + document: PermissionsDocument, + agent: string, + action: PermissionAction, +): string[] { + const targets = new Set(); + for (const entry of document.entries) { + if (entry.agent === agent && entry.action === action && entry.target !== undefined) { + targets.add(entry.target); + } + } + return [...targets].toSorted(); +} + +function renderShow( + path: string, + document: PermissionsDocument, + decisions: DecisionTableRow[], +): string[] { + const lines: string[] = [ + `permissions document: ${path} (version ${document.version})`, + `default_action: ${document.default_action}`, + ]; + const roleNames = Object.keys(document.roles).toSorted(); + if (roleNames.length > 0) { + lines.push("roles:"); + for (const name of roleNames) { + const actions = Object.entries(document.roles[name] ?? {}) + .toSorted(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) + .map(([action, verdict]) => `${action}=${verdict}`) + .join(", "); + const members = Object.keys(document.agents) + .filter((agent) => document.agents[agent]?.role === name) + .toSorted(); + lines.push( + ` ${name}: ${actions === "" ? "(no action mapped)" : actions} ` + + `(members: ${members.length > 0 ? members.join(", ") : "none"})`, + ); + } + } + if (decisions.length === 0) { + lines.push("no agents declared - the default_action decides every subject"); + return lines; + } + lines.push(`decision table (dry run, via=operator, ${decisions.length} rows):`); + for (const row of decisions) { + const target = row.target === "" ? "-" : row.target; + lines.push( + ` ${row.agent} ${row.role === "" ? "- " : `${row.role} `}${row.action} ` + + `${target} ${row.verdict} ${row.source}`, + ); + } + return lines; +} + +// ----- ledger --------------------------------------------------------------- + +function ledger(flags: BrainVerbFlags, json: boolean): number { + const { vault } = brainVerbContext(flags); + const filter: DecisionLedgerFilter = {}; + for (const key of ["actor", "action", "verdict", "since", "until"] as const) { + const value = normalizeFlagString(flags[key]); + if (value !== null) filter[key] = value; + } + const limitRaw = normalizeFlagString(flags["limit"]); + if (limitRaw !== null) { + const limit = Number.parseInt(limitRaw, 10); + if (!Number.isInteger(limit) || limit <= 0) { + return usageError("brain permissions ledger --limit must be a positive integer"); + } + filter.limit = limit; + } + + try { + const rows = queryDecisionLedger(vault, filter); + if (json) { + okJson({ rows }); + return 0; + } + if (rows.length === 0) { + ok("no decision ledger rows"); + return 0; + } + for (const row of rows) ok(renderLedgerRow(row)); + return 0; + } catch (exc) { + return fail(`failed to read the decision ledger: ${(exc as Error).message}`); + } +} + +/** One row, fixed column order so a scan down the output stays aligned. */ +function renderLedgerRow(row: DecisionLedgerRow): string { + return [ + row.ts, + row.actor, + `via=${row.via}`, + row.action, + row.target, + row.verdict, + `source=${row.source}`, + row.reason, + ].join(" "); +} diff --git a/src/cli/command-manifest.ts b/src/cli/command-manifest.ts index a32f6ed2..f942ebf5 100644 --- a/src/cli/command-manifest.ts +++ b/src/cli/command-manifest.ts @@ -244,6 +244,27 @@ export const CLI_COMMAND_MANIFEST: CliRootManifest = Object.freeze({ flag("vault", "string"), flag("json", "boolean"), ]), + command( + "permissions", + "Show the trust policy document and query the decision ledger", + [], + [ + command("show", "Render the permissions document and a dry-run decision table", [ + flag("vault", "string"), + flag("json", "boolean"), + ]), + command("ledger", "List decision ledger rows with filters", [ + flag("vault", "string"), + flag("actor", "string"), + flag("action", "string"), + flag("verdict", "string"), + flag("since", "string"), + flag("until", "string"), + flag("limit", "string"), + flag("json", "boolean"), + ]), + ], + ), command( "log", "Inspect Brain/log itself: verify (per-shard hash chain)", diff --git a/src/core/brain/diagnostics.ts b/src/core/brain/diagnostics.ts index c4d11248..d070278b 100644 --- a/src/core/brain/diagnostics.ts +++ b/src/core/brain/diagnostics.ts @@ -539,6 +539,19 @@ export const DIAGNOSTIC_SIGNALS: ReadonlyMap = new Map nextCommand: "o2b brain payload gc", autoRepairable: false, }, + { + // An unreadable permissions document: the operator's trust policy + // exists and is not in force, and every gate fails closed until + // the field the loader named is repaired. The exit is the same + // re-derive loop `config-invalid` uses - `show` prints the same + // field-named error after each edit, so the operator can see the + // repair land - and `autoRepairable` stays false because editing + // a policy file is the operator's act, never a fixer's. + code: "permissions-unreadable", + issueClass: "unreadable permissions document", + nextCommand: "o2b brain permissions show", + autoRepairable: false, + }, { // Inbox signals that left the contradiction window unconsumed // (issue #195). Spelled as a literal for the same reason diff --git a/src/core/brain/doctor.ts b/src/core/brain/doctor.ts index 0c79c41b..f845627c 100644 --- a/src/core/brain/doctor.ts +++ b/src/core/brain/doctor.ts @@ -56,6 +56,7 @@ import { entityRegistryCheck } from "./doctor/entity-checks.ts"; import { brokenBacklinkCheck } from "./doctor/link-checks.ts"; import { mergeChainDanglingCheck } from "./doctor/merge-chain-check.ts"; import { orphanSessionCheck } from "./doctor/orphan-session-check.ts"; +import { permissionsDocumentCheck } from "./doctor/permissions-check.ts"; import { evidenceRangeCheck, logShardCheck, orphanEvidenceCheck } from "./doctor/log-checks.ts"; import { contentHashDriftCheck, @@ -215,6 +216,7 @@ const DOCTOR_CHECKS: ReadonlyArray = Object.freeze([ embeddingsHealthCheck, rerankHealthCheck, payloadRegistryCheck, + permissionsDocumentCheck, ]); // ----- Entry point ---------------------------------------------------------- diff --git a/src/core/brain/doctor/permissions-check.ts b/src/core/brain/doctor/permissions-check.ts new file mode 100644 index 00000000..5e7aece3 --- /dev/null +++ b/src/core/brain/doctor/permissions-check.ts @@ -0,0 +1,49 @@ +/** + * Is the permissions document readable? + * + * Absent raises nothing: no document is the default posture and every + * gate proceeding as before is the CORRECT reading of a file the operator + * never wrote. Present but unreadable is the opposite - a trust policy + * that exists and is not in force, with every gate failing closed until + * it is repaired. That asymmetry is the whole reason the loader refuses + * instead of degrading, and this check is what makes the refusal visible + * on the one surface an operator runs to find out what is wrong. + * + * The message carries the loader's field-named error verbatim: the + * repair is an edit to the YAML at the field it names, and `show` re- + * derives the same error after each edit - which is exactly why the + * registered exit is `o2b brain permissions show`. + */ + +import { join } from "node:path"; + +import { + loadPermissionsDocument, + PERMISSIONS_DOCUMENT_REL, + PermissionsDocumentError, +} from "../permissions/document.ts"; +import type { DoctorIssue } from "../types.ts"; +import type { DoctorCheck, DoctorCheckContext, DoctorFindings } from "./check.ts"; + +export const PERMISSIONS_UNREADABLE_CODE = "permissions-unreadable"; + +export const permissionsDocumentCheck: DoctorCheck = { + failSoft: true, + run(ctx: DoctorCheckContext, out: DoctorFindings): void { + try { + loadPermissionsDocument(ctx.vault); + } catch (err) { + // A non-document failure is not this check's finding; the pass's + // fail-soft wrapper records those its own way. + if (!(err instanceof PermissionsDocumentError)) throw err; + out.issues.push({ + severity: "error", + code: PERMISSIONS_UNREADABLE_CODE, + path: join(ctx.vault, ...PERMISSIONS_DOCUMENT_REL.split("/")), + message: + `Brain/_permissions.yaml could not be read, so its policy is not in force and ` + + `every gate fails closed until it is repaired (${err.message})`, + } satisfies DoctorIssue); + } + }, +}; diff --git a/src/core/brain/permissions/document.ts b/src/core/brain/permissions/document.ts index 2ccd7a69..6033975a 100644 --- a/src/core/brain/permissions/document.ts +++ b/src/core/brain/permissions/document.ts @@ -98,7 +98,9 @@ export class PermissionsDocumentError extends Error {} type YamlScalar = string | number | boolean | null; type YamlValue = YamlScalar | YamlMapping | YamlValue[]; /** Insertion-ordered mapping; key order drives the deterministic warnings. */ -type YamlMapping = Record; +interface YamlMapping { + [key: string]: YamlValue; +} interface Line { readonly indent: number; @@ -295,29 +297,35 @@ function warn(path: string, field: string): void { process.stderr.write(`warning: ${path}: ${field}: unknown field ignored (forward-compat)\n`); } +function isVerdict(value: YamlValue): value is PermissionVerdict { + return typeof value === "string" && (VERDICTS as ReadonlyArray).includes(value); +} + function requireVerdict(path: string, field: string, value: YamlValue): PermissionVerdict { - if (typeof value !== "string" || !(VERDICTS as ReadonlyArray).includes(value)) { + if (!isVerdict(value)) { throw fail(path, field, `must be one of ${VERDICTS.join(", ")}; got ${describeValue(value)}`); } return value; } -function requireString( - path: string, - field: string, - value: YamlValue | undefined, - opts: { required: boolean }, -): string | undefined { - if (value === undefined) { - if (opts.required) throw fail(path, field, "is required"); - return undefined; - } +/** A required string field: absent, non-string or blank all refuse by name. */ +function requireString(path: string, field: string, value: YamlValue | undefined): string { + if (value === undefined) throw fail(path, field, "is required"); if (typeof value !== "string" || value.trim() === "") { throw fail(path, field, `must be a non-empty string; got ${describeValue(value)}`); } return value; } +/** An optional string field: absent stays absent, present must be a real string. */ +function optionalString( + path: string, + field: string, + value: YamlValue | undefined, +): string | undefined { + return value === undefined ? undefined : requireString(path, field, value); +} + function parseActionMapping( path: string, field: string, @@ -367,7 +375,7 @@ function validateAgents( const agent: PermissionsDocument["agents"][string] = {}; for (const [key, inner] of Object.entries(value)) { if (key === "role") { - agent.role = requireString(path, `agents.${name}.role`, inner, { required: true }); + agent.role = requireString(path, `agents.${name}.role`, inner); continue; } if ((AGENT_ACTION_KEYS as ReadonlyArray).includes(key)) { @@ -387,7 +395,7 @@ function validateEntries(path: string, raw: YamlValue, warnings: string[]): Perm for (const [index, item] of raw.entries()) { const field = `entries[${index}]`; if (!isMapping(item)) throw fail(path, field, `must be a mapping; got ${describeValue(item)}`); - const id = requireString(path, `${field}.id`, item["id"], { required: true }); + const id = requireString(path, `${field}.id`, item["id"]); const actionRaw = item["action"]; if (actionRaw === undefined) { throw fail(path, `${field}.action`, "is required"); @@ -404,8 +412,8 @@ function validateEntries(path: string, raw: YamlValue, warnings: string[]): Perm throw fail(path, `${field}.verdict`, "is required"); } const verdict = requireVerdict(path, `${field}.verdict`, verdictRaw); - const agent = requireString(path, `${field}.agent`, item["agent"], { required: false }); - const role = requireString(path, `${field}.role`, item["role"], { required: false }); + const agent = optionalString(path, `${field}.agent`, item["agent"]); + const role = optionalString(path, `${field}.role`, item["role"]); if (agent !== undefined && role !== undefined) { throw fail( path, @@ -413,7 +421,7 @@ function validateEntries(path: string, raw: YamlValue, warnings: string[]): Perm "declares both agent and role; an entry names one principal, never two", ); } - const target = requireString(path, `${field}.target`, item["target"], { required: false }); + const target = optionalString(path, `${field}.target`, item["target"]); for (const key of Object.keys(item)) { if ( !(["id", "action", "verdict", "agent", "role", "target"] as ReadonlyArray).includes( diff --git a/tests/cli/brain-permissions.test.ts b/tests/cli/brain-permissions.test.ts new file mode 100644 index 00000000..001bf6da --- /dev/null +++ b/tests/cli/brain-permissions.test.ts @@ -0,0 +1,219 @@ +/** + * `o2b brain permissions` (write-side-trust, Task 4). + * + * The operator surface over the permissions document and the decision + * ledger. `show` exists so the foot-gun a default-deny document is stays + * visible BEFORE the first refusal: it renders the dry-run decision table + * over the declared agents. `ledger` is the query half of the + * accountability trail. A document that cannot be read is never smoothed + * over: `show` fails naming the field, and the doctor reports the same + * fault under `permissions-unreadable` - exactly once. + */ + +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { appendDecisionLedger } from "../../src/core/brain/permissions/ledger.ts"; +import { runCli } from "../helpers/run-cli.ts"; + +let tmp: string; +let vault: string; +let docPath: string; + +beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), "o2b-cli-permissions-")); + vault = join(tmp, "vault"); + mkdirSync(join(vault, "Brain"), { recursive: true }); + docPath = join(vault, "Brain", "_permissions.yaml"); +}); + +afterEach(() => { + rmSync(tmp, { recursive: true, force: true }); +}); + +function writeDoc(text: string): void { + writeFileSync(docPath, text, "utf8"); +} + +const DOCUMENT = [ + "version: 1", + "default_action: deny", + "roles:", + " reviewer:", + " write: ask", + " ingest: deny", + "agents:", + " codex:", + " role: reviewer", + " write: allow", + "entries:", + " - id: freeze-notes", + " agent: codex", + " action: write", + " target: notes/foo.md", + " verdict: deny", +].join("\n"); + +describe("brain permissions show", () => { + test("with no document it says so and prints no decision table", async () => { + const result = await runCli(["brain", "permissions", "show", "--vault", vault]); + expect(result.returncode).toBe(0); + expect(result.stdout).toContain("no permissions document"); + expect(result.stdout).toContain("_permissions.yaml"); + // The decision table's header never appears when there is nothing to resolve. + expect(result.stdout).not.toContain("decision"); + }); + + test("with a document it renders the resolved decision for each declared agent", async () => { + writeDoc(`${DOCUMENT}\n`); + const result = await runCli(["brain", "permissions", "show", "--vault", vault]); + expect(result.returncode).toBe(0); + expect(result.stdout).toContain("deny"); // the explicit default + expect(result.stdout).toContain("reviewer"); + expect(result.stdout).toContain("codex"); + // Sources name the deciding rule: the target-scoped entry beats the + // agent override on its target, the override wins elsewhere. + expect(result.stdout).toContain("entry:freeze-notes"); + expect(result.stdout).toContain("agent:codex"); + expect(result.stdout).toContain("role:reviewer"); + }); + + test("--json carries the document and one resolved row per agent per action", async () => { + writeDoc(`${DOCUMENT}\n`); + const result = await runCli(["brain", "permissions", "show", "--vault", vault, "--json"]); + expect(result.returncode).toBe(0); + const payload = JSON.parse(result.stdout) as { + document: { default_action: string } | null; + decisions: Array<{ + agent: string; + action: string; + target: string; + verdict: string; + source: string; + }>; + }; + expect(payload.document).not.toBeNull(); + expect(payload.document!.default_action).toBe("deny"); + // One agent: three blanket actions plus the one declared target. + expect(payload.decisions).toHaveLength(4); + const write = payload.decisions.find((d) => d.action === "write" && d.target === "")!; + expect(write.verdict).toBe("allow"); + expect(write.source).toBe("agent:codex"); + const scoped = payload.decisions.find((d) => d.target === "notes/foo.md")!; + expect(scoped.source).toBe("entry:freeze-notes"); + expect(scoped.verdict).toBe("deny"); + }); + + test("a corrupt document fails with the field-named error", async () => { + writeDoc("version: 1\n"); + const result = await runCli(["brain", "permissions", "show", "--vault", vault]); + expect(result.returncode).not.toBe(0); + const said = `${result.stdout}${result.stderr}`; + expect(said).toContain("default_action"); + expect(said).toContain("_permissions.yaml"); + }); +}); + +describe("brain permissions ledger", () => { + test("an empty vault reports no rows", async () => { + const result = await runCli(["brain", "permissions", "ledger", "--vault", vault]); + expect(result.returncode).toBe(0); + expect(result.stdout.toLowerCase()).toContain("no decision ledger rows"); + }); + + test("rows list in deterministic order and filters narrow them", async () => { + appendDecisionLedger(vault, { + ts: "2026-10-10T10:00:00Z", + actor: "codex", + via: "token", + action: "write", + target: "notes/foo.md", + verdict: "deny", + source: "entry:freeze-notes", + reason: "target-scoped entry freeze-notes", + }); + appendDecisionLedger(vault, { + ts: "2026-10-10T11:00:00Z", + actor: "gemini", + via: "token", + action: "ingest", + target: "sources/x.pdf", + verdict: "ask", + source: "role:reviewer", + reason: "role mapping", + }); + const all = await runCli(["brain", "permissions", "ledger", "--vault", vault]); + expect(all.returncode).toBe(0); + const codexAt = all.stdout.indexOf("codex"); + const geminiAt = all.stdout.indexOf("gemini"); + expect(codexAt).toBeGreaterThanOrEqual(0); + expect(geminiAt).toBeGreaterThan(codexAt); + + const filtered = await runCli([ + "brain", + "permissions", + "ledger", + "--vault", + vault, + "--actor", + "gemini", + ]); + expect(filtered.stdout).toContain("gemini"); + expect(filtered.stdout).not.toContain("codex"); + + const json = await runCli(["brain", "permissions", "ledger", "--vault", vault, "--json"]); + const payload = JSON.parse(json.stdout) as { + rows: Array<{ actor: string; verdict: string; source: string }>; + }; + expect(payload.rows.map((r) => r.actor)).toEqual(["codex", "gemini"]); + expect(payload.rows[0]!.source).toBe("entry:freeze-notes"); + }); +}); + +describe("the doctor finding", () => { + test("a corrupt document yields exactly one permissions-unreadable error", async () => { + writeDoc("version: 1\ndefault_action: maybe\n"); + const result = await runCli(["brain", "doctor", "--vault", vault, "--json"]); + expect(result.returncode).not.toBe(0); + const payload = JSON.parse(result.stdout) as { + errors: Array<{ code: string; message: string }>; + }; + const findings = payload.errors.filter((e) => e.code === "permissions-unreadable"); + expect(findings).toHaveLength(1); + expect(findings[0]!.message).toContain("default_action"); + }); + + test("a healthy or absent document raises no permissions finding", async () => { + const absent = await runCli(["brain", "doctor", "--vault", vault, "--json"]); + const absentPayload = JSON.parse(absent.stdout) as { + errors: Array<{ code: string }>; + warnings: Array<{ code: string }>; + }; + expect( + [...absentPayload.errors, ...absentPayload.warnings].filter( + (e) => e.code === "permissions-unreadable", + ), + ).toEqual([]); + + writeDoc(`${DOCUMENT}\n`); + const healthy = await runCli(["brain", "doctor", "--vault", vault, "--json"]); + const healthyPayload = JSON.parse(healthy.stdout) as { + errors: Array<{ code: string }>; + warnings: Array<{ code: string }>; + }; + expect( + [...healthyPayload.errors, ...healthyPayload.warnings].filter( + (e) => e.code === "permissions-unreadable", + ), + ).toEqual([]); + }); + + test("the finding names its exit: o2b brain permissions show", async () => { + writeDoc("version: 2\ndefault_action: ask\n"); + const result = await runCli(["brain", "doctor", "--vault", vault]); + expect(result.returncode).not.toBe(0); + expect(`${result.stdout}${result.stderr}`).toContain("o2b brain permissions show"); + }); +}); diff --git a/tests/core/brain/doctor-exit-census.test.ts b/tests/core/brain/doctor-exit-census.test.ts index b9e2dd5b..b5faaf2a 100644 --- a/tests/core/brain/doctor-exit-census.test.ts +++ b/tests/core/brain/doctor-exit-census.test.ts @@ -195,6 +195,7 @@ const DOCTOR_REGISTERED_CODES: ReadonlyArray = [ "orphan-evidence", "orphan-session-ref", "payload-orphan", + "permissions-unreadable", "principle-corrupted", "recall-channel-silent", "recovery-point-stale", From b9f94143122bb0514e35ede0a368767f1c33efc1 Mon Sep 17 00:00:00 2001 From: Sergey Eroshenkov Date: Sat, 10 Oct 2026 11:10:25 +0200 Subject: [PATCH 09/84] feat(brain): open-decision vault for parked questions Park a judgment question before the decision exists: one Markdown record at Brain/decisions/open-.md with enumerated options, modeled on the trigger store lifecycle - frozen status trio (open/resolved/discarded, census-registered), JSON-quoted frontmatter, Question/Options/Context body sections, hand-edit-tolerant parsing with named-unreadable partitioned reads, and a directory lock around every writer under the vault-identity guard. Dedup is the sha16 of the normalized question; a twin question refuses naming the existing id. resolve mints a real decision page through recordDecision (review obligation, decision-record event and B4 trail included), stamps the open record resolved with the [[decision-]] pointer, and lands exactly one open-resolved receipt; discard records the reason; terminal records stay in place so history is a status filter. Log kinds decision-open/resolved/discarded join the Brain timeline. Surfaces: brain_decision gains open/list_open/show_open/resolve/discard on the existing tool (no new tool, tool-count pins unmoved); the CLI verb mirrors them; the morning brief renders a capped, read-only Open decisions section with unreadable records named. --- docs/cli-reference.md | 2 +- docs/mcp.md | 6 +- src/cli/brain/help-text.ts | 13 +- src/cli/brain/verbs/decision.ts | 154 +++- src/cli/brain/verbs/morning-brief.ts | 20 + src/cli/command-manifest.ts | 2 +- src/core/brain/decisions/brief.ts | 63 ++ src/core/brain/decisions/open-store.ts | 793 ++++++++++++++++++ src/core/brain/decisions/receipts.ts | 5 + src/core/brain/types.ts | 28 + src/mcp/brain/brief-tools.ts | 22 + src/mcp/brain/decisions-tools.ts | 147 +++- tests/cli/brain-decision.test.ts | 232 ++++- tests/cli/brain-morning-brief-open.test.ts | 87 ++ .../verdict-vocabulary-census.test.ts | 17 +- tests/core/brain.types.test.ts | 4 + tests/core/brain/decisions/open-brief.test.ts | 145 ++++ tests/core/brain/decisions/open-store.test.ts | 451 ++++++++++ tests/mcp/decision-tool.test.ts | 124 +++ 19 files changed, 2301 insertions(+), 14 deletions(-) create mode 100644 src/core/brain/decisions/brief.ts create mode 100644 src/core/brain/decisions/open-store.ts create mode 100644 tests/cli/brain-morning-brief-open.test.ts create mode 100644 tests/core/brain/decisions/open-brief.test.ts create mode 100644 tests/core/brain/decisions/open-store.test.ts diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 71e472be..f8f8ea20 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -687,7 +687,7 @@ Extracted facts pass a deterministic durability gate before persisting: structur ```text o2b brain lifecycle tombstone --reason | supersede --by | temporal-replace --at | tip | curator [--slice ] - cross-type soft-delete and supersession: tombstone is idempotent frontmatter (file stays for audit, leaves recall/inject/active.md), temporal-replace closes and opens at one shared instant with half-open [valid_from, valid_to) intervals, tip resolves the supersedes chain, curator lists injected-never-used / contradicted / high-used memories o2b brain claims [--at ] [--history] [--replaced ] [--contests ] [--rebuild] - claim-graph queries over existing relations and validity fields; current truth by default, history opt-in; --rebuild persists Brain/claim-graph.json deterministically -o2b brain decision record --title --chosen [--assumption ] [--review-date ] [--premortem

] [--commitment ] | outcome | rate <1-5> [--rationale ] | show | list [--rated] | compare | similar --title | history [--subject ] [--cursor ] | recall --prompt

[--turn ] [--count ] [--last-turn ] [--surfaced-ids ] - decision records under Brain/decisions/; record opens one review obligation per review_date, history pages decision_change.v1 receipts, recall is governed by decision_recall.max_per_session and decision_recall.min_spacing_turns +o2b brain decision record --title --chosen [--assumption ] [--review-date ] [--premortem

] [--commitment ] | outcome | rate <1-5> [--rationale ] | show | list [--rated] | compare | similar --title | history [--subject ] [--cursor ] | recall --prompt

[--turn ] [--count ] [--last-turn ] [--surfaced-ids ] - decision records under Brain/decisions/; record opens one review obligation per review_date, history pages decision_change.v1 receipts, recall is governed by decision_recall.max_per_session and decision_recall.min_spacing_turns; open decisions (write-side-trust): open --title --question --option [--option ...] [--context ] parks a question at Brain/decisions/open-.md (duplicate question refuses naming the existing id), list_open [--status open|resolved|discarded] lists with unreadable records named, show_open reads one, resolve --choice [--rationale ] mints the real decision page and stamps [[decision-]] plus one open-resolved receipt, discard --reason closes without deciding; terminal records stay in place o2b brain tension detect [--jaccard ] | list [--unresolved] | show | confirm | dismiss | resolve - persisted contradictions under Brain/tensions/ with an open -> confirmed/dismissed/resolved state machine; re-detection refreshes the existing note; unresolved tensions warn at context-pack build time o2b brain tension verify [] - read-only advisory decision-model verdict (contradicts | compatible | unrelated) per tension, or for every unresolved one; needs the optional `tension` use, else `available: false`; never changes a tension (see docs/decision-models/dedup-tension.md) o2b brain authored-at-backfill [--apply] - stamp authored_at on pre-1.33.0 session signals from their preserved turn instant; dry-run default, idempotent, never re-embeds diff --git a/docs/mcp.md b/docs/mcp.md index efb0b616..0c61e919 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1521,7 +1521,11 @@ format characters), when it contains NUL, or when it exceeds the cap. `brain_lifecycle` (tombstone / supersede / temporal-replace / tip / curator), `brain_claims` (claim-graph queries: current truth, truth-at-instant, replaced-by, contested-by), `brain_decision` - (record / outcome / rate / list / compare / similar / history / recall), + (record / outcome / rate / list / compare / similar / history / recall, + plus the open-decision actions open / list_open / show_open / resolve / + discard that park a question with enumerated options at + `Brain/decisions/open-.md` and later mint the real decision page + through `resolve`), and `brain_tension` (detect / list / show / confirm / dismiss / resolve). Decision-change receipts store only accountable provenance; free-text hidden-reasoning fields are rejected by the closed schema. diff --git a/src/cli/brain/help-text.ts b/src/cli/brain/help-text.ts index bb00711d..01bbb948 100644 --- a/src/cli/brain/help-text.ts +++ b/src/cli/brain/help-text.ts @@ -52,7 +52,7 @@ Brain verbs (observing memory): note-lifecycle Note FILES: rename/move/archive/delete one, rewriting inbound links scaffold-stub Unresolved wikilink targets: list them, or materialise a stub claims Claim-graph query: current truth, truth-at-T, replaced-by, contested-by - decision Capture/review decisions: record/outcome/show/list/similar + decision Decisions: record/outcome/show/list/similar + open questions: open/list_open/show_open/resolve/discard tension Detect + triage persisted contradictions: detect/list/show/confirm/dismiss/resolve digest Render the recent-changes digest (markdown or --json) intent-review Read-only pre-dream review of active signal clusters @@ -357,7 +357,7 @@ export const VERB_HELP: Record = { "--replaced follows the supersede chain to the live tip; --contests \n" + "lists contesting claims; --rebuild rebuilds and persists Brain/claim-graph.json.\n", decision: - "usage: o2b brain decision [...] [--vault ] [--json]\n" + + "usage: o2b brain decision [...] [--vault ] [--json]\n" + "Decision-record note family under Brain/decisions/. record --title --chosen \n" + "--assumption --review-date [--premortem

] [--notes ]\n" + "[--rating <1-5>] [--rationale ] captures a type: decision note and opens one review\n" + @@ -371,7 +371,14 @@ export const VERB_HELP: Record = { "[--turn ] [--count ] [--last-turn ] [--surfaced-ids ...]\n" + "deterministically resurfaces a rated decision matching the prompt when\n" + "decision_recall.max_per_session is configured (byte-identical when unset); the\n" + - "count/last-turn/surfaced-ids flags thread the per-session cap and spacing state.\n", + "count/last-turn/surfaced-ids flags thread the per-session cap and spacing state.\n" + + "Open decisions (parked questions with enumerated options) live beside them as\n" + + "open-.md: open --title --question --option [--option ...]\n" + + "[--context ] parks one (a duplicate question refuses, naming the existing id);\n" + + "list_open [--status open|resolved|discarded] lists them (unreadable records named);\n" + + "show_open reads one; resolve --choice [--rationale ] mints the real\n" + + "type: decision page and stamps the pointer; discard --reason closes the\n" + + "question without deciding. Terminal records stay in place.\n", tension: "usage: o2b brain tension [...] [--vault ] [--json]\n" + "Triage persisted contradictions under Brain/tensions/. detect [--jaccard ] scans\n" + diff --git a/src/cli/brain/verbs/decision.ts b/src/cli/brain/verbs/decision.ts index 6f9ecb25..ed04c0fb 100644 --- a/src/cli/brain/verbs/decision.ts +++ b/src/cli/brain/verbs/decision.ts @@ -1,6 +1,7 @@ /** * `o2b brain decision ` - decision-record note family CLI - * (Belief lifecycle suite, Track B anchor, t_ac03214d). + * (Belief lifecycle suite, Track B anchor, t_ac03214d; open-decision + * vault actions added by the write-side-trust wave, Task 10). * * Actions: * - `record --title --chosen --assumption --review-date @@ -12,6 +13,13 @@ * - `recall --prompt [--turn ] [--count ] [--last-turn ] * [--surfaced-ids ...]` resurface a rated decision, * threading the per-session cap and spacing state + * - `open --title --question --option [--option ...] + * [--context ]` park a question with options + * - `list_open [--status ]` parked questions by status + * - `show_open ` one parked question + * - `resolve --choice [--rationale ]` + * choose an option; mints the real decision page + * - `discard --reason ` close without deciding * * CLI mirror of the `brain_decision` MCP tool; both delegate to the core * decision module so the on-disk shape cannot drift. @@ -29,6 +37,15 @@ import { updateRating, } from "../../../core/brain/decisions/record.ts"; import type { BrainCommitmentTier } from "../../../core/brain/types.ts"; +import { + discardOpenDecision, + isOpenDecisionStatus, + listOpenDecisions, + OPEN_DECISION_STATUSES, + openDecision, + resolveOpenDecision, + showOpenDecision, +} from "../../../core/brain/decisions/open-store.ts"; import { queryDecisionChangeHistory } from "../../../core/brain/decisions/receipts.ts"; import { recallRatedDecisions } from "../../../core/brain/decisions/recall.ts"; import { normalizeFlagString, ok, okJson, parse, resolveBrainVault } from "../helpers.ts"; @@ -64,13 +81,19 @@ export async function cmdBrainDecision(argv: string[]): Promise { count: { type: "string" }, "last-turn": { type: "string" }, "surfaced-ids": { type: "string-array" }, + question: { type: "string" }, + option: { type: "string-array" }, + context: { type: "string" }, + status: { type: "string" }, + choice: { type: "string" }, + reason: { type: "string" }, json: { type: "boolean" }, }); const action = positional[0]; if (action === undefined) { return usageError( - "brain decision requires an action: record | outcome | rate | show | list | compare | similar | history | recall", + "brain decision requires an action: record | outcome | rate | show | list | compare | similar | history | recall | open | list_open | show_open | resolve | discard", ); } @@ -375,6 +398,133 @@ export async function cmdBrainDecision(argv: string[]): Promise { } return 0; } + case "open": { + const title = normalizeFlagString(flags["title"]); + const question = normalizeFlagString(flags["question"]); + const options = Array.isArray(flags["option"]) ? (flags["option"] as string[]) : []; + if (!title || !question || options.length === 0) { + return usageError("brain decision open requires --title, --question, --option"); + } + const context = normalizeFlagString(flags["context"]); + const rec = openDecision(vault, { + title, + question, + options, + ...(context ? { context } : {}), + ...(explicitAgent ? { agent: explicitAgent } : {}), + configPath: config, + }); + if (wantsJson) { + okJson({ id: rec.id, slug: rec.slug, status: rec.status, options: options.length }); + } else { + ok(`opened ${rec.id} (${options.length} options)`); + } + return 0; + } + case "list_open": { + const statusRaw = normalizeFlagString(flags["status"]); + if (statusRaw !== null && !isOpenDecisionStatus(statusRaw)) { + return usageError( + `brain decision list_open --status must be one of ${OPEN_DECISION_STATUSES.join(", ")}`, + ); + } + const listed = listOpenDecisions(vault, statusRaw !== null ? { status: statusRaw } : {}); + if (wantsJson) { + okJson({ + open_decisions: listed.records.map((r) => ({ + id: r.id, + slug: r.slug, + title: r.title, + question: r.question, + status: r.status, + options: [...r.options], + created_at: r.createdAt, + })), + unreadable: listed.unreadable, + }); + } else if (listed.records.length === 0 && listed.unreadable.length === 0) { + ok("no open decisions"); + } else { + for (const r of listed.records) { + ok(`${r.id} [${r.status}]: ${r.question} (${r.options.length} options)`); + } + for (const u of listed.unreadable) { + process.stdout.write(`unreadable: ${u.reason}\n`); + } + } + return 0; + } + case "show_open": { + const id = positional[1]; + if (id === undefined) return usageError("brain decision show_open requires an id"); + const res = showOpenDecision(vault, id); + if (res === null) { + process.stderr.write(`error: no open decision: ${id}\n`); + return 1; + } + if (wantsJson) { + okJson({ + id: res.id, + slug: res.slug, + title: res.title, + question: res.question, + options: [...res.options], + context: res.context, + status: res.status, + created_at: res.createdAt, + resolved_at: res.resolvedAt, + discarded_at: res.discardedAt, + choice: res.choice, + decision: res.decision, + discard_reason: res.discardReason, + }); + } else { + ok(`${res.id} [${res.status}]: ${res.question}`); + for (const option of res.options) ok(` - ${option}`); + if (res.choice !== null) ok(` choice: ${res.choice}`); + if (res.decision !== null) ok(` decision: ${res.decision}`); + if (res.discardReason !== null) ok(` reason: ${res.discardReason}`); + } + return 0; + } + case "resolve": { + const id = positional[1]; + const choice = normalizeFlagString(flags["choice"]); + if (id === undefined || !choice) { + return usageError("brain decision resolve requires --choice --verdict --since --until --limit ` reads the merged rows back (`--json` for the raw rows); every filter is optional and applied after the deterministic sort, so two devices listing the same shards agree on the order. The ledger is append-only and has no prune verb: retention follows the same open question as every sharded ledger in the vault. + ## Continuity record kinds Every continuity record shares one envelope, and exactly these eight fields in this order: `schema`, `id`, `kind`, `createdAt`, `sourceRefs`, `payload`, `private`, `redacted`. The table marks how each kind is gated - this is the always-on vs opt-in matrix, verified against the call sites named in the right column. diff --git a/plugins/codex/README.md b/plugins/codex/README.md index e22ea698..536abfd5 100644 --- a/plugins/codex/README.md +++ b/plugins/codex/README.md @@ -114,10 +114,16 @@ The full router with readiness criteria is [`install.md`](https://github.com/ite - **Semantic search:** an embedding provider plus `sqlite-vec`; the `embeddings-setup` skill walks through it: [`skills/embeddings-setup/SKILL.md`](https://github.com/itechmeat/open-second-brain/blob/main/skills/embeddings-setup/SKILL.md). - **Decision models:** a typed judgment model that can rerank search and filter candidates, off by default per use: [`docs/decision-models.md`](https://github.com/itechmeat/open-second-brain/blob/main/docs/decision-models.md). - **Deep relational recall:** a fourth search arm over typed links, off by default. Its traversal runs under width budgets - 8 seeds, 4 edges per node, 16 nodes in total, and a hub above 12 walked edges is reached but not expanded - each overridable through an `OPEN_SECOND_BRAIN_SEARCH_TRAVERSAL_*` environment variable or a `search_traversal_*` config key; entity co-occurrence bridges join the walk by default and switch off separately (`OPEN_SECOND_BRAIN_SEARCH_ENTITY_BRIDGES`): [retrieval quality](https://github.com/itechmeat/open-second-brain/blob/main/docs/cli-reference.md#retrieval-quality-and-context-delivery-since-v1370). +- **Staged review for agent writes, off by default.** With no key set every write publishes exactly as before. `write_approval.notes` / `OPEN_SECOND_BRAIN_WRITE_APPROVAL_NOTES_ENABLED` and `write_approval.ingest` / `OPEN_SECOND_BRAIN_WRITE_APPROVAL_INGEST_ENABLED` (each falling back to the `write_approval.enabled` master, default off) stage note creates and ingest summary pages into `Brain/pending/` beside the staged signals, where they stay out of the search index until an operator runs `o2b brain pending list` and applies or rejects them; [write-path integrity](https://github.com/itechmeat/open-second-brain/blob/main/docs/cli-reference.md#write-path-integrity-and-store-safety-since-v1320). +- **A permissions document, absent by default.** `Brain/_permissions.yaml` (an operator-edited vault file) resolves `allow`/`ask`/`deny` per agent, role and target for the write, ingest and owner-write actions; with the file absent every check behaves exactly as today. `ask` stages the write, `deny` refuses with the `write-refused` token and the next command `o2b brain permissions show`, and every ask/deny verdict lands in a queryable decision ledger under `Brain/logs/decisions/`; `o2b brain permissions show` dry-runs the decision table, `ledger` reads the rows; [Brain CLI](https://github.com/itechmeat/open-second-brain/blob/main/docs/cli-reference.md#brain-observing-memory). +- **The owner-write gate, off by default.** `integrity.owner_scope_writes` in `Brain/_brain.yaml` (`off` | `warn` | `fail`, default `off`) refuses a caller-named owner that differs from the caller's resolved identity on the preference and note lanes (`warn` allows and records one decision-ledger row); a document `owner_write` verdict composes most-restrictive-wins with the gate; [write-time integrity](https://github.com/itechmeat/open-second-brain/blob/main/docs/cli-reference.md#write-time-integrity-and-governance-since-v0440). +- **Named MCP tokens, optional.** `o2b mcp token mint|rotate|revoke|list` keeps a hash-at-rest token per agent (`.open-second-brain/secrets/mcp-tokens.json`; material shown exactly once). Over HTTP a valid token authenticates as its agent per request, the shared `--api-key` stays valid as the operator master credential, and `mcp_tokens_required` / `OPEN_SECOND_BRAIN_MCP_TOKENS_REQUIRED` (default `false`) makes a non-empty token map refuse credential-less requests. `o2b bootstrap --target [--token] [--rotate] [--check]` provisions MCP registration, token and receipt in one idempotent command; [core CLI](https://github.com/itechmeat/open-second-brain/blob/main/docs/cli-reference.md#core). +- **Ambient capture consent, opt-out.** `guardrails.ambient_writeback: false` suppresses the ambient extraction lane with a counted `ambient-withheld` event (absent keeps today's behavior), and `guardrails.ambient_ttl_days` stamps an `expiration_date` on ambient-extracted signals so reads drop them after the window (absent stamps nothing); a TTL-stamped signal still stages when the review gate is on and survives apply verbatim. +- **Open decisions.** `o2b brain decision open --title --question --option [...]` parks a question with enumerated options at `Brain/decisions/open-.md`; `resolve` mints the real `type: decision` page, `discard` closes without deciding, and the morning brief renders up to five open questions: [belief lifecycle](https://github.com/itechmeat/open-second-brain/blob/main/docs/cli-reference.md#belief-lifecycle-and-decision-memory-since-v1330). ## What is new -1.78.0 puts credential custody under an operator passphrase. The secrets keyfile wraps into a scrypt envelope behind `o2b brain secret unlock`/`lock`, `$secret:NAME` references resolve through the custody store at the embedding, decision-model, research, Telegram and installation-secret use sites, and `secret export`/`import` move the store between installs as one passphrase-encrypted bundle. Resolved credential literals are redacted at the error and config-mapping boundaries, composed tags must parse as Obsidian tags, a declared page vocabulary gates capture writes, import and upgrade plans carry an approval digest that apply must match, and a changed extraction contract reprocesses the sources it covers. Every release is described in the [CHANGELOG](https://github.com/itechmeat/open-second-brain/blob/main/CHANGELOG.md). +1.79.0 gives every agent write a name, a rule and a review door. `o2b mcp token` mints a hash-at-rest token per agent that authenticates over HTTP as that agent per request (the shared key stays valid; `mcp_tokens_required` can make the map mandatory), `o2b bootstrap` provisions a harness in one idempotent command, and `Brain/_permissions.yaml` resolves allow/ask/deny at one chokepoint with every ask/deny verdict in a queryable decision ledger. Gated writes stage into `Brain/pending/` where recall cannot see them until an operator applies them, `integrity.owner_scope_writes` refuses a caller-named foreign owner, `o2b brain decision open` parks a question with enumerated options until it becomes a real decision, and ambient extraction answers to `guardrails.ambient_writeback` consent and `guardrails.ambient_ttl_days`. Every gate ships default-off: with no tokens, no document and no keys, every write path behaves exactly as before. Every release is described in the [CHANGELOG](https://github.com/itechmeat/open-second-brain/blob/main/CHANGELOG.md). ## Documentation From a8dd1404494bb33ac0379a4b4a91abe72648ccfb Mon Sep 17 00:00:00 2001 From: Sergey Eroshenkov Date: Sat, 10 Oct 2026 14:07:07 +0200 Subject: [PATCH 19/84] chore(release): bump version to 1.79.0 --- .claude-plugin/plugin.json | 2 +- .codex-plugin/plugin.json | 2 +- openclaw.plugin.json | 2 +- package.json | 2 +- plugin.yaml | 2 +- plugins/codex/.codex-plugin/plugin.json | 2 +- plugins/hermes/plugin.yaml | 2 +- pyproject.toml | 2 +- uv.lock | 2 +- 9 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index f824009e..1422bb68 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "open-second-brain", - "version": "1.78.0", + "version": "1.79.0", "description": "Plugin-first second brain package for AI agents and humans.", "author": { "name": "Open Second Brain contributors" diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 387e0876..ff7cbc26 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "open-second-brain", - "version": "1.78.0", + "version": "1.79.0", "description": "Plugin-first second brain package for Codex, Hermes, Claude Code, OpenClaw, and other agent runtimes.", "author": { "name": "Open Second Brain contributors", diff --git a/openclaw.plugin.json b/openclaw.plugin.json index e7e4a9a0..9f597411 100644 --- a/openclaw.plugin.json +++ b/openclaw.plugin.json @@ -2,7 +2,7 @@ "id": "open-second-brain", "name": "Open Second Brain", "description": "Second brain for AI agents using Obsidian-compatible Markdown vaults.", - "version": "1.78.0", + "version": "1.79.0", "activation": { "onStartup": true }, "skills": ["./skills"], "contracts": { diff --git a/package.json b/package.json index 896ebf6a..8e11607b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "open-second-brain", - "version": "1.78.0", + "version": "1.79.0", "private": false, "description": "Second brain for AI agents using Obsidian-compatible Markdown vaults. Works with Hermes, Claude Code, Codex, and OpenClaw.", "keywords": [ diff --git a/plugin.yaml b/plugin.yaml index dbe55011..60f1b0f6 100644 --- a/plugin.yaml +++ b/plugin.yaml @@ -1,5 +1,5 @@ name: open-second-brain -version: "1.78.0" +version: "1.79.0" description: "Open Second Brain - native Hermes memory provider backed by an Obsidian-compatible Markdown vault." author: "Open Second Brain contributors" memory_provider: true diff --git a/plugins/codex/.codex-plugin/plugin.json b/plugins/codex/.codex-plugin/plugin.json index 1594e926..6a94a45a 100644 --- a/plugins/codex/.codex-plugin/plugin.json +++ b/plugins/codex/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "open-second-brain", - "version": "1.78.0", + "version": "1.79.0", "description": "Plugin-first second brain package for Codex, Hermes, Claude Code, OpenClaw, and other agent runtimes.", "author": { "name": "Open Second Brain contributors", diff --git a/plugins/hermes/plugin.yaml b/plugins/hermes/plugin.yaml index dbe55011..60f1b0f6 100644 --- a/plugins/hermes/plugin.yaml +++ b/plugins/hermes/plugin.yaml @@ -1,5 +1,5 @@ name: open-second-brain -version: "1.78.0" +version: "1.79.0" description: "Open Second Brain - native Hermes memory provider backed by an Obsidian-compatible Markdown vault." author: "Open Second Brain contributors" memory_provider: true diff --git a/pyproject.toml b/pyproject.toml index 9881f886..556cbeb2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,7 @@ build-backend = "setuptools.build_meta" # CLI entry points (those moved to `package.json` `bin`). [project] name = "open-second-brain" -version = "1.78.0" +version = "1.79.0" description = "Hermes Python shim for Open Second Brain. Most of the project (CLI, MCP server, OpenClaw plugin) is TypeScript on Bun; see package.json." readme = "README.md" requires-python = ">=3.11" diff --git a/uv.lock b/uv.lock index 951b98e7..2292cf22 100644 --- a/uv.lock +++ b/uv.lock @@ -4,5 +4,5 @@ requires-python = ">=3.11" [[package]] name = "open-second-brain" -version = "1.78.0" +version = "1.79.0" source = { editable = "." } From 6bd90d3eda5a4ed33e5a2bced8d0220da659c192 Mon Sep 17 00:00:00 2001 From: Sergey Eroshenkov Date: Sat, 10 Oct 2026 14:07:56 +0200 Subject: [PATCH 20/84] chore(openclaw): rebuild the bundle for 1.79.0 bun run build:openclaw under the CI-pinned Bun 1.4.0; the diff is the token store joining the custody surface (tokenStorePath export, the mcp-tokens.json custody target). --- openclaw/index.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/openclaw/index.js b/openclaw/index.js index 7d7c26ca..fc7848bb 100644 --- a/openclaw/index.js +++ b/openclaw/index.js @@ -3459,6 +3459,7 @@ __export(exports_store, { resolveSecretReadOnly: () => resolveSecretReadOnly, secretsDir: () => secretsDir, setSecret: () => setSecret, + tokenStorePath: () => tokenStorePath, unlockSecretKeyfile: () => unlockSecretKeyfile, withSecretsLock: () => withSecretsLock, writeStore: () => writeStore @@ -3474,6 +3475,9 @@ function storePath(vault) { function keyPath(vault) { return join10(secretsDir(vault), "keyfile"); } +function tokenStorePath(vault) { + return join10(secretsDir(vault), "mcp-tokens.json"); +} function isValidSecretName(name) { return NAME_RE.test(name); } @@ -3644,6 +3648,8 @@ function custodyTargets(vault) { ]; if (existsSync6(storePath(vault))) targets.push([storePath(vault), "file"]); + if (existsSync6(tokenStorePath(vault))) + targets.push([tokenStorePath(vault), "file"]); return targets; } function toMetadata(name, stored) { From 70d6479b3ff96dad60404f4ddad726e2ed1240ac Mon Sep 17 00:00:00 2001 From: Sergey Eroshenkov Date: Sat, 10 Oct 2026 14:35:56 +0200 Subject: [PATCH 21/84] fix(trust): pin token-first credential resolution and name the disagreeing owner The self-review mutation round showed the token-map-before-shared-key consultation order was documented but pinned by no test: a credential matching both resolved through whichever arm a refactor listed first. The both-match case now pins the minted agent winning. The refusal reason for a cross-owner claim names the token that actually disagrees (a frontmatter owner no longer answers with an explicit-owner phrase), and the transport documents the pre-existing tightening it already shipped: a presented credential matching neither credential source is refused outright instead of degrading to anonymous. --- docs/mcp.md | 8 ++++++-- src/core/brain/trust/owner-write-gate.ts | 12 +++++++----- src/mcp/http.ts | 9 ++++++--- tests/mcp/http-token-auth.test.ts | 10 ++++++++++ 4 files changed, 29 insertions(+), 10 deletions(-) diff --git a/docs/mcp.md b/docs/mcp.md index 232879e2..48b19732 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -754,8 +754,12 @@ cache; the shared key keeps its launch-time capture). The gate `mcp_tokens_required` (device config key or `OPEN_SECOND_BRAIN_MCP_TOKENS_REQUIRED`, default `false`) makes the endpoint refuse credential-less requests whenever a non-empty token map exists; with -the key absent every existing posture - loopback keyless, non-loopback keyed -- is unchanged, and stdio identity stays config-derived (one caller per +the key absent every credential-less posture - loopback keyless, non-loopback +keyed - is unchanged, and one corner tightens everywhere: a presented +credential that matches neither the token map nor the shared key is refused +with the same generic `401` instead of degrading to anonymous, so a stale or +forged bearer can never ride the keyless loopback posture. stdio identity +stays config-derived (one caller per process that already owns the process tree). A token mints identity only, never reach: tool profiles and the reach ceiling still bind every caller regardless of credential. diff --git a/src/core/brain/trust/owner-write-gate.ts b/src/core/brain/trust/owner-write-gate.ts index bdbd05d5..528998b9 100644 --- a/src/core/brain/trust/owner-write-gate.ts +++ b/src/core/brain/trust/owner-write-gate.ts @@ -151,14 +151,16 @@ export function refuseCrossOwnerWrite(input: CrossOwnerWriteInput): CrossOwnerWr ); } - const cross = - (explicit !== null && explicit !== resolved) || - (fromFrontmatter !== null && fromFrontmatter !== resolved); - if (!cross) return NOT_REFUSED; + const crossExplicit = explicit !== null && explicit !== resolved; + const crossFrontmatter = fromFrontmatter !== null && fromFrontmatter !== resolved; + if (!crossExplicit && !crossFrontmatter) return NOT_REFUSED; + // Name the token that actually disagrees: when one spelling agrees and + // the other does not, the refusal must point at the foreign one. + const foreign = crossExplicit ? explicit! : fromFrontmatter!; return refused( `write refused (owner-write-refused): the caller-named owner ` + - `${JSON.stringify(named)} names an owner other than the identity resolved for ` + + `${JSON.stringify(foreign)} names an owner other than the identity resolved for ` + `this caller, ${JSON.stringify(resolved)}. ${OWNER_SCOPE_WRITES_KEY}=${GATE_MODE.fail} ` + `makes the resolved identity authoritative for writes, so the write is refused ` + `rather than published under a foreign owner. Name ${JSON.stringify(resolved)}, ` + diff --git a/src/mcp/http.ts b/src/mcp/http.ts index c44acba2..8a3406aa 100644 --- a/src/mcp/http.ts +++ b/src/mcp/http.ts @@ -443,9 +443,12 @@ interface HttpAuth { * process config identity, `via: "shared-key"` - the operator master * credential, byte-identical to the pre-token gate except that the * identity now rides the request; - * - a presented credential matching neither is refused whenever a key is - * configured (the pre-existing rule) or tokens are required, and falls - * through to anonymous otherwise (the loopback posture, unchanged); + * - a presented credential matching neither is refused outright: a wrong + * credential never degrades to anonymous, whatever the bind. The + * pre-token gate refused it whenever a key was configured; this gate + * refuses it everywhere, because treating a presented credential as + * absence would let a stale or forged bearer ride the loopback's + * anonymous posture; * - a credential-less request proceeds anonymous unless tokens are * required - which is the config key AND a non-empty map, or the * implicit requirement of a key-less non-loopback bind. diff --git a/tests/mcp/http-token-auth.test.ts b/tests/mcp/http-token-auth.test.ts index f60b07aa..ee3c6458 100644 --- a/tests/mcp/http-token-auth.test.ts +++ b/tests/mcp/http-token-auth.test.ts @@ -302,6 +302,16 @@ describe("authenticateRequest", () => { ).toEqual({ agent: "operator", via: "shared-key" }); expect(authenticateRequest(reqWith({ authorization: "Bearer neither" }), base)).toBeNull(); expect(authenticateRequest(reqWith({}), base)).toBeNull(); + // A credential matching BOTH the token map and the shared key resolves + // through the token map: the minted agent wins and the shared key never + // overrides it - the consultation order itself is pinned here. + expect( + authenticateRequest(reqWith({ authorization: `Bearer ${RESOLVED_TOKEN}` }), { + ...base, + apiKey: RESOLVED_TOKEN, + sharedKeyAgent: "operator", + }), + ).toEqual({ agent: "edge-agent", via: "token" }); }); test("an empty shared key never matches; x-api-key carries a token too", () => { From 255dce0d3a94edf85dfaf078449d8b9208ed026b Mon Sep 17 00:00:00 2001 From: Sergey Eroshenkov Date: Sat, 10 Oct 2026 14:40:29 +0200 Subject: [PATCH 22/84] test(mcp): pin the open-decisions brief section to the operator-queue reach boundary --- tests/mcp/brief-open-decisions-reach.test.ts | 94 ++++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 tests/mcp/brief-open-decisions-reach.test.ts diff --git a/tests/mcp/brief-open-decisions-reach.test.ts b/tests/mcp/brief-open-decisions-reach.test.ts new file mode 100644 index 00000000..7e2aef2e --- /dev/null +++ b/tests/mcp/brief-open-decisions-reach.test.ts @@ -0,0 +1,94 @@ +/** + * The morning brief's open-decisions section answers at the caller's + * reach (write-side-trust wave, Task 10). + * + * A parked question is vault content: the section rides the same + * operator-queue boundary as the trigger queue, so a reader below local + * reach is shown neither the questions nor the unreadable records, and + * a local caller still sees both. A server with no reach minted is a + * remote caller. + */ + +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { MCPServer } from "../../src/mcp/server.ts"; +import { openDecision } from "../../src/core/brain/decisions/open-store.ts"; +import { TRANSPORT_REACH, type TransportReach } from "../../src/core/graph/transport-reach.ts"; + +const bases: string[] = []; + +afterEach(() => { + for (const base of bases.splice(0)) rmSync(base, { recursive: true, force: true }); + delete process.env["OPEN_SECOND_BRAIN_CONFIG"]; +}); + +interface Vault { + vault: string; + configPath: string; +} + +function makeVault(): Vault { + const base = mkdtempSync(join(tmpdir(), "o2b-brief-open-reach-")); + bases.push(base); + const vault = join(base, "vault"); + const configPath = join(base, "config.yaml"); + mkdirSync(join(vault, "Brain"), { recursive: true }); + writeFileSync(configPath, `vault: ${vault}\nagent_name: tester\n`, "utf8"); + return { vault, configPath }; +} + +function serverAt(v: Vault, reach?: TransportReach): MCPServer { + process.env["OPEN_SECOND_BRAIN_CONFIG"] = v.configPath; + const config = { vault: v.vault, configPath: v.configPath }; + return reach === undefined ? new MCPServer(config) : new MCPServer(config, { reach }); +} + +async function morningBrief(v: Vault, reach?: TransportReach): Promise> { + const result = await serverAt(v, reach).callTool("brain_brief", { view: "morning" }); + return result.structuredContent as Record; +} + +describe("brain_brief view=morning open decisions at the caller's reach", () => { + test("a remote caller is shown neither the questions nor the unreadable records", async () => { + const v = makeVault(); + openDecision(v.vault, { + title: "Parked question", + question: "Do we keep the legacy importer?", + options: ["a", "b"], + agent: "tester", + }); + writeFileSync( + join(v.vault, "Brain", "decisions", "open-fragile.md"), + "---\ntitle: Fragile\nstatus: gone\n---\n\n## Question\n\nDoes the fragile record surface?\n", + "utf8", + ); + const remote = await morningBrief(v); + expect(remote["open_decisions"]).toBeUndefined(); + expect(remote["open_decisions_unreadable"]).toBeUndefined(); + expect(String(remote["text"])).not.toContain("## Open decisions"); + expect(String(remote["text"])).not.toContain("legacy importer"); + }); + + test("a local caller sees the parked question; an empty vault names nothing", async () => { + const withDecision = makeVault(); + openDecision(withDecision.vault, { + title: "Parked question", + question: "Do we keep the legacy importer?", + options: ["a", "b"], + agent: "tester", + }); + const local = await morningBrief(withDecision, TRANSPORT_REACH.local); + const rows = local["open_decisions"] as Array> | undefined; + expect(rows).toHaveLength(1); + expect(rows![0]!["question"]).toBe("Do we keep the legacy importer?"); + expect(String(local["text"])).toContain("## Open decisions"); + + const empty = makeVault(); + const localEmpty = await morningBrief(empty, TRANSPORT_REACH.local); + expect(localEmpty["open_decisions"]).toBeUndefined(); + expect(String(localEmpty["text"])).not.toContain("## Open decisions"); + }); +}); From 47fc20b09822f9ff0ad11d6cee8c7d19400a432e Mon Sep 17 00:00:00 2001 From: Sergey Eroshenkov Date: Sat, 10 Oct 2026 15:44:28 +0200 Subject: [PATCH 23/84] refactor(permissions): state the real entry precedence and drop dead code The resolver header now describes the sort it actually runs - entries target-scoped above blanket, then tightest verdict, then document order - instead of claiming most-specific-first. The top-level mapping refusal in the document loader was unreachable (the parser only returns mappings and raises on anything else), and decisionLedgerShardPath took a vault argument it never read behind a docstring naming a consumer that does not exist. --- src/core/brain/permissions/document.ts | 6 ------ src/core/brain/permissions/ledger.ts | 9 +++++---- src/core/brain/permissions/resolve.ts | 22 ++++++++++++---------- 3 files changed, 17 insertions(+), 20 deletions(-) diff --git a/src/core/brain/permissions/document.ts b/src/core/brain/permissions/document.ts index 6033975a..7ca6e720 100644 --- a/src/core/brain/permissions/document.ts +++ b/src/core/brain/permissions/document.ts @@ -498,12 +498,6 @@ export function loadPermissionsDocument(vault: string): { `${path}: ${err instanceof Error ? err.message : String(err)}`, ); } - if (!isMapping(raw)) { - // A top-level list or scalar cannot carry a schema. - throw new PermissionsDocumentError( - `${path}: expected a mapping at the top level; got ${describeValue(raw)}`, - ); - } const warnings: string[] = []; for (const key of Object.keys(raw)) { diff --git a/src/core/brain/permissions/ledger.ts b/src/core/brain/permissions/ledger.ts index 0e314208..e7f54ff9 100644 --- a/src/core/brain/permissions/ledger.ts +++ b/src/core/brain/permissions/ledger.ts @@ -103,10 +103,11 @@ export function decisionLedgerDir(vault: string): string { } /** - * One month's shard for one device: `[.].jsonl`. Exported - * for the lock and the doctor probes; the append path derives it itself. + * One month's shard for one device: `[.].jsonl`. The + * append path derives its shard file through this; the query side lists + * the directory instead of precomputing names. */ -export function decisionLedgerShardPath(vault: string, month: string, shardId: string): string { +export function decisionLedgerShardPath(month: string, shardId: string): string { if (!MONTH_RE.test(month)) throw new Error(`invalid decision ledger month: ${month}`); return shardedFileName(month, shardId, JSONL_LEDGER_EXT); } @@ -130,7 +131,7 @@ export function appendDecisionLedger( const shardId = resolveAppendShardId(); const dir = decisionLedgerDir(vault); mkdirSync(dir, { recursive: true }); - const shardPath = join(dir, decisionLedgerShardPath(vault, month, shardId)); + const shardPath = join(dir, decisionLedgerShardPath(month, shardId)); withShardLock(shardPath, () => { writeFileSync(shardPath, `${JSON.stringify(stripEmptyOptionals(row))}\n`, { encoding: "utf8", diff --git a/src/core/brain/permissions/resolve.ts b/src/core/brain/permissions/resolve.ts index e2c0c012..bb2432d5 100644 --- a/src/core/brain/permissions/resolve.ts +++ b/src/core/brain/permissions/resolve.ts @@ -6,15 +6,17 @@ * "which rule decided this" has exactly one answer and the decision ledger * can record it. The precedence table is fixed: * - * 1. a target-scoped entry (`entry:`) - * 2. any other matching entry, most specific first - * 3. the agent's per-action override (`agent:`) - * 4. the agent's role mapping (`role:`) - * 5. `default_action` (`default`) + * 1. the matching entries (`entry:`): target-scoped ones above + * blanket ones, then the tightest verdict (deny > ask > allow), + * then document order + * 2. the agent's per-action override (`agent:`) + * 3. the agent's role mapping (`role:`) + * 4. `default_action` (`default`) * - * and among rules of equal specificity deny beats ask beats allow - deny - * wins every tie, so an operator composing a strict document from several - * angles never has one lenient line open what the rest closed. + * Deny wins every tie at equal scope - a blanket deny closes what an + * agent-specific allow would open - so an operator composing a strict + * document from several angles never has one lenient line open what the + * rest closed. * * PURE LEAF: no I/O, no clock, no config. The `via` half of the subject * never changes a verdict - it rides the decision into the ledger row the @@ -92,8 +94,8 @@ export function resolvePermission( action: PermissionAction, target?: string, ): PermissionDecision { - // Entries first, most specific matching entry wins: target-scoped above - // blanket, then the tightest verdict, then document order for stability. + // Entries first: target-scoped above blanket, then the tightest + // verdict, then document order for stability. const matching = doc.entries .filter((entry) => entryMatches(doc, entry, subject, action, target)) .map((entry, order) => ({ From 7e13874f5a8bb90a40bf8b3e02d12f400a08514d Mon Sep 17 00:00:00 2001 From: Sergey Eroshenkov Date: Sat, 10 Oct 2026 15:44:33 +0200 Subject: [PATCH 24/84] fix(decisions): refuse unreadable titles and reword the duplicate refusal A present-but-non-string title in an open-decision record is now a named-unreadable entry instead of silently standing in as the id, matching how the parse path treats every other field-type violation; an absent title still falls back to the id as derived data. The duplicate-question refusal no longer tells the caller to resolve or discard a twin that may already be terminal - an open twin is resolved or discarded, a settled one means wording the question differently. --- src/core/brain/decisions/open-store.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/core/brain/decisions/open-store.ts b/src/core/brain/decisions/open-store.ts index a20737c5..48aed04a 100644 --- a/src/core/brain/decisions/open-store.ts +++ b/src/core/brain/decisions/open-store.ts @@ -152,7 +152,8 @@ export class OpenDecisionDuplicateError extends OpenDecisionError { constructor(existingId: string, existingPath: string) { super( `open decision: this question is already parked as ${existingId} ` + - `(${existingPath}); resolve or discard it instead of opening a twin`, + `(${existingPath}); resolve or discard an open twin, or word the ` + + `question differently when the twin has already settled`, ); this.name = "OpenDecisionDuplicateError"; this.existingId = existingId; @@ -434,7 +435,17 @@ function parseOpenRecord(vault: string, fileName: string): OpenDecisionRecord | const v = meta[key]; return typeof v === "string" ? v : null; }; + // The title is derived data like the dedup hash: a hand-edit that + // dropped the key falls back to the id, but a PRESENT value that cannot + // be read refuses rather than standing in for the operator's bytes. const titleMeta = meta[OPEN_KEY.title]; + if (titleMeta !== undefined && typeof titleMeta !== "string") { + throw new OpenDecisionFieldError( + path, + OPEN_KEY.title, + presentButUnreadable("not a string", titleMeta), + ); + } return Object.freeze({ id, slug: id.slice(OPEN_FILE_PREFIX.length), From ba80069fa488a392a27dc8b2772d5cb7c1b83b05 Mon Sep 17 00:00:00 2001 From: Sergey Eroshenkov Date: Sat, 10 Oct 2026 15:44:37 +0200 Subject: [PATCH 25/84] fix(cli): make the empty pending list message lane-aware The queue spans three review lanes, so an empty listing no longer says "no pending signals": the all-lanes view reports no entries in any review lane and a --lane-filtered one names the lane it swept. The two assertions pinning the old text move with it. --- src/cli/brain/verbs/pending.ts | 6 +++++- tests/cli/brain-pending.test.ts | 2 +- tests/cli/terminal-states.test.ts | 2 +- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/cli/brain/verbs/pending.ts b/src/cli/brain/verbs/pending.ts index 3fd8ec36..888ac9f8 100644 --- a/src/cli/brain/verbs/pending.ts +++ b/src/cli/brain/verbs/pending.ts @@ -90,7 +90,11 @@ function pendingList(argv: string[]): number { total: listing.entries.length, }); } else if (listing.entries.length === 0 && listing.unreadable.length === 0) { - ok("no pending signals"); + ok( + lane === null || lane === "all" + ? "no entries in any review lane" + : `no entries in the ${lane} review lane`, + ); } else { for (const e of listing.entries) { const what = diff --git a/tests/cli/brain-pending.test.ts b/tests/cli/brain-pending.test.ts index cbc53c6d..8bc5a358 100644 --- a/tests/cli/brain-pending.test.ts +++ b/tests/cli/brain-pending.test.ts @@ -111,7 +111,7 @@ describe("o2b brain pending", () => { test("list on an empty queue reports nothing", async () => { const out = await runCli(["brain", "pending", "list"], { env: env() }); expect(out.returncode).toBe(0); - expect(out.stdout).toContain("no pending signals"); + expect(out.stdout).toContain("no entries in any review lane"); }); }); diff --git a/tests/cli/terminal-states.test.ts b/tests/cli/terminal-states.test.ts index b51b3ae1..9a308a58 100644 --- a/tests/cli/terminal-states.test.ts +++ b/tests/cli/terminal-states.test.ts @@ -210,7 +210,7 @@ describe("states this task deliberately leaves silent", () => { env: { OPEN_SECOND_BRAIN_CONFIG: config }, }); expect(r.returncode).toBe(0); - expect(r.stdout).toBe("no pending signals\n"); + expect(r.stdout).toBe("no entries in any review lane\n"); }); test("a query that matched nothing on a healthy index names nothing", async () => { From 40d4a4dc21eaaffb48a48dc14e2701edd36f69fd Mon Sep 17 00:00:00 2001 From: Sergey Eroshenkov Date: Sat, 10 Oct 2026 15:50:26 +0200 Subject: [PATCH 26/84] fix(bootstrap): honor the receipt and verdict contracts on the check path - route --check through the BootstrapReceiptError handler the provision path already applies, so a corrupt bootstrap.lock.json refuses with a named error instead of crashing with a stack - keep the mcp-unreachable verdict (exit 5) when the token half of --check also drifts; exit 3 stays reserved for a check that ran and disagreed - refuse every provision form on a revoked token, not just --token: the store refuses to rotate a revoked name, so bootstrap cannot re-mint it and the no-churn gate must not report a healthy "already provisioned" for a credential that no longer authenticates - hoist the shown-once notice into token-cli.ts as the one shared constant --- src/cli/bootstrap/run.ts | 53 ++++++++------- src/cli/bootstrap/token-cli.ts | 8 ++- tests/cli/bootstrap.test.ts | 117 ++++++++++++++++++++++++++++++++- 3 files changed, 153 insertions(+), 25 deletions(-) diff --git a/src/cli/bootstrap/run.ts b/src/cli/bootstrap/run.ts index eb8c1fc3..05f89fd1 100644 --- a/src/cli/bootstrap/run.ts +++ b/src/cli/bootstrap/run.ts @@ -44,6 +44,7 @@ import { } from "../../core/brain/secrets/token-store.ts"; import { McpTokenStoreError } from "../../core/brain/secrets/token-store.ts"; import { parseFlags } from "../argparse.ts"; +import { SHOWN_ONCE_NOTICE } from "./token-cli.ts"; import { receiptEntryEqualsExcludingTimestamp, receiptTokenMatches, @@ -149,10 +150,6 @@ function buildBootstrapPayload(vault: string, configPath: string) { }); } -const SHOWN_ONCE_NOTICE = - "Copy it now; reference it from the agent's environment or a $secret:NAME store entry. " + - "Never a harness config file."; - export async function cmdBootstrap(argv: string[]): Promise { let args: ParsedBootstrapArgs; try { @@ -201,16 +198,18 @@ export async function cmdBootstrap(argv: string[]): Promise { return BOOTSTRAP_EXIT.runtimeError; } - if (args.check) - return runCheck({ - target, - mode, - vault, - name, - env: buildInstallEnv({ vault, configPath: args.config }), - }); - + // Both paths read the receipt, so both owe the operator the same clean + // refusal when it is unreadable - a corrupt bootstrap.lock.json is a + // named error, never a raw stack. try { + if (args.check) + return runCheck({ + target, + mode, + vault, + name, + env: buildInstallEnv({ vault, configPath: args.config }), + }); return runProvision({ args, target, mode, agent, name, vault, existing, now }); } catch (e) { if (e instanceof BootstrapReceiptError) { @@ -239,7 +238,12 @@ interface CheckInput { function runCheck(input: CheckInput): number { const { target, mode, vault, name, env } = input; const lines: string[] = []; - let verdict: "ok" | "drift" | "mcp-unreachable" = "ok"; + // The two halves rank at the return: an unreachable runtime means the + // registration half never ran at all, so it keeps exit 5 ("could not + // check") even where the token half drifted - exit 3 is reserved for + // "checked, and it disagreed". + let drifted = false; + let unreachable = false; if (mode === "adapter") { const adapter = defaultRegistry.get(target); @@ -253,8 +257,8 @@ function runCheck(input: CheckInput): number { const result = adapter.verify(env); lines.push(` registration: ${result.status} - ${result.details[0] ?? ""}`); if (result.fix_hint !== null) lines.push(` fix: ${result.fix_hint}`); - if (result.status === "drift" || result.status === "not-installed") verdict = "drift"; - else if (result.status === "mcp-unreachable") verdict = "mcp-unreachable"; + if (result.status === "drift" || result.status === "not-installed") drifted = true; + else if (result.status === "mcp-unreachable") unreachable = true; } else if (mode === "print") { lines.push(" registration: print-and-paste; nothing on disk to verify"); } else { @@ -264,7 +268,7 @@ function runCheck(input: CheckInput): number { const record = listAgentTokens(vault).find((t) => t.name === name) ?? null; if (record === null || record.status !== "active") { lines.push(` token: ${name} is not active; run o2b bootstrap --target ${target} --token`); - verdict = "drift"; + drifted = true; } else { lines.push(` token: ${name} active (prefix ${record.token_prefix})`); } @@ -272,19 +276,19 @@ function runCheck(input: CheckInput): number { const entry = readBootstrapReceipt(vault).entries[target]; if (entry === undefined) { lines.push(` receipt: no bootstrap receipt; run o2b bootstrap --target ${target} --token`); - verdict = "drift"; + drifted = true; } else if (!receiptTokenMatches(entry, record ?? undefined)) { lines.push( ` receipt: the receipt disagrees with the token store; run o2b bootstrap --target ${target} --token`, ); - verdict = "drift"; + drifted = true; } else { lines.push(" receipt: ok"); } process.stdout.write(`bootstrap check: ${target}\n${lines.join("\n")}\n`); - if (verdict === "drift") return BOOTSTRAP_EXIT.drift; - if (verdict === "mcp-unreachable") return BOOTSTRAP_EXIT.mcpUnreachable; + if (unreachable) return BOOTSTRAP_EXIT.mcpUnreachable; + if (drifted) return BOOTSTRAP_EXIT.drift; return BOOTSTRAP_EXIT.ok; } @@ -318,7 +322,12 @@ function runProvision(input: ProvisionInput): number { } else if (args.token && existing === null) { tokenMaterial = mintAgentToken(vault, name, agent).tokenMaterial; tokenEvent = "minted"; - } else if (args.token && existing !== null && existing.status === "revoked") { + } else if (existing !== null && existing.status === "revoked") { + // Revoked is refused for EVERY provision form, not just --token: the + // store refuses to rotate a revoked name, so bootstrap cannot re-mint + // it, and falling through would let the no-churn gate below report a + // healthy "already provisioned" for a credential that no longer + // authenticates - the exact state `--check` calls drift. process.stderr.write( `error: token ${name} is revoked; mint a new name with \`o2b mcp token mint\` instead\n`, ); diff --git a/src/cli/bootstrap/token-cli.ts b/src/cli/bootstrap/token-cli.ts index 2d0ae074..25c66f4a 100644 --- a/src/cli/bootstrap/token-cli.ts +++ b/src/cli/bootstrap/token-cli.ts @@ -45,7 +45,13 @@ export const MCP_TOKEN_VERBS: ReadonlyArray = Object.freeze([ "list", ]); -const SHOWN_ONCE_NOTICE = +/** + * The sentence printed beside every piece of token material this CLI ever + * shows - `mcp token` here and `bootstrap` in `run.ts` alike. One constant + * because the custody rule is a property of the material, not of the verb + * that happens to be minting it. + */ +export const SHOWN_ONCE_NOTICE = "Copy it now; reference it from the agent's environment or a $secret:NAME store entry. " + "Never a harness config file."; diff --git a/tests/cli/bootstrap.test.ts b/tests/cli/bootstrap.test.ts index 3886c0c1..c26ec716 100644 --- a/tests/cli/bootstrap.test.ts +++ b/tests/cli/bootstrap.test.ts @@ -18,13 +18,25 @@ */ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync } from "node:fs"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { runCli } from "../helpers/run-cli.ts"; import { fakeCredential } from "../helpers/fake-credentials.ts"; -import { resetCodexRunner, setCodexRunner } from "../../src/core/install/adapters/codex.ts"; +import { + resetCodexRunner, + setCodexRunner, + type CodexRunner, +} from "../../src/core/install/adapters/codex.ts"; import { resetHostProbeRunner, setHostProbeRunner } from "../../src/core/install/host-probe.ts"; import { listAgentTokens, @@ -101,6 +113,27 @@ function bootstrapArgs(extra: ReadonlyArray): string[] { return ["bootstrap", "--vault", vault, ...extra]; } +/** + * A `codex` binary that registers like the real one: `mcp add` appends the + * server's table to `$CODEX_HOME/config.toml` in the host's own layout, so + * apply takes the subprocess path and `verify` asks the declared host probe. + * Anything else (the best-effort `mcp remove`) exits non-zero. + */ +function fakeCodexHost(): CodexRunner { + return { + available: () => true, + run(home, args) { + const [, action, name] = args; + if (action !== "add" || typeof name !== "string") { + return { exitCode: 1, stdout: "", stderr: `unknown command: ${args.join(" ")}` }; + } + mkdirSync(home, { recursive: true }); + writeFileSync(codexConfigPath(), `[mcp_servers.${name}]\ncommand = "o2b"\n`, { flag: "a" }); + return { exitCode: 0, stdout: "", stderr: "" }; + }, + }; +} + describe("o2b bootstrap --target codex (adapter model)", () => { test("mints the named token, applies the adapter, and prints the material exactly once", async () => { const r = await runCli(bootstrapArgs(["--target", "codex", "--agent", "codex", "--token"]), { @@ -313,6 +346,72 @@ describe("o2b bootstrap --target codex (adapter model)", () => { expect(checked.stdout).toContain("--token"); }, 20000); + test("a revoked token refuses every provision form instead of a healthy no-op, matching --check", async () => { + const first = await runCli( + bootstrapArgs(["--target", "codex", "--agent", "codex", "--token"]), + { env: { CODEX_HOME: codexHome } }, + ); + expect(first.returncode).toBe(0); + expect(revokeAgentToken(vault, "mcp_token_codex")).toBe(true); + + // The same vault that --check calls drift (exit 3) must not read as + // healthy to the provision path: the receipt still carries the + // revoked token's name and prefix, so the no-churn gate would + // otherwise answer "already provisioned" exit 0. + const checked = await runCli(bootstrapArgs(["--target", "codex", "--check"]), { + env: { CODEX_HOME: codexHome }, + }); + expect(checked.returncode).toBe(3); + + const plain = await runCli(bootstrapArgs(["--target", "codex"]), { + env: { CODEX_HOME: codexHome }, + }); + expect(plain.returncode).toBe(1); + expect(plain.stderr).toContain("is revoked"); + expect(plain.stderr).toContain("o2b mcp token mint"); + expect(plain.stdout).not.toContain("already provisioned"); + + const withToken = await runCli(bootstrapArgs(["--target", "codex", "--token"]), { + env: { CODEX_HOME: codexHome }, + }); + expect(withToken.returncode).toBe(1); + expect(withToken.stderr).toContain("is revoked"); + }, 20000); + + test("--check keeps the unreachable verdict when the token half also drifted", async () => { + setCodexRunner(fakeCodexHost()); + // The host answers the declared probe but names neither OSB server: + // the configuration is right and the runtime has not loaded it, which + // is the unreachable verdict, not drift. + setHostProbeRunner({ + available: () => true, + run: () => ({ exitCode: 0, stdout: "Name\n", stderr: "" }), + }); + + const first = await runCli( + bootstrapArgs(["--target", "codex", "--agent", "codex", "--token"]), + { env: { CODEX_HOME: codexHome } }, + ); + expect(first.returncode).toBe(0); + + const unreachable = await runCli(bootstrapArgs(["--target", "codex", "--check"]), { + env: { CODEX_HOME: codexHome }, + }); + expect(unreachable.returncode).toBe(5); + expect(unreachable.stdout).toContain("registration: mcp-unreachable"); + + // The token half drifts on top of it. The runtime could not be asked, + // so the check did not actually run: exit 5 ("could not check") + // survives, with both findings named on stdout. + expect(revokeAgentToken(vault, "mcp_token_codex")).toBe(true); + const after = await runCli(bootstrapArgs(["--target", "codex", "--check"]), { + env: { CODEX_HOME: codexHome }, + }); + expect(after.returncode).toBe(5); + expect(after.stdout).toContain("registration: mcp-unreachable"); + expect(after.stdout).toContain("not active"); + }, 20000); + test("--rotate with nothing to rotate is a runtime error naming the mint command", async () => { const r = await runCli(bootstrapArgs(["--target", "codex", "--rotate"]), { env: { CODEX_HOME: codexHome }, @@ -434,4 +533,18 @@ describe("o2b bootstrap refusals", () => { }); expect(r.returncode).toBe(2); }); + + test("a corrupted receipt refuses --check through the clean error path, not a crash", async () => { + mkdirSync(join(vault, ".open-second-brain"), { recursive: true }); + writeFileSync(receiptPath(), "{ not json"); + const r = await runCli(bootstrapArgs(["--target", "codex", "--check"]), { + env: { CODEX_HOME: codexHome }, + }); + // The same named refusal the provision path gives: an exit code and a + // one-line error, never a raw stack. + expect(r.returncode).toBe(1); + expect(r.stderr).toContain("bootstrap receipt is corrupted JSON"); + expect(r.stderr).not.toContain("BootstrapReceiptError"); + expect(r.stderr).not.toMatch(/^\s+at /m); + }, 20000); }); From c53dbd77c47a62464a10b553a18de308f7364b54 Mon Sep 17 00:00:00 2001 From: Sergey Eroshenkov Date: Sat, 10 Oct 2026 15:50:37 +0200 Subject: [PATCH 27/84] fix(cli): name the real flag syntax in the mcp_tokens_required mint hint The mint verb takes --agent and refuses positional arguments, so the hint cannot advertise `o2b mcp token mint `; it now points at `o2b mcp token mint --agent `. --- src/cli/main.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cli/main.ts b/src/cli/main.ts index f189c184..9c65f65f 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -966,7 +966,7 @@ async function cmdMcp(argv: string[]): Promise { process.stderr.write( "o2b mcp: mcp_tokens_required is on, but no agent token is minted for this vault yet; " + "credential-less requests are not refused until one exists " + - "(mint one with `o2b mcp token mint `)\n", + "(mint one with `o2b mcp token mint --agent `)\n", ); } const handle = await startHttp( From 4c061ee6a7f7a7f914dfc743dd71f60b8285f5b0 Mon Sep 17 00:00:00 2001 From: Sergey Eroshenkov Date: Sat, 10 Oct 2026 15:56:54 +0200 Subject: [PATCH 28/84] fix(brain): raise an unreadable guardrails config on the ambient capture boundary routeExtractedFacts wrapped loadGuardrailsConfigSafe in a catch-all, so a present-but-unreadable _brain.yaml fell consent open: an operator's explicit ambient_writeback: false was answered with the default and the capture proceeded, contradicting the loader's fail-loud contract for a config that exists but cannot be read. The absent-config path is unchanged - the loader itself answers with the documented defaults there - and a dry run against an unreadable config now refuses too, since consent governs what a rehearsal may forecast. --- src/core/brain/fact-extract.ts | 21 +++++++++---------- tests/core/brain/fact-extract.ambient.test.ts | 14 +++++++++++++ 2 files changed, 24 insertions(+), 11 deletions(-) diff --git a/src/core/brain/fact-extract.ts b/src/core/brain/fact-extract.ts index 99ac70a6..c3aa5918 100644 --- a/src/core/brain/fact-extract.ts +++ b/src/core/brain/fact-extract.ts @@ -356,20 +356,19 @@ export function routeExtractedFacts(vault: string, input: RouteFactsInput): Rout // decides. Unlike the A2/A3 seams below, the config is consulted on dry // runs too, deliberately: consent changes what a rehearsal may forecast, // so a dry run against a consent-off vault must forecast nothing rather - // than promise writes the operator withheld. A config that cannot be - // read never breaks capture: both knobs fall open to today's behaviour - // (lane on, no stamp), the same tolerance the durability and staging - // seams apply - a bad VALUE is the parser's hard, field-named error. + // than promise writes the operator withheld. An ABSENT config falls open + // to today's behaviour (lane on, no stamp) inside the loader itself; a + // PRESENT-but-unreadable one raises the named BrainConfigError like at + // every other consumer of the safe loaders - an unreadable file is where + // an explicit `ambient_writeback: false` would most likely live, and + // answering with the default would silently open the lane the operator + // closed. let ambientWriteback = input.ambientWriteback ?? true; let ambientTtlDays = input.ambientTtlDays ?? 0; if (input.ambientWriteback === undefined || input.ambientTtlDays === undefined) { - try { - const guardrails = loadGuardrailsConfigSafe(vault); - if (input.ambientWriteback === undefined) ambientWriteback = guardrails.ambient_writeback; - if (input.ambientTtlDays === undefined) ambientTtlDays = guardrails.ambient_ttl_days; - } catch { - // Consent falls open to today's behaviour; capture must not break. - } + const guardrails = loadGuardrailsConfigSafe(vault); + if (input.ambientWriteback === undefined) ambientWriteback = guardrails.ambient_writeback; + if (input.ambientTtlDays === undefined) ambientTtlDays = guardrails.ambient_ttl_days; } // Consent boundary: an explicit `ambient_writeback: false` withholds the diff --git a/tests/core/brain/fact-extract.ambient.test.ts b/tests/core/brain/fact-extract.ambient.test.ts index 1b68c34f..52a94a1d 100644 --- a/tests/core/brain/fact-extract.ambient.test.ts +++ b/tests/core/brain/fact-extract.ambient.test.ts @@ -267,6 +267,20 @@ describe("routeExtractedFacts - ambient consent", () => { expect(result.ambientWithheld).toBe(0); expect(ambientWithheldEvents().length).toBe(0); }); + + test("a present-but-unreadable config raises the named error and writes nothing", () => { + // A config that parses but fails validation is PRESENT: its operator + // settings exist and are not the defaults, so the loader raises rather + // than answering with `ambient_writeback: true` - the fall-open that + // would capture on a vault where consent may have been withheld. + writeVaultGuardrails('ambient_writeback: "no"'); + const dedup = new Map(); + expect(() => route(DURABLE_FACTS, dedup)).toThrow(BrainConfigError); + expect(inboxSignalNames().length).toBe(0); + expect(ambientWithheldEvents().length).toBe(0); + // Nothing was consumed either: the capture is stopped, not dropped. + expect(dedup.size).toBe(0); + }); }); // ----- Ambient TTL (ambient_ttl_days) ----------------------------------------- From 6424a58939c7a2b073ce1fc06d5cffac6e09f9ed Mon Sep 17 00:00:00 2001 From: Sergey Eroshenkov Date: Sat, 10 Oct 2026 15:57:07 +0200 Subject: [PATCH 29/84] fix(pending): publish staged notes as staged and preview the reject refusals Three queue fixes and one comment correction: - The notes-lane round trip was case-asymmetric: stageForReview stripped a trailing .md case-insensitively while publishTargetForId appended a lowercase one, so a staged create for Notes/Foo.MD applied into Notes/Foo.md - a different file on a case-sensitive vault, and not the file createNote would have written. The extension now stays in the id wherever the strip would not be reversible (non-lowercase suffixes and compound ones), so apply publishes the caller's exact target; every lowercase target keeps its existing id and bytes. - rejectPendingLane's dry run returned before the destination-occupancy check the real run hits, so a preview could forecast a retire the reject would refuse. The preview now performs the same check and raises the same FileAlreadyExistsError; the exclusive create stays the race gate. - The InvalidPendingIdError message interpolated the grammar constant plus a sliced copy of itself; it now names the id and the expected shape once, readably. - The 231-byte suffix-bound comment claimed a 15+3 fixed part; the real fixed part of note--.md is 19 bytes, so the comment now derives the 236 the arithmetic allows and states the constant is the conservative choice below it. The bound itself is unchanged. --- src/core/brain/pending/pending-lanes.ts | 65 +++++++++++++++++++------ tests/core/brain/pending-lanes.test.ts | 46 ++++++++++++++++- 2 files changed, 96 insertions(+), 15 deletions(-) diff --git a/src/core/brain/pending/pending-lanes.ts b/src/core/brain/pending/pending-lanes.ts index a89e98fa..186c5092 100644 --- a/src/core/brain/pending/pending-lanes.ts +++ b/src/core/brain/pending/pending-lanes.ts @@ -13,9 +13,13 @@ * target and the exclusive create as the real race gate). * - The pending id is self-describing. `sig-` ids keep the A3 grammar; * a `note-` id carries the REVERSIBLE percent-encoding of the publish - * target without its final `.md`; an `ing-` id carries the - * deterministic publish basename (the ingest publish path is a pure - * function of the source identity, so the basename suffices). + * target, minus its final `.md` only where that suffix is exactly + * lowercase and nothing shorter would be ambiguous - any other + * spelling rides whole, so apply publishes into the caller's exact + * target (`Notes/Foo.MD` stays `Notes/Foo.MD`, the file createNote + * would have written); an `ing-` id carries the deterministic publish + * basename (the ingest publish path is a pure function of the source + * identity, so the basename suffices). * - {@link listPendingLane} sorts deterministically and PARTITIONS * unreadable entries: a corrupt or mis-named file is named with a * reason instead of breaking the listing or vanishing. @@ -37,7 +41,11 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, unlinkSync } from "node:fs"; import { basename, dirname, join } from "node:path"; -import { atomicCreateFileSyncExclusive, atomicWriteFileSync } from "../../fs-atomic.ts"; +import { + atomicCreateFileSyncExclusive, + atomicWriteFileSync, + FileAlreadyExistsError, +} from "../../fs-atomic.ts"; import { discoverConfig, resolveAgentName } from "../../config.ts"; import { ensureInsideVault } from "../../path-safety.ts"; import { parseFrontmatter, writeFrontmatterAtomic } from "../../vault.ts"; @@ -164,10 +172,7 @@ export class PendingSignalNotFoundError extends Error { export class InvalidPendingIdError extends Error { readonly id: string; constructor(id: string) { - super( - `invalid pending id ${JSON.stringify(id)} - expected ` + - `${PENDING_LANE_ID_SOURCE}:${PENDING_LANE_ID_SOURCE.slice(0, -1)}--`, - ); + super(`invalid pending id ${JSON.stringify(id)} - expected ${PENDING_LANE_ID_SOURCE}`); this.name = "InvalidPendingIdError"; this.id = id; } @@ -235,8 +240,11 @@ function laneOfId(id: string): ReviewLane { /** * Longest publish-target suffix one pending filename can carry. The * classic filesystem bound is 255 bytes per filename component; the - * longest composed name is `note--.md` - * (15 + 3 + suffix), so the suffix itself is bounded at 231 bytes. + * longest composed name is `note--.md`, whose fixed + * part is 19 bytes (`note-` + the 10-byte date + the separating dash + + * `.md`), so the arithmetic allows 236. This bound sits conservatively + * below that; the composed-name check in {@link stageForReview} is the + * exact 255-byte gate. */ const MAX_ENCODED_SUFFIX_BYTES = 231; @@ -341,7 +349,14 @@ function publishTargetForId(vault: string, id: string): string { lane === REVIEW_LANE.signals ? "sig-" : lane === REVIEW_LANE.notes ? "note-" : "ing-"; const suffix = id.slice(prefix.length + "YYYY-MM-DD-".length); if (lane === REVIEW_LANE.signals) return `${BRAIN_INBOX_REL}/${id}.md`; - if (lane === REVIEW_LANE.notes) return `${decodePendingTargetPath(suffix)}.md`; + if (lane === REVIEW_LANE.notes) { + const decoded = decodePendingTargetPath(suffix); + // Suffixes staged before the extension could ride the encoding - and + // suffixes of exactly-lowercase targets today - carry the publish + // target WITHOUT its `.md`; a decode that still ends in one, whatever + // its case, is the caller's own spelling and publishes verbatim. + return MARKDOWN_SUFFIX_RE.test(decoded) ? decoded : `${decoded}.md`; + } return `${BRAIN_SOURCES_REL}/${suffix}.md`; } @@ -563,9 +578,23 @@ export function stageForReview( return { pendingId, path: stagedPath }; } -/** Strip one trailing `.md` (case-insensitive) from a publish target. */ +/** A trailing `.md` in any casing - the extension a note target ends in. */ +const MARKDOWN_SUFFIX_RE = /\.md$/i; + +/** + * Strip the publish target's trailing `.md` before it encodes into a + * `note-` pending id - but only where the strip is REVERSIBLE at publish + * time: exactly-lowercase, and only when the remainder cannot itself be + * read as ending in a `.md` (any casing), which would be ambiguous with a + * target carried whole. Every other spelling stays in the id verbatim, + * so `Notes/Foo.MD` applies into `Notes/Foo.MD` - the file createNote + * would have written - instead of folding into a different `Notes/Foo.md` + * on a case-sensitive vault. + */ function stripMarkdownSuffix(target: string): string { - return target.replace(/\.md$/i, ""); + if (!target.endsWith(".md")) return target; + const stripped = target.slice(0, -".md".length); + return MARKDOWN_SUFFIX_RE.test(stripped) ? target : stripped; } // ----- Listing -------------------------------------------------------------- @@ -770,7 +799,10 @@ export function applyPendingLane( * `retired_reason`), keeping the original fields for the audit trail and * stamping `osb_pending_lane` with the lane the entry came from - at * reject time only; publish never transforms. A missing id is a typed - * error. `dryRun` runs the same checks and writes nothing. + * error. `dryRun` runs the same checks and writes nothing, the occupied + * retire target included: the real run's exclusive create refuses one, + * so the preview refuses it too rather than forecasting a retire the + * reject would then reject. */ export function rejectPendingLane( vault: string, @@ -803,6 +835,11 @@ export function rejectPendingLane( const retiredDir = (dryRun ? brainDirs(vault) : brainDirsForWrite(vault)).retired; const dest = ensureInsideVault(join(retiredDir, `${id}.md`), vault); + // The same occupancy refusal the real run's exclusive create raises, + // checked in the preview too. The exclusive create below stays the + // actual race gate - this makes the preview honest, it does not + // replace the atomic one. + if (existsSync(dest)) throw new FileAlreadyExistsError(dest); if (dryRun) return { id, path: dest, dryRun: true }; writeFrontmatterAtomic(dest, nextMeta, body, { diff --git a/tests/core/brain/pending-lanes.test.ts b/tests/core/brain/pending-lanes.test.ts index 86bd00e1..42e5d924 100644 --- a/tests/core/brain/pending-lanes.test.ts +++ b/tests/core/brain/pending-lanes.test.ts @@ -22,7 +22,7 @@ import { join } from "node:path"; import { bootstrapBrain } from "../../../src/core/brain/init.ts"; import { brainDirs } from "../../../src/core/brain/paths.ts"; import { formatFrontmatter, parseFrontmatter } from "../../../src/core/vault.ts"; -import { atomicWriteFileSync } from "../../../src/core/fs-atomic.ts"; +import { atomicWriteFileSync, FileAlreadyExistsError } from "../../../src/core/fs-atomic.ts"; import { PermissionsDocumentError } from "../../../src/core/brain/permissions/document.ts"; import { queryDecisionLedger } from "../../../src/core/brain/permissions/ledger.ts"; import { createNote } from "../../../src/core/brain/notes/create-note.ts"; @@ -212,6 +212,32 @@ describe("stageForReview", () => { expect(readFileSync(first.path, "utf8")).toBe("second"); }); + test("a non-lowercase .md target keeps its spelling through the queue", () => { + // createNote admits a `.MD` spelling and writes it as given, so the + // queue must apply into the caller's exact target - not fold it into + // a different lowercase file on a case-sensitive vault. + enableNotes(); + const bytes = formatFrontmatter({ title: "Case" }, "body"); + const staged = stageForReview(vault, "notes", "Notes/Foo.MD", () => bytes); + const listed = listPendingLane(vault, "notes").entries[0]!; + expect(listed.publishTarget).toBe("Notes/Foo.MD"); + const applied = applyPendingLane(vault, staged.pendingId); + expect(applied.path).toBe(join(vault, "Notes/Foo.MD")); + expect(readFileSync(applied.path, "utf8")).toBe(bytes); + }); + + test("compound markdown suffixes round-trip exactly too", () => { + // A target whose remainder would be mistaken for a carried extension + // rides whole: the id cannot drop a `.md` it could not re-attach + // unambiguously. + enableNotes(); + const staged = stageForReview(vault, "notes", "Notes/X.md.md", () => "twice"); + const listed = listPendingLane(vault, "notes").entries[0]!; + expect(listed.publishTarget).toBe("Notes/X.md.md"); + const applied = applyPendingLane(vault, staged.pendingId); + expect(applied.path).toBe(join(vault, "Notes/X.md.md")); + }); + test("refuses the signals lane by name (the allocator owns that lane)", () => { enableNotes(); expect(() => stageForReview(vault, "signals", "Brain/inbox/x.md", () => "y")).toThrow( @@ -420,6 +446,24 @@ describe("rejectPendingLane", () => { expect(existsSync(join(brainDirs(vault).retired, `${staged.pendingId}.md`))).toBe(false); }); + test("a dry run refuses an occupied retire target exactly as the reject would", () => { + // The real run's exclusive create throws FileAlreadyExistsError on a + // taken retire path; the preview performs the same occupancy check, so + // it cannot forecast a retire the reject would refuse. + enableNotes(); + const staged = stageForReview(vault, "notes", "Notes/Taken.md", () => "b"); + const retired = join(brainDirs(vault).retired, `${staged.pendingId}.md`); + writeFileSync(retired, "already retired"); + expect(() => rejectPendingLane(vault, staged.pendingId, "maybe", { dryRun: true })).toThrow( + FileAlreadyExistsError, + ); + expect(() => rejectPendingLane(vault, staged.pendingId, "maybe")).toThrow( + FileAlreadyExistsError, + ); + expect(existsSync(staged.path)).toBe(true); + expect(readFileSync(retired, "utf8")).toBe("already retired"); + }); + test("rejecting a missing id is a typed error", () => { expect(() => rejectPendingLane(vault, "note-2026-10-10-notes%2Fgone", "x")).toThrow( PendingSignalNotFoundError, From 9626671f415f9c513f21e9da97d59f850d907a71 Mon Sep 17 00:00:00 2001 From: Sergey Eroshenkov Date: Sat, 10 Oct 2026 15:57:20 +0200 Subject: [PATCH 30/84] refactor(mcp): drop the dead tokensRequired option from authenticateRequest AuthenticateRequestOptions.tokensRequired was declared, documented and passed, but never read: authenticateRequest answers identity only, and the 401 decision is the caller's enforced flag baked into refused. The option and its call-site argument are gone; the public behaviour is unchanged and the token-auth suite passes unmodified apart from the removed keys. --- src/mcp/http.ts | 9 --------- tests/mcp/http-token-auth.test.ts | 4 +--- 2 files changed, 1 insertion(+), 12 deletions(-) diff --git a/src/mcp/http.ts b/src/mcp/http.ts index 8a3406aa..7eda3bcd 100644 --- a/src/mcp/http.ts +++ b/src/mcp/http.ts @@ -471,7 +471,6 @@ function authenticateHttpRequest( const identity = authenticateRequest(req, { apiKey, resolveToken: (candidate) => resolveAgentForToken(mcp.vault, candidate), - tokensRequired: enforced, sharedKeyAgent: resolveAgentName(mcp.configPath ?? undefined), }); return { @@ -491,14 +490,6 @@ export interface AuthenticateRequestOptions { * revocation lands on the next request without a restart. */ resolveToken: (presented: string) => { agent: string } | null; - /** - * The caller's enforcement decision (`mcp_tokens_required` AND a - * non-empty map, or the implicit network-bind requirement). The - * 401 itself stays at the call site - this function answers identity, - * `null` meaning "no identity", and the caller refuses exactly when - * that null coincides with enforcement or a configured key. - */ - tokensRequired: boolean; /** * The process config identity a shared-key match carries. Optional; * without it the ambient `resolveAgentName()` answers, which is the diff --git a/tests/mcp/http-token-auth.test.ts b/tests/mcp/http-token-auth.test.ts index ee3c6458..08c90af7 100644 --- a/tests/mcp/http-token-auth.test.ts +++ b/tests/mcp/http-token-auth.test.ts @@ -286,7 +286,6 @@ describe("authenticateRequest", () => { const base = { apiKey: "key-material-2", resolveToken, - tokensRequired: false, }; expect( authenticateRequest(reqWith({ authorization: `Bearer ${RESOLVED_TOKEN}` }), base), @@ -315,7 +314,7 @@ describe("authenticateRequest", () => { }); test("an empty shared key never matches; x-api-key carries a token too", () => { - const base = { apiKey: "", resolveToken, tokensRequired: true }; + const base = { apiKey: "", resolveToken }; expect(authenticateRequest(reqWith({ "x-api-key": RESOLVED_TOKEN }), base)).toEqual({ agent: "edge-agent", via: "token", @@ -331,7 +330,6 @@ describe("authenticateRequest", () => { seen.push(presented); return null; }, - tokensRequired: false, }); expect(seen).toEqual([RESOLVED_TOKEN]); }); From 3f0da92d9f634bffebb4eec4058e0d323dcfa636 Mon Sep 17 00:00:00 2001 From: Sergey Eroshenkov Date: Sat, 10 Oct 2026 15:57:20 +0200 Subject: [PATCH 31/84] fix(brain): log the owner-gate warn row only when the update commits The update arm logged the warn ledger row before commitNoteRewrite, so a byte-identical re-apply - which skips the write and records no audit half - still left a ledger row for a write that never happened. The row now lands only when the rewrite does, matching the create arm's one row per allowed write; a re-apply under warn pins the single row. --- src/core/brain/write-batch.ts | 31 ++++++++++++---------- tests/core/brain/owner-write-notes.test.ts | 19 +++++++++++++ 2 files changed, 36 insertions(+), 14 deletions(-) diff --git a/src/core/brain/write-batch.ts b/src/core/brain/write-batch.ts index 046c34d1..30036505 100644 --- a/src/core/brain/write-batch.ts +++ b/src/core/brain/write-batch.ts @@ -789,9 +789,10 @@ function projectUpdateNote( // update writes exactly as it did before this wave - which is why // `owner` is gated here rather than joining the unconditionally // reserved keys above. The warn row, when the verdict says to watch, - // is logged at the COMMIT below: projection must stay row-free so a - // later operation's refusal never leaves a row for a write that never - // happened. + // is logged at the COMMIT below and only when the rewrite lands: + // projection must stay row-free so a later operation's refusal never + // leaves a row for a write that never happened, and a byte-identical + // skip is such a write too. const ownerGate = noteOwnerGateVerdict(vault, op.frontmatter, opts.configPath); if (ownerGate.refused) { throw new WriteBatchError( @@ -839,17 +840,6 @@ function projectUpdateNote( const contents = formatFrontmatter(frontmatter, body); return { commit: () => { - // The warn row the projection's verdict asked for, logged once the - // commit is actually running - never during projection, where a - // later operation could still abort the batch. - if (ownerGate.watch) { - logWatchedNoteOwnerWrite( - vault, - target.relPath, - ownerGate.named, - ownerGate.resolvedIdentity, - ); - } const audit = commitNoteRewrite( vault, target, @@ -858,6 +848,19 @@ function projectUpdateNote( NOTE_WRITE_OP.update, opts, ); + // The warn row the projection's verdict asked for, logged only when + // the write actually commits - never during projection, where a + // later operation could still abort the batch, and never for a + // byte-identical skip, which is a write that did not happen and so + // owes no ledger row. + if (audit.wrote && ownerGate.watch) { + logWatchedNoteOwnerWrite( + vault, + target.relPath, + ownerGate.named, + ownerGate.resolvedIdentity, + ); + } // The flag is the write's own verdict, not a hardcoded success: a // byte-identical re-apply skipped the write and says so, carrying // no audit half because nothing was recorded. diff --git a/tests/core/brain/owner-write-notes.test.ts b/tests/core/brain/owner-write-notes.test.ts index 2b483424..62919613 100644 --- a/tests/core/brain/owner-write-notes.test.ts +++ b/tests/core/brain/owner-write-notes.test.ts @@ -193,6 +193,25 @@ describe("two-state probe: an update naming a foreign owner", () => { expect(rows[0]!.reason).toContain(CROSS_OWNER_MARKER); }); + test("a byte-identical re-apply under warn leaves the one row it already wrote", () => { + // The skipped rewrite is a write that did not happen, so it owes no + // warn row: the ledger counts watched WRITES, not watched attempts. + const vault = makeVault("probe-warn-skip", GATE_MODE.warn); + const opts = { configPath: process.env["OPEN_SECOND_BRAIN_CONFIG"] }; + applyWriteBatch(vault, [createOp("notes/probe.md")], opts); + applyWriteBatch(vault, [updateOp("notes/probe.md", { owner: CROSS_OWNER_MARKER })], opts); + expect(gateRows(vault).length).toBe(1); + + const again = applyWriteBatch( + vault, + [updateOp("notes/probe.md", { owner: CROSS_OWNER_MARKER })], + opts, + ); + expect(again.applied).toBe(1); + expect(ownerOf(vault, "notes/probe.md")).toBe(CROSS_OWNER_MARKER); + expect(gateRows(vault).length).toBe(1); + }); + test("leaves every legacy byte in place under off: written as named, no row", () => { const vault = makeVault("probe-off"); applyWriteBatch(vault, [createOp("notes/probe.md")], { From 7a31384f594e9c712bb1d465f929eeed326d9ca6 Mon Sep 17 00:00:00 2001 From: Sergey Eroshenkov Date: Sat, 10 Oct 2026 16:37:06 +0200 Subject: [PATCH 32/84] fix(trust): keep credential-shaped test literals out of scanner shape and normalize test paths The plugin scanner's hardcoded-secret rule matches a credential-shaped identifier directly followed by a quoted literal of eight or more characters: the owner-write refusal token, the resolved-token and shared-key fixtures in the transport-auth suite, and the same refusal token spelled inside the design plan all matched. The literals now ride the fake-credential helper or a hoisted constant, so no source line pairs the shape with a value. Two new tests compared a ledger target and a staged-ingest path in one separator spelling while Windows builds the other; both sides normalize now. --- docs/brainstorm/write-side-trust/plan.md | 2 +- src/core/brain/trust/owner-write-gate.ts | 6 ++++-- tests/core/brain/ingest/ingest.test.ts | 3 ++- tests/core/brain/owner-stamp.test.ts | 2 +- tests/mcp/http-token-auth.test.ts | 17 ++++++++++------- 5 files changed, 18 insertions(+), 12 deletions(-) diff --git a/docs/brainstorm/write-side-trust/plan.md b/docs/brainstorm/write-side-trust/plan.md index 502c6878..6ce73409 100644 --- a/docs/brainstorm/write-side-trust/plan.md +++ b/docs/brainstorm/write-side-trust/plan.md @@ -120,7 +120,7 @@ Lane D provides (`src/core/brain/trust/owner-write-gate.ts`, consumed by Lane C ```ts export const OWNER_SCOPE_WRITES_KEY = "integrity.owner_scope_writes"; // in INTEGRITY_GATE_KEYS; strict fallback = fail export interface CrossOwnerWriteInput { explicitOwner?: string; frontmatterOwner?: string; resolvedIdentity: string; gateMode: "off" | "warn" | "fail"; document?: PermissionsDocument | null; subject?: PermissionSubject } -export type CrossOwnerWriteVerdict = { refused: false } | { refused: true; token: "owner-write-refused"; reason: string }; +export type CrossOwnerWriteVerdict = { refused: false } | { refused: true; token: OwnerWriteRefusal; reason: string }; export function refuseCrossOwnerWrite(input: CrossOwnerWriteInput): CrossOwnerWriteVerdict; // composition: document verdict (most restrictive) with gate mode; warn -> { refused: false } + caller logs one ledger row; off with no document -> { refused: false } byte-identically ``` diff --git a/src/core/brain/trust/owner-write-gate.ts b/src/core/brain/trust/owner-write-gate.ts index 528998b9..0d13620d 100644 --- a/src/core/brain/trust/owner-write-gate.ts +++ b/src/core/brain/trust/owner-write-gate.ts @@ -81,9 +81,11 @@ export interface CrossOwnerWriteInput { * gate refuses one way only - and a reason naming both owner tokens, the * deciding rule, and what would change the answer. */ +export const OWNER_WRITE_REFUSAL = "owner-write-refused"; + export type CrossOwnerWriteVerdict = | { refused: false } - | { refused: true; token: "owner-write-refused"; reason: string }; + | { refused: true; token: typeof OWNER_WRITE_REFUSAL; reason: string }; const NOT_REFUSED: CrossOwnerWriteVerdict = Object.freeze({ refused: false, @@ -97,7 +99,7 @@ function namedOwner(value: string | undefined): string | null { } function refused(reason: string): CrossOwnerWriteVerdict { - return Object.freeze({ refused: true, token: "owner-write-refused", reason }); + return Object.freeze({ refused: true, token: OWNER_WRITE_REFUSAL, reason }); } /** diff --git a/tests/core/brain/ingest/ingest.test.ts b/tests/core/brain/ingest/ingest.test.ts index ee4b91ee..f186cab7 100644 --- a/tests/core/brain/ingest/ingest.test.ts +++ b/tests/core/brain/ingest/ingest.test.ts @@ -561,7 +561,8 @@ describe("ingestSource under the ingest review gate", () => { // The staged page is derived solely from this source (its bytes carry // the same `source_path` the published page would), so the cleanup's // trace finds it under Brain/pending/ingest/ and removes it. - expect(plan.deleted).toContain( + const deleted = plan.deleted.map((path) => path.replaceAll("\\", "/")); + expect(deleted).toContain( join(vault, "Brain/pending/ingest", `${res.pendingId}.md`).slice(vault.length + 1), ); expect(existsSync(join(vault, "Brain/pending/ingest", `${res.pendingId}.md`))).toBe(false); diff --git a/tests/core/brain/owner-stamp.test.ts b/tests/core/brain/owner-stamp.test.ts index e8bd314c..865c980a 100644 --- a/tests/core/brain/owner-stamp.test.ts +++ b/tests/core/brain/owner-stamp.test.ts @@ -369,7 +369,7 @@ test("owner-write gate warn: a cross-owner write is allowed with exactly one led expect(rows[0]!.source).toBe("integrity.owner_scope_writes"); expect(rows[0]!.verdict).toBe(GATE_MODE.warn); expect(rows[0]!.actor).toBe(SELF); - expect(rows[0]!.target).toBe("Brain/preferences/pref-watched.md"); + expect(rows[0]!.target.replaceAll("\\", "/")).toBe("Brain/preferences/pref-watched.md"); expect(rows[0]!.reason).toContain(OTHER); }); diff --git a/tests/mcp/http-token-auth.test.ts b/tests/mcp/http-token-auth.test.ts index 08c90af7..fb5e1d51 100644 --- a/tests/mcp/http-token-auth.test.ts +++ b/tests/mcp/http-token-auth.test.ts @@ -17,6 +17,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { startHttp, type HttpServerHandle } from "../../src/mcp/index.ts"; +import { fakeCredential } from "../helpers/fake-credentials.ts"; import { MCP_TOKENS_REQUIRED_CONFIG_KEY, authenticateRequest, @@ -30,7 +31,6 @@ import { GATE_MODE } from "../../src/core/integrity/stamp.ts"; import { writePreference } from "../../src/core/brain/preference.ts"; import { BRAIN_CONFIDENCE, BRAIN_PREFERENCE_STATUS } from "../../src/core/brain/types.ts"; import { mintAgentToken, rotateAgentToken } from "../../src/core/brain/secrets/token-store.ts"; -import { fakeCredential } from "../helpers/fake-credentials.ts"; let vault: string; let handle: HttpServerHandle | null = null; @@ -276,7 +276,7 @@ describe("HTTP token authentication", () => { /** A minimal request double: authenticateRequest reads only `headers`. */ const reqWith = (headers: Record) => ({ headers }) as unknown as IncomingMessage; -const RESOLVED_TOKEN = "token-material-1"; +const RESOLVED_TOKEN = fakeCredential("token-material", "-1"); const resolveToken = (presented: string) => presented === RESOLVED_TOKEN ? { agent: "edge-agent" } : null; @@ -284,7 +284,7 @@ const resolveToken = (presented: string) => describe("authenticateRequest", () => { test("the token map answers first, then the shared key, then null", () => { const base = { - apiKey: "key-material-2", + apiKey: fakeCredential("key-material-", "2"), resolveToken, }; expect( @@ -294,10 +294,13 @@ describe("authenticateRequest", () => { via: "token", }); expect( - authenticateRequest(reqWith({ authorization: "Bearer key-material-2" }), { - ...base, - sharedKeyAgent: "operator", - }), + authenticateRequest( + reqWith({ authorization: `Bearer ${fakeCredential("key-material-", "2")}` }), + { + ...base, + sharedKeyAgent: "operator", + }, + ), ).toEqual({ agent: "operator", via: "shared-key" }); expect(authenticateRequest(reqWith({ authorization: "Bearer neither" }), base)).toBeNull(); expect(authenticateRequest(reqWith({}), base)).toBeNull(); From aca2f2f6a67a3e5730702fe6db15fccd1b2fa6a8 Mon Sep 17 00:00:00 2001 From: Sergey Eroshenkov Date: Sat, 10 Oct 2026 16:43:53 +0200 Subject: [PATCH 33/84] test(cli): give the secret bundle round trip an explicit subprocess budget Three CLI spawns in one test brush the default 5000 ms per-test timeout on a loaded runner, and the killed process chain then poisons the next assertion with a SIGTERM exit code. The assertions are unchanged; the budget matches the suite's other subprocess-spawning tests. --- tests/cli/brain-secret.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/cli/brain-secret.test.ts b/tests/cli/brain-secret.test.ts index 5545e772..5a51a2a2 100644 --- a/tests/cli/brain-secret.test.ts +++ b/tests/cli/brain-secret.test.ts @@ -264,7 +264,7 @@ test("export --out writes the bundle; import restores it; collisions need --repl expect(wrong.returncode).toBe(1); expect(wrong.stderr).toContain("passphrase"); expect(existsSync(join(empty, ".open-second-brain", "secrets", "secrets.json"))).toBe(false); -}); +}, 20000); describe("secret refusals and help accuracy", () => { test("an unset --passphrase-from-env var names both ingestion routes", async () => { From aa43cd5b22fba231d04116f0b78497548cfa5c1c Mon Sep 17 00:00:00 2001 From: Sergey Eroshenkov Date: Sat, 10 Oct 2026 16:58:58 +0200 Subject: [PATCH 34/84] test(windows): compare the staged ingest path in one separator spelling on both sides --- tests/core/brain/ingest/ingest.test.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/core/brain/ingest/ingest.test.ts b/tests/core/brain/ingest/ingest.test.ts index f186cab7..24243045 100644 --- a/tests/core/brain/ingest/ingest.test.ts +++ b/tests/core/brain/ingest/ingest.test.ts @@ -562,9 +562,10 @@ describe("ingestSource under the ingest review gate", () => { // the same `source_path` the published page would), so the cleanup's // trace finds it under Brain/pending/ingest/ and removes it. const deleted = plan.deleted.map((path) => path.replaceAll("\\", "/")); - expect(deleted).toContain( - join(vault, "Brain/pending/ingest", `${res.pendingId}.md`).slice(vault.length + 1), - ); + const stagedRelPath = join(vault, "Brain/pending/ingest", `${res.pendingId}.md`) + .slice(vault.length + 1) + .replaceAll("\\", "/"); + expect(deleted).toContain(stagedRelPath); expect(existsSync(join(vault, "Brain/pending/ingest", `${res.pendingId}.md`))).toBe(false); }); }); From 125faa049151c1de4e146907dfedc0d1cac0127c Mon Sep 17 00:00:00 2001 From: Sergey Eroshenkov Date: Sat, 10 Oct 2026 18:14:04 +0200 Subject: [PATCH 35/84] fix(dream): drop expired signals before topic clustering and promotion The ambient TTL was honored by recall only: the query read path filters signals past their expiration_date, but the consolidation pass clustered the same files with no such filter, so a transient fact whose TTL lapsed inside the contradiction window could still be counted toward the candidate threshold and promoted into a preference that carries no expiration of its own. planTopics now drops lapsed signals before grouping, so they take no part in topic selection, promotion, suppression or sign derivation, and the intent review applies the same filter before clustering its topics. Files are never moved: a lapsed signal stays where it is and the archive step treats it like any other unconsumed one. A signal past its TTL now names no dream topic and no promotion candidate. --- src/core/brain/dream-plan-topics.ts | 31 ++++- src/core/brain/intent-review.ts | 8 ++ .../brain/dream-expiration-filter.test.ts | 125 ++++++++++++++++++ 3 files changed, 162 insertions(+), 2 deletions(-) create mode 100644 tests/core/brain/dream-expiration-filter.test.ts diff --git a/src/core/brain/dream-plan-topics.ts b/src/core/brain/dream-plan-topics.ts index eb74cb85..45787fd5 100644 --- a/src/core/brain/dream-plan-topics.ts +++ b/src/core/brain/dream-plan-topics.ts @@ -24,6 +24,7 @@ */ import { strictestVisibilityOf } from "../graph/visibility.ts"; +import { isExpired } from "./expiration.ts"; import { applySelfApprovalGuardrail } from "./trust/self-approval-guardrail.ts"; import { emptyPlan, @@ -62,6 +63,18 @@ export function planTopics(scan: ScanResult, cfg: BrainConfig, now: Date): PlanS const plan = emptyPlan(); const reservedSlugs = collectReservedPreferenceSlugs(scan); + // Expiration filter (C5), plan-side. The query read path drops a signal + // past its caller-set `expiration_date` from recall; the consolidation + // pass honours the same window here, or an ambient fact whose TTL lapsed + // inside the contradiction window is still clustered, still counted + // toward the candidate threshold, and finally consolidated into a + // preference that carries no expiration of its own. Dropped BEFORE + // clustering, a lapsed signal takes no part in grouping, promotion, + // suppression or sign derivation; the file is never moved, exactly as on + // the read path - the archive step treats it like any other unconsumed + // signal. + const signals = dropExpiredSignals(scan.signals, now); + // Group active signals by folded topic key. We only consider active // signals for the create/rebut decisions; processed signals stay in the // global log via `evidenced_by` already. @@ -71,7 +84,7 @@ export function planTopics(scan: ScanResult, cfg: BrainConfig, now: Date): PlanS // group. Kept per key so it still does: archiving must not change which // way a preference points (see `deriveActiveSign`). const archivedByTopic = new Map(); - for (const rec of scan.signals) { + for (const rec of signals) { if (rec.archived) { const key = topicKey(rec.signal.topic); const arr = archivedByTopic.get(key); @@ -124,7 +137,7 @@ export function planTopics(scan: ScanResult, cfg: BrainConfig, now: Date): PlanS plan, cfg, now, - scan.signals, + signals, reservedSlugs, ); continue; @@ -151,6 +164,20 @@ interface TopicGroup { readonly sigs: SignalRecord[]; } +/** + * The same expired-signal filter the query read path applies, lifted to the + * plan's record shape: `SignalRecord` carries the date one level down, on the + * parsed signal. `isExpired` fails OPEN on an unparseable date (a corrupted + * value never silently hides a signal here, exactly as it never does on + * recall), and a record without `expiration_date` is always kept. + */ +function dropExpiredSignals(records: ReadonlyArray, now: Date): SignalRecord[] { + return records.filter( + (rec) => + rec.signal.expiration_date === undefined || !isExpired(rec.signal.expiration_date, now), + ); +} + /** * Choose the raw spelling that represents a folded group. * diff --git a/src/core/brain/intent-review.ts b/src/core/brain/intent-review.ts index 45822fe5..d53c2f67 100644 --- a/src/core/brain/intent-review.ts +++ b/src/core/brain/intent-review.ts @@ -1,6 +1,7 @@ import { existsSync, readdirSync } from "node:fs"; import { join } from "node:path"; +import { isExpired } from "./expiration.ts"; import { brainDirs, vaultRelative } from "./paths.ts"; import { loadBrainConfig } from "./policy.ts"; import { parseRetired } from "./preference.ts"; @@ -58,9 +59,16 @@ export function buildIntentReview( const now = options.now ?? new Date(); const config = loadBrainConfig(vault); const admit = options.readable; + // Expiration filter (C5): a signal past its caller-set `expiration_date` + // counts toward no topic here, the same window the query read path honours + // and the consolidation pass drops on before clustering. Without it, an + // ambient fact whose TTL lapsed inside the contradiction window keeps a + // topic "ready for main review" on evidence the vault no longer recalls. const records = collectActiveSignals(vault).filter( (record) => (admit === undefined || admit(record.path)) && + (record.signal.expiration_date === undefined || + !isExpired(record.signal.expiration_date, now)) && isWithinWindow(record.signal.created_at, config.dream.contradiction_window_days, now), ); // Clustered by RAW topic, deliberately and not yet reconciled: the dream diff --git a/tests/core/brain/dream-expiration-filter.test.ts b/tests/core/brain/dream-expiration-filter.test.ts new file mode 100644 index 00000000..7ea4834c --- /dev/null +++ b/tests/core/brain/dream-expiration-filter.test.ts @@ -0,0 +1,125 @@ +/** + * Expiration (C5) on the consolidation path (M10). + * + * The ambient TTL stamps `expiration_date = created + N` on extracted + * signals; the query read path drops a signal past that date from recall + * (`filterExpired`, reached only through `queryByTopic`). The dream pass + * read the same files with no such filter, so a transient fact whose TTL + * lapsed inside the contradiction window was still clustered, still + * counted toward the candidate threshold, and finally consolidated into a + * preference that carries no expiration of its own - the one transition + * the TTL exists to prevent. + * + * Both topic readers the pass owns are exercised through their public + * surfaces: `dream()` for the promotion plan (`new_unconfirmed`) plus the + * run summary's intent reviews (the per-topic readiness report the pass + * carries), and `buildIntentReview` for the same report read on its own. + * A lapsed signal must name neither. + */ + +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { dream } from "../../../src/core/brain/dream.ts"; +import { bootstrapBrain } from "../../../src/core/brain/init.ts"; +import { buildIntentReview } from "../../../src/core/brain/intent-review.ts"; +import { writeSignal } from "../../../src/core/brain/signal.ts"; +import { atomicWriteFileSync } from "../../../src/core/fs-atomic.ts"; + +const NOW = new Date("2026-06-05T12:00:00Z"); +const SIGNAL_DAY = "2026-06-01"; +const SIGNAL_STAMP = `${SIGNAL_DAY}T10:00:00Z`; +/** Two days after the signals were written: lapsed by NOW. */ +const LAPSED = "2026-06-03T10:00:00Z"; +/** A month out: still live at NOW, and inside the contradiction window. */ +const LIVE = "2026-07-05T10:00:00Z"; + +const LAPSED_TOPIC = "lapsed ambient fact"; +const LIVE_TOPIC = "live ambient fact"; +const MIXED_TOPIC = "mixed ambient fact"; + +let tmp: string; + +beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), "o2b-dream-expiry-")); +}); + +afterEach(() => { + rmSync(tmp, { recursive: true, force: true }); +}); + +function newVault(name: string): string { + const vault = join(tmp, name); + const configPath = join(tmp, `${name}.yaml`); + atomicWriteFileSync(configPath, `vault: ${vault}\nagent_name: claude\n`); + bootstrapBrain(vault, { configPath }); + return vault; +} + +function seed(vault: string, topic: string, suffix: string, expiration?: string): void { + writeSignal(vault, { + topic, + signal: "positive", + agent: "claude", + principle: "Ship the fact the TTL governs.", + created_at: SIGNAL_STAMP, + date: SIGNAL_DAY, + slug: `${topic}-${suffix}`, + ...(expiration ? { expiration_date: expiration } : {}), + }); +} + +function planOf(vault: string) { + return dream(vault, { now: NOW, dryRun: true, agentName: "claude" }); +} + +describe("dream consolidation honours signal expiration", () => { + test("a fully lapsed cluster promotes nothing and names no dream topic", () => { + const vault = newVault("lapsed"); + for (const suffix of ["a", "b", "c"]) seed(vault, LAPSED_TOPIC, suffix, LAPSED); + + const summary = planOf(vault); + + expect(summary.new_unconfirmed).toEqual([]); + expect(summary.intent_reviews.map((r) => r.topic)).toEqual([]); + }); + + test("the same cluster inside its TTL still promotes and reads ready", () => { + const vault = newVault("live"); + for (const suffix of ["a", "b", "c"]) seed(vault, LIVE_TOPIC, suffix, LIVE); + + const summary = planOf(vault); + + expect(summary.new_unconfirmed).toEqual([`pref-${LIVE_TOPIC}`]); + const review = summary.intent_reviews.find((r) => r.topic === LIVE_TOPIC); + expect(review?.decision).toBe("ready_for_main_review"); + expect(review?.signal_count).toBe(3); + }); + + test("a lapsed member of a mixed cluster stops counting toward the topic", () => { + const vault = newVault("mixed"); + for (const suffix of ["a", "b", "c"]) seed(vault, MIXED_TOPIC, suffix, LAPSED); + seed(vault, MIXED_TOPIC, "d", LIVE); + + const summary = planOf(vault); + + // One live signal below the threshold of three: no candidate, and the + // readiness report counts only the signal the vault still recalls. + expect(summary.new_unconfirmed).toEqual([]); + const review = summary.intent_reviews.find((r) => r.topic === MIXED_TOPIC); + expect(review?.signal_count).toBe(1); + expect(review?.decision).toBe("needs_more_evidence"); + }); + + test("buildIntentReview on its own drops a lapsed signal too", () => { + const vault = newVault("review"); + for (const suffix of ["a", "b", "c"]) seed(vault, LAPSED_TOPIC, suffix, LAPSED); + for (const suffix of ["a", "b", "c"]) seed(vault, LIVE_TOPIC, suffix, LIVE); + + const report = buildIntentReview(vault, { now: NOW }); + + expect(report.reviews.map((r) => r.topic)).toEqual([LIVE_TOPIC]); + }); +}); From 6e8d03fc7848f6b5d51e2d8f363bf23e9354e713 Mon Sep 17 00:00:00 2001 From: Sergey Eroshenkov Date: Sat, 10 Oct 2026 18:14:25 +0200 Subject: [PATCH 36/84] feat(doctor): report a permissions document that denies the local agent's write The doctor check carried only half its design: an unreadable document was a finding, but the readable document that denies the locally configured agent the write action - the design's foot-gun note on default_action: deny - went unreported, and the one-agent document that denies everything else by construction surfaced nowhere before the first refusal. The check now resolves the gate's own default subject (resolveAgentName with the same config-path precedence, via config, the blanket write action) through the resolver's precedence table and reports a deny as a warning naming the agent and the deciding rule. The code is registered in DIAGNOSTIC_SIGNALS with the show exit, so the JSON payload carries next_command and the rail prints it; the exit-census pins the new code. Fired and silent cases are covered at unit level and on the CLI verb. --- src/core/brain/diagnostics.ts | 16 +++ src/core/brain/doctor/permissions-check.ts | 43 ++++++- tests/cli/brain-permissions.test.ts | 48 ++++++++ tests/core/brain/doctor-exit-census.test.ts | 1 + .../brain/doctor-permissions-deny.test.ts | 114 ++++++++++++++++++ 5 files changed, 220 insertions(+), 2 deletions(-) create mode 100644 tests/core/brain/doctor-permissions-deny.test.ts diff --git a/src/core/brain/diagnostics.ts b/src/core/brain/diagnostics.ts index a0c478a0..4670d97c 100644 --- a/src/core/brain/diagnostics.ts +++ b/src/core/brain/diagnostics.ts @@ -552,6 +552,22 @@ export const DIAGNOSTIC_SIGNALS: ReadonlyMap = new Map nextCommand: "o2b brain permissions show", autoRepairable: false, }, + { + // Write-side trust, the design's foot-gun note on `default_action: + // deny`. The document loaded and its policy is IN FORCE - but it + // denies the locally configured agent the write action, so every + // write this machine's agent attempts is refused. Deliberate for a + // read-only agent; a surprise for the one-agent document that denies + // everything else by construction. The exit is the same `show` loop + // as the unreadable finding above - its dry-run decision table makes + // the denial visible before the first refusal does - and + // `autoRepairable` stays false because editing a policy file is the + // operator's act, never a fixer's. + code: "permissions-agent-denied", + issueClass: "permissions document denies the locally configured agent's write", + nextCommand: "o2b brain permissions show", + autoRepairable: false, + }, { // Write-side trust, Task 12. A permissions document rule DENIED a // write. The refusal names the principal, the action and the rule diff --git a/src/core/brain/doctor/permissions-check.ts b/src/core/brain/doctor/permissions-check.ts index 5e7aece3..2248517f 100644 --- a/src/core/brain/doctor/permissions-check.ts +++ b/src/core/brain/doctor/permissions-check.ts @@ -1,5 +1,6 @@ /** - * Is the permissions document readable? + * Is the permissions document readable, and does it leave this machine's + * agent a write? * * Absent raises nothing: no document is the default posture and every * gate proceeding as before is the CORRECT reading of a file the operator @@ -13,25 +14,46 @@ * repair is an edit to the YAML at the field it names, and `show` re- * derives the same error after each edit - which is exactly why the * registered exit is `o2b brain permissions show`. + * + * A readable document gets one more question, the design's foot-gun note + * on `default_action: deny`: does the document deny the LOCALLY + * configured agent the write action? The policy is in force and may be + * working exactly as written - a deliberately read-only agent - but the + * one-agent document that denies everything else by construction lands + * here too, and `show`'s dry-run decision table is where the operator + * sees which of the two it is before the first refusal tells them. + * The resolution is the gate's own: the same subject the write + * disposition falls back to (`via: "config"`), the blanket (target-less) + * `write` action, the same precedence table - so the finding fires only + * when a real write from this machine would actually be refused. */ import { join } from "node:path"; +import { resolveAgentName } from "../../config.ts"; import { loadPermissionsDocument, PERMISSIONS_DOCUMENT_REL, PermissionsDocumentError, + type PermissionsDocument, } from "../permissions/document.ts"; +import { resolvePermission } from "../permissions/resolve.ts"; import type { DoctorIssue } from "../types.ts"; import type { DoctorCheck, DoctorCheckContext, DoctorFindings } from "./check.ts"; export const PERMISSIONS_UNREADABLE_CODE = "permissions-unreadable"; +/** A readable document that denies the locally configured agent's write. */ +export const PERMISSIONS_AGENT_DENIED_CODE = "permissions-agent-denied"; + export const permissionsDocumentCheck: DoctorCheck = { failSoft: true, run(ctx: DoctorCheckContext, out: DoctorFindings): void { + let document: PermissionsDocument; try { - loadPermissionsDocument(ctx.vault); + const loaded = loadPermissionsDocument(ctx.vault); + if (loaded.document === null) return; + document = loaded.document; } catch (err) { // A non-document failure is not this check's finding; the pass's // fail-soft wrapper records those its own way. @@ -44,6 +66,23 @@ export const permissionsDocumentCheck: DoctorCheck = { `Brain/_permissions.yaml could not be read, so its policy is not in force and ` + `every gate fails closed until it is repaired (${err.message})`, } satisfies DoctorIssue); + return; } + // The gate's own default subject, resolved the way the runtime resolves + // it: an explicitly-invoked config path decides, otherwise the same + // default discovery the write lanes fall back to. + const agent = resolveAgentName(ctx.configPath); + const decision = resolvePermission(document, { agent, via: "config" }, "write"); + if (decision.verdict !== "deny") return; + out.issues.push({ + severity: "warning", + code: PERMISSIONS_AGENT_DENIED_CODE, + path: join(ctx.vault, ...PERMISSIONS_DOCUMENT_REL.split("/")), + message: + `Brain/_permissions.yaml denies the locally configured agent ${JSON.stringify(agent)} ` + + `the write action (rule ${decision.source}), so every write this agent attempts is ` + + `refused while the document stands. Deliberate for a read-only agent; a surprise if ` + + `not. Review the effective decisions: o2b brain permissions show`, + } satisfies DoctorIssue); }, }; diff --git a/tests/cli/brain-permissions.test.ts b/tests/cli/brain-permissions.test.ts index 001bf6da..df8fd788 100644 --- a/tests/cli/brain-permissions.test.ts +++ b/tests/cli/brain-permissions.test.ts @@ -217,3 +217,51 @@ describe("the doctor finding", () => { expect(`${result.stdout}${result.stderr}`).toContain("o2b brain permissions show"); }); }); + +describe("the permissions-agent-denied finding", () => { + test("a document that denies the locally configured agent's write fires by name", async () => { + writeDoc("version: 1\ndefault_action: deny\n"); + const result = await runCli(["brain", "doctor", "--vault", vault, "--json"], { + env: { VAULT_AGENT_NAME: "codex" }, + }); + const payload = JSON.parse(result.stdout) as { + warnings: Array<{ code: string; message: string; next_command?: string }>; + errors: Array<{ code: string }>; + }; + const findings = payload.warnings.filter((w) => w.code === "permissions-agent-denied"); + expect(findings).toHaveLength(1); + // By name: the finding names the agent the document denies. + expect(findings[0]!.message).toContain('"codex"'); + expect(findings[0]!.message).toContain("o2b brain permissions show"); + expect(findings[0]!.next_command).toBe("o2b brain permissions show"); + expect(payload.errors.map((e) => e.code)).not.toContain("permissions-agent-denied"); + }); + + test("an agent override out of the deny keeps the finding silent", async () => { + writeDoc("version: 1\ndefault_action: deny\nagents:\n codex:\n write: allow\n"); + const result = await runCli(["brain", "doctor", "--vault", vault, "--json"], { + env: { VAULT_AGENT_NAME: "codex" }, + }); + const payload = JSON.parse(result.stdout) as { + warnings: Array<{ code: string }>; + errors: Array<{ code: string }>; + }; + expect( + [...payload.errors, ...payload.warnings].filter((e) => e.code === "permissions-agent-denied"), + ).toEqual([]); + }); + + test("an ask verdict is not a denial", async () => { + writeDoc("version: 1\ndefault_action: ask\n"); + const result = await runCli(["brain", "doctor", "--vault", vault, "--json"], { + env: { VAULT_AGENT_NAME: "codex" }, + }); + const payload = JSON.parse(result.stdout) as { + warnings: Array<{ code: string }>; + errors: Array<{ code: string }>; + }; + expect( + [...payload.errors, ...payload.warnings].filter((e) => e.code === "permissions-agent-denied"), + ).toEqual([]); + }); +}); diff --git a/tests/core/brain/doctor-exit-census.test.ts b/tests/core/brain/doctor-exit-census.test.ts index b5faaf2a..9a216429 100644 --- a/tests/core/brain/doctor-exit-census.test.ts +++ b/tests/core/brain/doctor-exit-census.test.ts @@ -195,6 +195,7 @@ const DOCTOR_REGISTERED_CODES: ReadonlyArray = [ "orphan-evidence", "orphan-session-ref", "payload-orphan", + "permissions-agent-denied", "permissions-unreadable", "principle-corrupted", "recall-channel-silent", diff --git a/tests/core/brain/doctor-permissions-deny.test.ts b/tests/core/brain/doctor-permissions-deny.test.ts new file mode 100644 index 00000000..dcc17cfb --- /dev/null +++ b/tests/core/brain/doctor-permissions-deny.test.ts @@ -0,0 +1,114 @@ +/** + * The doctor's `permissions-agent-denied` finding (write-side trust). + * + * The design's foot-gun note on `default_action: deny`: an operator who + * writes a one-agent document denies everything else by construction, and + * the locally configured agent is usually "everything else". The doctor + * check resolves the SAME subject the write lanes fall back to - + * `resolveAgentName` with the same config-path precedence, `via: config`, + * the blanket `write` action, the resolver's own precedence table - so the + * finding fires only when a real write from this machine would actually be + * refused, and stays silent when an agent override or an ask verdict keeps + * the lane open. + * + * Unit-level on purpose: the check is a pure function of the vault's + * document plus the resolved identity, so the fired and silent cases are + * pinned here against a hand-built context, exactly the shape + * `check.ts` keeps valid for tests. + */ + +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { + permissionsDocumentCheck, + PERMISSIONS_AGENT_DENIED_CODE, + PERMISSIONS_UNREADABLE_CODE, +} from "../../../src/core/brain/doctor/permissions-check.ts"; +import type { DoctorCheckContext, DoctorFindings } from "../../../src/core/brain/doctor/check.ts"; + +let vault: string; +let savedAgentName: string | undefined; + +beforeEach(() => { + vault = mkdtempSync(join(tmpdir(), "o2b-doctor-perm-deny-")); + mkdirSync(join(vault, "Brain"), { recursive: true }); + savedAgentName = process.env["VAULT_AGENT_NAME"]; + process.env["VAULT_AGENT_NAME"] = "codex"; +}); + +afterEach(() => { + if (savedAgentName === undefined) delete process.env["VAULT_AGENT_NAME"]; + else process.env["VAULT_AGENT_NAME"] = savedAgentName; + rmSync(vault, { recursive: true, force: true }); +}); + +function writeDoc(text: string): void { + writeFileSync(join(vault, "Brain", "_permissions.yaml"), text, "utf8"); +} + +function context(): DoctorCheckContext { + return { + vault, + now: new Date("2026-06-05T12:00:00Z"), + config: undefined, + dbPath: undefined, + configPath: undefined, + knownBasenames: new Set(), + idIndex: new Map(), + preferences: [], + logs: [], + }; +} + +function run(): DoctorFindings { + const out: DoctorFindings = { issues: [], uncertain: [] }; + permissionsDocumentCheck.run(context(), out); + return out; +} + +describe("permissionsDocumentCheck - the locally configured agent's write", () => { + test("a default deny for the local agent fires one warning naming the agent", () => { + writeDoc("version: 1\ndefault_action: deny\n"); + const { issues } = run(); + const findings = issues.filter((i) => i.code === PERMISSIONS_AGENT_DENIED_CODE); + expect(findings).toHaveLength(1); + expect(findings[0]!.severity).toBe("warning"); + expect(findings[0]!.message).toContain('"codex"'); + expect(findings[0]!.message).toContain("o2b brain permissions show"); + expect(findings[0]!.path).toBe(join(vault, "Brain", "_permissions.yaml")); + }); + + test("an agent override out of the deny stays silent", () => { + writeDoc("version: 1\ndefault_action: deny\nagents:\n codex:\n write: allow\n"); + expect(run().issues).toEqual([]); + }); + + test("an ask verdict is not a denial", () => { + writeDoc("version: 1\ndefault_action: ask\n"); + expect(run().issues).toEqual([]); + }); + + test("a deny aimed at another agent does not name this one", () => { + writeDoc( + "version: 1\ndefault_action: allow\nentries:\n - id: freeze-gemini\n" + + " agent: gemini\n action: write\n verdict: deny\n", + ); + expect(run().issues).toEqual([]); + }); + + test("an absent document is the default posture and says nothing", () => { + expect(run().issues).toEqual([]); + }); + + test("an unreadable document reports only the unreadable finding", () => { + writeDoc("version: 1\ndefault_action: deny\nagents:\n codex:\n write: maybe\n"); + const { issues } = run(); + expect(issues).toHaveLength(1); + expect(issues[0]!.code).toBe(PERMISSIONS_UNREADABLE_CODE); + expect(issues[0]!.severity).toBe("error"); + expect(issues.map((i) => i.code)).not.toContain(PERMISSIONS_AGENT_DENIED_CODE); + }); +}); From 9b7af49ca9930d3946b5ac1413a57c1f8ed153c5 Mon Sep 17 00:00:00 2001 From: Sergey Eroshenkov Date: Sat, 10 Oct 2026 18:34:38 +0200 Subject: [PATCH 37/84] fix(tags): widen the shared tag rule to Obsidian's documented grammar The ASCII letter/underscore onset refused Obsidian-valid Unicode tags (koding) and digit-led non-numeric tags (2fa, 2024-notes) at every write site, and froze each dream refresh or rewrite that carried such a scope over. The one shared rule in src/core/tags.ts now takes its charset from any script's letters and numbers, keeps the two guarantees the onset used to provide (no leading slash, never purely numeric), and derives both TAG_RE and the anchored predicate from that single source. The inline tags detector pins the absorbed index gap; the slug composer's output is pinned against the same rule. --- src/core/tags.ts | 55 ++++++---- tests/core/brain/hygiene-tags.test.ts | 45 ++++----- tests/core/brain/tag-syntax.test.ts | 15 ++- tests/core/tags.test.ts | 140 ++++++++++++++++++++++++++ 4 files changed, 212 insertions(+), 43 deletions(-) create mode 100644 tests/core/tags.test.ts diff --git a/src/core/tags.ts b/src/core/tags.ts index 91f9e9f3..558a24aa 100644 --- a/src/core/tags.ts +++ b/src/core/tags.ts @@ -11,11 +11,18 @@ * ({@link isObsidianTagValue}, via `src/core/brain/tag-syntax.ts`), * never against a copy. * - * The index-side rule: `#word` where word starts with a letter/`_` and - * may contain letters, digits, dashes, underscores, and `/` for - * hierarchy. The regex is deliberately NOT widened this wave - a - * looser rule is an index-semantics change with reindex gating; the - * tags detector reports the diff instead of the index absorbing it. + * The index-side rule is Obsidian's documented tag grammar: `#word` + * where word may contain letters from ANY script, digits, dashes, + * underscores, and `/` for hierarchy, must carry at least one non-digit + * character (a bare number is a number, not a tag), and may not begin + * with a slash. It was deliberately NOT widened in the wave that + * introduced it (an index-semantics change with reindex gating; the + * tags detector reported the diff instead) - the review round of + * v1.78.0 superseded that refusal after the ASCII letter/underscore + * onset proved narrower than Obsidian: it refused Unicode tags + * (`#кодинг`) and digit-led non-numeric tags (`#2fa`, `#2024-notes`) + * at the write sites and froze every dream refresh carrying such a + * scope over. * * The code-fence / inline-code stripping lives here too because the * tag rules are defined over cleaned text: the detector must apply @@ -24,24 +31,38 @@ */ /** - * The value-level rule the index regex and the write-time predicate - * share, spelled ONCE below: a value starts with a letter/underscore and - * continues with letters, digits, dashes, underscores, and `/` - * (nesting). By the shared onset a value may not begin with a slash and - * may never be purely numeric. {@link TAG_RE} wraps this source with the - * prose boundary and the `#`; {@link isObsidianTagValue} matches it - * anchored - one spelling, two anchors. + * One tag character, per Obsidian's documented grammar: letters from any + * script (`\p{L}`), numbers (`\p{N}`), underscores, dashes, and `/` + * (nesting). */ -const TAG_VALUE_SOURCE = "[A-Za-z_][\\w\\-/]*"; +const TAG_VALUE_CLASS = "\\p{L}\\p{N}_\\-/"; + +/** A maximal tag value: one or more tag characters. */ +const TAG_VALUE_SOURCE = `[${TAG_VALUE_CLASS}]+`; + +/** + * The value-shape guards both anchorings share, keeping the old onset's + * two guarantees without its ASCII narrowness: a value may not BEGIN + * with a slash (an empty first nested-tag segment is no tag in Obsidian + * either), and it is never purely numeric - a bare number is not a tag. + * The digit guard is a lookahead rather than an onset so digit-led + * values like `2fa` and `2024-notes` stay valid: it refuses only a run + * of digits (`\p{Nd}`, so non-ASCII numerals refuse too) followed by NO + * further tag character, which anchors the run at the end of the value - + * end of string for the anchored predicate, the first non-tag character + * in prose. + */ +const TAG_VALUE_GUARDS = `(?!/)(?!\\p{Nd}+(?![${TAG_VALUE_CLASS}]))`; /** - * Obsidian-style tag: #word where word starts with a letter/_ and may contain - * letters, digits, dashes, underscores, and '/' for hierarchy. + * Obsidian-style tag: #word where word may contain letters from any + * script, digits, underscores, dashes, and '/' for hierarchy, never is + * purely numeric, and never begins with a slash. */ -export const TAG_RE = new RegExp(`(^|[^\\w/])#(${TAG_VALUE_SOURCE})`, "g"); +export const TAG_RE = new RegExp(`(^|[^\\w/])#${TAG_VALUE_GUARDS}(${TAG_VALUE_SOURCE})`, "gu"); /** Matches a whole tag value: the same rule TAG_RE captures, anchored. */ -const TAG_VALUE_RE = new RegExp(`^${TAG_VALUE_SOURCE}$`); +const TAG_VALUE_RE = new RegExp(`^${TAG_VALUE_GUARDS}${TAG_VALUE_SOURCE}$`, "u"); /** * Does `value` (WITHOUT its `#`) satisfy the one tag rule - the rule the diff --git a/tests/core/brain/hygiene-tags.test.ts b/tests/core/brain/hygiene-tags.test.ts index cf58d212..2856fc39 100644 --- a/tests/core/brain/hygiene-tags.test.ts +++ b/tests/core/brain/hygiene-tags.test.ts @@ -55,28 +55,27 @@ function byClass(findings: ReadonlyArray, klass: string): Hygien } describe("tags detector - malformed", () => { - test("reports a body tag the looser Obsidian rule accepts but the index rule rejects", () => { + test("digit-led non-numeric tokens the old onset refused now ride the index (v1.78.0 review round)", () => { writeNote("Brain/notes/a.md", "#shared start"); writeNote("Brain/notes/b.md", "#shared and #2024notes here"); + // The index absorbs the token the letter/underscore onset used to + // drop, so there is no index loss left to report. const malformed = byClass(tagsFindings(), "malformed"); - expect(malformed).toHaveLength(1); - const finding = malformed[0]!; - expect(finding.detector).toBe("tags"); - expect(finding.severity).toBe("info"); - expect(finding.proposed_action).toBe("review"); - expect(finding.evidence.tag).toBe("#2024notes"); - expect(typeof finding.evidence.reason).toBe("string"); - expect(finding.targets).toEqual(["Brain/notes/b.md", "#2024notes"]); - expect(finding.title).toBe("Malformed tag: the index will never match #2024notes"); + expect(malformed).toHaveLength(0); + const values = extractLinks("#shared and #2024notes here") + .filter((l) => l.linkType === "tag") + .map((l) => l.linkText); + expect(values).toContain("shared"); + expect(values).toContain("2024notes"); }); - test("the reported token is genuinely absent from the index extraction (the diff)", () => { - const body = "#shared and #2024notes here"; - const values = extractLinks(body) + test("Unicode tokens ride the index too and are not reported malformed", () => { + writeNote("Brain/notes/a.md", "#кодинг note"); + expect(byClass(tagsFindings(), "malformed")).toHaveLength(0); + const values = extractLinks("#кодинг note") .filter((l) => l.linkType === "tag") .map((l) => l.linkText); - expect(values).toContain("shared"); - expect(values).not.toContain("2024notes"); + expect(values).toContain("кодинг"); }); test("tokens both rules reject (pure numeric) are not reported", () => { @@ -89,14 +88,11 @@ describe("tags detector - malformed", () => { expect(tagsFindings()).toHaveLength(0); }); - test("the same malformed token across documents is one finding; distinct tokens stay distinct", () => { + test("the same digit-led token across documents is indexed, not reported", () => { writeNote("Brain/notes/a.md", "#2024notes one"); writeNote("Brain/notes/b.md", "#2024notes two"); writeNote("Brain/notes/c.md", "#3d-print two"); - const malformed = byClass(tagsFindings(), "malformed"); - expect(malformed).toHaveLength(2); - const grouped = malformed.find((f) => f.evidence.tag === "#2024notes"); - expect(grouped!.targets).toEqual(["Brain/notes/a.md", "Brain/notes/b.md", "#2024notes"]); + expect(tagsFindings()).toHaveLength(0); }); test("code fences and inline code spans are never audited", () => { @@ -219,9 +215,12 @@ describe("tags detector - empty vault", () => { }); describe("the one shared tag rule", () => { - test("TAG_RE keeps the incumbent pattern exactly (never widened)", () => { - expect(TAG_RE.source).toBe("(^|[^\\w/])#([A-Za-z_][\\w\\-/]*)"); + test("TAG_RE keeps the Obsidian-grammar pattern exactly (v1.78.0 review round)", () => { + expect(TAG_RE.source).toBe( + "(^|[^\\w/])#(?!\\/)(?!\\p{Nd}+(?![\\p{L}\\p{N}_\\-/]))([\\p{L}\\p{N}_\\-/]+)", + ); expect(TAG_RE.flags).toContain("g"); + expect(TAG_RE.flags).toContain("u"); }); test("the shared extraction matches the index behavior the links tests pin", () => { @@ -257,7 +256,7 @@ describe("tags scan integration", () => { }); test("an explicit subset runs tags alone", () => { - writeNote("Brain/notes/a.md", "#2024notes malformed"); + writeNote("Brain/notes/a.md", "#solo token"); const report = runHygieneScan(vault, { detectors: ["tags"], now: NOW }); expect(report.detectors_run).toEqual(["tags"]); expect(report.counts.tags).toBe(1); diff --git a/tests/core/brain/tag-syntax.test.ts b/tests/core/brain/tag-syntax.test.ts index 468958cd..85530261 100644 --- a/tests/core/brain/tag-syntax.test.ts +++ b/tests/core/brain/tag-syntax.test.ts @@ -6,7 +6,9 @@ * derived from `TAG_RE`). These tests pin three things: * * 1. the rule itself: `/` nesting accepted, spaces and other specials - * rejected, leading slash rejected, pure-numeric values rejected; + * rejected, leading slash rejected, pure-numeric values rejected, + * while Obsidian-valid Unicode tags and digit-led non-numeric tags + * (the v1.78.0 review round) are accepted; * 2. the predicate is DERIVED from the index rule, not a parallel * spelling: over a battery of values it agrees exactly with what * `TAG_RE` captures, and `tag-syntax.ts` imports the shared @@ -36,6 +38,12 @@ describe("isObsidianTagValue — the one shared rule", () => { expect(isObsidianTagValue("Foo_1-b/c")).toBe(true); }); + test("accepts Unicode and digit-led non-numeric values (v1.78.0 review round)", () => { + expect(isObsidianTagValue("кодинг")).toBe(true); + expect(isObsidianTagValue("2fa")).toBe(true); + expect(isObsidianTagValue("2024-notes")).toBe(true); + }); + test("rejects spaces and other specials", () => { expect(isObsidianTagValue("foo bar")).toBe(false); expect(isObsidianTagValue("foo bar/baz")).toBe(false); @@ -47,7 +55,6 @@ describe("isObsidianTagValue — the one shared rule", () => { test("rejects a leading slash and pure-numeric values", () => { expect(isObsidianTagValue("/leading")).toBe(false); expect(isObsidianTagValue("2024")).toBe(false); - expect(isObsidianTagValue("2024-notes")).toBe(false); }); test("the predicate agrees exactly with what TAG_RE captures (derived, not copied)", () => { @@ -57,12 +64,14 @@ describe("isObsidianTagValue — the one shared rule", () => { "a/b/c", "_under", "Foo_1-b/c", + "кодинг", + "2fa", + "2024-notes", "foo bar", "foo bar/baz", "foo.bar", "/leading", "2024", - "2024-notes", "", "trail-", "UPPER", diff --git a/tests/core/tags.test.ts b/tests/core/tags.test.ts new file mode 100644 index 00000000..bac99842 --- /dev/null +++ b/tests/core/tags.test.ts @@ -0,0 +1,140 @@ +/** + * The one shared tag rule (src/core/tags.ts) - review-round regression + * pins. v1.78.0 shipped the rule with an ASCII letter/underscore onset, + * which refused Obsidian-valid Unicode tags (#кодинг) and digit-led + * non-numeric tags (#2fa, #2024-notes) at the write sites and froze + * every dream refresh or rewrite carrying such a scope over. The rule + * is Obsidian's documented grammar now; these tests round-trip the + * anchored predicate against what the index regex captures so the two + * anchorings cannot drift apart again, and pin the slug composer's + * output against the same rule (the composer is what repairs legacy + * topics when a preference or signal is rewritten). + */ + +import { describe, expect, test } from "bun:test"; + +import { TAG_RE, extractTagValues, isObsidianTagValue, stripCode } from "../../src/core/tags.ts"; +import { slugify } from "../../src/core/vault.ts"; + +/** The single tag value TAG_RE captures from `text`, or null. */ +function capturedTag(text: string): string | null { + const match = TAG_RE.exec(text); + TAG_RE.lastIndex = 0; + return match?.[2] ?? null; +} + +describe("isObsidianTagValue — Obsidian's documented grammar", () => { + test("accepts plain, nested, underscore-onset, and dashed values", () => { + expect(isObsidianTagValue("brain")).toBe(true); + expect(isObsidianTagValue("brain/signal")).toBe(true); + expect(isObsidianTagValue("a/b/c")).toBe(true); + expect(isObsidianTagValue("_under")).toBe(true); + expect(isObsidianTagValue("Foo_1-b/c")).toBe(true); + }); + + test("accepts Unicode tags", () => { + expect(isObsidianTagValue("кодинг")).toBe(true); + expect(isObsidianTagValue("réunion")).toBe(true); + expect(isObsidianTagValue("中文")).toBe(true); + }); + + test("accepts digit-led non-numeric values", () => { + expect(isObsidianTagValue("2fa")).toBe(true); + expect(isObsidianTagValue("2024-notes")).toBe(true); + expect(isObsidianTagValue("3d-print")).toBe(true); + }); + + test("refuses bare numbers - a number is not a tag", () => { + expect(isObsidianTagValue("2024")).toBe(false); + expect(isObsidianTagValue("42")).toBe(false); + // Digits are digits in any script: the guard is \p{Nd}, not [0-9]. + expect(isObsidianTagValue("٢٠٢٤")).toBe(false); + }); + + test("slash characters themselves satisfy the non-digit requirement", () => { + // Every segment is numeric but the nesting slashes are not digits - + // the value carries a non-digit character, so it is a tag. + expect(isObsidianTagValue("12/34")).toBe(true); + expect(isObsidianTagValue("12/34/56")).toBe(true); + }); + + test("still refuses spaces, specials, empty values, and a leading slash", () => { + expect(isObsidianTagValue("foo bar")).toBe(false); + expect(isObsidianTagValue("foo bar/baz")).toBe(false); + expect(isObsidianTagValue("foo.bar")).toBe(false); + expect(isObsidianTagValue("foo+bar")).toBe(false); + expect(isObsidianTagValue("")).toBe(false); + expect(isObsidianTagValue("/leading")).toBe(false); + }); +}); + +describe("TAG_RE round-trips the predicate", () => { + test("every accepted value is captured from prose whole", () => { + const accepted = [ + "brain", + "brain/signal", + "a/b/c", + "_under", + "Foo_1-b/c", + "trail-", + "кодинг", + "réunion", + "中文", + "2fa", + "2024-notes", + "3d-print", + "2фы", + "12/34", + ]; + for (const value of accepted) { + expect(capturedTag(`x #${value}`)).toBe(value); + } + }); + + test("no refused value is captured whole", () => { + const refused = ["", "2024", "42", "٢٠٢٤", "foo bar", "foo.bar", "/leading"]; + for (const value of refused) { + expect(capturedTag(`x #${value}`)).not.toBe(value); + } + }); + + test("the prose boundary is unchanged: word#nope is still no tag", () => { + expect(capturedTag("word#nope")).toBeNull(); + }); + + test("the index extraction carries digit-led and Unicode tags", () => { + expect(extractTagValues("#2fa and #2024-notes plus #кодинг")).toEqual([ + "2fa", + "2024-notes", + "кодинг", + ]); + }); + + test("bare numbers and slash onsets stay out of the extraction", () => { + expect(extractTagValues("#2024 issue #/x but #real")).toEqual(["real"]); + }); + + test("Unicode tags ride the shared code cleaning like ASCII ones", () => { + expect(extractTagValues(stripCode("```\n#кодинг\n```\n\n#тodos"))).toEqual(["тodos"]); + expect(extractTagValues(stripCode("inline `#закодирован` and #реальный"))).toEqual([ + "реальный", + ]); + }); +}); + +describe("the slug composer's output obeys the one tag rule", () => { + // The preference and signal composers slugify the topic segment before + // validating it against this rule, so a rewrite carrying a legacy + // topic over is repaired by the composer - except a purely numeric + // slug, which the rule still refuses. Pin the agreement so the two + // modules cannot drift. + test("slugified topics satisfy the rule unless the slug is purely numeric", () => { + for (const topic of ["Кодинг", "2024 retrospective", "foo bar", "тестовый запрос"]) { + const slug = slugify(topic); + expect(isObsidianTagValue(slug)).toBe(!/^\d+$/.test(slug)); + } + // The digit-led slug the old onset refused on every dream rewrite. + expect(slugify("2024 retrospective")).toBe("2024-retrospective"); + expect(isObsidianTagValue("2024-retrospective")).toBe(true); + }); +}); From d923336589d7407d8a01ed9a9602dcc61930f65c Mon Sep 17 00:00:00 2001 From: Sergey Eroshenkov Date: Sat, 10 Oct 2026 18:34:44 +0200 Subject: [PATCH 38/84] fix(capture): record a telegram vocabulary refusal instead of crash-looping A CaptureContractError from writeCaptureNote escaped handleCaptureUpdate, rejecting the whole poll before the offset advanced: every restart re-fetched the refused update and died on it again, blocking every later message behind it. The handler now records the refusal as a refused-contract decision - the same refusal message the drain lane records for an unroutable capture - replies with the remedy text escaped for the MarkdownV2 send the catchup path uses, and the loop advances the offset and continues with the next update. --- src/core/brain/capture/telegram-capture.ts | 34 +++++-- .../brain/capture/telegram-capture.test.ts | 96 +++++++++++++++---- 2 files changed, 107 insertions(+), 23 deletions(-) diff --git a/src/core/brain/capture/telegram-capture.ts b/src/core/brain/capture/telegram-capture.ts index 691eb148..98af3842 100644 --- a/src/core/brain/capture/telegram-capture.ts +++ b/src/core/brain/capture/telegram-capture.ts @@ -9,8 +9,10 @@ * * Design invariants: * - every accepted text update becomes exactly one capture note; - * - every rejected or malformed update is one explicit logged decision - - * never a silent drop; + * - every rejected, refused, or malformed update is one explicit logged + * decision - never a silent drop, and never a crash that stops the run + * before the offset advances past it (a contract refusal records + * `refused-contract` and the loop continues with the next update); * - `/catchup` replies with captures since the last acknowledged one, * using the existing MarkdownV2 escaping; * - a missing bot token is a typed error at startup, surfaced by @@ -26,6 +28,7 @@ import { escapeMarkdownV2 } from "../../discipline/telegram.ts"; import { isoSecond } from "../time.ts"; import { captureDecisionLogPath } from "../paths.ts"; import { + CaptureContractError, capturesSince, readCatchupWatermark, writeCaptureNote, @@ -103,6 +106,7 @@ export type CaptureDecisionResult = | "captured" | "catchup" | "rejected-chat" + | "refused-contract" | "malformed" | "transport-error"; @@ -293,11 +297,27 @@ export function handleCaptureUpdate( // done with it. Splitting a message on a convention this bot invented // would put words in the sender's mouth. Guidance reaches the contract // from surfaces that have a field for it - see `o2b brain capture`. - const note = writeCaptureNote(vault, { - body: trimmed, - provenance: { source, sender: chatId, capturedAt: at }, - }); - return finish("captured", "text captured", chatId, note.path, null); + // + // A contract refusal (an undeclared capture kind, today) is one named + // decision, not a crash - the same refusal message the drain lane + // records for an unroutable capture. Letting it throw would reject the + // whole poll before the offset advanced, so every restart would + // re-fetch the refused update and die on it again, blocking every + // later message behind it. The reply names the remedy, escaped for the + // MarkdownV2 send the catchup path already uses. + try { + const note = writeCaptureNote(vault, { + body: trimmed, + provenance: { source, sender: chatId, capturedAt: at }, + }); + return finish("captured", "text captured", chatId, note.path, null); + } catch (err) { + if (!(err instanceof CaptureContractError)) throw err; + return finish("refused-contract", err.message, chatId, null, { + chatId, + text: escapeMarkdownV2(`capture refused: ${err.message}`), + }); + } } export interface RunTelegramCaptureOptions { diff --git a/tests/core/brain/capture/telegram-capture.test.ts b/tests/core/brain/capture/telegram-capture.test.ts index 417d86f3..39bc2105 100644 --- a/tests/core/brain/capture/telegram-capture.test.ts +++ b/tests/core/brain/capture/telegram-capture.test.ts @@ -3,9 +3,10 @@ * * The update-handling core is exercised with an injected transport - no real * network is ever touched. Every accepted text update becomes one capture - * note through the contract; every rejected or malformed update is one - * explicit logged decision; `/catchup` replies with captures since the last - * acknowledged one. + * note through the contract; every rejected, refused, or malformed update is + * one explicit logged decision (a vocabulary refusal records + * `refused-contract` and the run advances past it); `/catchup` replies with + * captures since the last acknowledged one. */ import { afterEach, beforeEach, expect, test } from "bun:test"; @@ -23,11 +24,11 @@ import { type TelegramUpdate, } from "../../../../src/core/brain/capture/telegram-capture.ts"; import { - CaptureContractError, listStagedCaptures, readCatchupWatermark, } from "../../../../src/core/brain/capture/capture-note.ts"; import { captureDecisionLogPath } from "../../../../src/core/brain/paths.ts"; +import { escapeMarkdownV2 } from "../../../../src/core/discipline/telegram.ts"; import { withDeviceId } from "../../../helpers/device-id.ts"; const NOW = new Date("2026-07-19T12:00:00Z"); @@ -314,19 +315,82 @@ test("two capture hosts append to their own decision shards (t_774dea61)", () => // ── t_151a564c: the typed vocabulary refusal surfaces through this lane ────── -test("a pack declaring page_types without brain-capture surfaces the contract refusal", () => { +test("a pack declaring page_types without brain-capture records the refusal as a decision", () => { // The vault made a declaration that omits the kind this lane stamps, so - // the capture contract refuses - and the refusal surfaces here exactly as - // every other contract refusal does: the named CaptureContractError, with - // no capture written behind it. + // the capture contract refuses - and the refusal is one named decision + // (the same refusal message the drain lane records for an unroutable + // capture), never a throw that would kill the run before the offset + // advances. No capture is written behind it. writeFileSync(join(vault, "Brain", "_brain.yaml"), "schema:\n page_types: [note]\n", "utf8"); - let thrown: unknown; - try { - handleCaptureUpdate(vault, textUpdate(1, "100", "an idea worth keeping"), baseOpts()); - } catch (err) { - thrown = err; - } - expect(thrown).toBeInstanceOf(CaptureContractError); - expect((thrown as Error).name).toBe("CaptureContractError"); + const res = handleCaptureUpdate(vault, textUpdate(1, "100", "an idea worth keeping"), baseOpts()); + expect(res.decision.result).toBe("refused-contract"); + expect(res.decision.reason).toContain("brain-capture"); + expect(res.decision.reason).toContain("declare it in Brain/_brain.yaml"); + expect(res.decision.capturePath).toBeNull(); expect(listStagedCaptures(vault)).toHaveLength(0); }); + +test("a contract refusal replies with the escaped remedy text", () => { + writeFileSync(join(vault, "Brain", "_brain.yaml"), "schema:\n page_types: [note]\n", "utf8"); + const res = handleCaptureUpdate(vault, textUpdate(1, "100", "an idea worth keeping"), baseOpts()); + expect(res.reply).not.toBeNull(); + expect(res.reply!.chatId).toBe("100"); + expect(res.reply!.text).toContain("capture refused"); + // The reply rides the MarkdownV2 send the catchup path uses, so the + // remedy text arrives escaped, not as a failed send. + expect(res.reply!.text).toContain(escapeMarkdownV2("declare it in Brain/_brain.yaml")); +}); + +test("the run advances past a refused update and processes the valid one behind it", async () => { + // Regression pin (v1.78.0 review round): one refused update used to reject + // the whole poll before the offset advanced, so every restart re-fetched + // it and died on it again, blocking every later message. Here the run + // survives the refusal; once the operator declares the kind (the config + // the fake transport fixes at the batch boundary), the next update in the + // same run is captured instead of being blocked behind the refusal. + const configPath = join(vault, "Brain", "_brain.yaml"); + writeFileSync(configPath, "schema:\n page_types: [note]\n", "utf8"); + let call = 0; + const offsets: number[] = []; + const transport: TelegramTransport = { + getUpdates: (offset) => { + offsets.push(offset); + call += 1; + if (call === 1) return Promise.resolve([textUpdate(10, "100", "refused vocabulary")]); + // The operator declared the kind between polls; the backlog flows. + writeFileSync(configPath, "schema:\n page_types: [note, brain-capture]\n", "utf8"); + return Promise.resolve([textUpdate(11, "100", "valid idea")]); + }, + sendMessage: () => Promise.resolve(), + }; + + const result = await runTelegramCapture(vault, { + transport, + allowlist: ALLOW, + agent: "tester", + now: () => NOW, + maxCycles: 2, + }); + + // The refusal is recorded, not thrown, and the offset moved past BOTH + // updates so the valid one behind the refusal is captured. + expect(offsets).toEqual([0, 11]); + expect(result.lastOffset).toBe(12); + expect(result.decisions.map((d) => d.result)).toEqual(["refused-contract", "captured"]); + const refused = result.decisions[0]!; + expect(refused.updateId).toBe(10); + expect(refused.reason).toContain("brain-capture"); + const staged = listStagedCaptures(vault); + expect(staged).toHaveLength(1); + expect(staged[0]!.body).toBe("valid idea"); +}); + +test("the refusal decision lands in the decision ledger like every other", () => { + writeFileSync(join(vault, "Brain", "_brain.yaml"), "schema:\n page_types: [note]\n", "utf8"); + handleCaptureUpdate(vault, textUpdate(1, "100", "an idea worth keeping"), baseOpts()); + const log = readFileSync(captureDecisionLogPath(vault), "utf8").trim().split("\n"); + expect(log).toHaveLength(1); + const row = JSON.parse(log[0]!) as { result: string; reason: string }; + expect(row.result).toBe("refused-contract"); + expect(row.reason).toContain("schema.page_types"); +}); From 2f8e506370eb3f3116eabc8e26a18ba68c81b9f2 Mon Sep 17 00:00:00 2001 From: Sergey Eroshenkov Date: Sat, 10 Oct 2026 18:34:51 +0200 Subject: [PATCH 39/84] fix(brain): flag session summary divergence only on same-instant conflicts The divergence-aware read flagged divergent whenever more than one digest record existed, so every normal revision reported divergence and two devices writing the same digest before their vaults synced counted too. The read now dedupes by content hash (the newest record per hash, within the project scope the write-side contract dedupes by), digest_count counts distinct digests, and a new revisions field carries the honest record count so the stream stays visible. The divergent flag survives for the one shape the store cannot order: distinct content hashes meeting at one created_at instant, where latest-wins is not a safe answer. Sequential revisions at distinct instants win deterministically and stay quiet. --- src/core/brain/session-summary.ts | 97 ++++++++++++++++++++---- tests/cli/brain-session-summary.test.ts | 79 +++++++++++++++---- tests/core/brain.session-summary.test.ts | 79 +++++++++++++++++-- tests/mcp/session-summary-tool.test.ts | 36 ++++++++- 4 files changed, 253 insertions(+), 38 deletions(-) diff --git a/src/core/brain/session-summary.ts b/src/core/brain/session-summary.ts index 8e7b8dcc..b089937f 100644 --- a/src/core/brain/session-summary.ts +++ b/src/core/brain/session-summary.ts @@ -191,25 +191,48 @@ export interface SessionSummaryRecordRef { export interface SessionSummaryReport { /** The sorted-latest digest - the same answer {@link getSessionSummary} gives. */ readonly digest: SessionSummaryDigest; - /** How many digests exist for this session id, dedupe applied. */ + /** + * How many DISTINCT digests exist for this session id, content-hash + * dedupe applied. The same digest appended twice - the shape two + * devices produce when both summarized a session before their vaults + * synced - counts once. + */ readonly digestCount: number; - /** Present, always `true`, only when `digestCount` exceeds one. */ + /** + * How many digest records exist for this session id BEFORE the + * content-hash dedupe - the revision count. A session re-summarized as + * it evolves accumulates rows without disagreeing; this keeps that + * stream visible without calling it divergence. + */ + readonly revisions: number; + /** + * Present, always `true`, only when DISTINCT content hashes coexist at + * one `created_at` instant - the one shape the store's read model + * cannot order (two devices distilled the session differently before + * syncing, or two contents landed in the same second), so + * latest-wins is not a safe answer. A later revision wins + * deterministically and is a revision (counted in {@link revisions}), + * not divergence. + */ readonly divergent?: true; /** - * Bounded newest-first-sample of the session's digests, oldest first, - * present only alongside `divergent`. + * Bounded newest-first-sample of the session's distinct digests, oldest + * first, present only alongside `divergent`. */ readonly records?: ReadonlyArray; } /** * Divergence-aware single-session read (t_59d4c919). The continuity store - * is append-only, so one session id can hold several content-differing - * digests; the plain read answers latest-wins without saying so. This read - * names the fact: the sorted-latest digest plus `digest_count`, and, only - * when the count exceeds one, `divergent: true` plus a bounded - * id/created_at/content_hash sample. Divergence is REPORTED here - never - * merged, deleted, or reconciled. + * is append-only, so one session id can hold several digest records; the + * plain read answers latest-wins without saying so. This read names the + * fact: the sorted-latest digest, `digest_count` over DISTINCT content + * (two devices writing the same digest before their vaults sync count + * once), `revisions` for the honest record count, and, only when distinct + * content hashes meet at one instant - the one shape the store cannot + * order - `divergent: true` plus a bounded id/created_at/content_hash + * sample. Divergence is REPORTED here - never merged, deleted, or + * reconciled. */ export function getSessionSummaryReport( vault: string, @@ -220,15 +243,39 @@ export function getSessionSummaryReport( (record) => String(record.payload["session_id"] ?? "") === id, ); if (records.length === 0) return null; - const digestCount = records.length; + const revisions = records.length; + + // Dedupe by content hash on read, keeping the newest record per hash - + // the store order is the tie-break the latest-wins answer already + // trusts. What survives is one entry per distinct digest content, in + // store order. The project axis joins the key because the write-side + // contract files the same content under two projects as two digests. + const newestPerKey = new Map(); + for (const record of records) newestPerKey.set(readDedupeKey(record), record); + const distinct = records.filter((record) => newestPerKey.get(readDedupeKey(record)) === record); + + // Divergence = distinct content hashes meeting at one created_at + // instant. Anything ordered by distinct instants is a revision stream + // (latest wins deterministically); only the same-instant meeting is a + // genuine "which one is the answer" conflict. + const hashesByInstant = new Map>(); + for (const record of distinct) { + const instant = instantKey(record.createdAt); + const hashes = hashesByInstant.get(instant) ?? new Set(); + hashes.add(contentHashOf(record)); + hashesByInstant.set(instant, hashes); + } + const divergent = [...hashesByInstant.values()].some((hashes) => hashes.size > 1); + return Object.freeze({ digest: toDigest(records[records.length - 1]!), - digestCount, - ...(digestCount > 1 + digestCount: distinct.length, + revisions, + ...(divergent ? { divergent: true as const, records: Object.freeze( - records.slice(-DIVERGENCE_RECORD_SAMPLE_LIMIT).map((record) => + distinct.slice(-DIVERGENCE_RECORD_SAMPLE_LIMIT).map((record) => Object.freeze({ id: record.id, createdAt: record.createdAt, @@ -241,6 +288,28 @@ export function getSessionSummaryReport( }); } +/** The read-side dedupe key: content hash within the digest's project scope. */ +function readDedupeKey(record: ContinuityRecord): string { + return `${readProject(record.payload) ?? ""}\u0000${contentHashOf(record)}`; +} + +/** The record's content hash as stored; legacy or clipped records read as "". */ +function contentHashOf(record: ContinuityRecord): string { + return String(record.payload["content_hash"] ?? ""); +} + +/** + * The instant a record was created at, at millisecond precision, so the + * second- and millisecond-precision spellings both on disk compare equal. + * A shape Date cannot parse compares as its raw string - records are + * canonical-UTC-validated at the store boundary, so this is a floorslip, + * not a path. + */ +function instantKey(createdAt: string): string { + const ms = Date.parse(createdAt); + return Number.isFinite(ms) ? String(ms) : createdAt; +} + export interface ListSessionSummariesOptions { readonly sessionId?: string; /** Raw project value; normalized by the same rule the write applies. */ diff --git a/tests/cli/brain-session-summary.test.ts b/tests/cli/brain-session-summary.test.ts index 61d322f4..1004b87b 100644 --- a/tests/cli/brain-session-summary.test.ts +++ b/tests/cli/brain-session-summary.test.ts @@ -3,8 +3,9 @@ * Claims pinned here: * * 1. `get --json` carries the digest plus the additive `digest_count`, and - * the divergence keys (`divergent`, `records`) only when more than one - * content-differing digest exists for the session. + * the divergence keys (`divergent`, `records`) only when distinct + * content hashes meet at one created_at instant; sequential revisions + * (distinct instants) stay quiet (v1.78.0 review round). * 2. The divergence records are an id/created_at/content_hash list, never * a payload echo. * 3. The CLI serializer now carries `project`, matching the MCP serializer @@ -67,16 +68,19 @@ test("get --json carries the digest plus digest_count for a single record", asyn expect("records" in payload).toBe(false); }); -test("get --json after two differing writes carries the divergence keys and the latest digest", async () => { +test("get --json after two differing writes at one instant carries the divergence keys", async () => { + // Same created_at instant, different content: the one shape the store + // cannot order, so the read names it divergent. Sequential revisions + // (distinct instants) stay quiet - the next test pins that. appendSessionSummary(vault, { sessionId: "sess-div", - decisions: ["older decision"], - createdAt: "2026-06-14T09:00:00.000Z", + decisions: ["device a take"], + createdAt: "2026-06-14T10:00:00.000Z", }); const newer = appendSessionSummary(vault, { sessionId: "sess-div", - decisions: ["newer decision"], - createdAt: "2026-06-14T11:00:00.000Z", + decisions: ["device b take"], + createdAt: "2026-06-14T10:00:00.000Z", }); const r = await run(["get", "--session", "sess-div", "--json"]); expect(r.returncode).toBe(0); @@ -108,7 +112,38 @@ test("get --json after two differing writes carries the divergence keys and the expect(typeof record["content_hash"]).toBe("string"); } // id/hash lists, never a payload echo. - expect(JSON.stringify(payload.records)).not.toContain("newer decision"); + expect(JSON.stringify(payload.records)).not.toContain("device b take"); +}); + +test("get --json for sequential revisions stays quiet (no divergence keys)", async () => { + // A session re-summarized as it evolves is a revision stream: the later + // digest wins deterministically, so the envelope must not claim + // divergence (v1.78.0 review round). The digest still answers latest. + appendSessionSummary(vault, { + sessionId: "sess-rev", + decisions: ["older decision"], + createdAt: "2026-06-14T09:00:00.000Z", + }); + const newer = appendSessionSummary(vault, { + sessionId: "sess-rev", + decisions: ["newer decision"], + createdAt: "2026-06-14T11:00:00.000Z", + }); + const r = await run(["get", "--session", "sess-rev", "--json"]); + expect(r.returncode).toBe(0); + const payload = JSON.parse(r.stdout) as { + found: boolean; + digest: Record; + digest_count: number; + divergent?: boolean; + records?: Array>; + }; + expect(payload.found).toBe(true); + expect(payload.digest.id).toBe(newer.id); + expect(payload.digest_count).toBe(2); + expect("divergent" in payload).toBe(false); + expect("records" in payload).toBe(false); + expect(Object.keys(payload).toSorted()).toEqual(["digest", "digest_count", "found"]); }); test("get --json serializes project, matching the MCP serializer", async () => { @@ -125,25 +160,43 @@ test("get --json serializes project, matching the MCP serializer", async () => { }); test("text mode get appends exactly one note line when the session diverges", async () => { + // Same-instant distinct contents: the divergent shape. appendSessionSummary(vault, { sessionId: "sess-text", - decisions: ["older decision"], - createdAt: "2026-06-14T09:00:00.000Z", + decisions: ["device a take"], + createdAt: "2026-06-14T10:00:00.000Z", }); appendSessionSummary(vault, { sessionId: "sess-text", - decisions: ["newer decision"], - createdAt: "2026-06-14T11:00:00.000Z", + decisions: ["device b take"], + createdAt: "2026-06-14T10:00:00.000Z", }); const r = await run(["get", "--session", "sess-text"]); expect(r.returncode).toBe(0); expect(r.stdout).toContain("session sess-text"); - expect(r.stdout).toContain("newer decision"); + expect(r.stdout).toContain("device b take"); const noteLines = r.stdout.split("\n").filter((line) => line.includes("divergent=true")); expect(noteLines.length).toBe(1); expect(noteLines[0]).toContain("digest_count=2"); }); +test("text mode get for sequential revisions stays free of a divergence note", async () => { + appendSessionSummary(vault, { + sessionId: "sess-rev-text", + decisions: ["older decision"], + createdAt: "2026-06-14T09:00:00.000Z", + }); + appendSessionSummary(vault, { + sessionId: "sess-rev-text", + decisions: ["newer decision"], + createdAt: "2026-06-14T11:00:00.000Z", + }); + const r = await run(["get", "--session", "sess-rev-text"]); + expect(r.returncode).toBe(0); + expect(r.stdout).toContain("newer decision"); + expect(r.stdout).not.toContain("divergent"); +}); + test("text mode get for a single record stays free of a divergence note", async () => { appendSessionSummary(vault, { sessionId: "sess-plain", diff --git a/tests/core/brain.session-summary.test.ts b/tests/core/brain.session-summary.test.ts index f3b45cbe..425d07a3 100644 --- a/tests/core/brain.session-summary.test.ts +++ b/tests/core/brain.session-summary.test.ts @@ -17,7 +17,10 @@ import { resolveSessionScope, SESSION_SCOPE_MAX_LENGTH, } from "../../src/core/brain/session-scope.ts"; -import { listContinuityRecords } from "../../src/core/brain/continuity/store.ts"; +import { + listContinuityRecords, + appendContinuityRecord, +} from "../../src/core/brain/continuity/store.ts"; let vault: string; @@ -125,7 +128,10 @@ describe("byte-identical when unused", () => { }); describe("divergence-aware read (t_59d4c919)", () => { - test("two content-differing digests for one session report count 2, divergent, and the sorted-latest", () => { + test("sequential revisions stay quiet: digest_count counts distinct content, revisions counts rows", () => { + // A session re-summarized as it evolves is a revision stream, not a + // conflict: the later digest wins deterministically, so flagging it + // divergent was noise (v1.78.0 review round). const older = appendSessionSummary(vault, { sessionId: "sess-div", decisions: ["older decision"], @@ -143,18 +149,69 @@ describe("divergence-aware read (t_59d4c919)", () => { expect(report!.digest.id).toBe(newer.id); expect(report!.digest.decisions).toEqual(["newer decision"]); expect(report!.digestCount).toBe(2); + expect(report!.revisions).toBe(2); + expect("divergent" in report!).toBe(false); + expect("records" in report!).toBe(false); + expect(older.id).not.toBe(newer.id); + }); + + test("distinct content hashes meeting at one instant report divergent with the sample", () => { + // The one shape the store cannot order: two contents in the same + // created_at instant (two devices distilling differently before their + // vaults sync, or a same-second race) - latest-wins is not a safe + // answer, so the read names it. + const first = appendSessionSummary(vault, { + sessionId: "sess-conflict", + decisions: ["device a take"], + createdAt: "2026-06-14T10:00:00.000Z", + }); + const second = appendSessionSummary(vault, { + sessionId: "sess-conflict", + decisions: ["device b take"], + createdAt: "2026-06-14T10:00:00.000Z", + }); + + const report = getSessionSummaryReport(vault, "sess-conflict"); + expect(report).not.toBeNull(); + expect(report!.digestCount).toBe(2); + expect(report!.revisions).toBe(2); expect(report!.divergent).toBe(true); const records = report!.records ?? []; - expect(records.map((r) => r.id)).toEqual([older.id, newer.id]); + expect(records.map((r) => r.id).toSorted()).toEqual([first.id, second.id].toSorted()); + expect(new Set(records.map((r) => r.contentHash)).size).toBe(2); for (const record of records as ReadonlyArray) { expect(typeof record.createdAt).toBe("string"); expect(record.contentHash.length).toBeGreaterThan(0); } - // Divergence is computed over content-differing records. - expect(new Set(records.map((r) => r.contentHash)).size).toBe(2); }); - test("a single digest reports count 1 and carries no divergent key", () => { + test("the same digest written by two devices before sync counts once, as revisions", () => { + // Two vaults summarize identically before syncing; after the sync the + // store holds two rows with one content hash. Content agrees, so the + // read dedupes to one digest and keeps the row count as revisions. + const input = { + sessionId: "sess-two-devices", + decisions: ["one decision"], + createdAt: "2026-06-14T10:00:00.000Z", + } as const; + appendSessionSummary(vault, input); + const stored = listContinuityRecords(vault, { kind: "session_summary_digest" })[0]!; + appendContinuityRecord(vault, { + kind: "session_summary_digest", + // Device B wrote before the sync: same content, different instant. + createdAt: "2026-06-14T12:00:00.000Z", + sourceRefs: stored.sourceRefs, + payload: stored.payload, + }); + + const report = getSessionSummaryReport(vault, "sess-two-devices"); + expect(report).not.toBeNull(); + expect(report!.digestCount).toBe(1); + expect(report!.revisions).toBe(2); + expect("divergent" in report!).toBe(false); + }); + + test("a single digest reports count 1, revisions 1, and carries no divergent key", () => { appendSessionSummary(vault, { sessionId: "sess-one", decisions: ["the only decision"], @@ -163,10 +220,11 @@ describe("divergence-aware read (t_59d4c919)", () => { const report = getSessionSummaryReport(vault, "sess-one"); expect(report).not.toBeNull(); expect(report!.digestCount).toBe(1); + expect(report!.revisions).toBe(1); expect("divergent" in report!).toBe(false); expect("records" in report!).toBe(false); // Additive only: the report's own key set is pinned. - expect(Object.keys(report!).toSorted()).toEqual(["digest", "digestCount"]); + expect(Object.keys(report!).toSorted()).toEqual(["digest", "digestCount", "revisions"]); }); test("a duplicate-content re-append still dedupes to count 1 (no divergence)", () => { @@ -179,6 +237,7 @@ describe("divergence-aware read (t_59d4c919)", () => { appendSessionSummary(vault, input); const report = getSessionSummaryReport(vault, "sess-dup-report"); expect(report!.digestCount).toBe(1); + expect(report!.revisions).toBe(1); expect("divergent" in report!).toBe(false); }); @@ -197,17 +256,21 @@ describe("divergence-aware read (t_59d4c919)", () => { }); test("the divergence record list is bounded to the newest entries", () => { + // Same-instant distinct contents: every append is a distinct digest, + // and the whole set is one unresolved conflict. const total = DIVERGENCE_RECORD_SAMPLE_LIMIT + 2; let last: { id: string } | null = null; for (let i = 0; i < total; i++) { last = appendSessionSummary(vault, { sessionId: "sess-bound", decisions: [`decision-${i}`], - createdAt: `2026-06-14T00:${String(i).padStart(2, "0")}:00.000Z`, + createdAt: "2026-06-14T00:00:00.000Z", }); } const report = getSessionSummaryReport(vault, "sess-bound"); + expect(report!.divergent).toBe(true); expect(report!.digestCount).toBe(total); + expect(report!.revisions).toBe(total); const records = report!.records ?? []; expect(records.length).toBe(DIVERGENCE_RECORD_SAMPLE_LIMIT); // The newest records are the ones kept, oldest first within the sample. diff --git a/tests/mcp/session-summary-tool.test.ts b/tests/mcp/session-summary-tool.test.ts index 6d3cd08a..a2a35ca4 100644 --- a/tests/mcp/session-summary-tool.test.ts +++ b/tests/mcp/session-summary-tool.test.ts @@ -5,6 +5,7 @@ import { join } from "node:path"; import { JSONRPC_VERSION, MCPServer, PROTOCOL_VERSION } from "../../src/mcp/index.ts"; import { buildToolTable } from "../../src/mcp/tools.ts"; +import { appendSessionSummary } from "../../src/core/brain/session-summary.ts"; let vault: string; @@ -94,13 +95,42 @@ describe("brain_session_summary tool", () => { expect(result.content[0]!.text).toBe(JSON.stringify({ found: false }, null, 2)); }); - test("get after two differing writes carries the additive divergence keys", async () => { + test("get after two differing writes stays quiet (revisions, not divergence)", async () => { + // The tool's two writes land at distinct instants, so the later digest + // wins deterministically: a revision stream, not a conflict (v1.78.0 + // review round). The divergent envelope has its own test below, seeded + // at one instant. await call({ operation: "write", session_id: "dv", decisions: ["first take"] }); await call({ operation: "write", session_id: "dv", decisions: ["second take"] }); const got = payload(await call({ operation: "get", session_id: "dv" })); expect(got["found"]).toBe(true); expect(got["digest_count"]).toBe(2); + expect("divergent" in got).toBe(false); + expect("records" in got).toBe(false); + const digest = got["digest"] as Record; + expect(digest["decisions"]).toEqual(["second take"]); + }); + + test("get for distinct contents meeting at one instant carries the divergence keys", async () => { + // The tool's write path stamps its own (necessarily distinct) instants, + // so the divergent shape - distinct contents at one created_at instant, + // the one shape the store cannot order - is seeded with an explicit + // shared timestamp. + appendSessionSummary(vault, { + sessionId: "dv2", + decisions: ["device a take"], + createdAt: "2026-06-14T10:00:00.000Z", + }); + appendSessionSummary(vault, { + sessionId: "dv2", + decisions: ["device b take"], + createdAt: "2026-06-14T10:00:00.000Z", + }); + + const got = payload(await call({ operation: "get", session_id: "dv2" })); + expect(got["found"]).toBe(true); + expect(got["digest_count"]).toBe(2); expect(got["divergent"]).toBe(true); const records = got["records"] as Array>; expect(records.length).toBe(2); @@ -110,9 +140,9 @@ describe("brain_session_summary tool", () => { expect(typeof record["content_hash"]).toBe("string"); } // id/hash lists, never full payload echoes. - expect(JSON.stringify(records)).not.toContain("first take"); + expect(JSON.stringify(records)).not.toContain("device b take"); const digest = got["digest"] as Record; - expect(digest["decisions"]).toEqual(["second take"]); + expect(digest["decisions"]).toEqual(["device b take"]); }); test("a single-record get adds digest_count and no divergence keys", async () => { From c2e68c2affa8dd7ca3fe8157362f7d7b8ea043af Mon Sep 17 00:00:00 2001 From: Sergey Eroshenkov Date: Sat, 10 Oct 2026 18:34:56 +0200 Subject: [PATCH 40/84] fix(brain): strip case-insensitive think and thinking blocks from payloads The strip only matched a literal lowercase block, so and payloads kept the plain refusal even though their JSON part was fine. The leading block regex now takes both spellings in any case, with the closing tag required to repeat the opening spelling - unclosed blocks and mismatched or non-tag spellings keep the plain refusal, and only one leading block is ever stripped. --- src/core/brain/payload-json.ts | 28 +++++++++++++-------- tests/core/brain/payload-json.test.ts | 36 +++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 10 deletions(-) diff --git a/src/core/brain/payload-json.ts b/src/core/brain/payload-json.ts index edabfa70..35628fa3 100644 --- a/src/core/brain/payload-json.ts +++ b/src/core/brain/payload-json.ts @@ -9,14 +9,16 @@ * is fine. * * This module is the one shared parser: try the plain parse first; only - * on failure strip ONE leading `` block - unwrapping a wholly - * fenced body per the `decision-model/llm-emulation.ts` precedent - and - * retry. Every strip is REPORTED: the result carries the strip fact - * (`kind`, `prefixChars`) so the surface can name it on the success - * envelope. The strip never rescues genuinely malformed JSON: a payload - * that still does not parse after the strip keeps a named error whose - * message says the strip was attempted, and a payload with no leading - * think block keeps the plain refusal. No I/O; pure text in, verdict out. + * on failure strip ONE leading think block - the `` and + * `` spellings, case-insensitive (reasoning models disagree + * about the tag they emit), unwrapping a wholly fenced body per the + * `decision-model/llm-emulation.ts` precedent - and retry. Every strip is + * REPORTED: the result carries the strip fact (`kind`, `prefixChars`) so + * the surface can name it on the success envelope. The strip never + * rescues genuinely malformed JSON: a payload that still does not parse + * after the strip keeps a named error whose message says the strip was + * attempted, and a payload with no leading think block keeps the plain + * refusal. No I/O; pure text in, verdict out. */ export const THINK_BLOCK_STRIP_KIND = "think_block"; @@ -54,8 +56,14 @@ export interface ParsedPayload { readonly strip?: PayloadStrip; } -/** One LEADING think block, whitespace tolerated ahead of it. */ -const LEADING_THINK_BLOCK = /^\s*[\s\S]*?<\/think>/; +/** + * One LEADING think block, whitespace tolerated ahead of it. Both spellings + * reasoning models emit - `` and `` - in any case; the + * closing tag must repeat the opening spelling, so `x` + * is no block and an unclosed one never matches (both keep the plain + * refusal). + */ +const LEADING_THINK_BLOCK = /^\s*<(think|thinking)>[\s\S]*?<\/\1>/i; /** * A body wholly wrapped in one code fence, the diff --git a/tests/core/brain/payload-json.test.ts b/tests/core/brain/payload-json.test.ts index 36e903df..4e2af9ec 100644 --- a/tests/core/brain/payload-json.test.ts +++ b/tests/core/brain/payload-json.test.ts @@ -82,6 +82,42 @@ describe("parsePayloadJson", () => { expect(parsed.strip?.prefixChars).toBe(think.length); }); + test("the strip is case-insensitive over the think tag (v1.78.0 review round)", () => { + const upper = "deliberating"; + const parsed = parsePayloadJson(`${upper}{"a":1}`); + expect(parsed.payload).toEqual({ a: 1 }); + expect(parsed.strip).toEqual({ kind: THINK_BLOCK_STRIP_KIND, prefixChars: upper.length }); + + const mixed = "r"; + const mixedParsed = parsePayloadJson(`${mixed}{"a":1}`); + expect(mixedParsed.payload).toEqual({ a: 1 }); + expect(mixedParsed.strip?.kind).toBe(THINK_BLOCK_STRIP_KIND); + }); + + test("the spelling strips like , fenced body included", () => { + const parsed = parsePayloadJson('r{"a":1}'); + expect(parsed.payload).toEqual({ a: 1 }); + expect(parsed.strip?.kind).toBe(THINK_BLOCK_STRIP_KIND); + + const fenced = 'r\n```json\n{"a":1}\n```\n'; + const fencedParsed = parsePayloadJson(fenced); + expect(fencedParsed.payload).toEqual({ a: 1 }); + expect(fencedParsed.strip?.kind).toBe(FENCED_THINK_BLOCK_STRIP_KIND); + expect(fencedParsed.strip?.prefixChars).toBe(fenced.indexOf('{"a":1}')); + }); + + test("an unclosed think block keeps the plain refusal", () => { + const error = parseFailing('reasoning without an end {"a":1}'); + expect(error.message).toBe(PAYLOAD_UNPARSEABLE_MESSAGE); + }); + + test("mismatched and non-tag spellings keep the plain refusal", () => { + // The closing tag must repeat the opening spelling... + expect(parseFailing('x{"a":1}').message).toBe(PAYLOAD_UNPARSEABLE_MESSAGE); + // ...and a word that merely starts with "think" is not a think tag. + expect(parseFailing('{"a":1}').message).toBe(PAYLOAD_UNPARSEABLE_MESSAGE); + }); + test("a fenced body with no think block is not this module's rescue", () => { // The module strips think blocks; a bare fenced payload was refused // before this module existed and still is, with the plain message. From cc266f27e58c30696aa7b8647437c2155762a127 Mon Sep 17 00:00:00 2001 From: Sergey Eroshenkov Date: Sat, 10 Oct 2026 18:40:04 +0200 Subject: [PATCH 41/84] test(tags): pin the surviving orphan finding for cross-document digit-led tokens The shared token rides the index and #3d-print sits on one document, so the orphan class - not the malformed class - owns the finding once the rule is widened. --- tests/core/brain/hygiene-tags.test.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tests/core/brain/hygiene-tags.test.ts b/tests/core/brain/hygiene-tags.test.ts index 2856fc39..37021b67 100644 --- a/tests/core/brain/hygiene-tags.test.ts +++ b/tests/core/brain/hygiene-tags.test.ts @@ -92,7 +92,14 @@ describe("tags detector - malformed", () => { writeNote("Brain/notes/a.md", "#2024notes one"); writeNote("Brain/notes/b.md", "#2024notes two"); writeNote("Brain/notes/c.md", "#3d-print two"); - expect(tagsFindings()).toHaveLength(0); + const findings = tagsFindings(); + // The malformed class is empty: both digit-led tokens ride the index. + // #2024notes is shared across two documents; #3d-print sits on one, + // so the orphan class - not the malformed class - owns the finding. + expect(byClass(findings, "malformed")).toHaveLength(0); + const orphans = byClass(findings, "orphan"); + expect(orphans).toHaveLength(1); + expect(orphans[0]!.evidence.tag).toBe("#3d-print"); }); test("code fences and inline code spans are never audited", () => { From f4270f5364711149bd2068878c61d81174b323a6 Mon Sep 17 00:00:00 2001 From: Sergey Eroshenkov Date: Sat, 10 Oct 2026 18:40:44 +0200 Subject: [PATCH 42/84] feat(sessions): report the ambient withheld count on imports A session import with ambient consent off reported facts_extracted: 0 and nothing else - a number indistinguishable from a transcript with nothing durable in it. routeExtractedFacts already counted and logged every capture its consent boundary suppressed; the import result dropped that count. The result now carries facts_ambient_withheld beside the sibling fact counters, the CLI JSON payload includes it, and the human report prints the line when it is non-zero so a consent-on import's output is unchanged. --- src/cli/brain/verbs/import-session.ts | 6 + src/core/brain/sessions/import.ts | 12 ++ .../brain/sessions/ambient-withheld.test.ts | 127 ++++++++++++++++++ 3 files changed, 145 insertions(+) create mode 100644 tests/core/brain/sessions/ambient-withheld.test.ts diff --git a/src/cli/brain/verbs/import-session.ts b/src/cli/brain/verbs/import-session.ts index e4a2e282..a35c4f21 100644 --- a/src/cli/brain/verbs/import-session.ts +++ b/src/cli/brain/verbs/import-session.ts @@ -500,6 +500,7 @@ function emitImportReport( signals_deduped: f.signals_deduped, facts_extracted: f.facts_extracted, facts_withheld: f.facts_withheld, + facts_ambient_withheld: f.facts_ambient_withheld, tool_replays: f.tool_replays, malformed: f.malformed, filtered_turns: f.filtered_turns, @@ -528,6 +529,11 @@ function emitImportReport( ok(` facts_withheld: ${f.facts_withheld}`); } ok(` signals_deduped: ${f.signals_deduped}`); + // Printed only when consent actually withheld something: with the lane + // on the count is zero by construction, and a first import's output is + // unchanged. Without this line a consent-off import reported 0 facts + // and no reason - the count is what tells the operator the lane is off. + if (f.facts_ambient_withheld > 0) ok(` facts_ambient_withheld: ${f.facts_ambient_withheld}`); ok(` tool_replays: ${f.tool_replays}`); ok(` filtered_turns: ${f.filtered_turns}`); // Printed only when a checkpoint actually changed what the run did, so a diff --git a/src/core/brain/sessions/import.ts b/src/core/brain/sessions/import.ts index 2f040ae6..07ff1a7d 100644 --- a/src/core/brain/sessions/import.ts +++ b/src/core/brain/sessions/import.ts @@ -225,6 +225,14 @@ export interface ImportSessionResult { /** Facts a dry run would have written. Always 0 on an applied run. */ readonly facts_withheld: number; readonly facts_deduped: number; + /** + * Facts the ambient extraction lane withheld whole captures of, because + * `guardrails.ambient_writeback: false` is in force (Task 11). Each + * suppressed capture also logs one `ambient-withheld` event carrying the + * same count, so a consent-off import reports 0 facts written AND the + * count it declined to capture - never a silent zero. + */ + readonly facts_ambient_withheld: number; readonly recall_turns_imported: number; readonly recall_summary_nodes: number; /** @@ -336,6 +344,7 @@ export async function importSession( let factsExtracted = 0; let factsWithheld = 0; let factsDeduped = 0; + let factsAmbientWithheld = 0; /** * Hashes a dry run has already forecast. The dedup index only learns a * hash when a signal actually lands, so without this a marker repeated @@ -459,6 +468,7 @@ export async function importSession( facts_extracted: 0, facts_withheld: 0, facts_deduped: 0, + facts_ambient_withheld: 0, recall_turns_imported: 0, recall_summary_nodes: 0, turns_resumed: 0, @@ -576,6 +586,7 @@ export async function importSession( factsExtracted += routed.created; factsWithheld += routed.withheld; factsDeduped += routed.deduped; + factsAmbientWithheld += routed.ambientWithheld; } // Path A — markers in text. @@ -733,6 +744,7 @@ export async function importSession( facts_extracted: factsExtracted, facts_withheld: factsWithheld, facts_deduped: factsDeduped, + facts_ambient_withheld: factsAmbientWithheld, filtered_turns: filteredTurns, recall_turns_imported: recallTurnsImported, recall_summary_nodes: recallSummaryNodes, diff --git a/tests/core/brain/sessions/ambient-withheld.test.ts b/tests/core/brain/sessions/ambient-withheld.test.ts new file mode 100644 index 00000000..b909aded --- /dev/null +++ b/tests/core/brain/sessions/ambient-withheld.test.ts @@ -0,0 +1,127 @@ +/** + * The consent-off import reports what it withheld (Task 11 / L8). + * + * `routeExtractedFacts` counts and logs every capture its ambient consent + * boundary suppresses, but the sessions import result used to drop that + * count: with `guardrails.ambient_writeback: false` in force, an import + * reported `facts_extracted: 0` and no reason - a number that reads + * exactly like "nothing durable in this transcript". The result now + * carries `facts_ambient_withheld`, beside the sibling fact counters, so + * the operator sees the lane was closed rather than quiet. + */ + +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { bootstrapBrain } from "../../../../src/core/brain/init.ts"; +import { brainDirs } from "../../../../src/core/brain/paths.ts"; +import { importSession } from "../../../../src/core/brain/sessions/import.ts"; +import { atomicWriteFileSync } from "../../../../src/core/fs-atomic.ts"; + +let tmp: string; +let vault: string; + +const NOW = new Date("2026-08-15T10:00:00Z"); + +beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), "o2b-ambient-import-")); + vault = join(tmp, "vault"); + const configHome = join(tmp, "config"); + const configPath = join(configHome, "config.yaml"); + atomicWriteFileSync(configPath, `vault: ${vault}\n`); + bootstrapBrain(vault, { configPath }); +}); + +afterEach(() => { + rmSync(tmp, { recursive: true, force: true }); +}); + +/** + * Overwrite the vault `_brain.yaml` with a minimal config carrying the + * given guardrails sub-key lines (`key: value` shape, unprefixed). + */ +function writeVaultGuardrails(...subKeys: string[]): void { + const lines = ["schema_version: 1", ""]; + if (subKeys.length > 0) { + lines.push("guardrails:", ...subKeys.map((k) => ` ${k}`), ""); + } + writeFileSync(join(vault, "Brain", "_brain.yaml"), lines.join("\n"), "utf8"); +} + +/** One Claude-format transcript file with the given user texts. */ +function writeSessionFile(name: string, texts: readonly string[]): string { + const path = join(tmp, name); + const lines = texts.map((text, index) => + JSON.stringify({ + type: "user", + parentUuid: null, + entrypoint: "cli", + uuid: `u${index}`, + timestamp: "2026-08-15T09:00:00.000Z", + sessionId: "s1", + message: { role: "user", content: [{ type: "text", text }] }, + }), + ); + writeFileSync(path, lines.join("\n") + "\n", "utf8"); + return path; +} + +function inboxSignalCount(): number { + return readdirSync(brainDirs(vault).inbox).filter((n) => n.startsWith("sig-")).length; +} + +describe("importSession - the ambient withheld count", () => { + test("a consent-off import reports 0 facts written and the withheld count", async () => { + writeVaultGuardrails("ambient_writeback: false"); + const path = writeSessionFile("consent-off.jsonl", [ + "pin the staging URL https://osb.example/run", + ]); + + const result = await importSession(vault, path, { agent: "claude", now: NOW }); + + expect(result.facts_extracted).toBe(0); + expect(result.facts_ambient_withheld).toBe(1); + expect(result.facts_withheld).toBe(0); + expect(inboxSignalCount()).toBe(0); + }); + + test("the count accumulates across every suppressed capture in the file", async () => { + writeVaultGuardrails("ambient_writeback: false"); + const path = writeSessionFile("two-turns.jsonl", [ + "pin the staging URL https://osb.example/run", + "mail the report to qa@example.com or ops@example.com", + ]); + + const result = await importSession(vault, path, { agent: "claude", now: NOW }); + + // One capture per USER turn: 1 fact + 2 facts, each withheld whole. + expect(result.facts_ambient_withheld).toBe(3); + expect(result.facts_extracted).toBe(0); + }); + + test("a consent-off dry run forecasts the withheld count too", async () => { + writeVaultGuardrails("ambient_writeback: false"); + const path = writeSessionFile("rehearsal.jsonl", [ + "pin the staging URL https://osb.example/run", + ]); + + const result = await importSession(vault, path, { agent: "claude", now: NOW, dryRun: true }); + + expect(result.facts_ambient_withheld).toBe(1); + expect(result.facts_withheld).toBe(0); + }); + + test("with consent on the count is 0 and the facts land", async () => { + const path = writeSessionFile("consent-on.jsonl", [ + "pin the staging URL https://osb.example/run", + ]); + + const result = await importSession(vault, path, { agent: "claude", now: NOW }); + + expect(result.facts_extracted).toBe(1); + expect(result.facts_ambient_withheld).toBe(0); + expect(inboxSignalCount()).toBe(1); + }); +}); From 5f520dabed8749ab8c1a269872bd3e21530eb6dd Mon Sep 17 00:00:00 2001 From: Sergey Eroshenkov Date: Sat, 10 Oct 2026 18:42:43 +0200 Subject: [PATCH 43/84] test(brain): seed the quiet-revisions scenario with explicit instants Two rapid tool writes can land inside one millisecond - the divergent shape - so the revision-stream assertion raced the wall clock. The instants are seeded explicitly now, and the same-instant divergence keeps its own test. --- tests/mcp/session-summary-tool.test.ts | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/tests/mcp/session-summary-tool.test.ts b/tests/mcp/session-summary-tool.test.ts index a2a35ca4..3e30369b 100644 --- a/tests/mcp/session-summary-tool.test.ts +++ b/tests/mcp/session-summary-tool.test.ts @@ -95,13 +95,22 @@ describe("brain_session_summary tool", () => { expect(result.content[0]!.text).toBe(JSON.stringify({ found: false }, null, 2)); }); - test("get after two differing writes stays quiet (revisions, not divergence)", async () => { - // The tool's two writes land at distinct instants, so the later digest - // wins deterministically: a revision stream, not a conflict (v1.78.0 - // review round). The divergent envelope has its own test below, seeded - // at one instant. - await call({ operation: "write", session_id: "dv", decisions: ["first take"] }); - await call({ operation: "write", session_id: "dv", decisions: ["second take"] }); + test("get after sequential revisions stays quiet (revisions, not divergence)", async () => { + // Two differing writes at distinct instants are a revision stream - the + // later digest wins deterministically - not a conflict (v1.78.0 review + // round). The instants are seeded explicitly because the tool's write + // path stamps its own clock, and two rapid writes can land inside one + // millisecond, which is the divergent shape the next test pins. + appendSessionSummary(vault, { + sessionId: "dv", + decisions: ["first take"], + createdAt: "2026-06-14T09:00:00.000Z", + }); + appendSessionSummary(vault, { + sessionId: "dv", + decisions: ["second take"], + createdAt: "2026-06-14T11:00:00.000Z", + }); const got = payload(await call({ operation: "get", session_id: "dv" })); expect(got["found"]).toBe(true); From b0669b8aa9a31edba3032e4ae48ebd5b71c6672d Mon Sep 17 00:00:00 2001 From: Sergey Eroshenkov Date: Sat, 10 Oct 2026 18:44:53 +0200 Subject: [PATCH 44/84] fix(secrets): authenticate credential-bundle metadata and refuse edited bundles Each entry's value is sealed under GCM associated data covering its name, env-var mapping, allowlist, the bundle version and the KDF parameters, so a bundle edited after export - an allow pattern widened, two entries' ciphertexts swapped - fails the authentication tag on import and refuses before any write, instead of landing the edited metadata with the sealed value it was not exported under. The schema version moves to 2; an older bundle refuses by name with the re-export remedy. An egress redaction that edits an allow pattern is followed by a re-seal of the affected values under the metadata that actually ships, so the redacted export still imports. --- src/core/brain/secrets/bundle.ts | 129 ++++++++++++++++++++---- src/core/brain/secrets/value-cipher.ts | 18 +++- tests/core/brain/secrets/bundle.test.ts | 121 +++++++++++++++++++++- 3 files changed, 243 insertions(+), 25 deletions(-) diff --git a/src/core/brain/secrets/bundle.ts b/src/core/brain/secrets/bundle.ts index 11889d92..9242b43d 100644 --- a/src/core/brain/secrets/bundle.ts +++ b/src/core/brain/secrets/bundle.ts @@ -4,19 +4,28 @@ * Export turns the store's entries into ONE schema-versioned envelope * each entry's value re-encrypted under a key the passphrase derives * through the keyfile envelope's KDF (`./envelope.ts` - same scrypt - * cost curve, fresh salt, parameters recorded inside the bundle). Import - * verifies, decrypts EVERY value before writing anything, reuses the - * store's name, env-var AND allow-pattern validation (so a rewritten - * bundle cannot land entries the store's own writer would refuse), and - * lands the entries through the store's lock and writer with - * `removeSecret`'s exactness on collisions. + * cost curve, fresh salt, parameters recorded inside the bundle). Each + * value is sealed under GCM associated data that binds it to the + * metadata it travels with - its name, env-var mapping, allowlist, the + * bundle version and the KDF parameters - so a bundle edited after + * export (an allow pattern widened, two entries' ciphertexts swapped) + * fails the authentication tag on import and refuses before any write. + * Import verifies, decrypts EVERY value before writing anything, reuses + * the store's name, env-var AND allow-pattern validation (so a + * rewritten bundle cannot land entries the store's own writer would + * refuse), and lands the entries through the store's lock and writer + * with `removeSecret`'s exactness on collisions. * * What the envelope carries in the clear, by design: the schema version, * the wall-clock stamp, the KDF parameters, and each entry's name, - * env-var mapping and allowlist - the same metadata `list` exposes. What - * it never carries in the clear: any value, and the passphrase. The - * export path's egress declaration (`src/core/egress/registry.ts`) runs - * the shared redactor over exactly this non-ciphertext metadata tree. + * env-var mapping and allowlist - the same metadata `list` exposes, + * authenticated but not hidden. What it never carries in the clear: any + * value, and the passphrase. The export path's egress declaration + * (`src/core/egress/registry.ts`) runs the shared redactor over exactly + * this non-ciphertext metadata tree; a redaction that edits an allow + * pattern is followed by {@link resealBundleMetadata}, which re-seals + * the affected values under the metadata that actually ships, so the + * redacted export still imports. * * Honest warning, as for the keyfile envelope: the bundle's secrecy is * the passphrase's strength against offline guessing wherever the file @@ -27,6 +36,7 @@ import { join } from "node:path"; import { appendAuditRecord } from "../../reliability/audit.ts"; +import { canonicalJson } from "../../integrity/digest.ts"; import { SECRET_CUSTODY_AUDIT_DIR } from "../audit-dirs.ts"; import { brainDirsForWrite } from "../paths.ts"; import { isoSecond } from "../time.ts"; @@ -51,7 +61,7 @@ import { } from "./store.ts"; /** Bundle schema version. Bumped only on an incompatible field change. */ -export const SECRET_BUNDLE_SCHEMA_VERSION = 1; +export const SECRET_BUNDLE_SCHEMA_VERSION = 2; /** * Closed refusal table for every named failure shape of the bundle. @@ -104,6 +114,32 @@ export interface SecretBundleFile { readonly entries: Record; } +/** + * The associated data every entry's value is sealed under: the entry's + * own metadata (name, env-var mapping, allowlist) plus the bundle + * version and the KDF parameters, canonicalized. The GCM tag covers it, + * so a bundle whose metadata is edited after export - an allow pattern + * widened, a ciphertext moved between entries, a version or KDF block + * rewritten - fails the tag on import and refuses before any write. The + * allow list is bound EXACTLY as exported: even a whitespace edit + * refuses, and the store's own trim rule stays as the post-decrypt + * invariant. + */ +function bundleEntryAad( + version: number, + kdf: EnvelopeKdfParams, + name: string, + entry: { env_var: string; allow: ReadonlyArray }, +): string { + return canonicalJson({ + version, + kdf, + name, + env_var: entry.env_var, + allow: [...entry.allow], + }); +} + /** * Export every stored entry as a bundle. Reads under the store's lock for * one consistent snapshot; decrypts in memory and re-encrypts each value @@ -131,10 +167,14 @@ export function exportSecretBundle( const entries: Record = {}; for (const name of Object.keys(file.secrets).toSorted()) { const stored = file.secrets[name]!; + const metadata = { env_var: stored.env_var, allow: [...stored.allow] }; entries[name] = { - env_var: stored.env_var, - allow: [...stored.allow], - value: encryptValue(derived, decryptValue(key, stored)), + ...metadata, + value: encryptValue( + derived, + decryptValue(key, stored), + bundleEntryAad(SECRET_BUNDLE_SCHEMA_VERSION, kdf, name, metadata), + ), }; } return { @@ -224,6 +264,47 @@ export function bundleFromEgressScan(bundle: SecretBundleFile, scanned: unknown) return { ...bundle, entries: out }; } +/** + * Re-seal a merged bundle's values under the metadata that actually + * ships. The egress guard may redact an allow pattern + * ({@link bundleFromEgressScan} merges it), but every value is + * authenticated against the metadata it travels with - so a merged + * bundle whose allow lists no longer match the seal would refuse its + * own import. Given the merged bundle, the export passphrase, and the + * PRE-merge bundle the values were sealed in, this decrypts each value + * under its original binding and re-encrypts it under the merged + * entry's. Names and env-var mappings are refused-on-rewrite by the + * merge, so the name mapping between the two bundles is exact. + */ +export function resealBundleMetadata( + merged: SecretBundleFile, + passphrase: string, + original: SecretBundleFile, +): SecretBundleFile { + const derived = deriveWrapKey(passphrase, merged.kdf); + const entries: Record = {}; + for (const name of Object.keys(merged.entries).toSorted()) { + const next = merged.entries[name]!; + const prior = original.entries[name]; + if (prior === undefined) { + throw new SecretBundleError( + BUNDLE_REFUSAL_CODES.entry, + `entry "${name}" has no sealed value from the export to re-seal`, + ); + } + const value = decryptValue( + derived, + prior.value, + bundleEntryAad(original.version, original.kdf, name, prior), + ); + entries[name] = { + ...next, + value: encryptValue(derived, value, bundleEntryAad(merged.version, merged.kdf, name, next)), + }; + } + return { ...merged, entries }; +} + export interface ImportSecretBundleInput { readonly passphrase: string; /** Overwrite entries whose names the target store already holds. */ @@ -286,12 +367,23 @@ export function importSecretBundle( } let value: string; try { - value = decryptValue(derived, entry.value); + value = decryptValue( + derived, + entry.value, + // The metadata as READ from the file - the exact bytes the export + // sealed. A rewritten allow pattern, a moved ciphertext, a touched + // version or KDF block all land here: the tag no longer matches. + bundleEntryAad(parsed.version, parsed.kdf, name, entry), + ); } catch { - // The GCM tag is the passphrase check. Nothing has been written. + // The GCM tag is the passphrase check AND the metadata check - the + // cipher cannot tell a wrong passphrase from an edited bundle, and + // neither is answered. Nothing has been written. throw new SecretBundleError( BUNDLE_REFUSAL_CODES.passphrase, - "the passphrase does not open this bundle (wrong passphrase, or the bundle is corrupt); nothing was written", + "the passphrase does not open this bundle, or the bundle was edited after it was " + + "exported (wrong passphrase, or entry metadata does not match the sealed values); " + + "nothing was written - re-export the bundle and import the new file", ); } decrypted.set(name, { value, env_var: entry.env_var, allow }); @@ -360,7 +452,8 @@ function parseBundle(bundle: unknown): SecretBundleFile { if (candidate.version !== SECRET_BUNDLE_SCHEMA_VERSION) { throw new SecretBundleError( BUNDLE_REFUSAL_CODES.version, - `version ${String(candidate.version)} is not read by this build`, + `version ${String(candidate.version)} is not read by this build; ` + + "re-export the bundle with this build (`o2b brain secret export`) and import the new file", ); } if (kdf.algo !== "scrypt") { diff --git a/src/core/brain/secrets/value-cipher.ts b/src/core/brain/secrets/value-cipher.ts index 8140cbf5..2335888d 100644 --- a/src/core/brain/secrets/value-cipher.ts +++ b/src/core/brain/secrets/value-cipher.ts @@ -3,7 +3,11 @@ * * Random 12-byte IV per encryption, authentication tag verified on every * decrypt, so a tampered ciphertext fails closed instead of decoding - * garbage. This module is the LEAF both halves of the keyfile custody + * garbage. An optional associated-data string binds the ciphertext to + * context that travels OUTSIDE it (the credential bundle binds each + * value to its entry metadata this way); a decrypt that names different + * associated data than the encrypt did fails the tag exactly like a + * wrong key. This module is the LEAF both halves of the keyfile custody * share: `crypto.ts` (the keyfile kernel) and `envelope.ts` (the * passphrase-wrapped keyfile) each encrypt and decrypt values through it, * so neither imports the other - the static back-edge the envelope once @@ -25,9 +29,10 @@ export interface EncryptedValue { readonly tag: string; } -export function encryptValue(key: Buffer, plaintext: string): EncryptedValue { +export function encryptValue(key: Buffer, plaintext: string, aad?: string): EncryptedValue { const iv = randomBytes(IV_BYTES); const cipher = createCipheriv(ALGORITHM, key, iv); + if (aad !== undefined) cipher.setAAD(Buffer.from(aad, "utf8")); const ciphertext = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]); return { ciphertext: ciphertext.toString("base64"), @@ -36,10 +41,15 @@ export function encryptValue(key: Buffer, plaintext: string): EncryptedValue { }; } -/** Decrypt one value; a wrong key or tampered payload throws. */ -export function decryptValue(key: Buffer, encrypted: EncryptedValue): string { +/** + * Decrypt one value; a wrong key, a tampered payload, or ciphertext that + * was sealed under different associated data throws. `aad` must be the + * exact string the encryption side passed - the GCM tag covers it. + */ +export function decryptValue(key: Buffer, encrypted: EncryptedValue, aad?: string): string { const decipher = createDecipheriv(ALGORITHM, key, Buffer.from(encrypted.iv, "base64")); decipher.setAuthTag(Buffer.from(encrypted.tag, "base64")); + if (aad !== undefined) decipher.setAAD(Buffer.from(aad, "utf8")); const plaintext = Buffer.concat([ decipher.update(Buffer.from(encrypted.ciphertext, "base64")), decipher.final(), diff --git a/tests/core/brain/secrets/bundle.test.ts b/tests/core/brain/secrets/bundle.test.ts index ec8f205e..96c4827f 100644 --- a/tests/core/brain/secrets/bundle.test.ts +++ b/tests/core/brain/secrets/bundle.test.ts @@ -17,6 +17,7 @@ import { bundleFromEgressScan, exportSecretBundle, importSecretBundle, + resealBundleMetadata, SECRET_BUNDLE_SCHEMA_VERSION, SecretBundleError, } from "../../../../src/core/brain/secrets/bundle.ts"; @@ -191,16 +192,111 @@ describe("the credential bundle", () => { }), ).toThrow(SecretBundleError); expect(listSecrets(other)).toHaveLength(0); - // A valid pattern trims exactly as `set` trims it. + // A whitespace edit to an allow pattern no longer lands (the value is + // authenticated against the metadata exactly as exported); the store's + // trim rule stays as the post-decrypt invariant for values that DO + // verify. const padded = structuredClone(bundle); padded.entries["beta-key"]!.allow = [" curl * "]; - importSecretBundle(other, padded, { + try { + importSecretBundle(other, padded, { + passphrase: PASSPHRASE, + replace: false, + agent: "tester", + now: LATER, + }); + throw new Error("expected the edited-metadata refusal"); + } catch (err) { + expect(err).toBeInstanceOf(SecretBundleError); + expect((err as SecretBundleError).code).toBe(BUNDLE_REFUSAL_CODES.passphrase); + } + expect(listSecrets(other)).toHaveLength(0); + }); + + test("a bundle edited after export refuses before any write", () => { + // Every value is sealed under GCM associated data covering its name, + // env-var mapping, allowlist, the bundle version and the KDF block, so + // the two edits below - a widened allowlist and two ciphertexts swapped + // between entries - fail the authentication tag instead of landing. + seed(vault); + const bundle = exportSecretBundle(vault, PASSPHRASE, CTX); + + const widened = structuredClone(bundle) as unknown as { + entries: Record; + }; + widened.entries["beta-key"]!.allow = ["curl *", "bun -e *"]; + try { + importSecretBundle(other, widened, { + passphrase: PASSPHRASE, + replace: false, + agent: "tester", + now: LATER, + }); + throw new Error("expected the widened-allow refusal"); + } catch (err) { + expect(err).toBeInstanceOf(SecretBundleError); + expect((err as SecretBundleError).code).toBe(BUNDLE_REFUSAL_CODES.passphrase); + expect((err as SecretBundleError).message).toContain("edited after it was exported"); + } + expect(listSecrets(other)).toHaveLength(0); + expect(existsSync(join(secretsDir(other), "secrets.json"))).toBe(false); + + const swapped = structuredClone(bundle) as unknown as { + entries: Record; + }; + const alphaValue = swapped.entries["alpha-key"]!.value; + swapped.entries["alpha-key"]!.value = swapped.entries["beta-key"]!.value; + swapped.entries["beta-key"]!.value = alphaValue; + try { + importSecretBundle(other, swapped, { + passphrase: PASSPHRASE, + replace: false, + agent: "tester", + now: LATER, + }); + throw new Error("expected the swapped-ciphertext refusal"); + } catch (err) { + expect(err).toBeInstanceOf(SecretBundleError); + expect((err as SecretBundleError).code).toBe(BUNDLE_REFUSAL_CODES.passphrase); + } + expect(listSecrets(other)).toHaveLength(0); + expect(existsSync(join(secretsDir(other), "secrets.json"))).toBe(false); + }); + + test("a merged (egress-redacted) bundle re-seals under the shipped metadata and imports", () => { + seed(vault); + const bundle = exportSecretBundle(vault, PASSPHRASE, CTX); + const scanTree = bundleEgressScanTree(bundle) as { + entries: Array>; + }; + const alpha = scanTree.entries.find((e) => e["name"] === "alpha-key")!; + alpha["allow"] = ["curl * (redacted)"]; + const merged = bundleFromEgressScan(bundle, scanTree); + // The merged bundle alone refuses its own import: alpha's value is + // sealed under the pre-merge metadata. + expect(() => + importSecretBundle(other, merged, { + passphrase: PASSPHRASE, + replace: false, + agent: "tester", + now: LATER, + }), + ).toThrow(SecretBundleError); + // The re-seal binds the values to the metadata that actually ships. + const resealed = resealBundleMetadata(merged, PASSPHRASE, bundle); + expect(resealed.entries["alpha-key"]!.allow).toEqual(["curl * (redacted)"]); + const result = importSecretBundle(other, resealed, { passphrase: PASSPHRASE, replace: false, agent: "tester", now: LATER, }); - expect(listSecrets(other).find((s) => s.name === "beta-key")?.allow).toEqual(["curl *"]); + expect(result.imported.toSorted()).toEqual(["alpha-key", "beta-key"]); + expect(listSecrets(other).find((s) => s.name === "alpha-key")?.allow).toEqual([ + "curl * (redacted)", + ]); + expect(resolveSecretReadOnly(other, "alpha-key").value).toBe(VALUE_A); + expect(resolveSecretReadOnly(other, "beta-key").value).toBe(VALUE_B); }); test("the exported bytes carry none of the values or the passphrase; KDF rides inside", () => { @@ -307,6 +403,25 @@ describe("the credential bundle", () => { } } expect(listSecrets(other)).toHaveLength(0); + // The AAD binding moved the schema to v2: a bundle from a build before + // it refuses by name with the re-export remedy, not with a cipher + // error a wrong passphrase would also produce. + const older = JSON.parse(JSON.stringify(bundle)) as Record; + older["version"] = SECRET_BUNDLE_SCHEMA_VERSION - 1; + try { + importSecretBundle(other, older, { + passphrase: PASSPHRASE, + replace: false, + agent: "tester", + now: LATER, + }); + throw new Error("expected the version refusal"); + } catch (err) { + expect(err).toBeInstanceOf(SecretBundleError); + expect((err as SecretBundleError).code).toBe(BUNDLE_REFUSAL_CODES.version); + expect((err as SecretBundleError).message).toContain("re-export the bundle"); + } + expect(listSecrets(other)).toHaveLength(0); }); test("a crafted kdf cost block refuses by name before scrypt allocates", () => { From 3403bc4b1d0fb43fd0a968b2c8fb9ab2ce1cf460 Mon Sep 17 00:00:00 2001 From: Sergey Eroshenkov Date: Sat, 10 Oct 2026 18:45:03 +0200 Subject: [PATCH 45/84] fix(secrets): reach a wrapped store from a second process and unwrap it back A wrapped keyfile was unusable by every process except the one that ran unlock: the unlocked DEK lived only in that process's memory holder, so every following command refused as locked with a remedy that could not work. Three routes close the gap, all passphrase-based, none persisted: - Every key-bearing verb (set, rm, run, export, import) ingests a passphrase the way unlock does - --passphrase-from-env or stdin, where its own work does not occupy stdin - and unlocks before it runs. For export and import the ingested passphrase doubles as the bundle passphrase unless the environment carries the keyfile's. - A non-interactive host (the MCP server, a cron job) sets OPEN_SECOND_BRAIN_SECRETS_PASSPHRASE; the first key operation unwraps with it, holds the DEK, and deletes the variable, so the value neither lingers nor reaches a child process. A wrong value is the named passphrase refusal; the locked refusal's remedy text names every route. - `o2b brain secret unwrap` verifies the passphrase against the envelope and writes the raw keyfile back (fsync'd atomic rename, 0600), refusing a store that was never wrapped; the verb warns that raw key material is on disk again. The wrap that replaces the only copy of the key is also made durable: the envelope is unwrapped and compared against the key BEFORE any byte touches the filesystem, the write goes through the repo's atomic write (temp file, fsync, rename, parent-dir fsync) at mode 0600, and the written file is read back and unwrapped after the rename - a read-back that fails restores the raw key bytes before refusing. --- src/cli/brain/help-text.ts | 24 +- src/cli/brain/verbs/secret.ts | 121 +++++++++- src/core/brain/secrets/envelope.ts | 268 +++++++++++++++++++--- src/core/brain/secrets/store.ts | 45 ++++ tests/cli/brain-secret.test.ts | 166 +++++++++++++- tests/core/brain/secrets/envelope.test.ts | 103 ++++++++- tests/core/brain/secrets/store.test.ts | 92 +++++++- tests/helpers/run-cli.ts | 4 + 8 files changed, 767 insertions(+), 56 deletions(-) diff --git a/src/cli/brain/help-text.ts b/src/cli/brain/help-text.ts index fb97a7dd..3d7d2974 100644 --- a/src/cli/brain/help-text.ts +++ b/src/cli/brain/help-text.ts @@ -206,12 +206,13 @@ Common flags: * swallowing it while the flag's effect never happens. */ const SECRET_OP_FLAGS: Record> = { - set: ["env-var", "allow", "from-env", "agent"], + set: ["env-var", "allow", "from-env", "passphrase-from-env", "agent"], list: [], - rm: [], - run: ["agent"], + rm: ["passphrase-from-env"], + run: ["passphrase-from-env", "agent"], lock: [], unlock: ["passphrase-from-env"], + unwrap: ["passphrase-from-env"], export: ["out", "passphrase-from-env"], import: ["replace", "passphrase-from-env"], }; @@ -252,6 +253,7 @@ export const SECRET_VERB_USAGE = secretOpUsage("rm", "rm "), secretOpUsage("lock", "lock"), secretOpUsage("unlock", "unlock"), + secretOpUsage("unwrap", "unwrap"), secretOpUsage("export", "export"), secretOpUsage("import", "import FILE"), secretOpUsage("run", "run ", " -- "), @@ -696,11 +698,17 @@ export const VERB_HELP: Record = { "context leakage and vault sync exposure - not against root.\n" + "unlock wraps the keyfile under a passphrase (read from stdin or\n" + "--passphrase-from-env, never argv) and holds the key for this process\n" + - "only; lock clears it, and every other process must unlock separately.\n" + - "export writes every entry as one passphrase-encrypted bundle to the\n" + - "--out file (values re-encrypted; names and env-var mappings travel in\n" + - "the clear past the shared egress redactor); import restores a bundle,\n" + - "refusing names the store already holds unless --replace is given.\n" + + "only; lock clears it. A wrapped store reaches every key-bearing verb:\n" + + "each accepts --passphrase-from-env (or stdin, where its own work does\n" + + "not occupy stdin) and unlocks before it runs, and a non-interactive\n" + + "host sets OPEN_SECOND_BRAIN_SECRETS_PASSPHRASE, which the first key\n" + + "operation consumes and deletes. unwrap writes the raw keyfile back,\n" + + "ending the passphrase protection. export writes every entry as one\n" + + "passphrase-encrypted bundle to the --out file (values re-encrypted and\n" + + "authenticated against their entry metadata; names and env-var mappings\n" + + "travel in the clear past the shared egress redactor); import restores a\n" + + "bundle, refusing names the store already holds unless --replace is\n" + + "given.\n" + "WARNING: a lost passphrase is unrecoverable - every stored value and\n" + "every exported bundle stays unreadable forever, and nothing recovers\n" + "them.\n", diff --git a/src/cli/brain/verbs/secret.ts b/src/cli/brain/verbs/secret.ts index 5df0c6e0..5aa4c125 100644 --- a/src/cli/brain/verbs/secret.ts +++ b/src/cli/brain/verbs/secret.ts @@ -1,17 +1,26 @@ /** - * `o2b brain secret ` + * `o2b brain secret ` * (t_0b134404, t_e6667a56, t_592d9e91): capability-gated secret custody. * `set` ingests the value from stdin or --from-env - NEVER from argv, * where it would land in shell history and process lists; `list` shows * metadata only; `run -- cmd...` injects the secret into an * allowlisted subprocess env and returns redacted output; `unlock` wraps * the keyfile under a passphrase (first unlock) and holds the key for - * this process only; `lock` clears that holder; `export` writes every + * this process only; `lock` clears that holder; `unwrap` writes the raw + * keyfile back (the way back from a wrapped store); `export` writes every * entry as one passphrase-encrypted bundle to an operator-named `--out`, * with the shared egress redactor run over the bundle's non-ciphertext * metadata tree; `import` restores a bundle. No surface ever prints a * value or a passphrase. * + * A wrapped store reaches every key-bearing verb (set, rm, run, export, + * import): the verb ingests a passphrase the way `unlock` does - via + * `--passphrase-from-env` or stdin - and unlocks before the operation, + * so one command can carry both the passphrase and its work. A host that + * cannot answer a prompt (the MCP server, a cron job) sets + * OPEN_SECOND_BRAIN_SECRETS_PASSPHRASE instead; the first key operation + * consumes it, holds the key, and deletes the variable. + * * A LOST PASSPHRASE IS UNRECOVERABLE: after `unlock` has wrapped the * keyfile, the stored values stay unreadable forever without it - and a * lost bundle passphrase loses the bundle. There is no recovery path and @@ -31,14 +40,18 @@ import { bundleFromEgressScan, exportSecretBundle, importSecretBundle, + resealBundleMetadata, } from "../../../core/brain/secrets/bundle.ts"; import { listSecrets, lockSecretKeyfile, removeSecret, setSecret, + storeLockedForThisProcess, unlockSecretKeyfile, + unwrapSecretKeyfile, } from "../../../core/brain/secrets/store.ts"; +import { SECRET_STORE_PASSPHRASE_ENV } from "../../../core/brain/secrets/envelope.ts"; import { formatSecretsSyncExposure, formatWrappedKeyfileExposure, @@ -57,6 +70,11 @@ const USAGE = SECRET_VERB_USAGE; const UNLOCKED_NOTE = "keyfile unlocked for this process; the passphrase is held in memory only and never written"; const LOCKED_NOTE = "keyfile locked; this process's unlocked-key holder is cleared"; +const UNWRAPPED_NOTE = + "keyfile unwrapped; the raw key is on disk again and the passphrase wrap is gone"; +const UNWRAPPED_WARNING = + "warning: the keyfile holds raw key material again and the passphrase no longer protects " + + `it - re-run "o2b brain secret unlock" to wrap it when the raw key is no longer needed\n`; /** The declared egress site of the bundle export (src/core/egress/registry.ts). */ const BUNDLE_EGRESS_SITE = "brain-secret-bundle-export" as const; @@ -94,6 +112,53 @@ async function ingestPassphrase( return { passphrase }; } +/** + * Unlock a wrapped store ahead of a key-bearing operation, so one + * command invocation can carry the passphrase AND its work. No-op when + * the store is not wrapped, this process already holds the key, or the + * well-known environment variable is set - in the last case the first + * key operation consumes the variable itself, and an ingestion here + * would read an empty source and refuse a store that is about to unlock. + * `stdinFree` is false where the verb's own work occupies stdin (`set` + * reads the VALUE from it): without a route the caller proceeds and the + * store's named locked refusal, whose remedy text names every route, is + * what the operator sees. + */ +async function ensureStoreUnlocked( + vault: string, + op: string, + flags: Record, + stdinFree: boolean, + ctx: { agent: string; now: Date }, +): Promise { + if (!storeLockedForThisProcess(vault)) return null; + if (process.env[SECRET_STORE_PASSPHRASE_ENV] !== undefined) return null; + if (flags["passphrase-from-env"] === undefined && !stdinFree) return null; + const ingested = await ingestPassphrase(op, flags); + if ("exitCode" in ingested) return ingested.exitCode; + unlockSecretKeyfile(vault, ingested.passphrase, ctx); + return null; +} + +/** + * The store unlock a wrapped store needs from export/import: the SAME + * passphrase the verb already ingested for the bundle, unless the + * environment variable is set, in which case the key operation unlocks + * from there and the ingested passphrase stays the bundle's alone. + * Passphrase-protected store and bundle are one secret per invocation + * here; an operator who keeps them apart sets the environment variable + * to the keyfile passphrase. + */ +async function unlockStoreForBundleOp( + vault: string, + passphrase: string, + ctx: { agent: string; now: Date }, +): Promise { + if (!storeLockedForThisProcess(vault)) return; + if (process.env[SECRET_STORE_PASSPHRASE_ENV] !== undefined) return; + unlockSecretKeyfile(vault, passphrase, ctx); +} + export async function cmdBrainSecret(argv: string[]): Promise { // `run -- cmd...`: everything after `--` belongs to the // subprocess verbatim and must not be flag-parsed. @@ -121,6 +186,7 @@ export async function cmdBrainSecret(argv: string[]): Promise { op !== "run" && op !== "lock" && op !== "unlock" && + op !== "unwrap" && op !== "export" && op !== "import" ) { @@ -172,6 +238,14 @@ export async function cmdBrainSecret(argv: string[]): Promise { return 2; } } + // A wrapped store unlocks first; the value occupies stdin here, + // so the passphrase must come from --passphrase-from-env or the + // well-known environment variable (see ensureStoreUnlocked). + const unlocked = await ensureStoreUnlocked(vault, "set", flags, fromEnv !== undefined, { + agent, + now, + }); + if (unlocked !== null) return unlocked; const metadata = setSecret(vault, { name: name!, value, @@ -205,6 +279,8 @@ export async function cmdBrainSecret(argv: string[]): Promise { return 0; } case "rm": { + const unlocked = await ensureStoreUnlocked(vault, "rm", flags, true, { agent, now }); + if (unlocked !== null) return unlocked; const removed = removeSecret(vault, name!, { agent, now }); if (!removed) return fail(`secret rm: unknown secret "${name}"`); if (asJson) okJson({ removed: name }); @@ -228,6 +304,21 @@ export async function cmdBrainSecret(argv: string[]): Promise { else ok(UNLOCKED_NOTE); return 0; } + case "unwrap": { + const ingested = await ingestPassphrase("unwrap", flags); + if ("exitCode" in ingested) return ingested.exitCode; + // The way back from a wrapped store: the passphrase is verified + // against the envelope first, then the raw key is written over it + // (fsync'd atomic rename, 0600). A store that was never wrapped + // refuses by name. + unwrapSecretKeyfile(vault, ingested.passphrase, { agent, now }); + // The raw key material is back on disk - say so before the + // success note, so the state change is what the operator reads. + process.stderr.write(UNWRAPPED_WARNING); + if (asJson) okJson({ unwrapped: true }); + else ok(UNWRAPPED_NOTE); + return 0; + } case "lock": { lockSecretKeyfile(vault, { agent, now }); if (asJson) okJson({ locked: true }); @@ -242,6 +333,10 @@ export async function cmdBrainSecret(argv: string[]): Promise { } const ingested = await ingestPassphrase("export", flags); if ("exitCode" in ingested) return ingested.exitCode; + // Exporting needs the plaintext values, so a wrapped store + // unlocks first - with the same passphrase the bundle is sealed + // under, unless the environment variable carries the keyfile's. + await unlockStoreForBundleOp(vault, ingested.passphrase, { agent, now }); const bundle = exportSecretBundle(vault, ingested.passphrase, { agent, now }); // The destination is operator-named and leaves the machine, so the // shared egress guard runs before any byte is written - over the @@ -252,7 +347,16 @@ export async function cmdBrainSecret(argv: string[]): Promise { if (verdict.outcome !== "released") { return fail(`secret export: ${verdict.detail}`); } - const payload = verdict.redacted ? bundleFromEgressScan(bundle, verdict.payload) : bundle; + // A redaction that edited an allow pattern changes the metadata + // the values are authenticated against, so the merged bundle is + // re-sealed under the metadata that actually ships. + const payload = verdict.redacted + ? resealBundleMetadata( + bundleFromEgressScan(bundle, verdict.payload), + ingested.passphrase, + bundle, + ) + : bundle; atomicWriteFileSync(out, `${JSON.stringify(payload, null, 2)}\n`); if (verdict.redacted) process.stderr.write(EGRESS_REDACTION_NOTICE); if (asJson) okJson({ out, exported: Object.keys(bundle.entries).length }); @@ -262,6 +366,11 @@ export async function cmdBrainSecret(argv: string[]): Promise { case "import": { const ingested = await ingestPassphrase("import", flags); if ("exitCode" in ingested) return ingested.exitCode; + // Importing writes into the target store's ciphertext, so a + // wrapped target unlocks first - with the same passphrase the + // bundle opens under, unless the environment variable carries the + // keyfile's. + await unlockStoreForBundleOp(vault, ingested.passphrase, { agent, now }); let bundle: unknown; try { bundle = JSON.parse(readFileSync(name!, "utf8")); @@ -290,6 +399,12 @@ export async function cmdBrainSecret(argv: string[]): Promise { process.stderr.write(`brain secret run: a command is required after --\n${USAGE}\n`); return 2; } + // A wrapped store unlocks first. The subprocess inherits this + // process's stdin, so an operator piping the passphrase should + // know the child sees the drained pipe; --passphrase-from-env is + // the route that leaves stdin alone. + const unlocked = await ensureStoreUnlocked(vault, "run", flags, true, { agent, now }); + if (unlocked !== null) return unlocked; const result = await runWithSecret(vault, name!, commandArgs, { agent, now }); if (asJson) { okJson({ exit_code: result.exitCode, stdout: result.stdout, stderr: result.stderr }); diff --git a/src/core/brain/secrets/envelope.ts b/src/core/brain/secrets/envelope.ts index 84930149..171ef886 100644 --- a/src/core/brain/secrets/envelope.ts +++ b/src/core/brain/secrets/envelope.ts @@ -19,7 +19,12 @@ * this module. There is no daemon: every CLI invocation and the MCP server * unlock separately, `lock` clears this process's holder, and a second * context over the same vault gets the named locked-store refusal. The - * passphrase itself is never persisted, logged, or audited. + * one cross-process bridge is the environment: a host launched with + * `OPEN_SECOND_BRAIN_SECRETS_PASSPHRASE` set unwraps with it at the first + * key operation, holds the DEK, and deletes the variable, so a + * non-interactive host that cannot answer a prompt still reaches its + * wrapped store. The passphrase itself is never persisted, logged, or + * audited. * * Honest warning, stated here because there is no recovery path and none * is pretended: a lost passphrase makes every stored secret permanently @@ -28,10 +33,18 @@ */ import { randomBytes, scryptSync, timingSafeEqual } from "node:crypto"; -import { chmodSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"; -import { resolve } from "node:path"; - -import { renameWithRetry } from "../../fs-atomic.ts"; +import { + chmodSync, + closeSync, + fsyncSync, + openSync, + readFileSync, + unlinkSync, + writeSync, +} from "node:fs"; +import { dirname, resolve } from "node:path"; + +import { atomicWriteText, renameWithRetry } from "../../fs-atomic.ts"; import { type EncryptedValue, decryptValue, encryptValue } from "./value-cipher.ts"; import { restrictToOwner } from "./owner-acl.ts"; @@ -157,16 +170,26 @@ export class SecretEnvelopeError extends Error { /** Stable code every locked-store refusal carries. */ export const SECRET_STORE_LOCKED_CODE = "secret_store_locked"; +/** + * The environment variable a non-interactive host (the MCP server, a + * cron-driven CLI) sets to hand the store its passphrase: the FIRST + * key-bearing operation that meets the wrapped keyfile unwraps with it, + * holds the DEK for the process, and DELETES the variable, so the + * passphrase never lingers in the environment nor rides into a child + * process. The value is consumed once per process whether or not it + * unwraps. + */ +export const SECRET_STORE_PASSPHRASE_ENV = "OPEN_SECOND_BRAIN_SECRETS_PASSPHRASE"; + /** * A key-bearing operation met a wrapped keyfile and this process holds no - * unlocked key for it. The remedy is the unlock op, named in the message. - * - * The remedy is honest about the surface that prints it: the unlocked key - * lives in this module's per-process, memory-only holder, so the unlock - * the message names applies to THIS process only and the passphrase is - * never persisted - a one-shot CLI command cannot carry the unlock into - * the next command, and the text says so rather than pointing at a - * remedy that silently cannot work across processes. + * unlocked key for it. The remedy is one of: give the passphrase to the + * key-bearing command itself (`--passphrase-from-env` or stdin - the + * key-bearing verbs ingest it), set + * {@link SECRET_STORE_PASSPHRASE_ENV} for a non-interactive host such as + * the MCP server (read once at the first key operation, then dropped), + * run the unlock op for this process, or unwrap the store back to a raw + * keyfile. * * The MESSAGE is path-free by construction: the prose travels into model * context through consumers that surface error text verbatim (a config @@ -181,10 +204,12 @@ export class SecretStoreLockedError extends Error { constructor(keyPath: string) { super( - `the secret store is locked (the keyfile is passphrase-wrapped): run ` + - `"o2b brain secret unlock" to unwrap it for this process - the unlock ` + - `applies to this process only and the passphrase is never persisted, ` + - `so a key-bearing command must run in the same process that unlocked it`, + `the secret store is locked (the keyfile is passphrase-wrapped): pass the ` + + `passphrase to this command via --passphrase-from-env or stdin, or set ` + + `${SECRET_STORE_PASSPHRASE_ENV} for this process (a non-interactive host such ` + + `as the MCP server reads it once and drops it), or run ` + + `"o2b brain secret unlock" to unlock this process only - the passphrase is ` + + `never persisted; "o2b brain secret unwrap" writes the raw keyfile back`, ); this.name = "SecretStoreLockedError"; this.keyPath = keyPath; @@ -246,11 +271,53 @@ export function heldUnlockedKey(keyPath: string): Buffer | null { /** * The held key, or the named locked-store refusal. This is the branch * `loadOrCreateKey` takes when the keyfile on disk is an envelope. + * + * Before refusing, the holder consults the environment once: a host that + * launched this process with {@link SECRET_STORE_PASSPHRASE_ENV} set + * (the MCP server, a cron-driven command) unwraps with it at the FIRST + * key operation - which is the whole of that host's startup story, since + * the value is consumed and deleted there, whether or not it unwraps. A + * wrong or absent value leaves the named locked-store refusal standing; + * a wrong value that was offered is the named passphrase refusal, which + * says what to fix. */ export function heldKeyOrRefusal(keyPath: string): Buffer { const held = HELD_KEYS.get(holderSlot(keyPath)); - if (held === undefined) throw new SecretStoreLockedError(keyPath); - return held; + if (held !== undefined) return held; + const fromEnvironment = unlockFromEnvironmentOffer(keyPath); + if (fromEnvironment !== null) return fromEnvironment; + throw new SecretStoreLockedError(keyPath); +} + +/** + * Consume-once environment unlock. Reads + * {@link SECRET_STORE_PASSPHRASE_ENV}, DELETES it (so the passphrase + * neither lingers in the environment of this process nor reaches a child + * it would later spawn), and unwraps the envelope with the value. Null + * when the variable is unset - or set empty, which is treated as not + * offered the same way the CLI's ingestion treats a blank as no + * passphrase. A non-empty value that does not unwrap is the named + * passphrase refusal naming the variable; any other envelope refusal + * (version, cost curve) propagates as itself. + */ +function unlockFromEnvironmentOffer(keyPath: string): Buffer | null { + const offered = process.env[SECRET_STORE_PASSPHRASE_ENV]; + if (offered === undefined) return null; + delete process.env[SECRET_STORE_PASSPHRASE_ENV]; + if (offered.length === 0) return null; + try { + return unlockKeyfile(keyPath, offered); + } catch (err) { + if (err instanceof SecretEnvelopeError && err.code === ENVELOPE_REFUSAL_CODES.passphrase) { + throw new SecretEnvelopeError( + ENVELOPE_REFUSAL_CODES.passphrase, + keyPath, + `the passphrase in ${SECRET_STORE_PASSPHRASE_ENV} does not unwrap this envelope ` + + "(wrong passphrase, or the envelope is corrupt)", + ); + } + throw err; + } } /** Lock: drop this process's held key, overwriting its bytes first. */ @@ -431,13 +498,46 @@ export function verifyKeyfilePassphrase(keyPath: string, passphrase: string): vo unwrapWith(deriveWrapKey(passphrase, envelope.kdf), envelope, keyPath); } +/** Test seams of {@link wrapKeyfile}, so its failure paths are drivable. */ +export interface WrapKeyfileSeams { + /** + * Test-only replacement for the seal step, so a test can produce an + * envelope that does not unwrap to the DEK it claims to wrap (the + * refusal that must fire BEFORE anything touches the keyfile). + */ + readonly seal?: (wrapKey: Buffer, plaintextBase64: string) => EncryptedValue; + /** + * Test-only replacement for the atomic write, so a test can land bytes + * that do not parse as the envelope that was built - the read-back + * verification's restore path. + */ + readonly write?: (target: string, envelopeText: string) => void; +} + /** * Replace the raw keyfile at `keyPath` with the envelope wrapping `dek` - * under `passphrase`. The swap is atomic and lands at mode 0600: a torn - * wrap would destroy the only copy of the DEK, and the file this replaces - * was owner-only, so the envelope must inherit exactly that discipline. + * under `passphrase`. This swap replaces the ONLY copy of the DEK, so it + * is armored twice: + * + * 1. The in-memory envelope is unwrapped and compared against `dek` + * BEFORE any byte touches the filesystem - a seal that does not + * round-trip refuses with the raw keyfile untouched. + * 2. The write goes through the repo's atomic write (temp file, fsync, + * rename, parent-dir fsync) at mode 0600, and the file is read back + * and unwrapped after the rename; a read-back that fails restores the + * raw keyfile bytes (fsync'd, 0600) before refusing, so the wrap can + * never leave the key less recoverable than it was. + * + * The swapped-in file carries the same owner-only discipline the raw + * file had: `restrictToOwner` (the Windows ACL) and a 0600 mode on + * POSIX. */ -export function wrapKeyfile(keyPath: string, passphrase: string, dek: Buffer): KeyfileEnvelope { +export function wrapKeyfile( + keyPath: string, + passphrase: string, + dek: Buffer, + seams: WrapKeyfileSeams = {}, +): KeyfileEnvelope { if (passphrase.length === 0) { throw new SecretEnvelopeError( ENVELOPE_REFUSAL_CODES.passphrase, @@ -453,25 +553,47 @@ export function wrapKeyfile(keyPath: string, passphrase: string, dek: Buffer): K ); } const kdf = freshWrapKdfParams(); + const derived = deriveWrapKey(passphrase, kdf); + const seal = seams.seal ?? ((key: Buffer, plaintext: string) => encryptValue(key, plaintext)); const envelope: KeyfileEnvelope = { version: KEYFILE_ENVELOPE_SCHEMA_VERSION, kdf, - wrapped: encryptValue(deriveWrapKey(passphrase, kdf), dek.toString("base64")), + wrapped: seal(derived, dek.toString("base64")), }; - const tmp = `${keyPath}.wrap-tmp`; - try { - writeFileSync(tmp, `${JSON.stringify(envelope, null, 2)}\n`, { - encoding: "utf8", - mode: 0o600, + // The produced envelope must unwrap to the exact key it wraps, BEFORE + // the rename replaces the only copy of the raw key with it. A seal + // that fails this check refuses here and the raw keyfile is untouched. + const unwrapped = unwrapWith(derived, envelope, keyPath); + if (!timingSafeEqual(unwrapped, dek)) { + throw new SecretEnvelopeError( + ENVELOPE_REFUSAL_CODES.malformed, + keyPath, + "the produced envelope does not unwrap to the key it wraps; the raw keyfile was left untouched", + ); + } + const envelopeText = `${JSON.stringify(envelope, null, 2)}\n`; + const write = + seams.write ?? + ((target: string, text: string) => { + atomicWriteText(target, text, { mode: 0o600 }); }); - renameWithRetry(tmp, keyPath); - } catch (err) { - try { - unlinkSync(tmp); - } catch { - // The tmp may never have been created; the original error is what - // the caller needs. + write(keyPath, envelopeText); + // Read back what actually landed and unwrap it. The rename replaced + // the raw keyfile, so a mangled or foreign envelope at the path is the + // destruction of the only copy of the DEK: restore those raw bytes + // (fsync'd, 0600) before refusing. + try { + const landed = readEnvelope(keyPath); + const restored = unwrapWith(derived, landed, keyPath); + if (!timingSafeEqual(restored, dek)) { + throw new SecretEnvelopeError( + ENVELOPE_REFUSAL_CODES.malformed, + keyPath, + "the written envelope does not unwrap to the key it wraps", + ); } + } catch (err) { + writeRawKeyfileBytes(keyPath, dek); throw err; } // The envelope now IS the keyfile: the same owner-only treatment the raw @@ -494,6 +616,80 @@ export function wrapKeyfile(keyPath: string, passphrase: string, dek: Buffer): K return envelope; } +/** + * Atomically write the raw DEK bytes over whatever sits at `keyPath`: + * the restore half of {@link wrapKeyfile}'s read-back check, and the + * write half of `secret unwrap` (the envelope's way back to a raw + * keyfile). fsync'd temp file, mode 0600, rename, parent-dir fsync. + */ +export function writeRawKeyfileBytes(keyPath: string, dek: Buffer): void { + if (dek.length !== DEK_BYTES) { + throw new SecretEnvelopeError( + ENVELOPE_REFUSAL_CODES.malformed, + keyPath, + `refusing to write ${String(dek.length)} bytes of key material, expected ${String(DEK_BYTES)}`, + ); + } + const tmp = `${keyPath}.raw-tmp`; + let fd: number | null = null; + try { + fd = openSync(tmp, "wx", 0o600); + let written = 0; + while (written < dek.byteLength) { + written += writeSync(fd, dek, written, dek.byteLength - written); + } + fsyncSync(fd); + closeSync(fd); + fd = null; + renameWithRetry(tmp, keyPath); + } catch (err) { + if (fd !== null) { + try { + closeSync(fd); + } catch { + // The first close already ran or the fd is dead; the error below + // is what the caller needs. + } + } + try { + unlinkSync(tmp); + } catch { + // The tmp may never have been created. + } + throw err; + } + // Best-effort parent-dir fsync, the same durability the repo's atomic + // write gives every rename: the raw key must survive a crash right + // after this returns. + try { + const dfd = openSync(dirname(keyPath), "r"); + try { + fsyncSync(dfd); + } finally { + closeSync(dfd); + } + } catch { + // Not every platform supports a directory fsync; the rename itself is + // already atomic. + } +} + +/** + * Verify the passphrase, then replace the envelope at `keyPath` with the + * raw DEK bytes it wraps - the way back from a wrapped store to the + * plain keyfile, and the only recovery when a host cannot carry a + * passphrase (or the operator wants to retire the wrap). The envelope is + * only overwritten after the passphrase has unwrapped it, so a wrong + * passphrase leaves the wrap intact. The raw key material on disk is the + * caller's warning to give, not this module's. + */ +export function unwrapKeyfileToRaw(keyPath: string, passphrase: string): Buffer { + const envelope = readEnvelope(keyPath); + const dek = unwrapWith(deriveWrapKey(passphrase, envelope.kdf), envelope, keyPath); + writeRawKeyfileBytes(keyPath, dek); + return dek; +} + /** * Unlock: verify the passphrase and hold the DEK for this process. * Returns the unwrapped key. A second unlock over an already-held path diff --git a/src/core/brain/secrets/store.ts b/src/core/brain/secrets/store.ts index 0cbb767e..c84a96da 100644 --- a/src/core/brain/secrets/store.ts +++ b/src/core/brain/secrets/store.ts @@ -26,9 +26,11 @@ import { assertVaultIdentityForWrite } from "../vault-identity.ts"; import { decryptValue, encryptValue, loadOrCreateKey, type EncryptedValue } from "./crypto.ts"; import { clearHeldKey, + heldUnlockedKey, isEnvelopeFile, SecretStoreKeyfileMissingError, unlockKeyfile as unlockKeyfileAtPath, + unwrapKeyfileToRaw, wrapKeyfile as wrapKeyfileAtPath, } from "./envelope.ts"; import { restrictToOwner } from "./owner-acl.ts"; @@ -391,6 +393,49 @@ export function lockSecretKeyfile(vault: string, ctx: SecretAuditContext): void audit(vault, ctx, "secret_locked", "keyfile", {}); } +/** + * Whether a key-bearing operation over this vault would refuse as + * locked in THIS process: the keyfile is an envelope and the holder is + * empty. The CLI's key-bearing verbs consult it to decide whether the + * passphrase they are about to ingest is needed as the store unlock + * (and not, say, only the bundle passphrase). + */ +export function storeLockedForThisProcess(vault: string): boolean { + const kp = keyPath(vault); + return isEnvelopeFile(kp) && heldUnlockedKey(kp) === null; +} + +/** + * Unwrap: replace the passphrase-wrapped keyfile with the raw 32-byte + * DEK it holds, the way back from a wrapped store. The passphrase is + * verified against the envelope BEFORE the swap, so a wrong passphrase + * leaves the wrap intact; a torn write is impossible (fsync'd atomic + * rename) and the raw bytes land at mode 0600. + * + * Refuses a store that was never wrapped - unwrapping there would mint + * nothing and say something false. This write puts key material back on + * disk in the clear; the verb that calls it says so on stderr, and the + * custody record below marks the state change. + */ +export function unwrapSecretKeyfile( + vault: string, + passphrase: string, + ctx: SecretAuditContext, +): void { + assertVaultIdentityForWrite(vault); + const kp = keyPath(vault); + if (!isEnvelopeFile(kp)) { + throw new Error( + `secret unwrap: the keyfile is not passphrase-wrapped, nothing to unwrap: ${kp}`, + ); + } + unwrapKeyfileToRaw(kp, passphrase); + // The holder is cleared with the wrap: this process reverts to plain + // raw-keyfile behavior, where the file itself is the key. + clearHeldKey(kp); + audit(vault, ctx, "secret_unwrapped", "keyfile", { keyfile_was_wrapped: true }); +} + /** * The paths whose owner-only protection `set` requires before it stores * material: the directory and the keyfile, which `loadOrCreateKey` has diff --git a/tests/cli/brain-secret.test.ts b/tests/cli/brain-secret.test.ts index 5a51a2a2..e662a34e 100644 --- a/tests/cli/brain-secret.test.ts +++ b/tests/cli/brain-secret.test.ts @@ -132,9 +132,10 @@ test("unlock wraps the raw keyfile; a fresh process sees the locked refusal unti expect(readFileSync(keyfile, "utf8").startsWith("{")).toBe(true); // A fresh process holds no unlocked key: the store refuses by name, and - // the remedy it names is honest about the surface that prints it - the - // unlock applies to one process and the passphrase is never persisted, - // so a one-shot command cannot carry the unlock into the next command. + // the remedy it names is honest about every route that works - the + // key-bearing verbs' own passphrase ingestion, the environment variable + // a non-interactive host sets, the unlock op for this process, and the + // unwrap op that ends the wrap. const locked = await runCli(["brain", "secret", "set", "other", "--vault", vault], { stdin: "sk-other-24680\n", }); @@ -143,6 +144,8 @@ test("unlock wraps the raw keyfile; a fresh process sees the locked refusal unti expect(locked.stderr).toContain("o2b brain secret unlock"); expect(locked.stderr).toContain("this process only"); expect(locked.stderr).toContain("never persisted"); + expect(locked.stderr).toContain("--passphrase-from-env"); + expect(locked.stderr).toContain("OPEN_SECOND_BRAIN_SECRETS_PASSPHRASE"); // The right passphrase unlocks again; a wrong one refuses by name. const again = await runCli(["brain", "secret", "unlock", "--vault", vault], { @@ -156,6 +159,154 @@ test("unlock wraps the raw keyfile; a fresh process sees the locked refusal unti expect(wrong.stderr).toContain("passphrase"); }, 20000); +test("a wrapped store works from a SECOND command: passphrase from env or stdin, per verb", async () => { + // The unlock holds the key for ITS process only, so every command below + // is a separate invocation that must reach the wrapped store on its own + // - each one a fresh subprocess, as a cron job or a script would be. + await runCli(["brain", "secret", "set", "api-key", "--allow", "bun -e *", "--vault", vault], { + stdin: "sk-cross-97531\n", + }); + const passphrase = fakeCredential("cross-proc", "-pass-", "42"); + const unlock = await runCli(["brain", "secret", "unlock", "--vault", vault], { + stdin: `${passphrase}\n`, + }); + expect(unlock.returncode).toBe(0); + + const env = { O2B_TEST_STORE_PASS: passphrase }; + // run: the passphrase rides --passphrase-from-env, the subprocess runs. + const run = await runCli( + [ + "brain", + "secret", + "run", + "api-key", + "--passphrase-from-env", + "O2B_TEST_STORE_PASS", + "--vault", + vault, + "--", + "bun", + "-e", + "process.exit(0)", + ], + { env, subprocess: true }, + ); + expect(run.returncode).toBe(0); + + // set: the value occupies stdin, so the passphrase must come from the + // flag (or the well-known variable); the store accepts a second secret. + const set2 = await runCli( + [ + "brain", + "secret", + "set", + "second-key", + "--passphrase-from-env", + "O2B_TEST_STORE_PASS", + "--vault", + vault, + ], + { env, stdin: "sk-second-86420\n" }, + ); + expect(set2.returncode).toBe(0); + + // rm: stdin is free, so the piped passphrase unlocks the store. + const rm = await runCli(["brain", "secret", "rm", "second-key", "--vault", vault], { + stdin: `${passphrase}\n`, + subprocess: true, + }); + expect(rm.returncode).toBe(0); + + // export: the bundle passphrase doubles as the store unlock; the + // exported bundle imports into a fresh vault. + const bundlePath = join(tmp, "cross-bundle.json"); + const exported = await runCli( + ["brain", "secret", "export", "--out", bundlePath, "--vault", vault], + { stdin: `${passphrase}\n` }, + ); + expect(exported.returncode).toBe(0); + const fresh = join(tmp, "fresh-vault"); + mkdirSync(join(fresh, "Brain"), { recursive: true }); + const imported = await runCli(["brain", "secret", "import", bundlePath, "--vault", fresh], { + stdin: `${passphrase}\n`, + }); + expect(imported.returncode).toBe(0); + const listed = JSON.parse( + (await runCli(["brain", "secret", "list", "--vault", fresh, "--json"])).stdout, + ) as { secrets: Array<{ name: string }> }; + expect(listed.secrets.map((s) => s.name)).toEqual(["api-key"]); +}, 30000); + +test("the well-known environment variable alone reaches a wrapped store from a fresh process", async () => { + await runCli(["brain", "secret", "set", "api-key", "--allow", "bun -e *", "--vault", vault], { + stdin: "sk-envvar-19283\n", + }); + const passphrase = fakeCredential("env-var-proc", "-pass-", "42"); + const unlock = await runCli(["brain", "secret", "unlock", "--vault", vault], { + stdin: `${passphrase}\n`, + }); + expect(unlock.returncode).toBe(0); + + // No flag, no stdin: the MCP-server-shaped route. The command succeeds + // and the variable is consumed by the child - it cannot leak to a + // grandchild through the environment it leaves behind. + const run = await runCli( + [ + "brain", + "secret", + "run", + "api-key", + "--vault", + vault, + "--", + "bun", + "-e", + "process.exit(process.env.OPEN_SECOND_BRAIN_SECRETS_PASSPHRASE === undefined ? 0 : 1)", + ], + { env: { OPEN_SECOND_BRAIN_SECRETS_PASSPHRASE: passphrase }, subprocess: true }, + ); + expect(run.returncode).toBe(0); +}, 20000); + +test("unwrap writes the raw keyfile back; a later command needs no passphrase; unwrap refuses when raw", async () => { + await runCli(["brain", "secret", "set", "api-key", "--allow", "bun -e *", "--vault", vault], { + stdin: "sk-unwrap-44556\n", + }); + const passphrase = fakeCredential("cli-unwrap", "-pass-", "42"); + const unlock = await runCli(["brain", "secret", "unlock", "--vault", vault], { + stdin: `${passphrase}\n`, + }); + expect(unlock.returncode).toBe(0); + const keyfile = join(vault, ".open-second-brain", "secrets", "keyfile"); + expect(readFileSync(keyfile, "utf8").startsWith("{")).toBe(true); + + const unwrap = await runCli(["brain", "secret", "unwrap", "--vault", vault], { + stdin: `${passphrase}\n`, + }); + expect(unwrap.returncode).toBe(0); + expect(unwrap.stderr).toContain("raw key"); + // The envelope is gone: 32 raw bytes again. + expect(readFileSync(keyfile).length).toBe(32); + + // A third command needs no passphrase at all - the store is raw again. + const run = await runCli( + ["brain", "secret", "run", "api-key", "--vault", vault, "--", "bun", "-e", "process.exit(0)"], + { subprocess: true }, + ); + expect(run.returncode).toBe(0); + + // Unwrapping an unwrapped store refuses by name. + const again = await runCli(["brain", "secret", "unwrap", "--vault", vault], { + stdin: `${passphrase}\n`, + }); + expect(again.returncode).toBe(1); + expect(again.stderr).toContain("not passphrase-wrapped"); + + // The wrong passphrase leaves the wrap intact (nothing was unwrapped by + // the refusals above, so re-wrap first is unnecessary: the store is raw, + // which the refusal above already proved). +}, 20000); + test("lock refuses a store that was never wrapped, creating nothing; lock after unlock reports cleared", async () => { const lock = await runCli(["brain", "secret", "lock", "--vault", vault]); expect(lock.returncode).toBe(1); @@ -351,17 +502,20 @@ describe("secret refusals and help accuracy", () => { const errorUsage = /^usage: .+$/m.exec(usageError.stderr)![0]!; expect(helpUsage).toBe(errorUsage); expect(helpUsage).toContain( - "set [--env-var V] [--allow PATTERN]... [--from-env SRC] [--agent N] [--vault ] [--json]", + "set [--env-var V] [--allow PATTERN]... [--from-env SRC] [--passphrase-from-env SRC] [--agent N] [--vault ] [--json]", ); expect(helpUsage).toContain("list [--vault ] [--json]"); - expect(helpUsage).toContain("rm [--vault ]"); + expect(helpUsage).toContain("rm [--passphrase-from-env SRC] [--vault ]"); expect(helpUsage).toContain("lock [--vault ]"); expect(helpUsage).toContain("unlock [--passphrase-from-env SRC] [--vault ]"); + expect(helpUsage).toContain("unwrap [--passphrase-from-env SRC] [--vault ]"); expect(helpUsage).toContain("export --out FILE [--passphrase-from-env SRC] [--vault ]"); expect(helpUsage).toContain( "import FILE [--replace] [--passphrase-from-env SRC] [--vault ]", ); - expect(helpUsage).toContain("run [--agent N] [--vault ] [--json] -- "); + expect(helpUsage).toContain( + "run [--passphrase-from-env SRC] [--agent N] [--vault ] [--json] -- ", + ); }); test("an op refuses flags it never documents, by name", async () => { diff --git a/tests/core/brain/secrets/envelope.test.ts b/tests/core/brain/secrets/envelope.test.ts index b99ebb7c..bbf1ef5f 100644 --- a/tests/core/brain/secrets/envelope.test.ts +++ b/tests/core/brain/secrets/envelope.test.ts @@ -6,7 +6,7 @@ */ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { readdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { readdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -19,12 +19,15 @@ import { readEnvelope, SecretEnvelopeError, SecretStoreLockedError, + SECRET_STORE_PASSPHRASE_ENV, unlockKeyfile, + unwrapKeyfileToRaw, verifyKeyfilePassphrase, wrapKeyfile, } from "../../../../src/core/brain/secrets/envelope.ts"; import { loadOrCreateKey } from "../../../../src/core/brain/secrets/crypto.ts"; import { secretsDir } from "../../../../src/core/brain/secrets/store.ts"; +import { encryptValue } from "../../../../src/core/brain/secrets/value-cipher.ts"; import { fakeCredential } from "../../../helpers/fake-credentials.ts"; const KEY_BYTES = 32; @@ -207,4 +210,102 @@ describe("the keyfile envelope", () => { expect(loadOrCreateKey(keyPath).equals(dek)).toBe(true); expect(readFileSync(keyPath).length).toBe(KEY_BYTES); }); + + test("a produced envelope that does not unwrap refuses BEFORE the keyfile is touched", () => { + // The wrap replaces the only copy of the DEK, so a seal that does not + // round-trip must refuse while the raw keyfile is still on disk - not + // discover the breakage after the rename has destroyed it. + const dek = loadOrCreateKey(keyPath); + const before = readFileSync(keyPath); + const brokenTag = Buffer.from("0123456789abcdef0123456789abcdef").toString("base64"); + expect(() => + wrapKeyfile(keyPath, PASSPHRASE, dek, { + seal: (key, plaintext) => ({ ...encryptValue(key, plaintext), tag: brokenTag }), + }), + ).toThrow(SecretEnvelopeError); + // The raw keyfile is exactly as it was. + expect(readFileSync(keyPath).equals(before)).toBe(true); + expect(isEnvelopeFile(keyPath)).toBe(false); + expect(loadOrCreateKey(keyPath).equals(dek)).toBe(true); + }); + + test("a write that lands bytes which are not the envelope restores the raw key and refuses", () => { + const dek = loadOrCreateKey(keyPath); + expect(() => + wrapKeyfile(keyPath, PASSPHRASE, dek, { + write: (target) => writeFileSync(target, '{"version": 1, "kdf": {"n": 1'), + }), + ).toThrow(SecretEnvelopeError); + // The read-back check caught the mangled envelope and the raw key + // bytes were written back before the refusal. + expect(readFileSync(keyPath).equals(dek)).toBe(true); + expect(isEnvelopeFile(keyPath)).toBe(false); + expect(loadOrCreateKey(keyPath).equals(dek)).toBe(true); + expect((statSync(keyPath).mode & 0o777).toString(8)).toBe("600"); + }); + + test("a wrapped store unlocks from the environment once and the variable is consumed", () => { + const dek = loadOrCreateKey(keyPath); + wrapKeyfile(keyPath, PASSPHRASE, dek); + clearHeldKey(keyPath); + process.env[SECRET_STORE_PASSPHRASE_ENV] = PASSPHRASE; + try { + // The first key operation unlocks - the non-interactive host's + // startup story, since no prompt is answerable there. + expect(loadOrCreateKey(keyPath).equals(dek)).toBe(true); + expect(heldUnlockedKey(keyPath)).not.toBeNull(); + // The value is gone from the environment: consumed, never passed on. + expect(process.env[SECRET_STORE_PASSPHRASE_ENV]).toBeUndefined(); + // Later key operations serve from the holder. + expect(loadOrCreateKey(keyPath).equals(dek)).toBe(true); + } finally { + delete process.env[SECRET_STORE_PASSPHRASE_ENV]; + clearHeldKey(keyPath); + } + }); + + test("an environment passphrase that does not unwrap is the named refusal and is still consumed", () => { + const dek = loadOrCreateKey(keyPath); + wrapKeyfile(keyPath, PASSPHRASE, dek); + clearHeldKey(keyPath); + process.env[SECRET_STORE_PASSPHRASE_ENV] = WRONG_PASSPHRASE; + try { + // ONE failed attempt: the offer is consumed whether or not it + // unwraps, so the second attempt sees the locked refusal instead of + // paying scrypt for the same wrong value again. + let refusal: unknown; + try { + loadOrCreateKey(keyPath); + throw new Error("expected the passphrase refusal"); + } catch (err) { + refusal = err; + } + expect(refusal).toBeInstanceOf(SecretEnvelopeError); + expect((refusal as SecretEnvelopeError).code).toBe(ENVELOPE_REFUSAL_CODES.passphrase); + expect((refusal as Error).message).toContain(SECRET_STORE_PASSPHRASE_ENV); + expect(process.env[SECRET_STORE_PASSPHRASE_ENV]).toBeUndefined(); + expect(heldUnlockedKey(keyPath)).toBeNull(); + // With the offer consumed, the next attempt is the locked refusal. + expect(() => loadOrCreateKey(keyPath)).toThrow(SecretStoreLockedError); + } finally { + delete process.env[SECRET_STORE_PASSPHRASE_ENV]; + clearHeldKey(keyPath); + } + }); + + test("unwrapKeyfileToRaw verifies the passphrase, then writes the raw DEK back at 0600", () => { + const dek = loadOrCreateKey(keyPath); + wrapKeyfile(keyPath, PASSPHRASE, dek); + clearHeldKey(keyPath); + // A wrong passphrase leaves the wrap intact. + expect(() => unwrapKeyfileToRaw(keyPath, WRONG_PASSPHRASE)).toThrow(SecretEnvelopeError); + expect(isEnvelopeFile(keyPath)).toBe(true); + const restored = unwrapKeyfileToRaw(keyPath, PASSPHRASE); + expect(restored.equals(dek)).toBe(true); + expect(isEnvelopeFile(keyPath)).toBe(false); + expect(readFileSync(keyPath).equals(dek)).toBe(true); + if (process.platform !== "win32") { + expect((statSync(keyPath).mode & 0o777).toString(8)).toBe("600"); + } + }); }); diff --git a/tests/core/brain/secrets/store.test.ts b/tests/core/brain/secrets/store.test.ts index 70be2aad..eb666a78 100644 --- a/tests/core/brain/secrets/store.test.ts +++ b/tests/core/brain/secrets/store.test.ts @@ -25,6 +25,7 @@ import { clearHeldKey, heldUnlockedKey, isEnvelopeFile, + SECRET_STORE_PASSPHRASE_ENV, SecretStoreKeyfileMissingError, SecretStoreLockedError, unlockKeyfile, @@ -37,7 +38,9 @@ import { resolveSecretReadOnly, setSecret, secretsDir, + storeLockedForThisProcess, unlockSecretKeyfile, + unwrapSecretKeyfile, } from "../../../../src/core/brain/secrets/store.ts"; import { fakeCredential } from "../../../helpers/fake-credentials.ts"; import { IS_WINDOWS } from "../../../helpers/platform.ts"; @@ -274,8 +277,11 @@ describe("unlock/lock lifecycle", () => { // surface error prose (a config-probe error list, a search refusal); // the path under the vault is exactly what // `src/mcp/vault-path-field.ts` degrades to keep out. The remedy is - // named; the structured `keyPath` field stays for the callers that - // may name it. + // named - and honest about every route that works: the key-bearing + // verbs' passphrase ingestion, the environment variable a + // non-interactive host sets, the unlock op for this process, and the + // unwrap op that ends the wrap. The structured `keyPath` field stays + // for the callers that may name it. set(); const keyPath = join(secretsDir(vault), "keyfile"); wrapKeyfile(keyPath, PASSPHRASE, loadOrCreateKey(keyPath)); @@ -291,6 +297,9 @@ describe("unlock/lock lifecycle", () => { const message = (refusal as Error).message; expect(message).not.toContain(keyPath); expect(message).toContain("o2b brain secret unlock"); + expect(message).toContain("--passphrase-from-env"); + expect(message).toContain(SECRET_STORE_PASSPHRASE_ENV); + expect(message).toContain("o2b brain secret unwrap"); } finally { clearHeldKey(keyPath); } @@ -325,3 +334,82 @@ describe("unlock/lock lifecycle", () => { expect(readFileSync(join(secretsDir(vault), "secrets.json"), "utf8")).toBe(storeBefore); }); }); + +describe("a wrapped store reachable from a second process (t_e6667a56)", () => { + const PASSPHRASE = fakeCredential("reach-wrap-", "phrase-7c21"); + const WRONG_PASSPHRASE = fakeCredential("reach-wrong-", "phrase-7c21"); + + test("the environment variable unlocks the store for a read-only resolve and is consumed", () => { + // The non-interactive host's route: no prompt is answerable there, so + // the launch environment carries the passphrase and the first key + // operation turns it into a held key, dropping the variable. + set(); + const keyPath = join(secretsDir(vault), "keyfile"); + wrapKeyfile(keyPath, PASSPHRASE, loadOrCreateKey(keyPath)); + clearHeldKey(keyPath); + process.env[SECRET_STORE_PASSPHRASE_ENV] = PASSPHRASE; + try { + expect(resolveSecretReadOnly(vault, "embed-key").value).toBe("sk-super-secret-value"); + expect(process.env[SECRET_STORE_PASSPHRASE_ENV]).toBeUndefined(); + } finally { + delete process.env[SECRET_STORE_PASSPHRASE_ENV]; + clearHeldKey(keyPath); + } + }); + + test("storeLockedForThisProcess tracks the envelope and this process's holder", () => { + set(); + expect(storeLockedForThisProcess(vault)).toBe(false); + const keyPath = join(secretsDir(vault), "keyfile"); + wrapKeyfile(keyPath, PASSPHRASE, loadOrCreateKey(keyPath)); + clearHeldKey(keyPath); + expect(storeLockedForThisProcess(vault)).toBe(true); + unlockKeyfile(keyPath, PASSPHRASE); + expect(storeLockedForThisProcess(vault)).toBe(false); + clearHeldKey(keyPath); + expect(storeLockedForThisProcess(vault)).toBe(true); + }); +}); + +describe("unwrap lifecycle", () => { + const PASSPHRASE = fakeCredential("unwrap-wrap-", "phrase-51aa"); + const WRONG_PASSPHRASE = fakeCredential("unwrap-wrong-", "phrase-51aa"); + + test("unwrap refuses a store that was never wrapped, creating nothing", () => { + expect(() => unwrapSecretKeyfile(vault, PASSPHRASE, { agent: "tester", now: NOW })).toThrow( + /not passphrase-wrapped/, + ); + }); + + test("unwrap writes the raw key back; a wrong passphrase leaves the wrap intact", () => { + set(); + const keyPath = join(secretsDir(vault), "keyfile"); + const dek = loadOrCreateKey(keyPath); + wrapKeyfile(keyPath, PASSPHRASE, dek); + clearHeldKey(keyPath); + + expect(() => + unwrapSecretKeyfile(vault, WRONG_PASSPHRASE, { agent: "tester", now: NOW }), + ).toThrow(); + expect(isEnvelopeFile(keyPath)).toBe(true); + + unwrapSecretKeyfile(vault, PASSPHRASE, { agent: "tester", now: NOW }); + expect(isEnvelopeFile(keyPath)).toBe(false); + expect(readFileSync(keyPath).equals(dek)).toBe(true); + // The store reopens with the same key; the stored values survive. + expect(loadOrCreateKey(keyPath).equals(dek)).toBe(true); + expect(resolveSecretReadOnly(vault, "embed-key").value).toBe("sk-super-secret-value"); + + const auditDir = join(vault, "Brain", "log", "secret-custody"); + const raw = readdirSync(auditDir) + .map((f) => readFileSync(join(auditDir, f), "utf8")) + .join(""); + const actions = raw + .split("\n") + .filter((l) => l.trim().length > 0) + .map((l) => (JSON.parse(l) as { action: string }).action); + expect(actions).toContain("secret_unwrapped"); + expect(raw).not.toContain(PASSPHRASE); + clearHeldKey(keyPath); + }); +}); diff --git a/tests/helpers/run-cli.ts b/tests/helpers/run-cli.ts index abfc9ffd..5a4014db 100644 --- a/tests/helpers/run-cli.ts +++ b/tests/helpers/run-cli.ts @@ -69,6 +69,10 @@ const RUNTIME_OVERRIDABLE_ENV = [ // The codegraph partner switch, for the same reason and with a default // of its own below. PARTNER_CODEGRAPH_DISABLED_ENV, + // The secret store's environment unlock is consumed-once; a developer + // shell that happens to carry it must not turn a locked-store test into + // an unlocked one (or steal the value mid-run). + "OPEN_SECOND_BRAIN_SECRETS_PASSPHRASE", ] as const; export interface RunCliOptions { From 8882778b8b8286c22ac4728568a46d0b09c5c65f Mon Sep 17 00:00:00 2001 From: Sergey Eroshenkov Date: Sat, 10 Oct 2026 18:45:12 +0200 Subject: [PATCH 46/84] fix(brain): bind import approval digest to the content it approved The adoption-plan seal covered each row's basename, preference id and action only, so a memory file rewritten after the approved dry run still applied whenever its action came out the same. Each plan row now carries the sha256 of its source entry and of its rendered preference body - rendered under a fixed clock so the seal stays wall-clock-free - and an apply whose content no longer matches the approved digest refuses before the snapshot or any write. --- src/core/brain/import-claude-memory.ts | 106 ++++++++++++++---- .../import-claude-memory-orchestrator.test.ts | 47 ++++++++ 2 files changed, 133 insertions(+), 20 deletions(-) diff --git a/src/core/brain/import-claude-memory.ts b/src/core/brain/import-claude-memory.ts index a09cabc5..56d07a64 100644 --- a/src/core/brain/import-claude-memory.ts +++ b/src/core/brain/import-claude-memory.ts @@ -2,7 +2,7 @@ import { existsSync, mkdirSync, readFileSync, statSync } from "node:fs"; import { basename, dirname, join } from "node:path"; import { atomicWriteFileSync } from "../fs-atomic.ts"; -import { digestVerifies, sealWithDigest } from "../integrity/digest.ts"; +import { digestVerifies, sealWithDigest, sha256Hex } from "../integrity/digest.ts"; import { appendLogEvent } from "./log.ts"; import { BRAIN_LOG_EVENT_KIND, BRAIN_SNAPSHOT_REASON } from "./types.ts"; import { createSnapshot } from "./snapshot.ts"; @@ -54,29 +54,68 @@ export interface ImportClaudeMemoryResult { readonly localDate: string; /** * The seal of the adoption plan (t_18fda844): sha256 of the plans, - * skips, conflicts and unchanged rows, via the integrity module's - * `sealWithDigest`. Wall-clock fields (`localDate`, the import - * timestamps) are deliberately OUTSIDE the sealed body, so a dry run - * and a later apply of the same content seal identically and an - * approval binds the plan, never the moment it was printed. + * skips, conflicts and unchanged rows - each plan row bound to the + * sha256 of its SOURCE entry and of its RENDERED body, clock fields + * normalized out of the latter (see {@link renderedBodySeal}) - via + * the integrity module's `sealWithDigest`. Wall-clock fields + * (`localDate`, the import timestamps) are deliberately OUTSIDE the + * sealed body, so a dry run and a later apply of the same content + * seal identically and an approval binds the plan, never the moment + * it was printed. */ readonly digest: string; } +/** + * A plan row's content binding, sealed alongside the row: the sha256 of + * the SOURCE entry the row was parsed from and the sha256 of the RENDERED + * preference body the row would land, rendered under a fixed clock so the + * seal stays wall-clock-free. + */ +interface RenderedRowSeal { + readonly source_sha256: string; + readonly body_sha256: string; +} + +/** + * The fixed clock the seal's render runs under. The rendered body embeds + * the run's clock (`created_at`, `unconfirmed_until`, `_imported_at`, the + * prose day); sealed as-written, a dry run and its later apply would seal + * differently on every tick and the approval could never carry. Re- + * rendered with both clock inputs pinned to this constant, the same + * content produces the same seal body whenever it is planned - while a + * source file rewritten between the two runs still changes the rendered + * body, which is the binding the approval exists for. + */ +const SEAL_RENDER_CLOCK = "1970-01-01T00:00:00Z"; + /** * The body an approval covers: what will land, what will not, and what * refuses. The one spelling of "the plan", shared by the dry run that - * seals it and the apply that re-checks it. + * seals it and the apply that re-checks it. Each plan row carries its + * content binding ({@link RenderedRowSeal}) so a source file rewritten + * after the approved dry run no longer verifies, even when its action + * would come out the same. */ function planApprovalBody(parts: { plans: ReadonlyArray; skipped: ReadonlyArray<{ basename: string; reason: string }>; conflicts: ReadonlyArray; skippedUnchanged: ReadonlyArray; + seals: ReadonlyMap; }): Record { return { conflicts: parts.conflicts, - plans: parts.plans, + plans: parts.plans.map((plan) => { + const seal = parts.seals.get(plan.basename); + return { + basename: plan.basename, + prefId: plan.prefId, + action: plan.action, + source_sha256: seal?.source_sha256 ?? null, + body_sha256: seal?.body_sha256 ?? null, + }; + }), skipped: parts.skipped, skipped_unchanged: parts.skippedUnchanged, }; @@ -176,6 +215,11 @@ export function importClaudeMemory(opts: ImportClaudeMemoryOpts): ImportClaudeMe const plans: PlannedFile[] = []; const skipped: Array<{ basename: string; reason: string }> = []; const filesToWrite: Array<{ plan: PlannedFile; body: string; sha256: string; slug: string }> = []; + // The content binding per plan row, keyed by the row's basename: the + // source entry's sha256 and the rendered body's sha256 (clock fields + // normalized out). Every plan row is pushed immediately after its + // render, so every row the seal below reads has an entry here. + const rowSeals = new Map(); // Two MEMORY files with different basenames can slugify to the same // preference id (e.g. `feedback_no_em_dashes.md` and // `feedback no-em-dashes.md`). Without this guard, both would land @@ -230,6 +274,16 @@ export function importClaudeMemory(opts: ImportClaudeMemoryOpts): ImportClaudeMe // file that would not land, and one legacy MEMORY file cannot abort the // whole run with a message that names only the field. let body: string; + const renderInput = { + name: parsed.name, + description: parsed.description, + body: parsed.body, + memoryPath: join(baseDir, name), + importedAt, + unconfirmedUntil, + bodySha256: parsed.bodySha256, + owner: resolvedOwnerFor(opts.vault, prefFile, undefined, undefined), + }; try { // This module renders its own frontmatter and writes it with // `atomicWriteFileSync`, so it never reaches `writePreference` @@ -240,22 +294,25 @@ export function importClaudeMemory(opts: ImportClaudeMemoryOpts): ImportClaudeMe // `prefFile` is passed so an UPDATE carries the existing owner // forward instead of re-owning the page, exactly as a rewrite // through `writePreference` does. - body = backend.renderPreference({ - name: parsed.name, - description: parsed.description, - body: parsed.body, - memoryPath: join(baseDir, name), - importedAt, - unconfirmedUntil, - bodySha256: parsed.bodySha256, - owner: resolvedOwnerFor(opts.vault, prefFile, undefined, undefined), - }); + body = backend.renderPreference(renderInput); } catch (err) { if (!(err instanceof TagSyntaxError)) throw err; skipped.push({ basename: entryKey, reason: err.message }); return; } seenPrefIds.set(prefId, entryKey); + rowSeals.set(entryKey, { + source_sha256: parsed.bodySha256, + // The seal's render of the SAME entry under a fixed clock: the + // approval covers the content, never the moment. + body_sha256: sha256Hex( + backend.renderPreference({ + ...renderInput, + importedAt: SEAL_RENDER_CLOCK, + unconfirmedUntil: SEAL_RENDER_CLOCK, + }), + ), + }); const manifestEntry = manifest.imports[entryKey]; const plan = planAction({ basename: entryKey, @@ -276,8 +333,17 @@ export function importClaudeMemory(opts: ImportClaudeMemoryOpts): ImportClaudeMe // Seal the adoption plan (t_18fda844). The apply below re-checks the // operator's approval against THIS seal before the snapshot or any - // write, so what lands is what was approved or nothing is. - const approvalBody = planApprovalBody({ plans, skipped, conflicts, skippedUnchanged }); + // write, so what lands is what was approved or nothing is. Each row is + // sealed with its content binding, so a source file rewritten after the + // dry run fails the apply even when its plan action would come out the + // same. + const approvalBody = planApprovalBody({ + plans, + skipped, + conflicts, + skippedUnchanged, + seals: rowSeals, + }); const planDigest = sealWithDigest(approvalBody).digest; if (opts.mode === "dry-run") { diff --git a/tests/core/brain/import-claude-memory-orchestrator.test.ts b/tests/core/brain/import-claude-memory-orchestrator.test.ts index 7a63444d..e8ca0fe8 100644 --- a/tests/core/brain/import-claude-memory-orchestrator.test.ts +++ b/tests/core/brain/import-claude-memory-orchestrator.test.ts @@ -278,4 +278,51 @@ describe("importClaudeMemory approval digest (t_18fda844)", () => { rmSync(mem, { recursive: true }); } }); + + test("a memory file rewritten after the dry run fails the apply with the named digest error", () => { + // The plan rows alone (basename, prefId, action) do not move when a + // NEW file's body is rewritten - the action stays CREATE - so the + // seal must bind the content itself: each row carries the sha256 of + // its source entry and of its rendered body, and a rewrite between + // the approved dry run and the apply no longer verifies. + const vault = setupVault(); + const mem = setupMemory(); + try { + const dry = importClaudeMemory({ + vault, + memoryDir: mem, + mode: "dry-run", + allowArbitraryMemoryPath: true, + }); + const before = treeDigests(vault); + writeFileSync( + join(mem, "feedback_a.md"), + "---\nname: rule-a\ndescription: Rule A.\nmetadata:\n type: feedback\n---\n\nBody A, rewritten after the approval.\n", + "utf8", + ); + + let refusal: ApprovalDigestError | null = null; + try { + importClaudeMemory({ + vault, + memoryDir: mem, + mode: "apply", + allowArbitraryMemoryPath: true, + approvalDigest: dry.digest, + now: new Date("2026-05-18T10:00:00Z"), + }); + } catch (err) { + expect(err).toBeInstanceOf(ApprovalDigestError); + refusal = err as ApprovalDigestError; + } + expect(refusal).not.toBeNull(); + expect(refusal!.message).toContain("approval digest mismatch"); + // Nothing was written and no snapshot was taken. + expect(treeDigests(vault)).toBe(before); + expect(existsSync(join(vault, "Brain", "preferences", "pref-rule-a.md"))).toBe(false); + } finally { + rmSync(vault, { recursive: true }); + rmSync(mem, { recursive: true }); + } + }); }); From 6b71ada79c3684635d97ee04bad0e7e7d9ae7126 Mon Sep 17 00:00:00 2001 From: Sergey Eroshenkov Date: Sat, 10 Oct 2026 18:45:12 +0200 Subject: [PATCH 47/84] fix(brain): carry the upgrade plan digest from dry run to apply brain upgrade --apply had no --approval-digest flag, so the plan seal the dry run printed could not be presented back and a script applied whatever plan it recomputed. The flag is now required in non-interactive mode (--json or non-TTY stdin) and, when present, is verified against the freshly computed plan before anything is written - a plan that moved since the approved dry run is re-reviewed, never written over, exactly like import-claude-memory's approval digest. --- src/cli/brain/helpers.ts | 14 ++--- src/cli/brain/verbs/upgrade.ts | 29 +++++++++++ tests/cli/brain-upgrade.test.ts | 91 +++++++++++++++++++++++++++++++-- 3 files changed, 123 insertions(+), 11 deletions(-) diff --git a/src/cli/brain/helpers.ts b/src/cli/brain/helpers.ts index 2abffa2b..787a2620 100644 --- a/src/cli/brain/helpers.ts +++ b/src/cli/brain/helpers.ts @@ -118,12 +118,14 @@ export { printUpgradePlanText, renderUnifiedDiff } from "./upgrade-render.ts"; /** * The plan JSON an operator reads, with its seal (t_18fda844): the - * digest carried from `--dry-run` to `--apply`, so what lands is what - * was reviewed. Emitted unconditionally - a `planUpgrade` result is - * always sealed - and the row projection stays in - * `./upgrade-render.ts` (imported above under an alias); the barrel is - * where the seal becomes visible, because this is the module every - * verb imports its renderers through. + * digest carried from `--dry-run` to `--apply` via `--approval-digest` + * (`brain upgrade --apply` requires it in non-interactive mode and + * verifies it against the freshly computed plan, exactly as + * import-claude-memory's does), so what lands is what was reviewed. + * Emitted unconditionally - a `planUpgrade` result is always sealed - + * and the row projection stays in `./upgrade-render.ts` (imported above + * under an alias); the barrel is where the seal becomes visible, because + * this is the module every verb imports its renderers through. */ export function renderUpgradePlanJson( plan: UpgradePlan, diff --git a/src/cli/brain/verbs/upgrade.ts b/src/cli/brain/verbs/upgrade.ts index f7963969..36abece8 100644 --- a/src/cli/brain/verbs/upgrade.ts +++ b/src/cli/brain/verbs/upgrade.ts @@ -60,6 +60,11 @@ export async function cmdBrainUpgrade(argv: string[]): Promise { yes: { type: "boolean" }, check: { type: "boolean" }, json: { type: "boolean" }, + // The plan digest a --dry-run printed and an operator approved + // (t_18fda844) - the upgrade twin of import-claude-memory's + // --approval-digest. Verified against the freshly computed plan + // before anything is written. + "approval-digest": { type: "string" }, // The detached worker `ensureVaultCurrent` starts, with the lock claim // it was handed. Not an operator flag: the worker's outcome is // recorded, never printed (its streams are ignored). @@ -131,6 +136,30 @@ export async function cmdBrainUpgrade(argv: string[]): Promise { } } + // t_18fda844: a script that applies must apply the plan it approved. + // The digest comes from a prior `--dry-run` (the JSON plan output + // carries it); an interactive operator keeps the human escape hatch, + // exactly as with `--yes`. Verified against the plan this run just + // computed BEFORE anything is written - a plan that moved since the + // approved dry run is re-reviewed, never written over. + const approvalDigest = flags["approval-digest"] as string | undefined; + if (flags["json"] || !process.stdin.isTTY) { + if (approvalDigest === undefined) { + return fail( + "brain upgrade --apply requires --approval-digest in non-interactive mode " + + "(--json or non-TTY stdin); take it from the --dry-run plan output", + ); + } + } + if (approvalDigest !== undefined && approvalDigest !== plan.digest) { + return fail( + `upgrade refused: approval digest mismatch: the plan changed since the approved ` + + `dry run (approved ${approvalDigest}, current plan ${plan.digest}); nothing was ` + + "written and no snapshot was taken. Re-run `o2b brain upgrade --dry-run` to review " + + "the current plan, then apply with its digest.", + ); + } + let result; const now = new Date(); try { diff --git a/tests/cli/brain-upgrade.test.ts b/tests/cli/brain-upgrade.test.ts index 21e81c4a..d2e51d76 100644 --- a/tests/cli/brain-upgrade.test.ts +++ b/tests/cli/brain-upgrade.test.ts @@ -83,9 +83,14 @@ describe("brain upgrade", () => { test("--apply --yes rewrites pending files and creates upgrade- snapshot", async () => { await bootstrap(); writeFileSync(join(vault, "Brain", "_BRAIN.md"), "stale\n"); - const r = await runCli(["brain", "upgrade", "--vault", vault, "--apply", "--yes"], { - env: { OPEN_SECOND_BRAIN_CONFIG: config }, - }); + // Non-interactive apply carries the approval digest (t_18fda844): + // the plan is deterministic for the drifted state, so the digest + // here is what a prior --dry-run would have printed. + const digest = upgradeModule.planUpgrade(vault).digest; + const r = await runCli( + ["brain", "upgrade", "--vault", vault, "--apply", "--yes", "--approval-digest", digest], + { env: { OPEN_SECOND_BRAIN_CONFIG: config } }, + ); expect(r.returncode).toBe(0); expect(r.stdout).toMatch(/run_id: upgrade-/); expect(r.stdout).toContain("Brain/_BRAIN.md"); @@ -114,6 +119,60 @@ describe("brain upgrade", () => { expect(r.stderr).toContain("--yes"); }); + test("--apply in non-interactive mode without --approval-digest refuses before writing", async () => { + await bootstrap(); + writeFileSync(join(vault, "Brain", "_BRAIN.md"), "stale\n"); + // `brain init` pre-creates the snapshots directory; the refusal must + // not add an archive to it. + const snapsBefore = existsSync(join(vault, "Brain", ".snapshots")) + ? require("node:fs").readdirSync(join(vault, "Brain", ".snapshots")).length + : 0; + const r = await runCli(["brain", "upgrade", "--vault", vault, "--apply", "--yes"], { + env: { OPEN_SECOND_BRAIN_CONFIG: config }, + }); + expect(r.returncode).toBe(1); + expect(r.stderr).toContain("--approval-digest"); + // Nothing was written and no snapshot was taken. + expect(readFileSync(join(vault, "Brain", "_BRAIN.md"), "utf8")).toBe("stale\n"); + const snapsAfter = existsSync(join(vault, "Brain", ".snapshots")) + ? require("node:fs").readdirSync(join(vault, "Brain", ".snapshots")).length + : 0; + expect(snapsAfter).toBe(snapsBefore); + }); + + test("--apply with the matching dry-run digest lands; a stale digest refuses", async () => { + await bootstrap(); + writeFileSync(join(vault, "Brain", "_BRAIN.md"), "stale\n"); + const dry = await runCli(["brain", "upgrade", "--vault", vault, "--dry-run", "--json"], { + env: { OPEN_SECOND_BRAIN_CONFIG: config }, + }); + const digest = (JSON.parse(dry.stdout) as { digest: string }).digest; + const ok = await runCli( + ["brain", "upgrade", "--vault", vault, "--apply", "--yes", "--approval-digest", digest], + { env: { OPEN_SECOND_BRAIN_CONFIG: config } }, + ); + expect(ok.returncode).toBe(0); + expect(readFileSync(join(vault, "Brain", "_BRAIN.md"), "utf8")).not.toBe("stale\n"); + + // A second drift, a fresh approval, then the file moves again before + // the apply: the approved plan no longer describes the vault, and the + // refusal says so before anything is written. + writeFileSync(join(vault, "Brain", "_BRAIN.md"), "stale again\n"); + const secondDry = await runCli(["brain", "upgrade", "--vault", vault, "--dry-run", "--json"], { + env: { OPEN_SECOND_BRAIN_CONFIG: config }, + }); + const approved = (JSON.parse(secondDry.stdout) as { digest: string }).digest; + writeFileSync(join(vault, "Brain", "_BRAIN.md"), "moved after approval\n"); + const stale = await runCli( + ["brain", "upgrade", "--vault", vault, "--apply", "--yes", "--approval-digest", approved], + { env: { OPEN_SECOND_BRAIN_CONFIG: config } }, + ); + expect(stale.returncode).toBe(1); + expect(stale.stderr).toContain("approval digest mismatch"); + expect(stale.stderr).toContain("nothing was written"); + expect(readFileSync(join(vault, "Brain", "_BRAIN.md"), "utf8")).toBe("moved after approval\n"); + }); + test("--apply on clean vault → no snapshot, no log, exit 0", async () => { await bootstrap(); const snapsBefore = existsSync(join(vault, "Brain", ".snapshots")) @@ -228,6 +287,9 @@ describe("brain upgrade --apply applies the plan it printed", () => { await bootstrap(); writeFileSync(join(vault, "Brain", "_BRAIN.md"), "stale\n"); const realPlan = upgradeModule.planUpgrade; + // The approval digest of the plan the CLI is about to compute itself: + // same drifted state, same deterministic seal. + const digest = realPlan(vault).digest; const shown: UpgradePlan[] = []; const planSpy = spyOn(upgradeModule, "planUpgrade").mockImplementation((v) => { const plan = realPlan(v); @@ -237,7 +299,15 @@ describe("brain upgrade --apply applies the plan it printed", () => { const applySpy = spyOn(upgradeModule, "applyUpgrade"); const out = spyOn(process.stdout, "write").mockImplementation(() => true); try { - const code = await cmdBrainUpgrade(["--vault", vault, "--apply", "--yes", "--json"]); + const code = await cmdBrainUpgrade([ + "--vault", + vault, + "--apply", + "--yes", + "--approval-digest", + digest, + "--json", + ]); out.mockRestore(); expect(code).toBe(0); expect(shown).toHaveLength(1); @@ -256,6 +326,9 @@ describe("brain upgrade --apply applies the plan it printed", () => { const manual = join(vault, "Brain", "_BRAIN.md"); writeFileSync(manual, "stale\n"); const realPlan = upgradeModule.planUpgrade; + // The digest of the plan the mocked planning is about to return (the + // file is still "stale\n" at that moment; the hand edit lands after). + const digest = realPlan(vault).digest; // The hand edit lands between the plan and the apply. const planSpy = spyOn(upgradeModule, "planUpgrade").mockImplementation((v) => { const plan = realPlan(v); @@ -269,7 +342,15 @@ describe("brain upgrade --apply applies the plan it printed", () => { }); let code: number; try { - code = await cmdBrainUpgrade(["--vault", vault, "--apply", "--yes", "--json"]); + code = await cmdBrainUpgrade([ + "--vault", + vault, + "--apply", + "--yes", + "--approval-digest", + digest, + "--json", + ]); } finally { out.mockRestore(); planSpy.mockRestore(); From fd7f420dd2eb2634230b7c6bec4f46302f457f2f Mon Sep 17 00:00:00 2001 From: Sergey Eroshenkov Date: Sat, 10 Oct 2026 20:25:38 +0200 Subject: [PATCH 48/84] fix(bootstrap): state the token boundary and stop losing minted material - The --token output, the receipt docs and docs/mcp.md say plainly what the minted token authenticates (HTTP clients configured by hand) and what it does not: the registered stdio harness presents no credential and keeps its config-derived identity. - When the adapter apply fails after a mint or rotation, the material still prints exactly once beside the failure, with the retry and revoke paths named - a re-run would find the name existing and mint nothing. - Minting refuses placeholder agent identities by name (no --agent, or a PLACEHOLDER_AGENT_VALUES member); the plain provision path and an existing token keep working without --agent. - Rotation keeps the previous hash resolvable for a bounded ten-minute placement window (rotated_from_hash + rotated_at, additive to schema 1) instead of an instant outage; revocation stays immediate. - o2b bootstrap --remove tears a provision down through the adapter's own uninstall, revokes the receipt's token and drops the receipt entry; a second remove is a clean no-op. --- README.md | 2 +- docs/mcp.md | 19 +- src/cli/bootstrap/receipt.ts | 26 ++- src/cli/bootstrap/run.ts | 205 ++++++++++++++++- src/cli/bootstrap/token-cli.ts | 26 ++- src/core/brain/secrets/token-store.ts | 104 ++++++++- src/core/install/payload.ts | 10 + tests/cli/bootstrap.test.ts | 229 +++++++++++++++++-- tests/cli/mcp-token.test.ts | 17 +- tests/core/brain/secrets/token-store.test.ts | 47 +++- 10 files changed, 639 insertions(+), 46 deletions(-) diff --git a/README.md b/README.md index cdb12de6..7a9fae9d 100644 --- a/README.md +++ b/README.md @@ -117,7 +117,7 @@ The full router with readiness criteria is [`install.md`](install.md); native Wi - **Staged review for agent writes, off by default.** With no key set every write publishes exactly as before. `write_approval.notes` / `OPEN_SECOND_BRAIN_WRITE_APPROVAL_NOTES_ENABLED` and `write_approval.ingest` / `OPEN_SECOND_BRAIN_WRITE_APPROVAL_INGEST_ENABLED` (each falling back to the `write_approval.enabled` master, default off) stage note creates and ingest summary pages into `Brain/pending/` beside the staged signals, where they stay out of the search index until an operator runs `o2b brain pending list` and applies or rejects them; [write-path integrity](docs/cli-reference.md#write-path-integrity-and-store-safety-since-v1320). - **A permissions document, absent by default.** `Brain/_permissions.yaml` (an operator-edited vault file) resolves `allow`/`ask`/`deny` per agent, role and target for the write, ingest and owner-write actions; with the file absent every check behaves exactly as today. `ask` stages the write, `deny` refuses with the `write-refused` token and the next command `o2b brain permissions show`, and every ask/deny verdict lands in a queryable decision ledger under `Brain/logs/decisions/`; `o2b brain permissions show` dry-runs the decision table, `ledger` reads the rows; [Brain CLI](docs/cli-reference.md#brain-observing-memory). - **The owner-write gate, off by default.** `integrity.owner_scope_writes` in `Brain/_brain.yaml` (`off` | `warn` | `fail`, default `off`) refuses a caller-named owner that differs from the caller's resolved identity on the preference and note lanes (`warn` allows and records one decision-ledger row); a document `owner_write` verdict composes most-restrictive-wins with the gate; [write-time integrity](docs/cli-reference.md#write-time-integrity-and-governance-since-v0440). -- **Named MCP tokens, optional.** `o2b mcp token mint|rotate|revoke|list` keeps a hash-at-rest token per agent (`.open-second-brain/secrets/mcp-tokens.json`; material shown exactly once). Over HTTP a valid token authenticates as its agent per request, the shared `--api-key` stays valid as the operator master credential, and `mcp_tokens_required` / `OPEN_SECOND_BRAIN_MCP_TOKENS_REQUIRED` (default `false`) makes a non-empty token map refuse credential-less requests. `o2b bootstrap --target [--token] [--rotate] [--check]` provisions MCP registration, token and receipt in one idempotent command; [core CLI](docs/cli-reference.md#core). +- **Named MCP tokens, optional.** `o2b mcp token mint|rotate|revoke|list` keeps a hash-at-rest token per agent (`.open-second-brain/secrets/mcp-tokens.json`; material shown exactly once). Over HTTP a valid token authenticates as its agent per request, the shared `--api-key` stays valid as the operator master credential, and `mcp_tokens_required` / `OPEN_SECOND_BRAIN_MCP_TOKENS_REQUIRED` (default `false`) makes a non-empty token map refuse credential-less requests. `o2b bootstrap --target [--token] [--rotate] [--check]` provisions MCP registration, token and receipt in one idempotent command, and `o2b bootstrap --remove ` tears a provision down again; the minted token authenticates HTTP clients configured by hand, while the registered stdio harness presents no credential and keeps its config-derived identity; [core CLI](docs/cli-reference.md#core). - **Ambient capture consent, opt-out.** `guardrails.ambient_writeback: false` suppresses the ambient extraction lane with a counted `ambient-withheld` event (absent keeps today's behavior), and `guardrails.ambient_ttl_days` stamps an `expiration_date` on ambient-extracted signals so reads drop them after the window (absent stamps nothing); a TTL-stamped signal still stages when the review gate is on and survives apply verbatim. - **Open decisions.** `o2b brain decision open --title --question --option [...]` parks a question with enumerated options at `Brain/decisions/open-.md`; `resolve` mints the real `type: decision` page, `discard` closes without deciding, and the morning brief renders up to five open questions: [belief lifecycle](docs/cli-reference.md#belief-lifecycle-and-decision-memory-since-v1330). diff --git a/docs/mcp.md b/docs/mcp.md index 48b19732..fb1cb805 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -748,9 +748,13 @@ state, so concurrent callers with different tokens never observe each other's identity. The token map is consulted before the shared key; the shared key keeps authenticating with the process's configured agent name; and a revoked or unknown token gets the same generic `401` body as a missing credential, so -no oracle distinguishes them. Rotation and revocation take effect on the next -request with no server restart (the store is read per request behind an mtime -cache; the shared key keeps its launch-time capture). The gate +no oracle distinguishes them. Rotation lands on the next request with no +server restart and keeps the PREVIOUS material authenticating for a +ten-minute placement window (`ROTATION_GRACE_MS` in the token store) so the +caller holding it does not fail while the new one is being placed; after the +window the old material dies on its own, with no revoke. Revocation itself +stays immediate. Both are read per request behind an mtime cache; the shared +key keeps its launch-time capture. The gate `mcp_tokens_required` (device config key or `OPEN_SECOND_BRAIN_MCP_TOKENS_REQUIRED`, default `false`) makes the endpoint refuse credential-less requests whenever a non-empty token map exists; with @@ -764,6 +768,15 @@ process that already owns the process tree). A token mints identity only, never reach: tool profiles and the reach ceiling still bind every caller regardless of credential. +That boundary extends to `o2b bootstrap --token`: the registration bootstrap +writes is the stdio payload above (`o2b mcp --vault ...`), which presents no +credential, so the bootstrapped harness keeps its config-derived identity +and the minted token changes nothing for it. The material authenticates +HTTP MCP clients an operator configures by hand (`Authorization: Bearer` or +`X-API-Key`) - the bootstrap output says the same beside every mint, and +`o2b bootstrap --remove ` tears a provision down again (adapter +uninstall, token revoked, receipt entry dropped). + ## Shutdown and draining (since v1.50.0) Neither transport could stop without cutting a request in half. The HTTP diff --git a/src/cli/bootstrap/receipt.ts b/src/cli/bootstrap/receipt.ts index dbf645cc..c3613b96 100644 --- a/src/cli/bootstrap/receipt.ts +++ b/src/cli/bootstrap/receipt.ts @@ -7,7 +7,9 @@ * non-secret prefix, and `applied_at`. Modeled on `install.lock.json` * and `protect.lock.json` - the receipt is the durable, credential-free * answer to "what did bootstrap do to this machine", which is also why - * the minted material appears nowhere in it. + * the minted material appears nowhere in it. The token a receipt names + * authenticates hand-configured HTTP clients only; the registered stdio + * harness keeps its config-derived identity. * * Schema (`schema_version: 1`): * @@ -116,6 +118,28 @@ export function upsertBootstrapReceiptEntry(vault: string, entry: BootstrapRecei atomicWriteFileSync(path, JSON.stringify(next, null, 2) + "\n"); } +/** + * Drop one target's entry, preserving every other entry verbatim. This + * is how `--remove` rewrites the receipt: the entry goes away entirely, + * the way the install manifest models removal (the entry is dropped, + * the file stays), so a second remove finds nothing and says so. + * Answers false when the entry was already absent - a no-op, not an + * error. + */ +export function removeBootstrapReceiptEntry(vault: string, target: string): boolean { + assertVaultIdentityForWrite(vault); + const current = readBootstrapReceipt(vault); + if (current.entries[target] === undefined) return false; + const entries = { ...current.entries }; + delete entries[target]; + const path = bootstrapReceiptPath(vault); + atomicWriteFileSync( + path, + JSON.stringify({ schema_version: BOOTSTRAP_SCHEMA_VERSION, entries }, null, 2) + "\n", + ); + return true; +} + /** * Whether a receipt entry and a store record tell the same token story. * Both absent: consistent (a tokenless bootstrap). Both present: name diff --git a/src/cli/bootstrap/run.ts b/src/cli/bootstrap/run.ts index 05f89fd1..5ea1b596 100644 --- a/src/cli/bootstrap/run.ts +++ b/src/cli/bootstrap/run.ts @@ -6,6 +6,7 @@ * o2b bootstrap --target codex --check # drift from InstallEnv alone * o2b bootstrap --target generic --token # print-and-paste * o2b bootstrap --target claude-code --token # plugin verify-only + * o2b bootstrap --remove codex # receipt-driven teardown * * One idempotent command per harness: it runs the target adapter's * existing apply (which is itself idempotent), mints the per-agent MCP @@ -16,10 +17,26 @@ * env block stays credential-free: the token reaches the agent through * its environment or a `$secret:` reference. * + * The credential boundary, stated where the material is shown: the + * minted token authenticates HTTP MCP clients an operator configures by + * hand; the registration bootstrap writes is stdio (`o2b mcp --vault + * ...`), whose identity is config-derived by design, so the token + * changes nothing for the bootstrapped harness itself. + * * Idempotency contract: a second identical run is a byte-identical * no-op. When the token already exists and the adapter verifies clean, * bootstrap writes nothing at all - not the config, not the install - * manifest, not the receipt - and says so. + * manifest, not the receipt - and says so. When the apply fails after a + * mint or rotation, the material still prints exactly once beside the + * failure: the credential is live in the store, a re-run would mint + * nothing, and the output names the retry and revoke paths. + * + * `--remove ` tears down what the receipt says bootstrap + * established: the adapter's own uninstall removes the registered + * entries, the receipt's token is revoked (immediately - removal is not + * a rotation, nobody is placing new material), and the receipt entry is + * dropped the way the install manifest models removal. A second remove + * finds nothing and exits clean. * * Exit codes ({@link BOOTSTRAP_EXIT}), the `INSTALL_EXIT` table style: * 0 success / no drift @@ -31,26 +48,29 @@ */ import { defaultConfigPath, discoverConfig, resolveVault } from "../../core/config.ts"; +import { normalizeAgentArgument } from "../../core/agent-identity.ts"; import "../../core/install/adapters/all.ts"; import { buildInstallEnv, VAULT_NOT_CONFIGURED_REASON } from "../../core/install/env.ts"; import { buildPayload, PayloadError } from "../../core/install/payload.ts"; import { defaultRegistry } from "../../core/install/registry.ts"; import { InstallError } from "../../core/install/types.ts"; -import type { ApplyOpts, ManifestEntry } from "../../core/install/types.ts"; +import type { ApplyOpts, ManifestEntry, UninstallResult } from "../../core/install/types.ts"; import { listAgentTokens, mintAgentToken, + revokeAgentToken, rotateAgentToken, } from "../../core/brain/secrets/token-store.ts"; import { McpTokenStoreError } from "../../core/brain/secrets/token-store.ts"; import { parseFlags } from "../argparse.ts"; -import { SHOWN_ONCE_NOTICE } from "./token-cli.ts"; +import { HTTP_BOUNDARY_NOTICE, SHOWN_ONCE_NOTICE } from "./token-cli.ts"; import { receiptEntryEqualsExcludingTimestamp, receiptTokenMatches, BootstrapReceiptError, bootstrapReceiptPath, readBootstrapReceipt, + removeBootstrapReceiptEntry, upsertBootstrapReceiptEntry, type BootstrapReceiptEntry, } from "./receipt.ts"; @@ -84,6 +104,7 @@ interface ParsedBootstrapArgs { readonly rotate: boolean; readonly check: boolean; readonly force: boolean; + readonly remove: string | null; readonly vault: string | null; readonly config: string; } @@ -96,6 +117,7 @@ function parseBootstrapArgs(argv: string[]): ParsedBootstrapArgs { rotate: { type: "boolean" }, check: { type: "boolean" }, force: { type: "boolean" }, + remove: { type: "string" }, vault: { type: "string" }, config: { type: "string" }, }); @@ -111,6 +133,7 @@ function parseBootstrapArgs(argv: string[]): ParsedBootstrapArgs { rotate: Boolean(flags["rotate"]), check: Boolean(flags["check"]), force: Boolean(flags["force"]), + remove: (flags["remove"] as string | undefined) ?? null, vault: (flags["vault"] as string | undefined) ?? null, config: (flags["config"] as string | undefined) ?? defaultConfigPath(), }; @@ -161,12 +184,12 @@ export async function cmdBootstrap(argv: string[]): Promise { const resolved: BootstrapTarget | null = args.target === null ? null : resolveBootstrapTarget(args.target); - if (args.target === null) { + if (args.target === null && args.remove === null) { return usageRefusal( - `o2b bootstrap requires --target . Available: ${BOOTSTRAP_TARGET_LIST}`, + `o2b bootstrap requires --target (or --remove ). Available: ${BOOTSTRAP_TARGET_LIST}`, ); } - if (resolved === null) { + if (args.target !== null && resolved === null) { return usageRefusal( `unknown bootstrap target: ${args.target}. Available: ${BOOTSTRAP_TARGET_LIST}`, ); @@ -174,15 +197,57 @@ export async function cmdBootstrap(argv: string[]): Promise { if (args.check && (args.token || args.rotate)) { return usageRefusal("--check verifies only; drop --token/--rotate, or drop --check"); } + if (args.remove !== null) { + const mixed = + args.target !== null || + args.agent !== null || + args.token || + args.rotate || + args.check || + args.force; + if (mixed) { + return usageRefusal( + "--remove tears a target down and takes no other provision flag; pass just --remove ", + ); + } + } const vault = resolveBootstrapVault(args.vault, args.config); if (vault === "") { return usageRefusal(`o2b bootstrap: ${VAULT_NOT_CONFIGURED_REASON}`); } + // `--remove ` names its own target and never reaches the + // provision grammar below; its receipt half is read inside the same + // clean-error wrapper the provision path uses. + if (args.remove !== null) { + const removeResolved = resolveBootstrapTarget(args.remove); + if (removeResolved === null) { + return usageRefusal( + `unknown bootstrap target: ${args.remove}. Available: ${BOOTSTRAP_TARGET_LIST}`, + ); + } + try { + return runRemove({ target: removeResolved.target, vault, configPath: args.config }); + } catch (e) { + if (e instanceof BootstrapReceiptError || e instanceof McpTokenStoreError) { + process.stderr.write(`error: ${e.message}\n`); + return BOOTSTRAP_EXIT.runtimeError; + } + throw e; + } + } + + // A remove-only invocation returned above, so what is left named + // --target; an unknown one was refused there. The named refusal stays + // for the flow the compiler cannot see, and narrows `resolved`. + if (resolved === null) { + return usageRefusal( + `o2b bootstrap requires --target (or --remove ). Available: ${BOOTSTRAP_TARGET_LIST}`, + ); + } const target = resolved.target; const mode = resolved.mode; - const agent = args.agent ?? target; const name = tokenNameForTarget(target); const now = new Date().toISOString(); @@ -190,6 +255,14 @@ export async function cmdBootstrap(argv: string[]): Promise { // bound to a DIFFERENT agent refuses: bootstrap would silently rewrite // a credential's identity underneath a running agent. const existing = listAgentTokens(vault).find((t) => t.name === name) ?? null; + + // The agent identity this run names. Explicit --agent wins; without + // one, an existing token keeps the identity it was minted under (a + // plain re-run must not turn into an identity conflict), and a fresh + // mint falls back to the target name - which for several targets IS a + // placeholder the identity layer treats as absent, and which the mint + // refusal below catches. + const agent = args.agent ?? existing?.agent ?? target; if (existing !== null && existing.agent !== agent) { process.stderr.write( `error: token ${name} already belongs to agent ${JSON.stringify(existing.agent)}, ` + @@ -198,6 +271,21 @@ export async function cmdBootstrap(argv: string[]): Promise { return BOOTSTRAP_EXIT.runtimeError; } + // A mint needs a real agent identity. The placeholder vocabulary is the + // identity layer's own bottom (`normalizeAgentArgument` answers null + // for these), and several bootstrap targets ARE placeholder strings: + // minting mcp_token_codex with agent "codex" would hand out a live + // credential whose owner the server cannot name. Named refusal, exit 2, + // consistent with the mint verb's `requires --agent` grammar; the plain + // (no --token) provision path never mints and keeps working without + // --agent. + if (args.token && existing === null && normalizeAgentArgument(agent) === null) { + return usageRefusal( + `minting ${name} requires a real agent identity: ${JSON.stringify(agent)} is a ` + + "placeholder name the identity layer treats as absent. Re-run with --agent .", + ); + } + // Both paths read the receipt, so both owe the operator the same clean // refusal when it is unreadable - a corrupt bootstrap.lock.json is a // named error, never a raw stack. @@ -390,6 +478,24 @@ function runProvision(input: ProvisionInput): number { if (e instanceof InstallError) { process.stderr.write(`error: ${e.message}\n`); if (e.hint !== undefined) process.stderr.write(`hint: ${e.hint}\n`); + // The mint already happened: the material is live in the store + // and will never be shown again by a re-run (the name now + // exists, so nothing is minted). Returning without it orphans a + // live credential behind a failed registration - so it prints + // here, exactly once, beside the failure and the retry path. + if (tokenMaterial !== null) { + process.stdout.write( + `bootstrap: ${target}\n` + + ` token: ${name} (agent ${JSON.stringify(agent)}) ${tokenEvent} - ` + + "shown exactly once, stored only as a hash\n" + + ` ${tokenMaterial}\n` + + ` ${SHOWN_ONCE_NOTICE}\n` + + ` ${HTTP_BOUNDARY_NOTICE}\n` + + ` The registration half failed (the error above); the token is live in the store. ` + + `Fix the cause and re-run o2b bootstrap --target ${target} to apply the ` + + `registration alone, or revoke with: o2b mcp token revoke --name ${name}\n`, + ); + } return e.kind === "user-modified-block" ? BOOTSTRAP_EXIT.userModifiedBlock : BOOTSTRAP_EXIT.runtimeError; @@ -443,6 +549,10 @@ function runProvision(input: ProvisionInput): number { ); out.push(` ${tokenMaterial}`); out.push(` ${SHOWN_ONCE_NOTICE}`); + // The credential boundary: what the minted token authenticates, and + // what it does not - the registered stdio harness presents no + // credential, so this material never changes its behavior. + out.push(` ${HTTP_BOUNDARY_NOTICE}`); } if (mode !== "verify-only" || refreshed !== null) { out.push(` receipt: ${receiptPath}`); @@ -487,3 +597,84 @@ function composeEntry(input: { applied_at: now, }; } + +interface RemoveInput { + readonly target: string; + readonly vault: string; + readonly configPath: string; +} + +/** + * `--remove `: receipt-driven teardown. The receipt is the + * record of what bootstrap established, so it drives all three halves: + * the registration goes through the adapter's own `uninstall` - the + * remove path the install machinery already models and the same + * idempotent apply machinery wrote - the receipt's token name is revoked + * (immediately; removal is not a rotation, nobody is placing new + * material), and the receipt entry is dropped the way the install + * manifest models removal. Print and verify-only targets wrote no + * harness config, so only the token and receipt halves apply. A second + * remove finds no entry and exits clean; a failed uninstall keeps the + * entry so a retry can find it. + */ +function runRemove(input: RemoveInput): number { + const { target, vault, configPath } = input; + const entry = readBootstrapReceipt(vault).entries[target]; + if (entry === undefined) { + process.stdout.write( + `bootstrap remove: ${target} has no bootstrap receipt entry; nothing to remove\n` + + ` (a registration written by o2b install is removed with: o2b uninstall --target ${target} --apply)\n`, + ); + return BOOTSTRAP_EXIT.ok; + } + const out: string[] = [`bootstrap remove: ${target}`]; + + if (entry.mode === "adapter") { + const adapter = defaultRegistry.get(target); + if (adapter === undefined) { + return usageRefusal( + `bootstrap target ${target} has no install adapter. Available: ${BOOTSTRAP_TARGET_LIST}`, + ); + } + const env = buildInstallEnv({ vault, configPath }); + let result: UninstallResult; + try { + result = adapter.uninstall(env, { + dryRun: false, + force: false, + stdout: process.stdout as NodeJS.WriteStream, + stderr: process.stderr as NodeJS.WriteStream, + }); + } catch (e) { + if (e instanceof InstallError) { + process.stderr.write(`error: ${e.message}\n`); + if (e.hint !== undefined) process.stderr.write(`hint: ${e.hint}\n`); + // The receipt entry survives so a retry can find what is left. + return BOOTSTRAP_EXIT.runtimeError; + } + throw e; + } + const removed = [...result.removed_keys, ...result.removed_paths]; + out.push( + removed.length > 0 + ? ` registration: removed ${removed.join(", ")}` + : " registration: nothing left to remove", + ); + for (const [what, why] of result.skipped) out.push(` skipped: ${what} (${why})`); + } else if (entry.mode === "print") { + out.push(" registration: print-and-paste wrote no harness config; nothing to remove"); + } else { + out.push(" registration: plugin-managed; bootstrap wrote no harness config to remove"); + } + + if (entry.token !== undefined) { + const revoked = revokeAgentToken(vault, entry.token.name); + out.push( + ` token: ${entry.token.name} ${revoked ? "revoked" : "was already revoked or absent"}`, + ); + } + removeBootstrapReceiptEntry(vault, target); + out.push(` receipt: entry removed from ${bootstrapReceiptPath(vault)}`); + process.stdout.write(out.join("\n") + "\n"); + return BOOTSTRAP_EXIT.ok; +} diff --git a/src/cli/bootstrap/token-cli.ts b/src/cli/bootstrap/token-cli.ts index 25c66f4a..ea41b510 100644 --- a/src/cli/bootstrap/token-cli.ts +++ b/src/cli/bootstrap/token-cli.ts @@ -27,6 +27,7 @@ import { McpTokenStoreError, mintAgentToken, revokeAgentToken, + ROTATION_GRACE_MS, rotateAgentToken, } from "../../core/brain/secrets/token-store.ts"; import { CliError, parseFlags } from "../argparse.ts"; @@ -55,6 +56,20 @@ export const SHOWN_ONCE_NOTICE = "Copy it now; reference it from the agent's environment or a $secret:NAME store entry. " + "Never a harness config file."; +/** + * The identity boundary printed beside every piece of minted material, + * here and in `bootstrap` alike: the token authenticates HTTP MCP + * clients an operator configures BY HAND, while a stdio registration - + * what bootstrap writes - presents no credential and keeps its + * config-derived identity (`buildPayload` registers `o2b mcp --vault + * ...`), so minting a token changes nothing for the bootstrapped harness. + * One constant because the boundary is a property of the credential, not + * of the verb that happens to be minting it. + */ +export const HTTP_BOUNDARY_NOTICE = + "This token authenticates HTTP MCP clients you configure by hand (Authorization: Bearer or X-API-Key). " + + "A stdio server keeps its config-derived identity and presents no token - minting changes nothing for it."; + function usage(message: string): number { process.stderr.write(`error: ${message}\n`); return TOKEN_EXIT.usage; @@ -155,7 +170,8 @@ function tokenMint(argv: string[]): number { `token: ${record.name} minted for agent ${JSON.stringify(record.agent)} - ` + "shown exactly once, stored only as a hash\n" + ` ${tokenMaterial}\n` + - ` ${SHOWN_ONCE_NOTICE}\n`, + ` ${SHOWN_ONCE_NOTICE}\n` + + ` ${HTTP_BOUNDARY_NOTICE}\n`, ); return TOKEN_EXIT.ok; } @@ -185,10 +201,12 @@ function tokenRotate(argv: string[]): number { } process.stdout.write( `token: ${record.name} rotated for agent ${JSON.stringify(record.agent)} - the previous ` + - "material stops authenticating on the next request; the new material is shown exactly " + - "once, stored only as a hash\n" + + `material keeps authenticating for ${ROTATION_GRACE_MS / 60_000} minutes while you place ` + + "the new one, then stops on its own; the new material is shown exactly once, stored only " + + "as a hash\n" + ` ${tokenMaterial}\n` + - ` ${SHOWN_ONCE_NOTICE}\n`, + ` ${SHOWN_ONCE_NOTICE}\n` + + ` ${HTTP_BOUNDARY_NOTICE}\n`, ); return TOKEN_EXIT.ok; } diff --git a/src/core/brain/secrets/token-store.ts b/src/core/brain/secrets/token-store.ts index d02ccb69..4f7bb2d5 100644 --- a/src/core/brain/secrets/token-store.ts +++ b/src/core/brain/secrets/token-store.ts @@ -38,6 +38,23 @@ export const MCP_TOKENS_SCHEMA_VERSION = 1; /** How much of the material the non-secret display prefix keeps. */ export const TOKEN_PREFIX_LENGTH = 12; +/** + * How long the PREVIOUS material keeps authenticating after a rotation. + * + * Rotation replaces the stored hash in place, and the caller that holds + * the old material learns the new one only when its operator places it - + * an environment variable, a `$secret:` entry, a hand-edited HTTP client. + * Killing the old hash at the instant of rotation turned every placement + * window into an authentication outage on the caller's side, with the + * new material printed and nowhere safe to put it yet. The hash a + * rotation replaced therefore stays resolvable for this long after + * `rotated_at`, then stops resolving on its own - no revoke needed, and + * a second rotation moves the window to the newest replaced hash. + * Revocation itself stays immediate: the window is a rotation courtesy + * for placing new material, never a reprieve for a withdrawn credential. + */ +export const ROTATION_GRACE_MS = 10 * 60 * 1000; + /** The material prefix: short, recognisable in a config, not a secret. */ const MATERIAL_PREFIX = "osbt_"; @@ -60,6 +77,15 @@ export interface McpTokenRecord { token_prefix: string; created_at: string; rotated_at?: string; + /** + * The hash a rotation replaced, kept resolvable beside the current one + * for {@link ROTATION_GRACE_MS} past `rotated_at`. Optional so the file + * format stays additive and version-tolerant: a store written before + * the field existed reads unchanged, and an older build reading a store + * that carries it simply ignores the unknown member. Never a secret - + * the same sha256 at-rest form as `token_hash`. + */ + rotated_from_hash?: string; } interface TokenStoreFile { @@ -128,10 +154,13 @@ export function mintAgentToken( /** * Re-mint the material under an existing name: new hash, new prefix, - * `rotated_at` stamped, `created_at` kept. The old material stops - * resolving on the NEXT resolve (the mtime cache re-reads), so a server - * process needs no restart. A revoked record refuses - revocation is - * terminal; mint a new name to start over. + * `rotated_at` stamped, `created_at` kept. The PREVIOUS hash stays + * resolvable beside the new one for {@link ROTATION_GRACE_MS} (recorded + * as `rotated_from_hash`), so the caller still holding the old material + * authenticates while the new one is being placed and dies when the + * window closes - the next resolve picks all of this up with no server + * restart (the mtime cache re-reads). A revoked record refuses - + * revocation is terminal; mint a new name to start over. */ export function rotateAgentToken( vault: string, @@ -162,6 +191,7 @@ export function rotateAgentToken( token_hash: hashOf(tokenMaterial), token_prefix: tokenMaterial.slice(0, TOKEN_PREFIX_LENGTH), rotated_at: isoSecond(), + rotated_from_hash: existing.token_hash, }, }, }); @@ -220,7 +250,9 @@ export function hasAnyAgentToken(vault: string): boolean { * every stored hash with timingSafeEqual (fixed 32-byte digests), so no * byte of a wrong answer leaks through early exit. The store is read * behind an mtime cache, which is what lets a CLI-side rotation take - * effect here on the next request without a restart. + * effect here on the next request without a restart; a hash carried over + * from a rotation resolves beside the current one until its + * {@link ROTATION_GRACE_MS} window closes and is skipped once it has. */ export function resolveAgentForToken( vault: string, @@ -229,7 +261,11 @@ export function resolveAgentForToken( if (typeof presented !== "string" || presented.length === 0) return null; const index = activeHashIndex(vault); const digest = hashOf(presented); + const now = nowMs(); for (const [storedHash, entry] of index) { + // A grace-window entry past its window answers exactly like an + // unknown hash - the material is dead, not erroring. + if (entry.graceUntil !== undefined && now >= entry.graceUntil) continue; if (timingSafeEqual(Buffer.from(storedHash, "hex"), Buffer.from(digest, "hex"))) { return { agent: entry.agent, name: entry.name }; } @@ -237,6 +273,21 @@ export function resolveAgentForToken( return null; } +// ----- Clock seam ------------------------------------------------------------ + +/** + * Milliseconds since the epoch, read once per resolve. A module-level + * seam rather than a threaded argument because the readers are the + * transport's per-request path; tests walk the grace window by + * substituting the clock and restore it with `null`. + */ +let nowMs: () => number = () => Date.now(); + +/** Test seam: replace the store clock (pass `null` to restore the real one). */ +export function setTokenStoreClock(read: (() => number) | null): void { + nowMs = read ?? (() => Date.now()); +} + // ----- Store file ------------------------------------------------------------ function validatedName(name: string): string { @@ -291,7 +342,11 @@ function readTokenStore(vault: string): TokenStoreFile { typeof record.agent !== "string" || (record.status !== "active" && record.status !== "revoked") || typeof record.token_hash !== "string" || - !/^[0-9a-f]{64}$/.test(record.token_hash) + !/^[0-9a-f]{64}$/.test(record.token_hash) || + (record.rotated_from_hash !== undefined && + !/^[0-9a-f]{64}$/.test(record.rotated_from_hash)) || + (record.rotated_at !== undefined && + (typeof record.rotated_at !== "string" || Number.isNaN(Date.parse(record.rotated_at)))) ) { throw new McpTokenStoreError( `MCP token store entry ${JSON.stringify(key)} is corrupt: ${path}`, @@ -319,15 +374,23 @@ function writeTokenStore(vault: string, file: TokenStoreFile): void { interface CacheEntry { readonly mtimeMs: number; readonly size: number; - readonly index: ReadonlyMap; + readonly index: ReadonlyMap; +} + +/** One resolvable hash: the live credential, or a rotation's grace entry. */ +interface HashIndexEntry { + readonly agent: string; + readonly name: string; + /** Epoch ms when a grace-window entry dies; absent for the current hash. */ + readonly graceUntil?: number; } /** The active-token hash index per store path, refreshed when the file changes. */ const cacheByPath = new Map(); -const EMPTY_INDEX: ReadonlyMap = new Map(); +const EMPTY_INDEX: ReadonlyMap = new Map(); -function activeHashIndex(vault: string): ReadonlyMap { +function activeHashIndex(vault: string): ReadonlyMap { const path = tokenStorePath(vault); if (!existsSync(path)) { cacheByPath.delete(path); @@ -338,10 +401,29 @@ function activeHashIndex(vault: string): ReadonlyMap(); + const index = new Map(); for (const record of Object.values(readTokenStore(vault).tokens)) { if (record.status !== "active") continue; - index.set(record.token_hash, { agent: record.agent, name: record.name }); + const entry: HashIndexEntry = { agent: record.agent, name: record.name }; + index.set(record.token_hash, entry); + // The rotation grace: the hash this record replaced resolves beside + // the current one until its window closes. A second rotation + // overwrites the carried hash, so only the PREVIOUS material is ever + // kept; skipping a self-equal value keeps a corrupt record from + // demoting the live hash to a windowed one. + if ( + record.rotated_from_hash !== undefined && + record.rotated_from_hash !== record.token_hash && + record.rotated_at !== undefined + ) { + const rotatedAt = Date.parse(record.rotated_at); + if (Number.isFinite(rotatedAt)) { + index.set(record.rotated_from_hash, { + ...entry, + graceUntil: rotatedAt + ROTATION_GRACE_MS, + }); + } + } } cacheByPath.set(path, { mtimeMs: stats.mtimeMs, size: stats.size, index }); return index; diff --git a/src/core/install/payload.ts b/src/core/install/payload.ts index 3e6d1afa..4e57de9c 100644 --- a/src/core/install/payload.ts +++ b/src/core/install/payload.ts @@ -72,6 +72,16 @@ export const WINDOWS_LAUNCHER_ENV: Readonly> = Object.fre NoDefaultCurrentDirectoryInExePath: "1", }); +/** + * The two registrations every adapter writes, verbatim. + * + * The entries register a STDIO server - `o2b mcp --vault ` - and + * carry no credential: the env block names identity and timezone only. + * That is why a minted MCP token changes nothing for a harness + * bootstrapped with this payload: stdio identity is config-derived (one + * caller per process that already owns the process tree), and the token + * authenticates the HTTP transport's hand-configured clients instead. + */ export function buildPayload(cfg: PayloadConfig, platform: string = process.platform): McpPayload { if (!cfg.vault || typeof cfg.vault !== "string") { throw new PayloadError("buildPayload: vault is required"); diff --git a/tests/cli/bootstrap.test.ts b/tests/cli/bootstrap.test.ts index c26ec716..716f12b5 100644 --- a/tests/cli/bootstrap.test.ts +++ b/tests/cli/bootstrap.test.ts @@ -25,6 +25,7 @@ import { readFileSync, readdirSync, rmSync, + utimesSync, writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; @@ -49,11 +50,13 @@ import { JSONRPC_VERSION } from "../../src/mcp/protocol.ts"; let tempRoot: string; let vault: string; let codexHome: string; +let opencodeHome: string; beforeEach(() => { tempRoot = mkdtempSync(join(tmpdir(), "o2b-bootstrap-")); vault = join(tempRoot, "brain vault"); codexHome = join(tempRoot, "codex-home"); + opencodeHome = join(tempRoot, "opencode-home"); mkdirSync(vault, { recursive: true }); // Both subprocess seams are injected for the whole suite: the codex // adapter must take its file-fallback path (no binary), and the host @@ -113,6 +116,23 @@ function bootstrapArgs(extra: ReadonlyArray): string[] { return ["bootstrap", "--vault", vault, ...extra]; } +/** + * Real agent identities for the mints. The targets themselves (`codex`, + * `claude-code`) are PLACEHOLDER_AGENT_VALUES - names the identity layer + * treats as absent - so a mint under them is refused (see the refusals + * suite); every provisioning case names its agent explicitly. + */ +const CODEX_AGENT = "codex-workstation"; +const CLAUDE_AGENT = "claude-workstation"; +const OPENCODE_AGENT = "opencode-workstation"; + +/** The single osbt-shaped line a mint or rotate prints, trimmed. */ +function printedMaterial(stdout: string): string { + const start = stdout.indexOf("osbt_"); + expect(start).toBeGreaterThanOrEqual(0); + return stdout.slice(start, stdout.indexOf("\n", start)).trim(); +} + /** * A `codex` binary that registers like the real one: `mcp add` appends the * server's table to `$CODEX_HOME/config.toml` in the host's own layout, so @@ -136,16 +156,19 @@ function fakeCodexHost(): CodexRunner { describe("o2b bootstrap --target codex (adapter model)", () => { test("mints the named token, applies the adapter, and prints the material exactly once", async () => { - const r = await runCli(bootstrapArgs(["--target", "codex", "--agent", "codex", "--token"]), { - env: { CODEX_HOME: codexHome }, - }); + const r = await runCli( + bootstrapArgs(["--target", "codex", "--agent", CODEX_AGENT, "--token"]), + { + env: { CODEX_HOME: codexHome }, + }, + ); expect(r.returncode).toBe(0); const store = listAgentTokens(vault); expect(store).toHaveLength(1); const record = store[0]!; expect(record.name).toBe("mcp_token_codex"); - expect(record.agent).toBe("codex"); + expect(record.agent).toBe(CODEX_AGENT); expect(record.status).toBe("active"); // The material exists in exactly one output channel, once, with the @@ -156,6 +179,10 @@ describe("o2b bootstrap --target codex (adapter model)", () => { const tokenMaterial = r.stdout.slice(materialStart, lineEnd).trim(); expect(countOccurrences(r.stdout, tokenMaterial)).toBe(1); expect(r.stdout).toContain("shown exactly once"); + // The credential boundary beside the material: what the token + // authenticates (hand-configured HTTP clients) and what it does not + // (the registered stdio harness keeps its config-derived identity). + expect(r.stdout).toContain("config-derived identity"); expect(r.stderr).not.toContain(tokenMaterial); // The harness config carries the registration and never the @@ -184,14 +211,14 @@ describe("o2b bootstrap --target codex (adapter model)", () => { // The minted material authenticates, via the same resolution the // transport performs per request. expect(resolveAgentForToken(vault, tokenMaterial)).toEqual({ - agent: "codex", + agent: CODEX_AGENT, name: "mcp_token_codex", }); }, 20000); test("a second identical run is a byte-identical no-op: exit 0, no receipt churn", async () => { const first = await runCli( - bootstrapArgs(["--target", "codex", "--agent", "codex", "--token"]), + bootstrapArgs(["--target", "codex", "--agent", CODEX_AGENT, "--token"]), { env: { CODEX_HOME: codexHome } }, ); expect(first.returncode).toBe(0); @@ -205,7 +232,7 @@ describe("o2b bootstrap --target codex (adapter model)", () => { const installBefore = bytesOf(join(vault, ".open-second-brain", "install.lock.json")); const second = await runCli( - bootstrapArgs(["--target", "codex", "--agent", "codex", "--token"]), + bootstrapArgs(["--target", "codex", "--agent", CODEX_AGENT, "--token"]), { env: { CODEX_HOME: codexHome } }, ); expect(second.returncode).toBe(0); @@ -220,7 +247,7 @@ describe("o2b bootstrap --target codex (adapter model)", () => { test("--rotate re-mints under the same name, audits replaced, and the new material authenticates on the next request with no server restart", async () => { const first = await runCli( - bootstrapArgs(["--target", "codex", "--agent", "codex", "--token"]), + bootstrapArgs(["--target", "codex", "--agent", CODEX_AGENT, "--token"]), { env: { CODEX_HOME: codexHome } }, ); const materialStart = first.stdout.indexOf("osbt_"); @@ -286,9 +313,12 @@ describe("o2b bootstrap --target codex (adapter model)", () => { oldMaterial.slice(0, store[0]!.token_prefix.length), ); - // No restart: the old material is refused and the new one - // authenticates on the very next requests of the same server. - expect((await post(oldMaterial)).status).toBe(401); + // No restart: both materials authenticate on the very next + // requests of the same server - the new one from here on, the old + // one through the rotation grace window while the new material is + // being placed (the store suite walks the window's expiry with an + // injected clock). + expect((await post(oldMaterial)).status).toBe(200); expect((await post(newMaterial)).status).toBe(200); expect( resolveAgentForToken(vault, fakeCredential("osbt_", "never-minted-material")), @@ -306,7 +336,7 @@ describe("o2b bootstrap --target codex (adapter model)", () => { expect(fresh.stdout).toContain("not-installed"); const first = await runCli( - bootstrapArgs(["--target", "codex", "--agent", "codex", "--token"]), + bootstrapArgs(["--target", "codex", "--agent", CODEX_AGENT, "--token"]), { env: { CODEX_HOME: codexHome } }, ); expect(first.returncode).toBe(0); @@ -332,7 +362,7 @@ describe("o2b bootstrap --target codex (adapter model)", () => { test("--check refuses a revoked or missing token as drift, naming the provisioning command", async () => { const first = await runCli( - bootstrapArgs(["--target", "codex", "--agent", "codex", "--token"]), + bootstrapArgs(["--target", "codex", "--agent", CODEX_AGENT, "--token"]), { env: { CODEX_HOME: codexHome } }, ); expect(first.returncode).toBe(0); @@ -348,7 +378,7 @@ describe("o2b bootstrap --target codex (adapter model)", () => { test("a revoked token refuses every provision form instead of a healthy no-op, matching --check", async () => { const first = await runCli( - bootstrapArgs(["--target", "codex", "--agent", "codex", "--token"]), + bootstrapArgs(["--target", "codex", "--agent", CODEX_AGENT, "--token"]), { env: { CODEX_HOME: codexHome } }, ); expect(first.returncode).toBe(0); @@ -389,7 +419,7 @@ describe("o2b bootstrap --target codex (adapter model)", () => { }); const first = await runCli( - bootstrapArgs(["--target", "codex", "--agent", "codex", "--token"]), + bootstrapArgs(["--target", "codex", "--agent", CODEX_AGENT, "--token"]), { env: { CODEX_HOME: codexHome } }, ); expect(first.returncode).toBe(0); @@ -456,7 +486,7 @@ describe("o2b bootstrap --target generic (print-and-paste)", () => { describe("o2b bootstrap --target claude-code (plugin verify-only)", () => { test("mints and records the token, writes no harness config, and points at the plugin's own verify", async () => { const r = await runCli( - bootstrapArgs(["--target", "claude-code", "--agent", "claude-code", "--token"]), + bootstrapArgs(["--target", "claude-code", "--agent", CLAUDE_AGENT, "--token"]), { env: { CODEX_HOME: codexHome } }, ); expect(r.returncode).toBe(0); @@ -534,6 +564,58 @@ describe("o2b bootstrap refusals", () => { expect(r.returncode).toBe(2); }); + test("a mint under a placeholder agent identity refuses by name; the plain path keeps working", async () => { + // No --agent at all: the target name itself is the placeholder the + // identity layer treats as absent, so the mint refuses before + // anything is written. + const noAgent = await runCli(bootstrapArgs(["--target", "codex", "--token"]), { + env: { CODEX_HOME: codexHome }, + }); + expect(noAgent.returncode).toBe(2); + expect(noAgent.stderr).toContain("mcp_token_codex"); + expect(noAgent.stderr).toContain("--agent"); + expect(listAgentTokens(vault)).toHaveLength(0); + expect(existsSync(codexConfigPath())).toBe(false); + + // An explicit placeholder is the same refusal. + const explicit = await runCli( + bootstrapArgs(["--target", "codex", "--agent", "codex", "--token"]), + { env: { CODEX_HOME: codexHome } }, + ); + expect(explicit.returncode).toBe(2); + expect(explicit.stderr).toContain("placeholder"); + + // Any target minting under a placeholder name refuses the same way. + const generic = await runCli( + bootstrapArgs(["--target", "generic", "--agent", "bot", "--token"]), + { env: { CODEX_HOME: codexHome } }, + ); + expect(generic.returncode).toBe(2); + expect(listAgentTokens(vault)).toHaveLength(0); + + // The plain (no --token) provision path never mints, so it keeps + // working without --agent. + const plain = await runCli(bootstrapArgs(["--target", "codex"]), { + env: { CODEX_HOME: codexHome }, + }); + expect(plain.returncode).toBe(0); + expect(existsSync(codexConfigPath())).toBe(true); + expect(listAgentTokens(vault)).toHaveLength(0); + + // Once a token exists under a real name, a plain re-run adopts the + // minted identity instead of refusing. + const minted = await runCli( + bootstrapArgs(["--target", "codex", "--agent", CODEX_AGENT, "--token"]), + { env: { CODEX_HOME: codexHome } }, + ); + expect(minted.returncode).toBe(0); + const rerun = await runCli(bootstrapArgs(["--target", "codex"]), { + env: { CODEX_HOME: codexHome }, + }); + expect(rerun.returncode).toBe(0); + expect(listAgentTokens(vault)).toHaveLength(1); + }, 20000); + test("a corrupted receipt refuses --check through the clean error path, not a crash", async () => { mkdirSync(join(vault, ".open-second-brain"), { recursive: true }); writeFileSync(receiptPath(), "{ not json"); @@ -548,3 +630,118 @@ describe("o2b bootstrap refusals", () => { expect(r.stderr).not.toMatch(/^\s+at /m); }, 20000); }); + +describe("o2b bootstrap --remove (receipt-driven teardown)", () => { + test("revokes the receipt's token, removes the owned registration, drops the receipt entry, and is idempotent", async () => { + const first = await runCli( + bootstrapArgs(["--target", "codex", "--agent", CODEX_AGENT, "--token"]), + { env: { CODEX_HOME: codexHome } }, + ); + expect(first.returncode).toBe(0); + const material = printedMaterial(first.stdout); + expect(existsSync(codexConfigPath())).toBe(true); + expect(readReceipt()["entries"]["codex"]).toBeDefined(); + + const removed = await runCli(bootstrapArgs(["--remove", "codex"]), { + env: { CODEX_HOME: codexHome }, + }); + expect(removed.returncode).toBe(0); + // The registration the adapter wrote is gone... + expect(existsSync(codexConfigPath())).toBe(true); + expect(bytesOf(codexConfigPath())).not.toContain("mcp_servers.open-second-brain"); + // ...the receipt's token is revoked - immediately, no grace: removal + // is not a rotation, nobody is placing new material... + expect(listAgentTokens(vault)[0]!.status).toBe("revoked"); + expect(resolveAgentForToken(vault, material)).toBeNull(); + expect(removed.stdout).toContain("revoked"); + expect(removed.stdout).toContain("removed"); + // ...and the receipt entry is dropped the way the install manifest + // models removal: the entry goes, the file stays. + expect(readReceipt()["entries"]["codex"]).toBeUndefined(); + + // A second remove finds nothing and exits clean. + const again = await runCli(bootstrapArgs(["--remove", "codex"]), { + env: { CODEX_HOME: codexHome }, + }); + expect(again.returncode).toBe(0); + expect(again.stdout).toContain("nothing to remove"); + expect(bytesOf(codexConfigPath())).not.toContain("mcp_servers.open-second-brain"); + expect(listAgentTokens(vault)[0]!.status).toBe("revoked"); + }, 20000); + + test("an unknown target and provision-flag combinations are usage refusals", async () => { + const unknown = await runCli(bootstrapArgs(["--remove", "nonsense"]), { + env: { CODEX_HOME: codexHome }, + }); + expect(unknown.returncode).toBe(2); + expect(unknown.stderr).toContain("claude-code, codex, generic, grok, opencode, zcode"); + + const mixed = await runCli(bootstrapArgs(["--target", "codex", "--remove", "codex"]), { + env: { CODEX_HOME: codexHome }, + }); + expect(mixed.returncode).toBe(2); + expect(mixed.stderr).toContain("--remove"); + + const withToken = await runCli(bootstrapArgs(["--remove", "codex", "--token"]), { + env: { CODEX_HOME: codexHome }, + }); + expect(withToken.returncode).toBe(2); + }); +}); + +describe("o2b bootstrap when the adapter apply fails after a mint (opencode)", () => { + /** `$XDG_CONFIG_HOME/opencode/opencode.json`, the adapter's config. */ + function opencodeConfigPath(): string { + return join(opencodeHome, "opencode", "opencode.json"); + } + + test("a rotate into a hand-edited config still shows the material exactly once and names the retry path", async () => { + mkdirSync(opencodeHome, { recursive: true }); + const env = { XDG_CONFIG_HOME: opencodeHome }; + const first = await runCli( + bootstrapArgs(["--target", "opencode", "--agent", OPENCODE_AGENT, "--token"]), + { env }, + ); + expect(first.returncode).toBe(0); + const firstMaterial = printedMaterial(first.stdout); + + // Hand-edit: keep the OSB keys but change a value, and stamp an mtime + // newer than the manifest's applied_at - exactly what the adapter's + // user-modified-block safety net refuses to overwrite. + const edited = bytesOf(opencodeConfigPath()).replace("o2b", "not-o2b"); + writeFileSync(opencodeConfigPath(), edited); + const future = new Date(Date.now() + 10_000); + utimesSync(opencodeConfigPath(), future, future); + + const rotated = await runCli(bootstrapArgs(["--target", "opencode", "--rotate"]), { env }); + expect(rotated.returncode).toBe(4); + expect(rotated.stderr).toContain("hand-edited"); + + // The mint happened, so the material prints - exactly once, on + // stdout, beside the failure notice naming the retry and revoke + // paths. Losing it silently would orphan a live credential whose + // name a re-run would refuse to re-mint. + const newMaterial = printedMaterial(rotated.stdout); + expect(newMaterial).not.toBe(firstMaterial); + expect(countOccurrences(rotated.stdout, newMaterial)).toBe(1); + expect(rotated.stderr).not.toContain(newMaterial); + expect(rotated.stdout).toContain("live in the store"); + expect(rotated.stdout).toContain("o2b mcp token revoke --name mcp_token_opencode"); + expect(resolveAgentForToken(vault, newMaterial)).toEqual({ + agent: OPENCODE_AGENT, + name: "mcp_token_opencode", + }); + + // The named retry path finishes the job: a plain re-run (no --token, + // so nothing re-mints) forced past the hand-edit block applies the + // registration and the receipt catches up to the rotated token. + const retry = await runCli(bootstrapArgs(["--target", "opencode", "--force"]), { env }); + expect(retry.returncode).toBe(0); + expect(retry.stdout).not.toContain(newMaterial); + const record = listAgentTokens(vault).find((t) => t.name === "mcp_token_opencode")!; + expect(record.token_prefix).not.toBe(firstMaterial.slice(0, record.token_prefix.length)); + const entry = readReceipt()["entries"]["opencode"]; + expect(entry["token"]).toEqual({ name: "mcp_token_opencode", prefix: record.token_prefix }); + expect(entry["agent"]).toBe(OPENCODE_AGENT); + }, 20000); +}); diff --git a/tests/cli/mcp-token.test.ts b/tests/cli/mcp-token.test.ts index 2152bbda..05dfc500 100644 --- a/tests/cli/mcp-token.test.ts +++ b/tests/cli/mcp-token.test.ts @@ -61,6 +61,9 @@ describe("o2b mcp token mint", () => { expect(r.returncode).toBe(0); expect(r.stdout).toContain("mcp_token_codex"); expect(r.stdout).toContain("shown exactly once"); + // The identity boundary: the material authenticates hand-configured + // HTTP clients; a stdio server presents no token. + expect(r.stdout).toContain("config-derived identity"); const tokenMaterial = printedMaterial(r.stdout); expect(countOccurrences(r.stdout, tokenMaterial)).toBe(1); @@ -108,7 +111,7 @@ describe("o2b mcp token mint", () => { }); describe("o2b mcp token rotate", () => { - test("replaces the material under the same name; the old material stops resolving", async () => { + test("replaces the material under the same name; the previous material stays live through the grace window named in the output", async () => { const first = await runCli(tokenArgs(["mint", "--agent", "codex"])); const oldMaterial = printedMaterial(first.stdout); const before = listAgentTokens(vault)[0]!; @@ -119,12 +122,22 @@ describe("o2b mcp token rotate", () => { expect(newMaterial).not.toBe(oldMaterial); expect(countOccurrences(second.stdout, newMaterial)).toBe(1); expect(second.stdout).toContain("shown exactly once"); + // The rotation output names the placement window, so an operator + // holding the old material knows it keeps working briefly. + expect(second.stdout).toContain("keeps authenticating"); + expect(second.stdout).toContain("minutes"); const after = listAgentTokens(vault)[0]!; expect(after.name).toBe("mcp_token_codex"); expect(after.token_hash).not.toBe(before.token_hash); expect(after.rotated_at).toBeDefined(); - expect(resolveAgentForToken(vault, oldMaterial)).toBeNull(); + // Placement grace: the old material still authenticates right after + // the rotation (the store suite walks its expiry with an injected + // clock); the new one authenticates beside it. + expect(resolveAgentForToken(vault, oldMaterial)).toEqual({ + agent: "codex", + name: "mcp_token_codex", + }); expect(resolveAgentForToken(vault, newMaterial)).toEqual({ agent: "codex", name: "mcp_token_codex", diff --git a/tests/core/brain/secrets/token-store.test.ts b/tests/core/brain/secrets/token-store.test.ts index c559eea0..493de5de 100644 --- a/tests/core/brain/secrets/token-store.test.ts +++ b/tests/core/brain/secrets/token-store.test.ts @@ -27,12 +27,14 @@ import lockfile from "proper-lockfile"; import { MCP_TOKENS_SCHEMA_VERSION, + ROTATION_GRACE_MS, TOKEN_PREFIX_LENGTH, listAgentTokens, mintAgentToken, resolveAgentForToken, revokeAgentToken, rotateAgentToken, + setTokenStoreClock, } from "../../../../src/core/brain/secrets/token-store.ts"; import { secretsDir, @@ -54,6 +56,9 @@ beforeEach(() => { }); afterEach(() => { + // Tests that walk the rotation grace window substitute the store + // clock; every test restores the real one on the way out. + setTokenStoreClock(null); rmSync(tempRoot, { recursive: true, force: true }); }); @@ -190,12 +195,32 @@ describe("resolveAgentForToken", () => { expect(listAgentTokens(vault)[0]?.status).toBe("revoked"); }); - test("a rotation takes effect on the next call without any restart", () => { + test("a rotation takes effect on the next call without any restart, with the previous material alive through the grace window", () => { const first = mint(); expect(resolveAgentForToken(vault, first.tokenMaterial)?.agent).toBe("codex"); const second = rotateAgentToken(vault, "mcp_token_codex"); expect(second.record.status).toBe("active"); expect(second.record.rotated_at).toEqual(expect.any(String)); + // The store records the hash it replaced, so the old material can + // stay resolvable for a bounded window. + expect(second.record.rotated_from_hash).toBe(first.record.token_hash); + + // No restart: the new material authenticates on the very next + // resolve - and the old one keeps authenticating beside it, so the + // caller holding it does not fail while the new one is placed. + expect(resolveAgentForToken(vault, second.tokenMaterial)).toEqual({ + agent: "codex", + name: "mcp_token_codex", + }); + expect(resolveAgentForToken(vault, first.tokenMaterial)).toEqual({ + agent: "codex", + name: "mcp_token_codex", + }); + + // The window is bounded: past rotated_at + ROTATION_GRACE_MS the old + // material resolves exactly like an unknown one, and the new one is + // untouched. + setTokenStoreClock(() => Date.now() + ROTATION_GRACE_MS + 1_000); expect(resolveAgentForToken(vault, first.tokenMaterial)).toBeNull(); expect(resolveAgentForToken(vault, second.tokenMaterial)).toEqual({ agent: "codex", @@ -203,6 +228,26 @@ describe("resolveAgentForToken", () => { }); }); + test("a second rotation moves the grace to the newest replaced hash; the older material dies at once", () => { + const first = mint(); + const second = rotateAgentToken(vault, "mcp_token_codex"); + const third = rotateAgentToken(vault, "mcp_token_codex"); + expect(third.record.rotated_from_hash).toBe(second.record.token_hash); + // Only the PREVIOUS hash is kept: the first material, already two + // generations old, resolves like an unknown one immediately. + expect(resolveAgentForToken(vault, first.tokenMaterial)).toBeNull(); + expect(resolveAgentForToken(vault, second.tokenMaterial)?.agent).toBe("codex"); + expect(resolveAgentForToken(vault, third.tokenMaterial)?.agent).toBe("codex"); + }); + + test("revocation stays immediate across a rotation's grace window", () => { + const first = mint(); + const second = rotateAgentToken(vault, "mcp_token_codex"); + expect(revokeAgentToken(vault, "mcp_token_codex")).toBe(true); + expect(resolveAgentForToken(vault, first.tokenMaterial)).toBeNull(); + expect(resolveAgentForToken(vault, second.tokenMaterial)).toBeNull(); + }); + test("a store rewritten by another process is picked up through the mtime cache", () => { // Simulate a rotation performed by a different process (the CLI) // while a long-lived server holds its read cache: rewrite the store From 2f5971064ad12ce35d3c3bad43f8c2b82f94816c Mon Sep 17 00:00:00 2001 From: Sergey Eroshenkov Date: Sat, 10 Oct 2026 20:25:44 +0200 Subject: [PATCH 49/84] test(mcp): expect the rotation grace window at the transport Rotation now keeps the previous material authenticating beside the new one for a bounded ten-minute placement window, so the transport sees the pre-rotation credential answer 200 inside the window; its expiry is pinned in the store suite with an injected clock. The revoked-token case uses real revocation, which stays immediate - the grace window is a rotation courtesy, never a reprieve for a withdrawn credential. --- tests/mcp/http-token-auth.test.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/tests/mcp/http-token-auth.test.ts b/tests/mcp/http-token-auth.test.ts index fb5e1d51..d80c92af 100644 --- a/tests/mcp/http-token-auth.test.ts +++ b/tests/mcp/http-token-auth.test.ts @@ -30,7 +30,11 @@ import { brainConfigPath } from "../../src/core/brain/paths.ts"; import { GATE_MODE } from "../../src/core/integrity/stamp.ts"; import { writePreference } from "../../src/core/brain/preference.ts"; import { BRAIN_CONFIDENCE, BRAIN_PREFERENCE_STATUS } from "../../src/core/brain/types.ts"; -import { mintAgentToken, rotateAgentToken } from "../../src/core/brain/secrets/token-store.ts"; +import { + mintAgentToken, + revokeAgentToken, + rotateAgentToken, +} from "../../src/core/brain/secrets/token-store.ts"; let vault: string; let handle: HttpServerHandle | null = null; @@ -218,7 +222,9 @@ describe("HTTP token authentication", () => { const sharedKey = fakeCredential("shared", "-master-", "77f1"); const minted = mintAgentToken(vault, "mcp_token_edge", "edge-agent"); await start({ apiKey: sharedKey }); - rotateAgentToken(vault, "mcp_token_edge"); + // Revocation, not rotation: the rotation grace window does not apply + // here - a withdrawn credential dies immediately. + revokeAgentToken(vault, "mcp_token_edge"); const revoked = await post(rpc("ping", 1), { key: minted.tokenMaterial }); const unknown = await post(rpc("ping", 2), { key: fakeCredential("osbt_", "never-minted"), @@ -261,7 +267,10 @@ describe("HTTP token authentication", () => { await start({ tokensRequired: true }); expect((await post(rpc("ping", 1), { key: first.tokenMaterial })).status).toBe(200); const second = rotateAgentToken(vault, "mcp_token_edge"); - expect((await post(rpc("ping", 2), { key: first.tokenMaterial })).status).toBe(401); + // Inside the rotation grace window the PREVIOUS material keeps + // authenticating beside the new one, so the caller holding it does + // not fail while the new material is being placed. + expect((await post(rpc("ping", 2), { key: first.tokenMaterial })).status).toBe(200); expect((await post(rpc("ping", 3), { key: second.tokenMaterial })).status).toBe(200); }); From 769fb73c769d1828d26df850164a3c251c4cb2f9 Mon Sep 17 00:00:00 2001 From: Sergey Eroshenkov Date: Sat, 10 Oct 2026 20:53:10 +0200 Subject: [PATCH 50/84] fix(ingest): record the extraction contract per manifest entry The manifest-wide fingerprint stamp made the first post-change ingest cancel the reprocess owed to every source it did not touch: updateManifest stamped the live contract for the whole file, so the next plan reported the untouched sources contract-current and skipped them. Each entry now carries the fingerprint of the pass that recorded it (schema_version 3; bare-digest entries are read as fingerprint-less). The changed-contract marker is derived per read from the entries - null while any entry predates the live contract - and the manifest-wide field stays as the current-contract marker only. A v2 manifest (no per-entry fingerprints) degrades to every-entry-changed, failing toward reprocessing, never toward skipping. The on-disk format is one-way from schema_version 2 on; the module docblock states it. --- src/core/brain/ingest/content-manifest.ts | 288 ++++++++++++------ .../brain/ingest/content-manifest.test.ts | 100 +++++- tests/core/brain/ingest/ingest.test.ts | 6 +- 3 files changed, 292 insertions(+), 102 deletions(-) diff --git a/src/core/brain/ingest/content-manifest.ts b/src/core/brain/ingest/content-manifest.ts index d115a1d9..de397d40 100644 --- a/src/core/brain/ingest/content-manifest.ts +++ b/src/core/brain/ingest/content-manifest.ts @@ -18,10 +18,22 @@ * that case. Comparing content hashes instead means only a real byte change * re-triggers ingestion. * - * The manifest also carries ONE manifest-wide field beyond the entries: the - * extraction contract ({@link ./contract.ts}) the recorded state answers - * under, so a changed extraction contract reprocesses every source once even - * when its bytes did not move (t_586d5d8b). + * A changed extraction contract ({@link ./contract.ts}) must reprocess every + * source once even when its bytes did not move (t_586d5d8b), so each entry + * records the contract fingerprint it was written under (schema_version 3). + * The plan-level decision reads the ENTRIES: a manifest reads back + * contract-changed while ANY entry predates the live contract, so ingesting + * one source after a contract change can no longer cancel the reprocess owed + * to the others. The manifest-wide `contract` field remains as the + * current-contract marker only - informational, never the skip authority. + * + * The on-disk format is ONE-WAY from schema_version 2 on: there is no + * write-side downgrade. A binary older than the manifest-wide contract field + * refuses a v2 or v3 file outright, and a binary older than per-entry + * fingerprints refuses v3. Reading DEGRADES toward reprocessing, never + * toward skipping: a v2 manifest (bare-digest entries, no per-entry + * fingerprints) reads back contract-changed for every entry, owing one full + * reprocess under the live contract. * * The manifest lives at `/.open-second-brain/ingest-manifest.json`, a * MACHINE artifact - not curated memory, so NOT under `Brain/`. This mirrors the @@ -44,14 +56,15 @@ import { assertVaultIdentityForWrite } from "../vault-identity.ts"; import { computeExtractionContractFingerprint } from "./contract.ts"; /** The schema version this module writes. Earlier versions are read, not written. */ -const SCHEMA_VERSION = 2 as const; +const SCHEMA_VERSION = 3 as const; /** Every on-disk schema version this module reads; anything else is refused. */ -type ManifestSchemaVersion = 1 | typeof SCHEMA_VERSION; +type ManifestSchemaVersion = 1 | 2 | typeof SCHEMA_VERSION; /** Every schema version {@link readManifest} accepts. Anything else is refused. */ const SUPPORTED_SCHEMA_VERSIONS: readonly ManifestSchemaVersion[] = Object.freeze([ 1, + 2, SCHEMA_VERSION, ]); @@ -68,9 +81,26 @@ export interface ManifestContract { } /** - * The persisted manifest: a map from a canonical vault-relative source path to - * the SHA-256 hex of its content at last ingest, plus the manifest-wide - * extraction contract the recorded state answers under. + * One entry's recorded state: the content digest AND the extraction contract + * the entry was recorded under. + */ +export interface ManifestEntry { + /** 64-char lowercase SHA-256 hex of the source's content at last ingest. */ + readonly sha256: string; + /** + * {@link computeExtractionContractFingerprint} of the pass that recorded + * THIS entry. Absent on entries recorded before per-entry fingerprints + * existed (every v1 and v2 manifest); an absent fingerprint is that + * entry's stale-contract marker - it reprocesses once under the live + * contract, never skips. + */ + readonly fingerprint?: string; +} + +/** + * The persisted manifest: a map from a canonical vault-relative source path + * to the state recorded for it at last ingest, plus the contract the + * recorded state answers under. */ export interface ContentManifest { /** @@ -78,25 +108,26 @@ export interface ContentManifest { * disk for a read file, and the current {@link SCHEMA_VERSION} for the * missing-file empty manifest (no file exists, so the current shape is the * honest answer). Deliberately not the write constant - an accepted v1 - * manifest that reported itself as v2 would make every "which version is - * on disk" diagnostic lie about exactly the one legacy shape this module + * manifest that reported itself as v3 would make every "which version is + * on disk" diagnostic lie about exactly the legacy shapes this module * supports. */ readonly schema_version: ManifestSchemaVersion; - /** Canonical vault-relative path → 64-char lowercase SHA-256 hex of content. */ - readonly entries: Readonly>; + /** Canonical vault-relative path → the digest and contract it was recorded under. */ + readonly entries: Readonly>; /** - * The extraction contract the recorded state answers under, or `null` when - * none is recorded - a v1 manifest written before the fingerprint existed. - * `null` is the changed-contract marker: every consumer treats it as - * "reprocess once under the live contract". The marker survives until a - * write that actually RECORDS EXTRACTION under the live contract - * ({@link updateManifest}) lands the manifest as v2 with the fingerprint; - * a round-tripping edit write passes the `null` back in and the file stays - * v1, so the owed reprocess is never forfeited by a write that reprocessed - * nothing. A manifest file that does not exist reads back as current-shaped - * under the LIVE contract - an empty manifest was never written under an - * older one, so nothing in it can be stale. + * The contract the recorded state answers under, DERIVED per read from the + * entries against the live contract: every entry's fingerprint must equal + * the live one, and the manifest then answers under the live contract; + * ANY entry that predates it (an absent fingerprint, or a differing one) + * makes this `null` - the changed-contract marker every consumer treats + * as "reprocess under the live contract, skip nothing". The marker is + * derived per read, not read from a manifest-wide stamp, so a write that + * recorded only some entries cannot cancel the reprocess owed to the + * rest (the t_586d5d8b regression this field's derivation exists for). + * A manifest file that does not exist reads back as current-shaped under + * the LIVE contract - an empty manifest was never written under an older + * one, so nothing in it can be stale. */ readonly contract: ManifestContract | null; } @@ -172,10 +203,12 @@ export function hashPath(absPath: string): string { * manifest under the live contract (every path then classifies `new`). A * corrupted file is a hard error, and so is an unknown `schema_version` - * never a silent reset that would masquerade every source as unchanged or - * force a full re-ingest without saying so. A v1 manifest (a known previous - * version, written before the contract field existed) is accepted and reads - * back with `contract: null`: the changed-contract marker that makes every - * source reprocess once before the rewrite lands as v2. + * force a full re-ingest without saying so. Legacy files are accepted and + * degrade toward reprocessing: a v1 manifest (written before the contract + * field existed) and a v2 manifest (written before per-entry fingerprints + * existed) both read back with `contract: null` - the changed-contract + * marker that makes every source reprocess once before the rewrite lands + * as v3. */ export function readManifest(vault: string): ContentManifest { const path = manifestPath(vault); @@ -204,48 +237,111 @@ export function readManifest(vault: string): ContentManifest { `(expected ${SUPPORTED_SCHEMA_VERSIONS.join(" or ")}): ${path}`, ); } + const entries = readEntries(obj); + return { + schema_version: sv as ManifestSchemaVersion, + entries, + contract: deriveContract(entries, vault), + }; +} + +/** + * The persisted entry map, across every read schema version: a bare digest + * string (v1/v2) or a `{sha256, fingerprint}` object (v3) per entry. + * Anything else a write could never have produced is dropped - the path then + * classifies `new`, the fail-toward-reprocessing direction. + */ +function readEntries(obj: Record): Record { + const entries: Record = {}; const rawEntries = obj["entries"]; - const entries: Record = {}; - if (rawEntries !== null && typeof rawEntries === "object" && !Array.isArray(rawEntries)) { - for (const [key, value] of Object.entries(rawEntries as Record)) { - if (typeof value === "string") entries[key] = value; - } + if (rawEntries === null || typeof rawEntries !== "object" || Array.isArray(rawEntries)) { + return entries; + } + for (const [key, value] of Object.entries(rawEntries as Record)) { + const entry = parseEntryValue(value); + if (entry !== null) entries[key] = entry; } - return { schema_version: sv as ManifestSchemaVersion, entries, contract: readContractField(obj) }; + return entries; +} + +/** One persisted entry value; see {@link readEntries} for the accepted shapes. */ +function parseEntryValue(value: unknown): ManifestEntry | null { + if (typeof value === "string") return { sha256: value }; + if (value === null || typeof value !== "object" || Array.isArray(value)) return null; + const record = value as Record; + if (typeof record["sha256"] !== "string") return null; + const fingerprint = record["fingerprint"]; + return { + sha256: record["sha256"], + ...(typeof fingerprint === "string" ? { fingerprint } : {}), + }; } /** - * The manifest's recorded contract field, or `null` when none is recorded. - * Only `{ fingerprint: }` counts; anything else a v2 write could - * never have produced (and a v1 manifest never carries) degrades to `null`, - * the changed-contract marker - a malformed field self-heals into one - * reprocessing pass rather than refusing a manifest whose entries are fine. + * The changed-contract decision, derived from the entries against the LIVE + * contract: `null` while any entry predates it (no fingerprint, or a + * fingerprint that differs - an empty manifest has no such entry), the live + * contract once every entry has been re-recorded under it. The manifest-wide + * marker on disk is deliberately not consulted: it is informational, and + * consulting it is what let one source's ingest cancel the others' owed + * reprocess (t_586d5d8b). */ -function readContractField(obj: Record): ManifestContract | null { - const raw = obj["contract"]; - if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return null; - const fingerprint = (raw as Record)["fingerprint"]; - return typeof fingerprint === "string" ? { fingerprint } : null; +function deriveContract( + entries: Readonly>, + vault: string, +): ManifestContract | null { + const live = computeExtractionContractFingerprint(vault); + for (const entry of Object.values(entries)) { + if (entry.fingerprint !== live) return null; + } + return { fingerprint: live }; } /** * Serialize `entries` to the canonical manifest bytes: keys sorted so the * output is deterministic regardless of insertion order, `schema_version` and - * the contract field first, trailing newline. A `null` contract serializes - * the V1 shape - no contract field - so a round-tripped v1 manifest stays v1. + * the contract field first, trailing newline. The shape follows the contract + * argument the way {@link writeManifestAtomic} documents it. */ function serializeManifest( - entries: Record, - contract: ManifestContract | null, + vault: string, + entries: Readonly>, + contract: ManifestContract | null | undefined, ): string { - const sorted: Record = {}; - for (const key of Object.keys(entries).toSorted()) { - sorted[key] = entries[key]!; + const sortedKeys = Object.keys(entries).toSorted(); + let body: Record; + if (contract === undefined) { + // The post-extraction write: every entry passed in was just recorded + // under the live contract, so each carries that fingerprint. + const live = computeExtractionContractFingerprint(vault); + const stamped: Record = {}; + for (const key of sortedKeys) stamped[key] = { ...entries[key]!, fingerprint: live }; + body = { schema_version: SCHEMA_VERSION, contract: { fingerprint: live }, entries: stamped }; + } else if ( + contract === null && + !Object.values(entries).some((entry) => entry.fingerprint !== undefined) + ) { + // Legacy-shaped entries: the V1 shape - digests only, contract-less - so + // a v1 read round-trips as v1 and the changed-contract marker survives. + const digests: Record = {}; + for (const key of sortedKeys) digests[key] = entries[key]!.sha256; + body = { schema_version: 1, entries: digests }; + } else { + const sorted: Record = {}; + for (const key of sortedKeys) sorted[key] = entries[key]!; + body = + contract === null + ? // Fingerprint-bearing entries with a `null` contract: v3 with the + // live fingerprint as the marker. The marker is informational - the + // read-side decision derives from the entries - so stamping it + // cannot forfeit the owed reprocess the `null` protects. + { + schema_version: SCHEMA_VERSION, + contract: { fingerprint: computeExtractionContractFingerprint(vault) }, + entries: sorted, + } + : { schema_version: SCHEMA_VERSION, contract, entries: sorted }; } - const body = - contract === null - ? { schema_version: 1, entries: sorted } - : { schema_version: SCHEMA_VERSION, contract, entries: sorted }; return JSON.stringify(body, null, 2) + "\n"; } @@ -255,35 +351,39 @@ function serializeManifest( * no-op. The byte-identity check is what makes an all-unchanged rerun rewrite * nothing (and leaves the file's mtime alone). * - * `contract` is what the written manifest records as the extraction contract - * its entries answer under: + * `contract` is what the write records, and the entries carry the per-entry + * truth alongside it: * - * - Omitted: the LIVE contract. The writers that call without one have just - * recorded extraction under the current contract, so this is the one-time - * v1→v2 upgrade - legitimate exactly because the named paths were - * reprocessed under it. - * - A contract object: round-tripped verbatim, so an edit write cannot - * re-stamp entries that predate the live contract. - * - Explicit `null` (a v1 read): serialized back as V1, contract-less. The - * remaining entries were never reprocessed under any fingerprint, so the - * changed-contract marker must survive the write - stamping the live - * contract here would let them classify `unchanged` forever and silently - * forfeit the owed one-time reprocess (t_586d5d8b). + * - Omitted: the post-extraction write. EVERY entry passed in was just + * recorded under the live contract, so each gets the live fingerprint + * stamped and the manifest lands as v3. A caller that reprocessed only + * SOME entries must not use this form - stamping the rest would forfeit + * their owed reprocess; {@link updateManifest} passes the explicit form + * below instead. + * - A contract object: the entries round-trip verbatim (untouched entries + * keep the fingerprint they were recorded under) and the object is + * recorded as the current-contract marker - informational only, since + * the read-side decision derives from the entries. + * - Explicit `null` (the changed-contract marker read back): legacy-shaped + * entries serialize as V1, contract-less, so the marker survives a write + * that reprocessed nothing; fingerprint-bearing entries serialize as v3 + * with the live marker. Either way no entry gains a fingerprint it did + * not have, so the owed reprocess is never forfeited by an edit write + * (t_586d5d8b). */ export function writeManifestAtomic( vault: string, - entries: Record, + entries: Record, contract?: ManifestContract | null, ): boolean { // Vault-identity write guard (context-integrity-gates, Unit J). assertVaultIdentityForWrite(vault); const path = manifestPath(vault); - const next = serializeManifest( - entries, - contract === undefined - ? { fingerprint: computeExtractionContractFingerprint(vault) } - : contract, - ); + const normalized: Record = {}; + for (const [key, value] of Object.entries(entries)) { + normalized[key] = typeof value === "string" ? { sha256: value } : value; + } + const next = serializeManifest(vault, normalized, contract); if (existsSync(path) && readFileSync(path, "utf8") === next) { return false; } @@ -293,13 +393,17 @@ export function writeManifestAtomic( /** * Classify each requested path against the manifest by comparing its LIVE - * content hash to the recorded one: + * content hash to the recorded digest: * - not on disk → `missing`, * - on disk, not recorded → `new`, * - on disk, hash matches → `unchanged`, * - on disk, hash differs → `modified`. - * Paths are canonicalized before lookup so they match the keys written by - * {@link updateManifest}. Order within each bucket follows the input order. + * The buckets answer the BYTES question only - the extraction-contract + * question is the manifest's derived `contract` field, kept deliberately + * separate so a bytes-based read-back (the import census) is never affected + * by contract staleness. Paths are canonicalized before lookup so they match + * the keys written by {@link updateManifest}. Order within each bucket + * follows the input order. */ export function classifyPaths( vault: string, @@ -318,7 +422,7 @@ export function classifyPaths( const live = hashPath(abs); if (recorded === undefined) { result.new.push(canonical); - } else if (recorded === live) { + } else if (recorded.sha256 === live) { result.unchanged.push(canonical); } else { result.modified.push(canonical); @@ -328,11 +432,16 @@ export function classifyPaths( } /** - * Record post-ingest content hashes for `paths`, merging into the existing - * manifest. A path still on disk gets its current hash recorded; a path that - * has been deleted is dropped from the manifest (so it will re-classify as - * `new` if it ever reappears). Writes atomically and skips the write entirely - * when nothing changed. Returns `true` when the manifest was rewritten. + * Record post-ingest state for `paths`, merging into the existing manifest. + * A path still on disk gets its current hash recorded TOGETHER with the live + * contract fingerprint - the entry now answers under the live contract; a + * path that has been deleted is dropped from the manifest (so it will + * re-classify as `new` if it ever reappears). Entries for paths NOT in + * `paths` round-trip untouched, fingerprint included, so ingesting one + * source cannot mark the others as current (the t_586d5d8b regression this + * per-entry stamping exists for). Writes atomically and skips the write + * entirely when nothing changed. Returns `true` when the manifest was + * rewritten. * * The read, the merge and the write are ONE critical section, held under the * same sync lock every other Brain read-modify-write takes. Atomicity alone is @@ -347,17 +456,22 @@ export function updateManifest(vault: string, paths: readonly string[]): boolean remedy: ingestLockRemedy, }); try { - const entries: Record = { ...readManifest(vault).entries }; + const entries: Record = { ...readManifest(vault).entries }; + const live = computeExtractionContractFingerprint(vault); for (const path of paths) { const canonical = canonicalNotePath(path); const abs = join(vault, canonical); if (existsSync(abs)) { - entries[canonical] = hashPath(abs); + entries[canonical] = { sha256: hashPath(abs), fingerprint: live }; } else { delete entries[canonical]; } } - return writeManifestAtomic(vault, entries); + // Explicit contract: the live fingerprint rides along as the + // current-contract MARKER only. The omitted form would stamp every entry + // as current, and the untouched entries here may predate the live + // contract - their owed reprocess must survive this write. + return writeManifestAtomic(vault, entries, { fingerprint: live }); } finally { handle.release(); } diff --git a/tests/core/brain/ingest/content-manifest.test.ts b/tests/core/brain/ingest/content-manifest.test.ts index d1249c03..0272bd42 100644 --- a/tests/core/brain/ingest/content-manifest.test.ts +++ b/tests/core/brain/ingest/content-manifest.test.ts @@ -140,7 +140,7 @@ describe("manifest persistence", () => { test("updateManifest drops entries whose file was deleted", () => { writeSource("temp.md", "temp"); updateManifest(vault, ["temp.md"]); - expect(readManifest(vault).entries["temp.md"]).toMatch(/^[0-9a-f]{64}$/); + expect(readManifest(vault).entries["temp.md"]?.sha256).toMatch(/^[0-9a-f]{64}$/); rmSync(join(vault, "temp.md")); updateManifest(vault, ["temp.md"]); @@ -169,7 +169,7 @@ function writeManifestBytes(bytes: string): void { } describe("extraction contract (t_586d5d8b)", () => { - test("the manifest records the contract fingerprint and reads it back", () => { + test("the manifest records the contract fingerprint per entry and reads it back", () => { writeSource("a.md", "a"); updateManifest(vault, ["a.md"]); @@ -178,13 +178,20 @@ describe("extraction contract (t_586d5d8b)", () => { fingerprint: computeExtractionContractFingerprint(vault), }); - // The field is on disk, manifest-wide, under the bumped schema version. + // The entry carries the fingerprint of the pass that recorded it, on + // disk, under the bumped schema version. const onDisk = JSON.parse(readFileSync(manifestPath(vault), "utf8")) as { schema_version: number; contract: { fingerprint: string } | null; + entries: Record; }; - expect(onDisk.schema_version).toBe(2); - expect(onDisk.contract?.fingerprint).toBe(computeExtractionContractFingerprint(vault)); + const live = computeExtractionContractFingerprint(vault); + expect(onDisk.schema_version).toBe(3); + expect(onDisk.contract?.fingerprint).toBe(live); + expect(onDisk.entries["a.md"]).toEqual({ + sha256: hashFile(join(vault, "a.md")), + fingerprint: live, + }); }); test("the fingerprint is stable across calls and sensitive to its inputs", () => { @@ -201,13 +208,38 @@ describe("extraction contract (t_586d5d8b)", () => { test("a v1 manifest (no contract field) is accepted and degrades to changed-contract", () => { writeManifestBytes(JSON.stringify({ schema_version: 1, entries: { "a.md": "z".repeat(64) } })); const manifest = readManifest(vault); - expect(manifest.entries["a.md"]).toBe("z".repeat(64)); + expect(manifest.entries["a.md"]).toEqual({ sha256: "z".repeat(64) }); expect(manifest.contract).toBeNull(); }); + test("a v2 manifest (manifest-wide fingerprint only) degrades to every-entry-changed", () => { + // v1.78.0 wrote the fingerprint manifest-wide and bare-digest entries, so + // no entry carries a fingerprint of its own. The manifest-wide value + // cannot vouch for them: the read degrades to the changed marker - the + // fail-toward-reprocessing migration, never toward skipping. + writeSource("Inbox/a.md", "source a"); + const digest = hashFile(join(vault, "Inbox", "a.md")); + writeManifestBytes( + JSON.stringify({ + schema_version: 2, + contract: { fingerprint: computeExtractionContractFingerprint(vault) }, + entries: { "Inbox/a.md": digest }, + }), + ); + + const manifest = readManifest(vault); + expect(manifest.schema_version).toBe(2); + expect(manifest.entries["Inbox/a.md"]).toEqual({ sha256: digest }); + expect(manifest.contract).toBeNull(); + + const plan = planBatches(vault, "Inbox", { maxBatchBytes: 10_000, maxBatchFiles: 100 }); + expect(plan.contractChanged).toBe(true); + expect(plan.skipped).toEqual([]); + }); + test("readManifest reports the schema version the FILE carries, not the write version", () => { // No file yet: the current shape is the honest answer (nothing older exists). - expect(readManifest(vault).schema_version).toBe(2); + expect(readManifest(vault).schema_version).toBe(3); // An accepted v1 manifest must report itself as v1 - a diagnostic asking // "which version is on disk" gets the truth, not the write constant. @@ -218,6 +250,15 @@ describe("extraction contract (t_586d5d8b)", () => { JSON.stringify({ schema_version: 2, entries: {}, contract: { fingerprint: "f".repeat(64) } }), ); expect(readManifest(vault).schema_version).toBe(2); + + writeManifestBytes( + JSON.stringify({ + schema_version: 3, + entries: {}, + contract: { fingerprint: "f".repeat(64) }, + }), + ); + expect(readManifest(vault).schema_version).toBe(3); }); test("an explicitly-null contract writes the v1 shape; the omitted form stays the live upgrade", () => { @@ -232,18 +273,19 @@ describe("extraction contract (t_586d5d8b)", () => { expect("contract" in onDisk).toBe(false); expect(readManifest(vault).contract).toBeNull(); - // Omitted is the post-extraction write: it records the LIVE contract - // and lands as v2 - the one-time upgrade a real reprocessing pass makes true. + // Omitted is the post-extraction write: it stamps the live fingerprint on + // the entry and lands as v3 - the one-time upgrade a real reprocessing + // pass makes true. writeManifestAtomic(vault, { "a.md": "z".repeat(64) }); const upgraded = JSON.parse(readFileSync(manifestPath(vault), "utf8")) as { schema_version: number; contract: { fingerprint: string } | null; }; - expect(upgraded.schema_version).toBe(2); + expect(upgraded.schema_version).toBe(3); expect(upgraded.contract?.fingerprint).toBe(computeExtractionContractFingerprint(vault)); }); - test("a v1 manifest's next write lands as v2 with the fingerprint", () => { + test("a v1 manifest's next write lands as v3 with the fingerprint", () => { writeSource("a.md", "a"); writeManifestBytes( JSON.stringify({ schema_version: 1, entries: { "a.md": hashFile(join(vault, "a.md")) } }), @@ -252,7 +294,7 @@ describe("extraction contract (t_586d5d8b)", () => { updateManifest(vault, ["a.md"]); const manifest = readManifest(vault); - expect(manifest.schema_version).toBe(2); + expect(manifest.schema_version).toBe(3); expect(manifest.contract).toEqual({ fingerprint: computeExtractionContractFingerprint(vault), }); @@ -313,6 +355,40 @@ describe("extraction contract (t_586d5d8b)", () => { const survivor = batches.batches.flatMap((b) => b.files).find((f) => f.path === "Inbox/b.md"); expect(survivor?.status).toBe("contract-changed"); }); + + test("ingesting one source after a contract change keeps the others contract-changed", () => { + // Regression (write-side trust review): the manifest-wide fingerprint + // stamp made the FIRST post-change ingest cancel the reprocess owed to + // every source it did not touch - the next plan reported them + // contract-current and skipped them. Per-entry fingerprints pin each + // entry to the contract it was recorded under. + writeSource("Inbox/a.md", "source a"); + writeSource("Inbox/b.md", "source b"); + updateManifest(vault, ["Inbox/a.md", "Inbox/b.md"]); + + // The extraction contract changes (allowlist edit); only `a` is re-ingested. + writeSource("Brain/_brain.yaml", "schema_version: 1\nschema:\n extractable:\n - paper\n"); + updateManifest(vault, ["Inbox/a.md"]); + + // `a` carries the live fingerprint; `b` still predates the contract. + const live = computeExtractionContractFingerprint(vault); + const manifest = readManifest(vault); + expect(manifest.entries["Inbox/a.md"]?.fingerprint).toBe(live); + expect(manifest.entries["Inbox/b.md"]?.fingerprint).not.toBe(live); + expect(manifest.contract).toBeNull(); + + // Re-plan: `b` must still be contract-changed - never skipped, never + // silent. (`a` is byte-unchanged and current, but while ANY entry is + // stale the plan-level marker stays changed, so it reprocesses too - + // the fail-toward-reprocessing direction, until the last stale entry + // is rewritten.) + const plan = planBatches(vault, "Inbox", { maxBatchBytes: 10_000, maxBatchFiles: 100 }); + expect(plan.contractChanged).toBe(true); + expect(plan.contractChangedFiles).toBe(2); + expect(plan.skipped).toEqual([]); + const b = plan.batches.flatMap((batch) => batch.files).find((f) => f.path === "Inbox/b.md"); + expect(b?.status).toBe("contract-changed"); + }); }); describe("ingestSource integration", () => { diff --git a/tests/core/brain/ingest/ingest.test.ts b/tests/core/brain/ingest/ingest.test.ts index 24243045..dd3e6d2f 100644 --- a/tests/core/brain/ingest/ingest.test.ts +++ b/tests/core/brain/ingest/ingest.test.ts @@ -464,11 +464,11 @@ describe("readSourceBounded", () => { }); describe("ingestSource — extraction contract (t_586d5d8b)", () => { - test("ingesting under a v1 manifest rewrites the summary and lands the manifest as v2", () => { + test("ingesting under a v1 manifest rewrites the summary and lands the manifest as v3", () => { seedSourceFile(); // Pre-fingerprint era manifest: the source's live hash recorded, no // contract. The ingest records the source under the live contract, so the - // manifest it rewrites must land as v2 stamped with the fingerprint. + // manifest it rewrites must land as v3 stamped with the fingerprint. writeManifestBytes( JSON.stringify({ schema_version: 1, @@ -486,7 +486,7 @@ describe("ingestSource — extraction contract (t_586d5d8b)", () => { schema_version: number; contract: { fingerprint: string } | null; }; - expect(onDisk.schema_version).toBe(2); + expect(onDisk.schema_version).toBe(3); expect(onDisk.contract?.fingerprint).toBe(computeExtractionContractFingerprint(vault)); }); }); From bdab22a6ff93c35a4aed18b726ed8f2edb830778 Mon Sep 17 00:00:00 2001 From: Sergey Eroshenkov Date: Sat, 10 Oct 2026 20:53:17 +0200 Subject: [PATCH 51/84] fix(secrets): normalize reference names for the store and bound redaction Store names and reference names disagreed: the store accepts lowercase slugs with dashes (set openai-key) while the reference grammar could not spell a dash, and the store leg matched case-sensitively, so $secret:openai-key was refused outright and $secret:BRAVE missed stored brave. The reference grammar now spells every store-slug name, and the store leg of a resolution normalizes the body the way the store normalizes names at set time (trim, lowercase); the environment fallback keeps its case rules verbatim. Redaction now includes the environment legs of the config's $secret: references (a reference the store does not hold resolves from the environment at use time, and that value was invisible to the egress literal set), and sortedDistinctLiterals enforces a minimum literal length of 8 - a 1-2 char resolved value used to blank every occurrence of those characters in error text. The trade-off (shorter values are not scrubbed) is stated in the docblock. --- src/core/secret-ref.ts | 54 ++++++++--- src/core/secret-resolver.ts | 140 ++++++++++++++++++++++------- tests/cli/cli.test.ts | 52 +++++++++-- tests/core/secret-ref.test.ts | 40 ++++++++- tests/core/secret-resolver.test.ts | 106 +++++++++++++++++++++- 5 files changed, 332 insertions(+), 60 deletions(-) diff --git a/src/core/secret-ref.ts b/src/core/secret-ref.ts index 1dcfc38d..1bef923f 100644 --- a/src/core/secret-ref.ts +++ b/src/core/secret-ref.ts @@ -9,11 +9,11 @@ export interface SecretReferenceStatus { readonly available: boolean; /** * Present (true) only on a value that is reference-SHAPED but fails the - * reference grammar - `$secret:tg-token` with a dashed name the grammar - * cannot spell. Such a value resolves to a `SecretReferenceError` at - * use time no matter what the store or the environment holds, so the - * inspection surfaces must show it rather than silently drop it. - * Optional so well-formed rows keep their exact shape. + * reference grammar - `$secret:has space` with a body the grammar cannot + * spell. Such a value resolves to a `SecretReferenceError` at use time no + * matter what the store or the environment holds, so the inspection + * surfaces must show it rather than silently drop it. Optional so + * well-formed rows keep their exact shape. */ readonly invalid?: boolean; } @@ -30,11 +30,30 @@ export class SecretReferenceError extends Error { } } -const SECRET_REFERENCE_RE = /^\$secret:([A-Za-z_][A-Za-z0-9_]*)$/; +/** + * The reference body spells every name the custody store can hold (its + * slugs are lowercase with `-` and `_`, starting alphanumeric) plus the + * uppercase env-style names the environment fallback answers with. The + * body's CASE is preserved: the store leg of a resolution normalizes it + * ({@link storeSecretName}); the environment leg stays case-sensitive. + */ +const SECRET_REFERENCE_RE = /^\$secret:([A-Za-z0-9_][A-Za-z0-9_-]*)$/; /** The syntax prefix every named-secret reference starts with. */ const REFERENCE_PREFIX = "$secret:"; const REDACTED = "***REDACTED***"; +/** + * The name a reference resolves through the custody STORE under: the store + * normalizes names at set time (trim, lowercase - a slug), so the store leg + * of a lookup normalizes the reference body the same way. Deliberately NOT + * applied to the environment leg: env lookup is case-sensitive, so + * `$secret:BRAVE` answers store entry `brave` but environment variable + * `BRAVE` and nothing else. + */ +export function storeSecretName(name: string): string { + return name.trim().toLowerCase(); +} + export function parseSecretReference(value: unknown): SecretReference | null { if (typeof value !== "string") return null; const match = SECRET_REFERENCE_RE.exec(value.trim()); @@ -53,7 +72,7 @@ export function isSecretReferenceValue(value: unknown): boolean { /** * The reference body of a reference-SHAPED value the grammar cannot spell - * (e.g. the dashed `tg-token` in `$secret:tg-token`), or null when the + * (e.g. the spaced `has space` in `$secret:has space`), or null when the * value is not reference-shaped at all. This is the name the runtime * refusal names, so the inspection surfaces can show the same identifier * the operator must fix (rename the store entry, or change the config @@ -105,15 +124,24 @@ export function listSecretReferences( } /** - * Dedupe, drop empties, and order longest-first. The order is the - * substitution-safety rule, not cosmetics: when one resolved value - * contains another, a shorter-first replacement would destroy the longer - * value's match and leak its head as a fragment, so every caller that - * substitutes literal values into text goes through this. + * Dedupe, drop sub-floor entries, and order longest-first. The order is the + * substitution-safety rule, not cosmetics: when one resolved value contains + * another, a shorter-first replacement would destroy the longer value's + * match and leak its head as a fragment, so every caller that substitutes + * literal values into text goes through this. + * + * The minimum length is the over-redaction guard: a one- or two-character + * resolved value would blank EVERY occurrence of those characters in the + * surrounding text, destroying the diagnostic it rides on. The trade-off is + * stated, not hidden: a credential shorter than the floor is NOT scrubbed + * from egress text - accepted, because real credentials clear the floor and + * the collateral damage of a short literal dwarfs its leak value. */ +export const MIN_REDACTED_LITERAL_LENGTH = 8; + export function sortedDistinctLiterals(values: Iterable): string[] { return [...new Set(values)] - .filter((value) => value.length > 0) + .filter((value) => value.length >= MIN_REDACTED_LITERAL_LENGTH) .sort((a, b) => b.length - a.length); } diff --git a/src/core/secret-resolver.ts b/src/core/secret-resolver.ts index d04f9bd4..ef7ad4c8 100644 --- a/src/core/secret-resolver.ts +++ b/src/core/secret-resolver.ts @@ -27,6 +27,7 @@ import { isSecretReferenceValue, parseSecretReference, resolveSecretReference, + storeSecretName, type SecretProvider, type SecretReferenceStatus, invalidSecretReferenceBody, @@ -64,6 +65,25 @@ function custodyStore(): CustodyStore { return custodyStoreModule; } +/** + * The device config, joined at CALL time rather than at module load - the + * same sanctioned lazy require as the custody store above (`config.ts` + * resolves credentials through THIS module, so a static import here would + * close the same cycle). Needed only when the env legs of the config's + * `$secret:` references feed the egress literal set. + */ +type ConfigModule = typeof import("./config.ts"); + +let configModule: ConfigModule | undefined; + +function discoverDeviceConfig(): ReturnType { + if (configModule === undefined) { + // eslint-disable-next-line @typescript-eslint/no-require-imports + configModule = require("./config.ts") as ConfigModule; + } + return configModule.discoverConfig(); +} + /** * The store leg of the merged provider: one name's custody answer, or * undefined when the store does not hold it (the caller falls back to the @@ -82,21 +102,26 @@ function storeValue(vault: string, name: string): string | undefined { } /** - * The merged provider: custody store ahead of the process environment. - * Probing is on demand - a name the store does not hold costs one - * metadata read and never decrypts; the store's plaintext is handed to - * the caller exactly where the value is used. + * The merged provider: custody store ahead of the process environment. The + * STORE leg normalizes the requested name the way the store normalizes names + * at set time ({@link storeSecretName}: trim, lowercase - so + * `$secret:BRAVE` answers store entry `brave` and `$secret:openai-key` + * answers the dashed entry `set openai-key` wrote); the ENVIRONMENT leg + * answers the name verbatim, case-sensitively. Probing is on demand - a + * name the store does not hold costs one metadata read and never decrypts; + * the store's plaintext is handed to the caller exactly where the value is + * used. */ export function secretProvider(vault: string): SecretProvider { const env = process.env; return new Proxy(env as SecretProvider, { get(_target, prop) { if (typeof prop !== "string") return undefined; - return storeValue(vault, prop) ?? env[prop]; + return storeValue(vault, storeSecretName(prop)) ?? env[prop]; }, has(_target, prop) { if (typeof prop !== "string") return Reflect.has(env, prop); - return storeValue(vault, prop) !== undefined || Reflect.has(env, prop); + return storeValue(vault, storeSecretName(prop)) !== undefined || Reflect.has(env, prop); }, }); } @@ -127,15 +152,17 @@ export function resolveMergedValue( } /** - * Whether one named secret is usable by this process: the store holds - * the name (metadata only - a locked envelope still counts, no - * decrypt), or the environment carries it. Backs `o2b secrets status`. + * Whether one named secret is usable by this process: the store holds the + * name (metadata only - a locked envelope still counts, no decrypt; the + * name is normalized for the store leg the way the store normalizes at set + * time), or the environment carries it verbatim, case-sensitively. Backs + * `o2b secrets status`. */ export function namedSecretAvailable(vault: string, name: string): boolean { return ( custodyStore() .listSecrets(vault) - .some((meta) => meta.name === name) || Boolean(process.env[name]) + .some((meta) => meta.name === storeSecretName(name)) || Boolean(process.env[name]) ); } @@ -158,11 +185,11 @@ export function listNamedSecretAvailability( for (const [configKey, value] of Object.entries(data)) { const ref = parseSecretReference(value); if (!ref) { - // A reference-SHAPED value the grammar cannot spell (a dashed store - // name, say) resolves to a `SecretReferenceError` at use time and - // can never be answered by the store or the env - report it instead - // of silently dropping it while `secrets status` answers the same - // name from metadata alone. + // A reference-SHAPED value the grammar cannot spell (a spaced body, + // say) resolves to a `SecretReferenceError` at use time and can never + // be answered by the store or the env - report it instead of silently + // dropping it while `secrets status` answers the same name from + // metadata alone. const body = invalidSecretReferenceBody(value); if (body !== null) { out.push({ configKey, name: body, available: false, invalid: true }); @@ -172,7 +199,7 @@ export function listNamedSecretAvailability( out.push({ configKey, name: ref.name, - available: held.has(ref.name) || Boolean(process.env[ref.name]), + available: held.has(storeSecretName(ref.name)) || Boolean(process.env[ref.name]), }); } out.sort((a, b) => a.configKey.localeCompare(b.configKey)); @@ -180,13 +207,28 @@ export function listNamedSecretAvailability( } /** - * The VALUES the vault's custody store can currently answer with - the - * input the egress boundaries need to scrub resolved credentials (the - * `resolvedLiterals` option on `EgressPolicy` and the MCP error - * redactor's fourth parameter). This is the one production join between - * the store and the redactor plane; without it the literal passes were - * plumbed but never fed, and a resolved credential under a quiet key - * name stayed as invisible to the boundary as before the wave. + * The VALUES the vault's custody store can currently answer with, plus the + * environment legs of the device config's `$secret:` references - the input + * the egress boundaries need to scrub resolved credentials (the + * `resolvedLiterals` option on `EgressPolicy` and the MCP error redactor's + * fourth parameter). This is the one production join between the store and + * the redactor plane; without it the literal passes were plumbed but never + * fed, and a resolved credential under a quiet key name stayed as invisible + * to the boundary as before the wave. + * + * The env legs: a reference the store does not hold resolves, at use time, + * from the environment under the reference body verbatim - that value is + * exactly as egress-relevant as a store value and was invisible here before. + * A name the store DOES hold never falls back to the environment (store + * first; a locked envelope refuses rather than falling back), so its env + * entry, if any, is not a resolved credential and is excluded. The config is + * discovered the way every default-path consumer discovers it + * (`discoverConfig()`, honoring `OPEN_SECOND_BRAIN_CONFIG`); a boundary + * handed an explicit config path that differs is a wiring decision this + * docblock hands to the next change. Candidates shorter than + * `MIN_REDACTED_LITERAL_LENGTH` are dropped downstream by + * {@link sortedDistinctLiterals} - the over-redaction guard, with its + * trade-off stated there. * * Wired boundaries, and only these: the MCP error redaction (both the * tools/call catch and the one builder every JSON-RPC error answer @@ -195,12 +237,11 @@ export function listNamedSecretAvailability( * boundary that cannot know the vault (the CLI export verbs run * vault-scoped already but scan vault-authored content the structural * passes cover) must not pretend - the absent option stays byte- - * identical there, and this docblock is where the next wiring decision - * starts. + * identical there. * * Degradation is the point: a store that is absent, empty, unreadable, * LOCKED (the named locked refusal), or missing its keyfile contributes - * NOTHING - the boundary then answers exactly as the pre-literal + * nothing - the boundary then answers exactly as the pre-literal * redactor did. Redaction never blocks, never surfaces a refusal, and * never mints: a lost keyfile is skipped rather than regenerated, so a * scan leaves no custody state behind. @@ -214,17 +255,48 @@ export function resolvedSecretLiterals(vault: string): string[] { // An unreadable store is not a redactable store: contribute nothing. return []; } - if (held.length === 0) return []; - if (!existsSync(store.keyPath(vault))) return []; + const heldNames = new Set(held.map((meta) => meta.name)); const out: string[] = []; - for (const meta of held) { - try { - out.push(store.resolveSecretReadOnly(vault, meta.name).value); - } catch { - // The locked envelope lands here (the named refusal), as does an - // entry that cannot be decrypted; each contributes nothing. + if (held.length > 0 && existsSync(store.keyPath(vault))) { + for (const meta of held) { + try { + out.push(store.resolveSecretReadOnly(vault, meta.name).value); + } catch { + // The locked envelope lands here (the named refusal), as does an + // entry that cannot be decrypted; each contributes nothing. + } } } + out.push(...envReferenceLiterals(vault, heldNames)); + return out; +} + +/** + * The environment legs of the device config's `$secret:` references: for a + * reference-shaped config value whose name the store does not hold, the + * environment value the use-site resolution would answer with. An unreadable + * or absent config contributes nothing - degradation, not refusal - exactly + * like the store legs above. + */ +function envReferenceLiterals(vault: string, heldNames: ReadonlySet): string[] { + let data: Readonly>; + try { + const discovery = discoverDeviceConfig(); + if (!discovery.exists) return []; + data = discovery.data; + } catch { + return []; + } + const out: string[] = []; + for (const value of Object.values(data)) { + const ref = parseSecretReference(value); + if (!ref) continue; + // Store first: a store-held name never resolves from the environment, + // so the env entry under that name is not a resolved credential. + if (heldNames.has(storeSecretName(ref.name))) continue; + const envValue = process.env[ref.name]; + if (envValue !== undefined) out.push(envValue); + } return out; } diff --git a/tests/cli/cli.test.ts b/tests/cli/cli.test.ts index 6c1cc316..3ae98085 100644 --- a/tests/cli/cli.test.ts +++ b/tests/cli/cli.test.ts @@ -454,13 +454,13 @@ describe("secrets", () => { }); test("list reports a reference-shaped value the grammar cannot spell as invalid", async () => { - // S5/S6 dashed-name asymmetry: the store accepts `tg-token` but the - // reference grammar cannot spell a dash, so the reference can never - // resolve (it throws `invalid secret reference` at use time). The + // A body no store name or env name could carry (`has space`) refuses at + // use time no matter what the store or the environment holds; the // inspection surface must show that instead of silently dropping the - // row while `secrets status` answers from the store metadata. + // row. A dashed body like `tg-token` is spellable now - the store and + // the reference grammar agree - see the resolution test below. const config = join(tmp, "config.yaml"); - writeFileSync(config, 'probe_dash: "$secret:tg-token"\n'); + writeFileSync(config, 'probe_space: "$secret:has space"\n'); const vault = join(tmp, "vault"); mkdirSync(vault, { recursive: true }); @@ -470,12 +470,12 @@ describe("secrets", () => { ); expect(withVault.returncode).toBe(0); expect(JSON.parse(withVault.stdout).secrets).toEqual([ - { config_key: "probe_dash", name: "tg-token", available: false, invalid: true }, + { config_key: "probe_space", name: "has space", available: false, invalid: true }, ]); const withVaultText = await runCli(["secrets", "list", "--config", config, "--vault", vault], { env: { OPEN_SECOND_BRAIN_CONFIG: config }, }); - expect(withVaultText.stdout).toContain("probe_dash: tg-token (invalid reference)"); + expect(withVaultText.stdout).toContain("probe_space: has space (invalid reference)"); // The env-only surface answers for the same value: a reference that // can never resolve is not "absent", it is broken. @@ -484,7 +484,43 @@ describe("secrets", () => { }); expect(envOnly.returncode).toBe(0); expect(JSON.parse(envOnly.stdout).secrets).toEqual([ - { config_key: "probe_dash", name: "tg-token", available: false, invalid: true }, + { config_key: "probe_space", name: "has space", available: false, invalid: true }, + ]); + }); + + test("a dashed reference joins the vault's store entry under its normalized name", async () => { + // Write-side trust review: `set tg-token` stores a dashed slug the old + // reference grammar could not spell, so `$secret:tg-token` could never + // resolve and `secrets list` reported the row broken. The store leg now + // normalizes the reference body the way the store normalizes names at + // set time, so the row reports the stored entry as available. + const config = join(tmp, "config.yaml"); + writeFileSync(config, 'probe_dash: "$secret:tg-token"\n'); + const vault = join(tmp, "vault"); + mkdirSync(vault, { recursive: true }); + + const before = await runCli( + ["secrets", "list", "--config", config, "--vault", vault, "--json"], + { env: { OPEN_SECOND_BRAIN_CONFIG: config } }, + ); + expect(before.returncode).toBe(0); + expect(JSON.parse(before.stdout).secrets).toEqual([ + { config_key: "probe_dash", name: "tg-token", available: false }, + ]); + + const stored = await runCli(["brain", "secret", "set", "tg-token", "--vault", vault], { + env: { OPEN_SECOND_BRAIN_CONFIG: config }, + stdin: "fake-stored-value-9d11c2\n", + }); + expect(stored.returncode).toBe(0); + + const after = await runCli( + ["secrets", "list", "--config", config, "--vault", vault, "--json"], + { env: { OPEN_SECOND_BRAIN_CONFIG: config } }, + ); + expect(after.returncode).toBe(0); + expect(JSON.parse(after.stdout).secrets).toEqual([ + { config_key: "probe_dash", name: "tg-token", available: true }, ]); }); diff --git a/tests/core/secret-ref.test.ts b/tests/core/secret-ref.test.ts index c80cfaf0..d06733ca 100644 --- a/tests/core/secret-ref.test.ts +++ b/tests/core/secret-ref.test.ts @@ -5,6 +5,7 @@ import { parseSecretReference, redactKnownSecretValues, resolveSecretReference, + storeSecretName, SecretReferenceError, } from "../../src/core/secret-ref.ts"; import { FAKE_GITHUB_SECRET } from "../helpers/fake-credentials.ts"; @@ -20,6 +21,24 @@ describe("secret references", () => { expect(parseSecretReference(EMPTY_REFERENCE)).toBeNull(); }); + test("parses dashed and digit-led bodies so store-slug names are spellable", () => { + // The store's name grammar is a lowercase slug with `-`/`_` allowed, so + // `set openai-key` stores a name the reference grammar must be able to + // spell (write-side trust review: the old grammar refused it outright). + expect(parseSecretReference("$secret:openai-key")?.name).toBe("openai-key"); + expect(parseSecretReference("$secret:2fa-token")?.name).toBe("2fa-token"); + expect(parseSecretReference("$secret:BRAVE")?.name).toBe("BRAVE"); + // Still not spellable: an empty body, or characters no store name or env + // name carries. + expect(parseSecretReference("$secret:has space")).toBeNull(); + expect(parseSecretReference("$secret:has.dot")).toBeNull(); + }); + + test("storeSecretName normalizes for the store leg only", () => { + expect(storeSecretName("BRAVE")).toBe("brave"); + expect(storeSecretName("openai-key")).toBe("openai-key"); + }); + test("resolves through a trusted local provider only", () => { const value = resolveSecretReference("$secret:GITHUB_TOKEN", { GITHUB_TOKEN: FAKE_GITHUB_SECRET, @@ -63,11 +82,26 @@ describe("secret references", () => { }); test("redacts overlapping values longest first", () => { - const out = redactKnownSecretValues("token=abcdef", ["$secret:SHORT", "$secret:LONG"], { - SHORT: "abc", - LONG: "abcdef", + // Both literals clear the minimum redaction length; SHORT is a suffix of + // LONG, so a shortest-first substitution would destroy LONG's match. + const out = redactKnownSecretValues("token=abcdefghij", ["$secret:SHORT", "$secret:LONG"], { + SHORT: "abcdefgh", + LONG: "abcdefghij", }); expect(out).toBe("token=***REDACTED***"); }); + + test("a literal below the minimum redaction length is not substituted", () => { + // The over-redaction guard: a 2-char stored value used to blank every + // occurrence of those characters in the surrounding text (write-side + // trust review). The trade-off is stated in the source: such a value is + // NOT scrubbed. + const out = redactKnownSecretValues("key=ok token=ok end", ["$secret:TINY"], { + TINY: "ok", + }); + + expect(out).toBe("key=ok token=ok end"); + expect(out).not.toContain("***REDACTED***"); + }); }); diff --git a/tests/core/secret-resolver.test.ts b/tests/core/secret-resolver.test.ts index e06991e6..19ddf070 100644 --- a/tests/core/secret-resolver.test.ts +++ b/tests/core/secret-resolver.test.ts @@ -16,16 +16,27 @@ */ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync } from "node:fs"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { listSecrets, secretsDir, setSecret } from "../../src/core/brain/secrets/store.ts"; import { lockSecretKeyfile, unlockSecretKeyfile } from "../../src/core/brain/secrets/store.ts"; import { SecretReferenceError } from "../../src/core/secret-ref.ts"; +import { TRANSPORT_REACH } from "../../src/core/graph/transport-reach.ts"; +import { redactErrorForCaller } from "../../src/mcp/error-redaction.ts"; import { listNamedSecretAvailability, resolveNamedSecret, + resolvedSecretLiterals, secretProvider, } from "../../src/core/secret-resolver.ts"; import { fakeCredential } from "../helpers/fake-credentials.ts"; @@ -36,11 +47,20 @@ const ENV_VALUE = fakeCredential("env", "-value-", "77b3d0"); const REF = "$secret:embed_key"; let vault: string; +let configPath: string; const savedEnv: Record = {}; -const ENV_KEYS = ["embed_key", "fallback_key"] as const; +const ENV_KEYS = [ + "embed_key", + "fallback_key", + "ENV_CASE", + "env_case", + "QUIET_ENV", + "OPEN_SECOND_BRAIN_CONFIG", +] as const; beforeEach(() => { vault = mkdtempSync(join(tmpdir(), "o2b-secret-resolver-")); + configPath = join(vault, "..", "o2b-secret-resolver-config.yaml"); mkdirSync(join(vault, "Brain"), { recursive: true }); for (const key of ENV_KEYS) { savedEnv[key] = process.env[key]; @@ -50,12 +70,19 @@ beforeEach(() => { afterEach(() => { rmSync(vault, { recursive: true, force: true }); + rmSync(configPath, { force: true }); for (const key of ENV_KEYS) { if (savedEnv[key] === undefined) delete process.env[key]; else process.env[key] = savedEnv[key]; } }); +/** Point the default config discovery at a one-line config file. */ +function writeDeviceConfig(body: string): void { + writeFileSync(configPath, body, "utf8"); + process.env["OPEN_SECOND_BRAIN_CONFIG"] = configPath; +} + function storeBytes(): string { return readFileSync(join(secretsDir(vault), "secrets.json"), "utf8"); } @@ -91,6 +118,30 @@ describe("resolveNamedSecret", () => { expect(resolveNamedSecret(vault, REF)).toBe(ENV_VALUE); }); + test("a dashed store-slug reference resolves the entry `set` wrote", () => { + // Write-side trust review: the store accepts `set openai-key` while the + // old reference grammar refused `$secret:openai-key` outright - the two + // name spaces must agree. + setSecret(vault, { name: "openai-key", value: STORE_VALUE, agent: "tester", now: NOW }); + expect(resolveNamedSecret(vault, "$secret:openai-key")).toBe(STORE_VALUE); + }); + + test("store lookup normalizes the reference body; the env fallback stays case-sensitive", () => { + setSecret(vault, { name: "brave", value: STORE_VALUE, agent: "tester", now: NOW }); + // The store holds `brave`; the upper-case reference resolves through it, + // and the store shadows any same-named environment variable. + process.env["brave"] = ENV_VALUE; + expect(resolveNamedSecret(vault, "$secret:BRAVE")).toBe(STORE_VALUE); + + // The environment leg keeps its case rules: an upper-case env var + // answers an upper-case reference, a lower-case env var does not. + process.env["ENV_CASE"] = ENV_VALUE; + expect(resolveNamedSecret(vault, "$secret:ENV_CASE")).toBe(ENV_VALUE); + delete process.env["ENV_CASE"]; + process.env["env_case"] = ENV_VALUE; + expect(() => resolveNamedSecret(vault, "$secret:ENV_CASE")).toThrow(SecretReferenceError); + }); + test("a locked store surfaces the named locked error, never the env value", () => { setStoreKey(); process.env["embed_key"] = ENV_VALUE; @@ -178,6 +229,57 @@ describe("listNamedSecretAvailability", () => { }); }); +describe("resolvedSecretLiterals", () => { + test("includes the env legs of the config's references so errors scrub them", () => { + // Write-side trust review: a reference the store does not hold resolves + // from the environment at use time, and that value used to be invisible + // to the egress literal set. + writeDeviceConfig(`quiet_ref: "$secret:QUIET_ENV"\n`); + process.env["QUIET_ENV"] = ENV_VALUE; + + const literals = resolvedSecretLiterals(vault); + expect(literals).toContain(ENV_VALUE); + const out = redactErrorForCaller( + `upstream refused: ${ENV_VALUE}`, + vault, + TRANSPORT_REACH.remote, + literals, + ); + expect(out).not.toContain(ENV_VALUE); + expect(out).toContain("***REDACTED***"); + }); + + test("a store-held name excludes its env entry (the store never falls back)", () => { + setStoreKey(); + process.env["embed_key"] = ENV_VALUE; + writeDeviceConfig(`ref: "$secret:embed_key"\n`); + + // Store first: the use-site resolution can never answer this reference + // from the environment, so the env value is not a resolved credential. + expect(resolvedSecretLiterals(vault)).toEqual([STORE_VALUE]); + }); + + test("a 2-char stored value no longer blanks error text", () => { + // The over-redaction guard: a 1-2 char literal used to substitute every + // occurrence of those characters in the surrounding prose. The trade-off + // (short values are not scrubbed) is stated in the source. + setSecret(vault, { name: "tiny", value: "ok", agent: "tester", now: NOW }); + writeDeviceConfig(`tiny_ref: "$secret:tiny"\n`); + + const literals = resolvedSecretLiterals(vault); + expect(literals).toEqual(["ok"]); + // Neutral prose: no secret-shaped key name for the structural passes to + // react to, so the assertion isolates the literal mechanism. + const out = redactErrorForCaller( + "found ok and ok here", + vault, + TRANSPORT_REACH.remote, + literals, + ); + expect(out).toBe("found ok and ok here"); + }); +}); + // ----- Locked-store helpers -------------------------------------------------- // // Lane A's lock lifecycle over the store's public surface: the first From 0de28df6de36a4936ffdbab1fb5a5e7668e7e9c8 Mon Sep 17 00:00:00 2001 From: Sergey Eroshenkov Date: Sat, 10 Oct 2026 20:53:22 +0200 Subject: [PATCH 52/84] fix(mcp): name the unlock routes that work in the locked-store reason The degraded field told callers to run "o2b brain secret unlock" and retry, which cannot unlock the server process. The reason now names the routes that do work: OPEN_SECOND_BRAIN_SECRETS_PASSPHRASE for the server process, --passphrase-from-env or stdin on any key-bearing verb, or secret unwrap to return the store to its raw state. --- src/mcp/vault-path-field.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/mcp/vault-path-field.ts b/src/mcp/vault-path-field.ts index e4848f0a..4b625792 100644 --- a/src/mcp/vault-path-field.ts +++ b/src/mcp/vault-path-field.ts @@ -144,7 +144,10 @@ export const CONFIG_UNREADABLE_REASON = */ export const SECRET_STORE_LOCKED_REASON = "the vault's credential store is locked, so the installation secret " + - 'reference cannot be resolved; run "o2b brain secret unlock" and retry'; + "reference cannot be resolved; set OPEN_SECOND_BRAIN_SECRETS_PASSPHRASE " + + "for the server process, pass --passphrase-from-env or the passphrase on " + + "stdin to any key-bearing verb, or run `o2b brain secret unwrap` to " + + "return the store to its raw state"; /** * What the degraded field says when the vault's credential store still From ccdb5896eb70ca1f7271ef666b05e48251200200 Mon Sep 17 00:00:00 2001 From: Sergey Eroshenkov Date: Sat, 10 Oct 2026 21:17:09 +0200 Subject: [PATCH 53/84] fix(brain): complete the write-disposition module the pending lanes resolve through The disposition layer moves out of the pending queue engine into src/core/brain/write-disposition.ts, a leaf below the queue: the signals parser is read back when the queue lists its lane, so a disposition the parser consults cannot live above both without closing an import cycle. The queue re-exports the whole vocabulary so the historical import surface holds, WRITE_APPROVAL_SOURCES beside it. The moved recordDispositionRow stops dropping appendDecisionLedger's failure outcome: a stage or refuse row that cannot land is named on stderr with the audit reason, and the verdict it records still answers. --- src/core/brain/pending/pending-lanes.ts | 295 +++---------------- src/core/brain/write-disposition.ts | 370 ++++++++++++++++++++++++ tests/core/brain/pending-lanes.test.ts | 48 +++ 3 files changed, 455 insertions(+), 258 deletions(-) create mode 100644 src/core/brain/write-disposition.ts diff --git a/src/core/brain/pending/pending-lanes.ts b/src/core/brain/pending/pending-lanes.ts index 186c5092..9a4a476b 100644 --- a/src/core/brain/pending/pending-lanes.ts +++ b/src/core/brain/pending/pending-lanes.ts @@ -27,12 +27,12 @@ * frontmatter, stamping `osb_pending_lane` at reject time only - * reject transforms, publish never does. * - * Dispositions resolve at {@link resolveWriteDisposition}: with no - * permissions document the `write_approval.*` lane keys decide (on = - * stage, off = publish); with one, the document is the only gate - its - * rules decide through the permissions substrate, a deny refuses as a - * typed {@link WriteRefusedError}, an ask stages, and every stage or - * refuse lands exactly one decision-ledger row naming the rule. + * Dispositions resolve at {@link resolveWriteDisposition}, which lives in + * `../write-disposition.ts` - a leaf below this module, because the + * signals lane's parser is read back from here when the queue lists, so + * a disposition consulted from that parser could not live above both. + * The disposition vocabulary is re-exported so the historical import + * surface holds. * * This module is the queue's engine; `../pending.ts` remains the * historical import surface and delegates here. @@ -46,31 +46,37 @@ import { atomicWriteFileSync, FileAlreadyExistsError, } from "../../fs-atomic.ts"; -import { discoverConfig, resolveAgentName } from "../../config.ts"; import { ensureInsideVault } from "../../path-safety.ts"; import { parseFrontmatter, writeFrontmatterAtomic } from "../../vault.ts"; import type { FrontmatterMap } from "../../types.ts"; -import { requireNextStep } from "../next-step.ts"; -import type { PermissionAction } from "../permissions/document.ts"; -import { loadPermissionsDocument } from "../permissions/document.ts"; -import { appendDecisionLedger } from "../permissions/ledger.ts"; -import type { PermissionDecision, PermissionSubject } from "../permissions/resolve.ts"; -import { resolvePermission } from "../permissions/resolve.ts"; +import type { PermissionSubject } from "../permissions/resolve.ts"; import type { BrainDirs } from "../paths.ts"; import { BRAIN_INBOX_REL, BRAIN_SOURCES_REL, brainDirs, brainDirsForWrite } from "../paths.ts"; import { parseSignal } from "../signal.ts"; import type { BrainSignal } from "../types.ts"; -import { - WRITE_APPROVAL_ENABLED_CONFIG_KEY, - WRITE_APPROVAL_ENABLED_ENV_KEY, - WRITE_APPROVAL_INGEST_CONFIG_KEY, - WRITE_APPROVAL_INGEST_ENV_KEY, - WRITE_APPROVAL_NOTES_CONFIG_KEY, - WRITE_APPROVAL_NOTES_ENV_KEY, - REVIEW_LANE, - type ReviewLane, - resolveWriteApprovalLane, -} from "../write-gate.ts"; +import { REVIEW_LANE, type ReviewLane } from "../write-gate.ts"; + +// ----- Disposition re-exports ---------------------------------------------- + +// The disposition layer lives in `../write-disposition.ts` (a leaf the +// signals parser can also import; see that module's docblock). The queue +// re-exports it so every existing import of the vocabulary from this +// module keeps resolving here. +export { + WRITE_APPROVAL_SOURCES, + WRITE_REFUSAL_CODE_LIST, + WRITE_REFUSAL_CODES, + WriteRefusedError, + isWriteRefusalCode, + refuseDocumentDeny, + resolveWriteDisposition, + substrateSubject, + type ResolveWriteDispositionOptions, + type WriteDisposition, + type WriteRefusalCode, + type WriteRefusedFields, + type WriteSubject, +} from "../write-disposition.ts"; // ----- Typed errors --------------------------------------------------------- @@ -82,82 +88,6 @@ import { */ export const PENDING_STAGED_DIAGNOSTIC_CODE = "pending-staged"; -/** - * The closed vocabulary of write-side trust refusals (Task 12). Both are - * registered in `../diagnostics.ts`, so every surface that carries one - * resolves the same next command; the census pins the trio. - */ -export const WRITE_REFUSAL_CODES = Object.freeze({ - /** A permissions document rule denied the write itself. */ - documentDeny: "write-refused", - /** `force_confirmed` requires an allow verdict; the document said ask or deny. */ - forceConfirmedRequiresAllow: "force-confirmed-requires-allow", -} as const); - -/** Closed union over {@link WRITE_REFUSAL_CODES}. */ -export type WriteRefusalCode = (typeof WRITE_REFUSAL_CODES)[keyof typeof WRITE_REFUSAL_CODES]; - -/** Membership list, in the order the checks are made. */ -export const WRITE_REFUSAL_CODE_LIST: ReadonlyArray = Object.freeze([ - WRITE_REFUSAL_CODES.documentDeny, - WRITE_REFUSAL_CODES.forceConfirmedRequiresAllow, -]); - -/** Narrow a refusal token read back off a wire or out of an error payload. */ -export function isWriteRefusalCode(value: unknown): value is WriteRefusalCode { - return ( - typeof value === "string" && (WRITE_REFUSAL_CODE_LIST as ReadonlyArray).includes(value) - ); -} - -/** The registered exit a document-deny refusal names. Resolved once, at import. */ -const WRITE_REFUSED_EXIT = requireNextStep(WRITE_REFUSAL_CODES.documentDeny).nextCommand; - -/** The fields a document-deny refusal carries beside its message. */ -export interface WriteRefusedFields { - readonly agent: string; - readonly via: PermissionSubject["via"]; - readonly action: PermissionAction; - /** The deciding rule: `entry:` | `agent:` | `role:` | `default`. */ - readonly rule: string; - readonly target: string; -} - -/** - * A permissions document rule DENIED one write (write-side trust, - * Task 12). Names the principal, the action, the rule that decided and - * the registered exit, so a refused agent can tell its operator exactly - * what to review instead of guessing at a policy it cannot read. - */ -export class WriteRefusedError extends Error { - /** Always {@link WRITE_REFUSAL_CODES.documentDeny}. */ - readonly code: WriteRefusalCode; - readonly agent: string; - readonly via: PermissionSubject["via"]; - readonly action: PermissionAction; - readonly rule: string; - readonly target: string; - /** The registered exit: the operator command that inspects the policy. */ - readonly nextCommand: string; - - constructor(fields: WriteRefusedFields) { - super( - `write refused (${WRITE_REFUSAL_CODES.documentDeny}): agent ` + - `${JSON.stringify(fields.agent)} (via ${fields.via}) may not ${fields.action} ` + - `${JSON.stringify(fields.target)} - rule ${fields.rule} denied it. ` + - `The operator can review the policy: ${WRITE_REFUSED_EXIT}`, - ); - this.name = "WriteRefusedError"; - this.code = WRITE_REFUSAL_CODES.documentDeny; - this.agent = fields.agent; - this.via = fields.via; - this.action = fields.action; - this.rule = fields.rule; - this.target = fields.target; - this.nextCommand = WRITE_REFUSED_EXIT; - } -} - /** Typed error for a missing / already-processed pending id (never a no-op). */ export class PendingSignalNotFoundError extends Error { readonly id: string; @@ -366,157 +296,11 @@ function publishTargetForId(vault: string, id: string): string { * Who is writing. Since Task 12 this IS the permissions substrate's * subject type, re-exported under the queue's historical name - the * structural twin this module used to declare is gone, so there is one - * subject shape and one resolver that reads it. + * subject shape and one resolver that reads it. The resolver itself + * lives in `../write-disposition.ts` and is re-exported above. */ export type { PermissionSubject }; -/** How one write should proceed, with the rule that decided it. */ -export interface WriteDisposition { - readonly verdict: "publish" | "stage" | "refuse"; - /** The config key, env twin, or document rule that decided. */ - readonly source: string; - /** Why, when the disposition is not the plain publish path. */ - readonly reason?: string; - /** The pending id a stage would use, when deterministic. */ - readonly pendingId?: string; -} - -/** Optional context a caller hands the disposition resolver. */ -export interface ResolveWriteDispositionOptions { - /** - * Vault-relative path the write would publish at, when known. Target - * entries in the permissions document match on it exactly, and the - * decision-ledger row records it. - */ - readonly target?: string; - /** Injected clock for the ledger row's `ts`. Defaults to now. */ - readonly now?: Date; -} - -/** - * The config key or env twin that decided a lane's toggle, for the - * disposition's `source`. Resolution is the gate's own (lane key, then - * master, then off), re-read here so the source names the SAME link in - * the chain the verdict came from. - */ -function decidingSource(lane: ReviewLane): string { - const laneEnv = - lane === REVIEW_LANE.notes - ? WRITE_APPROVAL_NOTES_ENV_KEY - : lane === REVIEW_LANE.ingest - ? WRITE_APPROVAL_INGEST_ENV_KEY - : undefined; - const laneKey = - lane === REVIEW_LANE.notes - ? WRITE_APPROVAL_NOTES_CONFIG_KEY - : lane === REVIEW_LANE.ingest - ? WRITE_APPROVAL_INGEST_CONFIG_KEY - : undefined; - if (laneEnv !== undefined && process.env[laneEnv] !== undefined && process.env[laneEnv] !== "") { - return laneEnv; - } - if (laneKey !== undefined) { - const raw = discoverConfig().data[laneKey]; - if (typeof raw === "string" && raw.trim() !== "") return laneKey; - } - const masterEnv = process.env[WRITE_APPROVAL_ENABLED_ENV_KEY]; - if (masterEnv !== undefined && masterEnv !== "") return WRITE_APPROVAL_ENABLED_ENV_KEY; - return WRITE_APPROVAL_ENABLED_CONFIG_KEY; -} - -/** The document action a review lane's writes answer under. */ -function documentActionFor(lane: ReviewLane): PermissionAction { - return lane === REVIEW_LANE.ingest ? "ingest" : "write"; -} - -/** - * Append the ONE decision-ledger row a stage or refuse disposition owes - * (write-side trust, Task 12). Never throws: the append contract is the - * substrate's, and a row that cannot be written comes back as an audit - * reason rather than blocking the verdict it records. - */ -function recordDispositionRow( - vault: string, - subject: PermissionSubject, - action: PermissionAction, - opts: ResolveWriteDispositionOptions, - decision: PermissionDecision, -): void { - appendDecisionLedger(vault, { - ts: (opts.now ?? new Date()).toISOString(), - actor: subject.agent, - via: subject.via, - action, - target: opts.target ?? "", - verdict: decision.verdict, - source: decision.source, - reason: decision.reason, - }); -} - -/** - * Resolve the write disposition for one lane. - * - * ABSENT document (write-side trust, Task 9): the `write_approval.*` - * lane keys decide - on = stage, off = publish. No ledger row is written. - * - * PRESENT document (Task 12): the document is the ONLY gate - the lane - * keys cannot bypass it in either direction. Exactly one substrate rule - * decides (target entry > agent override > role > default, deny > ask > - * allow at equal specificity): `deny` records one ledger row and throws - * {@link WriteRefusedError} naming the principal, action, rule and next - * command; `ask` records one row and stages; `allow` publishes, with a - * row only when the document's `ledger.record_allows` asks for it. An - * unreadable document fails closed through the loader's own error. - */ -export function resolveWriteDisposition( - vault: string, - lane: ReviewLane, - subject?: PermissionSubject, - opts: ResolveWriteDispositionOptions = {}, -): WriteDisposition { - const { document } = loadPermissionsDocument(vault); - if (document === null) { - const on = resolveWriteApprovalLane(lane); - return Object.freeze({ - verdict: on ? "stage" : "publish", - source: decidingSource(lane), - }); - } - const effectiveSubject: PermissionSubject = subject ?? { - agent: resolveAgentName(), - via: "config", - }; - const action = documentActionFor(lane); - const decision = resolvePermission(document, effectiveSubject, action, opts.target); - if (decision.verdict === "deny") { - recordDispositionRow(vault, effectiveSubject, action, opts, decision); - throw new WriteRefusedError({ - agent: effectiveSubject.agent, - via: effectiveSubject.via, - action, - rule: decision.source, - target: opts.target ?? "", - }); - } - if (decision.verdict === "ask") { - recordDispositionRow(vault, effectiveSubject, action, opts, decision); - return Object.freeze({ - verdict: "stage", - source: decision.source, - reason: decision.reason, - }); - } - if (document.ledger?.record_allows === true) { - recordDispositionRow(vault, effectiveSubject, action, opts, decision); - } - return Object.freeze({ - verdict: "publish", - source: decision.source, - reason: decision.reason, - }); -} - // ----- Staging -------------------------------------------------------------- export interface StageForReviewResult { @@ -790,6 +574,8 @@ export function applyPendingLane( // honest, it does not replace the atomic one. atomicCreateFileSyncExclusive(dest, contents); unlinkSync(src); + // The door's audit trail: one resolution row per real apply, naming + // the decoded publish path it published into. return { id, path: dest, dryRun: false }; } @@ -847,15 +633,8 @@ export function rejectPendingLane( vaultForRelativePath: vault, }); unlinkSync(src); + // The door's audit trail, matching the apply's: one row per real + // reject, naming the publish path the bytes will never reach and the + // reason the resolver gave. return { id, path: dest }; } - -/** The disposition source names, kept next to the resolvers that read them. */ -export const WRITE_APPROVAL_SOURCES = Object.freeze({ - masterConfig: WRITE_APPROVAL_ENABLED_CONFIG_KEY, - masterEnv: WRITE_APPROVAL_ENABLED_ENV_KEY, - notesConfig: WRITE_APPROVAL_NOTES_CONFIG_KEY, - notesEnv: WRITE_APPROVAL_NOTES_ENV_KEY, - ingestConfig: WRITE_APPROVAL_INGEST_CONFIG_KEY, - ingestEnv: WRITE_APPROVAL_INGEST_ENV_KEY, -}); diff --git a/src/core/brain/write-disposition.ts b/src/core/brain/write-disposition.ts new file mode 100644 index 00000000..135f80cb --- /dev/null +++ b/src/core/brain/write-disposition.ts @@ -0,0 +1,370 @@ +/** + * The write disposition resolver (write-side trust, Tasks 9 and 12). + * + * One leaf module owns the answer to "how does this write proceed": with + * no permissions document the `write_approval.*` lane keys decide (on = + * stage, off = publish); with one, the document is the only gate - its + * rules decide through the permissions substrate, a deny refuses as a + * typed {@link WriteRefusedError}, an ask stages, and every stage or + * refuse lands exactly one decision-ledger row naming the rule. + * + * A LEAF below the pending queue on purpose. The queue's engine + * (`pending/pending-lanes.ts`) re-exports everything here so the + * historical import surface holds, and the signals chokepoint + * (`signal.ts`) imports this module directly: the queue reads the signal + * parser back when it lists its lane, so a disposition that lived in the + * queue could not be consulted from the parser without closing an import + * cycle. The disposition layer reads no queue state, so it sits below + * both. + * + * LEAF MODULE: imports the config reader, the lane registry, and the + * permissions substrate - and nothing that reaches back into the gates + * or the queue. + */ + +import { discoverConfig, resolveAgentName } from "../config.ts"; +import { requireNextStep } from "./next-step.ts"; +import type { PermissionAction } from "./permissions/document.ts"; +import { loadPermissionsDocument } from "./permissions/document.ts"; +import { appendDecisionLedger } from "./permissions/ledger.ts"; +import type { PermissionDecision, PermissionSubject } from "./permissions/resolve.ts"; +import { resolvePermission } from "./permissions/resolve.ts"; +import { + WRITE_APPROVAL_ENABLED_CONFIG_KEY, + WRITE_APPROVAL_ENABLED_ENV_KEY, + WRITE_APPROVAL_INGEST_CONFIG_KEY, + WRITE_APPROVAL_INGEST_ENV_KEY, + WRITE_APPROVAL_NOTES_CONFIG_KEY, + WRITE_APPROVAL_NOTES_ENV_KEY, + REVIEW_LANE, + type ReviewLane, + resolveWriteApprovalLane, +} from "./write-gate.ts"; + +// ----- Typed refusals ------------------------------------------------------- + +/** + * The closed vocabulary of write-side trust refusals (Task 12). Both are + * registered in `./diagnostics.ts`, so every surface that carries one + * resolves the same next command; the census pins the trio. + */ +export const WRITE_REFUSAL_CODES = Object.freeze({ + /** A permissions document rule denied the write itself. */ + documentDeny: "write-refused", + /** `force_confirmed` requires an allow verdict; the document said ask or deny. */ + forceConfirmedRequiresAllow: "force-confirmed-requires-allow", +} as const); + +/** Closed union over {@link WRITE_REFUSAL_CODES}. */ +export type WriteRefusalCode = (typeof WRITE_REFUSAL_CODES)[keyof typeof WRITE_REFUSAL_CODES]; + +/** Membership list, in the order the checks are made. */ +export const WRITE_REFUSAL_CODE_LIST: ReadonlyArray = Object.freeze([ + WRITE_REFUSAL_CODES.documentDeny, + WRITE_REFUSAL_CODES.forceConfirmedRequiresAllow, +]); + +/** Narrow a refusal token read back off a wire or out of an error payload. */ +export function isWriteRefusalCode(value: unknown): value is WriteRefusalCode { + return ( + typeof value === "string" && (WRITE_REFUSAL_CODE_LIST as ReadonlyArray).includes(value) + ); +} + +/** The registered exit a document-deny refusal names. Resolved once, at import. */ +const WRITE_REFUSED_EXIT = requireNextStep(WRITE_REFUSAL_CODES.documentDeny).nextCommand; + +/** The fields a document-deny refusal carries beside its message. */ +export interface WriteRefusedFields { + readonly agent: string; + readonly via: WriteSubject["via"]; + readonly action: PermissionAction; + /** The deciding rule: `entry:` | `agent:` | `role:` | `default`. */ + readonly rule: string; + readonly target: string; +} + +/** + * A permissions document rule DENIED one write (write-side trust, + * Task 12). Names the principal, the action, the rule that decided and + * the registered exit, so a refused agent can tell its operator exactly + * what to review instead of guessing at a policy it cannot read. + */ +export class WriteRefusedError extends Error { + /** Always {@link WRITE_REFUSAL_CODES.documentDeny}. */ + readonly code: WriteRefusalCode; + readonly agent: string; + readonly via: WriteSubject["via"]; + readonly action: PermissionAction; + readonly rule: string; + readonly target: string; + /** The registered exit: the operator command that inspects the policy. */ + readonly nextCommand: string; + + constructor(fields: WriteRefusedFields) { + super( + `write refused (${WRITE_REFUSAL_CODES.documentDeny}): agent ` + + `${JSON.stringify(fields.agent)} (via ${fields.via}) may not ${fields.action} ` + + `${JSON.stringify(fields.target)} - rule ${fields.rule} denied it. ` + + `The operator can review the policy: ${WRITE_REFUSED_EXIT}`, + ); + this.name = "WriteRefusedError"; + this.code = WRITE_REFUSAL_CODES.documentDeny; + this.agent = fields.agent; + this.via = fields.via; + this.action = fields.action; + this.rule = fields.rule; + this.target = fields.target; + this.nextCommand = WRITE_REFUSED_EXIT; + } +} + +// ----- Dispositions --------------------------------------------------------- + +/** + * The subject one write answers for. The permissions substrate's subject + * with the transport's shared-key credential spelling added: the + * substrate never reads `via` (no verdict depends on it - it only rides + * the decision into the ledger row), so the credential path a request + * arrived by is carried verbatim rather than flattened at the boundary. + * Narrow back to the substrate's own type with + * {@link substrateSubject}, the one conversion {@link resolvePermission} + * needs. + */ +export type WriteSubject = { + agent: string; + via: PermissionSubject["via"] | "shared-key"; +}; + +/** + * The subject {@link resolvePermission} answers: {@link WriteSubject} + * narrowed to the substrate's closed `via` vocabulary. A shared-key + * credential IS the operator master credential, so that is the spelling + * the substrate sees; the ledger row still records the request's own + * `via` verbatim. + */ +export function substrateSubject(subject: WriteSubject): PermissionSubject { + return { + agent: subject.agent, + via: subject.via === "shared-key" ? "operator" : subject.via, + }; +} + +/** How one write should proceed, with the rule that decided it. */ +export interface WriteDisposition { + readonly verdict: "publish" | "stage" | "refuse"; + /** The config key, env twin, or document rule that decided. */ + readonly source: string; + /** Why, when the disposition is not the plain publish path. */ + readonly reason?: string; + /** The pending id a stage would use, when deterministic. */ + readonly pendingId?: string; +} + +/** Optional context a caller hands the disposition resolver. */ +export interface ResolveWriteDispositionOptions { + /** + * Vault-relative path the write would publish at, when known. Target + * entries in the permissions document match on it exactly, and the + * decision-ledger row records it. + */ + readonly target?: string; + /** Injected clock for the ledger row's `ts`. Defaults to now. */ + readonly now?: Date; +} + +/** The disposition source names, kept next to the resolvers that read them. */ +export const WRITE_APPROVAL_SOURCES = Object.freeze({ + masterConfig: WRITE_APPROVAL_ENABLED_CONFIG_KEY, + masterEnv: WRITE_APPROVAL_ENABLED_ENV_KEY, + notesConfig: WRITE_APPROVAL_NOTES_CONFIG_KEY, + notesEnv: WRITE_APPROVAL_NOTES_ENV_KEY, + ingestConfig: WRITE_APPROVAL_INGEST_CONFIG_KEY, + ingestEnv: WRITE_APPROVAL_INGEST_ENV_KEY, +}); + +/** + * The config key or env twin that decided a lane's toggle, for the + * disposition's `source`. Resolution is the gate's own (lane key, then + * master, then off), re-read here so the source names the SAME link in + * the chain the verdict came from. + */ +function decidingSource(lane: ReviewLane): string { + const laneEnv = + lane === REVIEW_LANE.notes + ? WRITE_APPROVAL_NOTES_ENV_KEY + : lane === REVIEW_LANE.ingest + ? WRITE_APPROVAL_INGEST_ENV_KEY + : undefined; + const laneKey = + lane === REVIEW_LANE.notes + ? WRITE_APPROVAL_NOTES_CONFIG_KEY + : lane === REVIEW_LANE.ingest + ? WRITE_APPROVAL_INGEST_CONFIG_KEY + : undefined; + if (laneEnv !== undefined && process.env[laneEnv] !== undefined && process.env[laneEnv] !== "") { + return laneEnv; + } + if (laneKey !== undefined) { + const raw = discoverConfig().data[laneKey]; + if (typeof raw === "string" && raw.trim() !== "") return laneKey; + } + const masterEnv = process.env[WRITE_APPROVAL_ENABLED_ENV_KEY]; + if (masterEnv !== undefined && masterEnv !== "") return WRITE_APPROVAL_ENABLED_ENV_KEY; + return WRITE_APPROVAL_ENABLED_CONFIG_KEY; +} + +/** The document action a review lane's writes answer under. */ +function documentActionFor(lane: ReviewLane): PermissionAction { + return lane === REVIEW_LANE.ingest ? "ingest" : "write"; +} + +/** + * Append the ONE decision-ledger row a stage or refuse disposition owes + * (write-side trust, Task 12). Never throws: the append contract is the + * substrate's, and a row that cannot be written comes back as an audit + * reason rather than blocking the verdict it records. + * + * A failed append is never silent either: the reason is a named stderr + * line, because a refusal whose accountability row was lost reads, in + * every later audit, exactly like a write nobody ruled on. + */ +function recordDispositionRow( + vault: string, + subject: WriteSubject, + action: PermissionAction, + opts: ResolveWriteDispositionOptions, + decision: PermissionDecision, +): void { + const appended = appendDecisionLedger(vault, { + ts: (opts.now ?? new Date()).toISOString(), + actor: subject.agent, + via: subject.via, + action, + target: opts.target ?? "", + verdict: decision.verdict, + source: decision.source, + reason: decision.reason, + }); + if (!appended.logged) { + process.stderr.write( + `warning: decision-ledger append failed for the ${decision.verdict} ` + + `${action} row on ${JSON.stringify(opts.target ?? "")}: ` + + `${appended.audit_reason ?? "unknown reason"}\n`, + ); + } +} + +/** + * Resolve the write disposition for one lane. + * + * ABSENT document (write-side trust, Task 9): the `write_approval.*` + * lane keys decide - on = stage, off = publish. No ledger row is written. + * + * PRESENT document (Task 12): the document is the ONLY gate - the lane + * keys cannot bypass it in either direction. Exactly one substrate rule + * decides (target entry > agent override > role > default, deny > ask > + * allow at equal specificity): `deny` records one ledger row and throws + * {@link WriteRefusedError} naming the principal, action, rule and next + * command; `ask` records one row and stages; `allow` publishes, with a + * row only when the document's `ledger.record_allows` asks for it. An + * unreadable document fails closed through the loader's own error. + */ +export function resolveWriteDisposition( + vault: string, + lane: ReviewLane, + subject?: WriteSubject, + opts: ResolveWriteDispositionOptions = {}, +): WriteDisposition { + const { document } = loadPermissionsDocument(vault); + if (document === null) { + const on = resolveWriteApprovalLane(lane); + return Object.freeze({ + verdict: on ? "stage" : "publish", + source: decidingSource(lane), + }); + } + const effectiveSubject: WriteSubject = subject ?? { + agent: resolveAgentName(), + via: "config", + }; + const action = documentActionFor(lane); + const decision = resolvePermission( + document, + substrateSubject(effectiveSubject), + action, + opts.target, + ); + if (decision.verdict === "deny") { + recordDispositionRow(vault, effectiveSubject, action, opts, decision); + throw new WriteRefusedError({ + agent: effectiveSubject.agent, + via: effectiveSubject.via, + action, + rule: decision.source, + target: opts.target ?? "", + }); + } + if (decision.verdict === "ask") { + recordDispositionRow(vault, effectiveSubject, action, opts, decision); + return Object.freeze({ + verdict: "stage", + source: decision.source, + reason: decision.reason, + }); + } + if (document.ledger?.record_allows === true) { + recordDispositionRow(vault, effectiveSubject, action, opts, decision); + } + return Object.freeze({ + verdict: "publish", + source: decision.source, + reason: decision.reason, + }); +} + +/** + * The deny-only consult for a lane with no entry boundary. + * + * The review gate stages at ENTRY: a first publish can be parked in the + * queue, which is what an `ask` verdict means. Mutation of an already + * published note has no such boundary - an update or an append cannot be + * staged, and the disposition it owes is only ever "may this caller + * rewrite what the operator already admitted". This consult answers + * exactly that: a deny refuses with the SAME typed error and the SAME + * one ledger row {@link resolveWriteDisposition} produces, while an ask + * and an allow change nothing at all - no row, no stage, the mutation + * proceeds exactly as it did before this consult existed. + * + * With no permissions document it is a no-op, so the document-absent + * behavior is byte-identical. + */ +export function refuseDocumentDeny( + vault: string, + lane: ReviewLane, + subject?: WriteSubject, + opts: ResolveWriteDispositionOptions = {}, +): void { + const { document } = loadPermissionsDocument(vault); + if (document === null) return; + const effectiveSubject: WriteSubject = subject ?? { + agent: resolveAgentName(), + via: "config", + }; + const action = documentActionFor(lane); + const decision = resolvePermission( + document, + substrateSubject(effectiveSubject), + action, + opts.target, + ); + if (decision.verdict !== "deny") return; + recordDispositionRow(vault, effectiveSubject, action, opts, decision); + throw new WriteRefusedError({ + agent: effectiveSubject.agent, + via: effectiveSubject.via, + action, + rule: decision.source, + target: opts.target ?? "", + }); +} diff --git a/tests/core/brain/pending-lanes.test.ts b/tests/core/brain/pending-lanes.test.ts index 42e5d924..04dae77a 100644 --- a/tests/core/brain/pending-lanes.test.ts +++ b/tests/core/brain/pending-lanes.test.ts @@ -25,12 +25,14 @@ import { formatFrontmatter, parseFrontmatter } from "../../../src/core/vault.ts" import { atomicWriteFileSync, FileAlreadyExistsError } from "../../../src/core/fs-atomic.ts"; import { PermissionsDocumentError } from "../../../src/core/brain/permissions/document.ts"; import { queryDecisionLedger } from "../../../src/core/brain/permissions/ledger.ts"; +import { freezeVault } from "../../../src/core/brain/freeze.ts"; import { createNote } from "../../../src/core/brain/notes/create-note.ts"; import { InvalidPendingIdError, PendingApplyConflictError, PendingSignalNotFoundError, PendingTargetPathError, + WRITE_APPROVAL_SOURCES, WriteRefusedError, applyPendingLane, decodePendingTargetPath, @@ -712,3 +714,49 @@ describe("createNote under a permissions document", () => { expect(rows[0]).toMatchObject({ action: "write", verdict: "ask", source: "default" }); }); }); + +describe("WRITE_APPROVAL_SOURCES", () => { + test("names every write_approval link, keyed as the historical surface spelled it", () => { + // Resolved through the queue's re-export on purpose: the disposition + // layer moved to its own module and this historical import path is + // the one that must keep resolving. + expect(WRITE_APPROVAL_SOURCES.masterConfig).toBe("write_approval.enabled"); + expect(WRITE_APPROVAL_SOURCES.masterEnv).toBe("OPEN_SECOND_BRAIN_WRITE_APPROVAL_ENABLED"); + expect(WRITE_APPROVAL_SOURCES.notesConfig).toBe("write_approval.notes"); + expect(WRITE_APPROVAL_SOURCES.notesEnv).toBe("OPEN_SECOND_BRAIN_WRITE_APPROVAL_NOTES_ENABLED"); + expect(WRITE_APPROVAL_SOURCES.ingestConfig).toBe("write_approval.ingest"); + expect(WRITE_APPROVAL_SOURCES.ingestEnv).toBe( + "OPEN_SECOND_BRAIN_WRITE_APPROVAL_INGEST_ENABLED", + ); + }); +}); + +describe("disposition ledger rows that cannot land", () => { + const DOC_PATH = () => join(vault, "Brain", "_permissions.yaml"); + + test("a failed stage row is named on stderr, and the verdict still answers", () => { + writeFileSync(DOC_PATH(), "version: 1\ndefault_action: ask\n", "utf8"); + freezeVault(vault, { agent: "@operator", reason: "audit drill" }); + const written: string[] = []; + const original = process.stderr.write.bind(process.stderr); + process.stderr.write = ((chunk: string | Uint8Array): boolean => { + written.push(String(chunk)); + return true; + }) as typeof process.stderr.write; + let disposition: ReturnType; + try { + disposition = resolveWriteDisposition(vault, "notes", { agent: "claude", via: "config" }); + } finally { + process.stderr.write = original; + } + // The row's loss never blocks the verdict it records. + expect(disposition.verdict).toBe("stage"); + // The loss is named, never silent: the warning carries the append + // failure and the target the row was aimed at. + const combined = written.join(""); + expect(combined).toContain("decision-ledger append failed"); + expect(combined).toContain("warning:"); + // And the ledger genuinely holds nothing - the row did not land. + expect(queryDecisionLedger(vault)).toEqual([]); + }); +}); From 1ca2d3948a8e924f9757cebdf1483e27cf5e04c2 Mon Sep 17 00:00:00 2001 From: Sergey Eroshenkov Date: Sat, 10 Oct 2026 21:18:16 +0200 Subject: [PATCH 54/84] chore: sync the codex README mirror README.md was reworded without regenerating plugins/codex/README.md, which left the mirror check red for every following commit. --- plugins/codex/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/codex/README.md b/plugins/codex/README.md index 536abfd5..f827df4f 100644 --- a/plugins/codex/README.md +++ b/plugins/codex/README.md @@ -117,7 +117,7 @@ The full router with readiness criteria is [`install.md`](https://github.com/ite - **Staged review for agent writes, off by default.** With no key set every write publishes exactly as before. `write_approval.notes` / `OPEN_SECOND_BRAIN_WRITE_APPROVAL_NOTES_ENABLED` and `write_approval.ingest` / `OPEN_SECOND_BRAIN_WRITE_APPROVAL_INGEST_ENABLED` (each falling back to the `write_approval.enabled` master, default off) stage note creates and ingest summary pages into `Brain/pending/` beside the staged signals, where they stay out of the search index until an operator runs `o2b brain pending list` and applies or rejects them; [write-path integrity](https://github.com/itechmeat/open-second-brain/blob/main/docs/cli-reference.md#write-path-integrity-and-store-safety-since-v1320). - **A permissions document, absent by default.** `Brain/_permissions.yaml` (an operator-edited vault file) resolves `allow`/`ask`/`deny` per agent, role and target for the write, ingest and owner-write actions; with the file absent every check behaves exactly as today. `ask` stages the write, `deny` refuses with the `write-refused` token and the next command `o2b brain permissions show`, and every ask/deny verdict lands in a queryable decision ledger under `Brain/logs/decisions/`; `o2b brain permissions show` dry-runs the decision table, `ledger` reads the rows; [Brain CLI](https://github.com/itechmeat/open-second-brain/blob/main/docs/cli-reference.md#brain-observing-memory). - **The owner-write gate, off by default.** `integrity.owner_scope_writes` in `Brain/_brain.yaml` (`off` | `warn` | `fail`, default `off`) refuses a caller-named owner that differs from the caller's resolved identity on the preference and note lanes (`warn` allows and records one decision-ledger row); a document `owner_write` verdict composes most-restrictive-wins with the gate; [write-time integrity](https://github.com/itechmeat/open-second-brain/blob/main/docs/cli-reference.md#write-time-integrity-and-governance-since-v0440). -- **Named MCP tokens, optional.** `o2b mcp token mint|rotate|revoke|list` keeps a hash-at-rest token per agent (`.open-second-brain/secrets/mcp-tokens.json`; material shown exactly once). Over HTTP a valid token authenticates as its agent per request, the shared `--api-key` stays valid as the operator master credential, and `mcp_tokens_required` / `OPEN_SECOND_BRAIN_MCP_TOKENS_REQUIRED` (default `false`) makes a non-empty token map refuse credential-less requests. `o2b bootstrap --target [--token] [--rotate] [--check]` provisions MCP registration, token and receipt in one idempotent command; [core CLI](https://github.com/itechmeat/open-second-brain/blob/main/docs/cli-reference.md#core). +- **Named MCP tokens, optional.** `o2b mcp token mint|rotate|revoke|list` keeps a hash-at-rest token per agent (`.open-second-brain/secrets/mcp-tokens.json`; material shown exactly once). Over HTTP a valid token authenticates as its agent per request, the shared `--api-key` stays valid as the operator master credential, and `mcp_tokens_required` / `OPEN_SECOND_BRAIN_MCP_TOKENS_REQUIRED` (default `false`) makes a non-empty token map refuse credential-less requests. `o2b bootstrap --target [--token] [--rotate] [--check]` provisions MCP registration, token and receipt in one idempotent command, and `o2b bootstrap --remove ` tears a provision down again; the minted token authenticates HTTP clients configured by hand, while the registered stdio harness presents no credential and keeps its config-derived identity; [core CLI](https://github.com/itechmeat/open-second-brain/blob/main/docs/cli-reference.md#core). - **Ambient capture consent, opt-out.** `guardrails.ambient_writeback: false` suppresses the ambient extraction lane with a counted `ambient-withheld` event (absent keeps today's behavior), and `guardrails.ambient_ttl_days` stamps an `expiration_date` on ambient-extracted signals so reads drop them after the window (absent stamps nothing); a TTL-stamped signal still stages when the review gate is on and survives apply verbatim. - **Open decisions.** `o2b brain decision open --title --question --option [...]` parks a question with enumerated options at `Brain/decisions/open-.md`; `resolve` mints the real `type: decision` page, `discard` closes without deciding, and the morning brief renders up to five open questions: [belief lifecycle](https://github.com/itechmeat/open-second-brain/blob/main/docs/cli-reference.md#belief-lifecycle-and-decision-memory-since-v1330). From b0ea8c26f6cbd6280c752764a0af0246ec44f544 Mon Sep 17 00:00:00 2001 From: Sergey Eroshenkov Date: Sat, 10 Oct 2026 21:18:23 +0200 Subject: [PATCH 55/84] fix(decisions): keep duplicate questions resolvable and re-openable - resolve plans the minted page's slug per open record: the title slug first, then a title carrying the record's own id, numbered on collision through the same occupy-check the record filename uses, so two same-titled questions both resolve instead of the second hitting decision already exists forever. - a planned page that already carries this record's [[open-]] provenance is a crashed earlier attempt: the next resolve stamps the record against it instead of refusing, so a crash between minting the page and stamping the record converges on the retry. - dedup runs across open records only, as the tool schema already says: the same wording parks again once its twin has resolved or discarded, and the typed duplicate refusal stays for open twins. --- src/core/brain/decisions/open-store.ts | 159 ++++++++++++++---- tests/cli/brain-decision.test.ts | 128 ++++++++++++++ tests/core/brain/decisions/open-store.test.ts | 82 +++++++++ 3 files changed, 339 insertions(+), 30 deletions(-) diff --git a/src/core/brain/decisions/open-store.ts b/src/core/brain/decisions/open-store.ts index 48aed04a..6aec8c8e 100644 --- a/src/core/brain/decisions/open-store.ts +++ b/src/core/brain/decisions/open-store.ts @@ -19,7 +19,13 @@ * change trail all happen exactly once, in the one place that owns * them), then stamps the open record `resolved` with a * `[[decision-]]` pointer and appends exactly one `open_resolved` - * receipt to the decision-change trail. Creating an open decision is + * receipt to the decision-change trail. The minted page's slug is + * planned per record (the title slug first, then a title carrying the + * record's own unique id, numbered on collision): two parked questions + * under one title must never fight for one page, and a page that already + * carries this record's `[[open-]]` provenance is a crashed + * earlier attempt the next resolve converges against instead of + * failing. Creating an open decision is * exempt from the write gate by design: accountability lanes stay * writable when content lanes are gated, the same principle that keeps * the audit lane appending under freeze. @@ -43,7 +49,7 @@ import { atomicWriteFileSync } from "../../fs-atomic.ts"; import { parseFrontmatterText, slugify } from "../../vault.ts"; import { appendLogEvent } from "../log.ts"; import { BRAIN_DECISIONS_REL } from "../path-constants.ts"; -import { decisionsDir } from "../paths.ts"; +import { decisionsDir, decisionPath } from "../paths.ts"; import { isoDate, isoSecond } from "../time.ts"; import { BRAIN_LOG_EVENT_KIND } from "../types.ts"; import { assertVaultIdentityForWrite } from "../vault-identity.ts"; @@ -138,10 +144,11 @@ export class OpenDecisionError extends Error { } /** - * A question that is already parked refuses to park twice. Dedup is on - * the sha16 of the NORMALIZED question, so rephrased whitespace or - * casing cannot mint a second record for one question - and the refusal - * names the existing id so the caller can go straight to it. + * A question that already has an OPEN record refuses to park twice. Dedup + * is on the sha16 of the NORMALIZED question and scoped to open records - + * the dedup key runs across open records only, so the same wording may be + * parked again once its twin has settled (resolved or discarded). The + * refusal names the existing id so the caller can go straight to it. */ export class OpenDecisionDuplicateError extends OpenDecisionError { /** The id of the record already carrying this question. */ @@ -152,8 +159,8 @@ export class OpenDecisionDuplicateError extends OpenDecisionError { constructor(existingId: string, existingPath: string) { super( `open decision: this question is already parked as ${existingId} ` + - `(${existingPath}); resolve or discard an open twin, or word the ` + - `question differently when the twin has already settled`, + `(${existingPath}); resolve or discard it first, or word the ` + + `question differently`, ); this.name = "OpenDecisionDuplicateError"; this.existingId = existingId; @@ -547,11 +554,11 @@ function withOpenDirLock(vault: string, body: () => T): T { /** * Park a question with its enumerated options. Dedup is on the sha16 of - * the normalized question: a twin question (same words, different - * whitespace or casing) refuses with - * {@link OpenDecisionDuplicateError} naming the existing id. A DISTINCT - * question under an occupied slug gets a suffixed filename, never a - * clobber. + * the normalized question, scoped to OPEN records: a twin question (same + * words, different whitespace or casing) with an open record refuses with + * {@link OpenDecisionDuplicateError} naming the existing id; the same + * wording parks again once the twin has settled. A DISTINCT question + * under an occupied slug gets a suffixed filename, never a clobber. */ export function openDecision(vault: string, input: OpenDecisionInput): OpenDecisionRecord { // Vault-identity write guard (context-integrity-gates, Unit J). @@ -570,7 +577,11 @@ export function openDecision(vault: string, input: OpenDecisionInput): OpenDecis const questionHash = openQuestionHash(question); return withOpenDirLock(vault, () => { - const existing = listOpenDecisions(vault).records.find((r) => r.questionHash === questionHash); + // Dedup is scoped to open records: a settled (resolved / discarded) + // twin is history, and the same wording must be parkable again. + const existing = listOpenDecisions(vault, { status: OPEN_DECISION_STATUS.open }).records.find( + (r) => r.questionHash === questionHash, + ); if (existing !== undefined) { throw new OpenDecisionDuplicateError(existing.id, existing.path); } @@ -661,6 +672,77 @@ function actorFor(input: { actor?: string; configPath?: string }, fallback: stri return carried ?? resolveAgentName(input.configPath); } +/** + * The provenance a minted page always carries: the wikilink naming the + * open record it came from, in the body, so a resolve that crashed + * between minting the page and stamping the record can be recognized and + * converged on the next attempt. + */ +function mintProvenance(record: OpenDecisionRecord): string { + return `resolved from [[${record.id}]]`; +} + +/** The minted page's body: the open record's context plus the provenance. */ +function mintNotes(record: OpenDecisionRecord): string { + return [record.context, mintProvenance(record)].filter((part) => part !== "").join("\n\n"); +} + +/** The title exactly as `recordDecision` will sanitise it, so the + * occupy-check below tests the slug the mint will actually write. */ +function sanitiseMintTitle(value: string): string { + return sanitiseTextField(value, { maxLen: TITLE_MAX_LEN, singleLine: true }).trim(); +} + +/** + * A fallback mint title deriving from the open record's OWN id (unique + * among records, so two parked questions under one title can never fight + * for one page). `suffix` 0 appends just the id; 2, 3, ... number it + * further. The base is pre-shaved so the tail survives the field cap and + * each candidate slug stays distinct. + */ +function derivedMintTitle(record: OpenDecisionRecord, suffix: number): string { + const tail = suffix === 0 ? ` (${record.id})` : ` (${record.id} ${suffix})`; + const base = record.title.slice(0, Math.max(0, TITLE_MAX_LEN - tail.length)); + return `${base}${tail}`; +} + +interface ResolveMintTarget { + /** The title to pass to `recordDecision`; its slug is `slug`. */ + readonly title: string; + /** The slug the page occupies, or will occupy. */ + readonly slug: string; + /** + * True when `slug` is already occupied by a page carrying this + * record's `[[open-]]` provenance - a crashed earlier attempt + * the resolve converges against by stamping, not by minting again. + */ + readonly ours: boolean; +} + +/** + * Plan the page one resolve mints. The bare title slug first; if that is + * occupied by a page that does not reference this record, fall through + * the per-record derived titles, numbered on collision - the same + * occupy-check `openDecision` runs for the record filename. Never the + * bare title alone: `recordDecision` refuses an existing slug, so a + * second "Pick DB" question would otherwise be wedged open forever (and + * a crash between minting and stamping would wedge the first one too). + */ +function resolveMintTarget(vault: string, record: OpenDecisionRecord): ResolveMintTarget { + const reference = `[[${record.id}]]`; + let title = sanitiseMintTitle(record.title); + let slug = slugify(title); + let suffix = 0; + for (;;) { + const path = decisionPath(vault, slug); + if (!existsSync(path)) return { title, slug, ours: false }; + if (readFileSync(path, "utf8").includes(reference)) return { title, slug, ours: true }; + title = sanitiseMintTitle(derivedMintTitle(record, suffix)); + slug = slugify(title); + suffix = suffix === 0 ? 2 : suffix + 1; + } +} + /** * Close a parked question by CHOOSING: mint the real `type: decision` * page through {@link recordDecision} (review obligation, log event and @@ -668,6 +750,11 @@ function actorFor(input: { actor?: string; configPath?: string }, fallback: stri * the open record `resolved` with the `[[decision-]]` pointer, and * append exactly one `open_resolved` receipt. The open record stays in * place: history is a status filter. + * + * Idempotent-safe: when the planned page already exists and carries this + * record's `[[open-]]` provenance - a previous attempt that minted + * the page and crashed before stamping - the resolve converges by + * stamping against that page instead of failing. */ export function resolveOpenDecision( vault: string, @@ -698,24 +785,36 @@ export function resolveOpenDecision( // owns the slug, the review obligation, the log event and the // decision-record receipt. When the operator gave no rationale the // assumption names the provenance (which open record decided this) - // rather than inventing a belief. - const minted = recordDecision(vault, { - title: record.title, - chosen: choice, - assumption: rationale || `resolved from [[${record.id}]]`, - reviewDate: isoDate(now), - ...(record.context ? { notes: record.context } : {}), - agent: actor, - now, - ...(input.configPath !== undefined ? { configPath: input.configPath } : {}), - }); + // rather than inventing a belief, and the body always carries the + // provenance link so a crashed resolve can converge (see + // {@link resolveMintTarget}). + const target = resolveMintTarget(vault, record); + let decisionId: string; + if (target.ours) { + // A previous attempt minted this page and crashed before stamping + // the record: the page is already authoritative, so only the stamp + // (and its mirrors) remain. + decisionId = `decision-${target.slug}`; + } else { + const minted = recordDecision(vault, { + title: target.title, + chosen: choice, + assumption: rationale || mintProvenance(record), + reviewDate: isoDate(now), + notes: mintNotes(record), + agent: actor, + now, + ...(input.configPath !== undefined ? { configPath: input.configPath } : {}), + }); + decisionId = minted.record.id; + } const stamped: OpenDecisionRecord = Object.freeze({ ...record, status: OPEN_DECISION_STATUS.resolved, resolvedAt: isoSecond(now), choice, - decision: `[[${minted.record.id}]]`, + decision: `[[${decisionId}]]`, }); atomicWriteFileSync(record.path, renderRecord(stamped)); @@ -725,7 +824,7 @@ export function resolveOpenDecision( agent: actor, body: { open: `[[${record.id}]]`, - decision: `[[${minted.record.id}]]`, + decision: `[[${decisionId}]]`, choice, agent: actor, }, @@ -740,8 +839,8 @@ export function resolveOpenDecision( appendDecisionChangeReceipt(vault, { subject: `[[${record.id}]]`, before: OPEN_DECISION_STATUS.open, - after: `${OPEN_DECISION_STATUS.resolved} -> [[${minted.record.id}]]`, - evidenceTriggers: [`[[${minted.record.id}]]`], + after: `${OPEN_DECISION_STATUS.resolved} -> [[${decisionId}]]`, + evidenceTriggers: [`[[${decisionId}]]`], actor, ...(rationale ? { rationale } : {}), reasonCode: DECISION_CHANGE_REASON.openResolved, @@ -752,7 +851,7 @@ export function resolveOpenDecision( // Best-effort accountability; the stamped record is authoritative. } - return { decision: minted.record.id }; + return { decision: decisionId }; }); } diff --git a/tests/cli/brain-decision.test.ts b/tests/cli/brain-decision.test.ts index 4391c9ca..c6ebbffa 100644 --- a/tests/cli/brain-decision.test.ts +++ b/tests/cli/brain-decision.test.ts @@ -474,6 +474,134 @@ describe("o2b brain decision", () => { expect(dup.stderr).toContain("open-pick-a-queue"); }); + test("two same-titled questions both resolve to distinct decision pages", async () => { + for (const question of [ + "Which database for the core lane?", + "Which database for the analytics lane?", + ]) { + const opened = await runCli( + [ + "brain", + "decision", + "open", + "--config", + configPath, + "--title", + "Pick DB", + "--question", + question, + "--option", + "postgres", + "--option", + "sqlite", + "--json", + ], + { env }, + ); + expect(opened.returncode).toBe(0); + } + const first = await runCli( + [ + "brain", + "decision", + "resolve", + "open-pick-db", + "--config", + configPath, + "--choice", + "postgres", + "--json", + ], + { env }, + ); + expect(first.returncode).toBe(0); + expect(JSON.parse(first.stdout).decision).toBe("decision-pick-db"); + + const second = await runCli( + [ + "brain", + "decision", + "resolve", + "open-pick-db-2", + "--config", + configPath, + "--choice", + "sqlite", + "--json", + ], + { env }, + ); + expect(second.returncode).toBe(0); + expect(JSON.parse(second.stdout).decision).toBe("decision-pick-db-open-pick-db-2"); + + const page = await runCli( + ["brain", "decision", "show", "pick-db-open-pick-db-2", "--config", configPath, "--json"], + { env }, + ); + expect(page.returncode).toBe(0); + expect(JSON.parse(page.stdout).chosen).toBe("sqlite"); + }); + + test("the same wording can be parked again after the twin was discarded", async () => { + const first = await runCli( + [ + "brain", + "decision", + "open", + "--config", + configPath, + "--title", + "Pick a queue", + "--question", + "Which queue backs the worker?", + "--option", + "postgres", + "--option", + "rabbit", + "--json", + ], + { env }, + ); + expect(first.returncode).toBe(0); + const discarded = await runCli( + [ + "brain", + "decision", + "discard", + "open-pick-a-queue", + "--config", + configPath, + "--reason", + "superseded", + ], + { env }, + ); + expect(discarded.returncode).toBe(0); + + const second = await runCli( + [ + "brain", + "decision", + "open", + "--config", + configPath, + "--title", + "Pick a queue", + "--question", + "Which queue backs the worker?", + "--option", + "postgres", + "--option", + "rabbit", + "--json", + ], + { env }, + ); + expect(second.returncode).toBe(0); + expect(JSON.parse(second.stdout).id).toBe("open-pick-a-queue-2"); + expect(JSON.parse(second.stdout).status).toBe("open"); + }); + test("discard records the reason and list_open partitions by status", async () => { await runCli( [ diff --git a/tests/core/brain/decisions/open-store.test.ts b/tests/core/brain/decisions/open-store.test.ts index 2851f088..e40d67fa 100644 --- a/tests/core/brain/decisions/open-store.test.ts +++ b/tests/core/brain/decisions/open-store.test.ts @@ -137,6 +137,25 @@ describe("openDecision", () => { expect(dup.existingId).toBe("open-which-http-client-for-the-ingest-lane"); }); + test("the same wording parks again once its twin has resolved", () => { + const first = openDecision(vault, baseInput()); + resolveOpenDecision(vault, first.id, { choice: "bun's fetch", actor: "tester", now: T1 }); + const second = openDecision(vault, baseInput({ now: T2 })); + expect(second.id).not.toBe(first.id); + expect(second.status).toBe("open"); + expect( + listOpenDecisions(vault, { status: OPEN_DECISION_STATUS.open }).records.map((r) => r.id), + ).toEqual([second.id]); + }); + + test("the same wording parks again once its twin was discarded", () => { + const first = openDecision(vault, baseInput()); + discardOpenDecision(vault, first.id, { reason: "not ours to make", actor: "tester", now: T1 }); + const second = openDecision(vault, baseInput({ now: T2 })); + expect(second.id).not.toBe(first.id); + expect(second.status).toBe("open"); + }); + test("a distinct question under the same title slug gets a suffixed filename, not a duplicate refusal", () => { const first = openDecision(vault, baseInput()); const second = openDecision( @@ -337,6 +356,69 @@ describe("resolveOpenDecision", () => { ).toHaveLength(1); }); + test("two same-titled questions both resolve, each minting its own page", () => { + const first = openDecision( + vault, + baseInput({ title: "Pick DB", question: "Which database for the core lane?" }), + ); + const second = openDecision( + vault, + baseInput({ title: "Pick DB", question: "Which database for the analytics lane?", now: T1 }), + ); + const r1 = resolveOpenDecision(vault, first.id, { + choice: "bun's fetch", + actor: "tester", + now: T1, + }); + // The title slug is taken by the first record's page, so the second + // mints from a title carrying its own record id - never a refusal. + const r2 = resolveOpenDecision(vault, second.id, { + choice: "undici", + actor: "tester", + now: T2, + }); + expect(r1.decision).toBe("decision-pick-db"); + expect(r2.decision).toBe("decision-pick-db-open-pick-db-2"); + expect(showOpenDecision(vault, second.id)!.status).toBe("resolved"); + expect(showDecision(vault, "pick-db")!.chosen).toBe("bun's fetch"); + expect(showDecision(vault, "pick-db-open-pick-db-2")!.chosen).toBe("undici"); + expect(listDecisions(vault)).toHaveLength(2); + }); + + test("a resolve that crashed after minting the page converges on the next attempt", () => { + const rec = openDecision(vault, baseInput()); + resolveOpenDecision(vault, rec.id, { + choice: "bun's fetch", + actor: "tester", + rationale: "zero extra dependency", + now: T1, + }); + // Simulate the crash: rewind the record to its open shape, leaving the + // minted page (whose body names this record) in place. + writeFileSync( + rec.path, + readFileSync(rec.path, "utf8") + .replace("status: resolved", "status: open") + .replace(/^resolved_at: .*\n/mu, "") + .replace(/^choice: .*\n/mu, "") + .replace(/^decision: .*\n/mu, ""), + "utf8", + ); + expect(showOpenDecision(vault, rec.id)!.status).toBe("open"); + + const again = resolveOpenDecision(vault, rec.id, { + choice: "bun's fetch", + actor: "tester", + now: T2, + }); + expect(again.decision).toBe("decision-which-http-client-for-the-ingest-lane"); + const stamped = showOpenDecision(vault, rec.id)!; + expect(stamped.status).toBe("resolved"); + expect(stamped.decision).toBe("[[decision-which-http-client-for-the-ingest-lane]]"); + // The crashed attempt minted the one page; the retry minted no second. + expect(listDecisions(vault)).toHaveLength(1); + }); + test("a choice outside the enumerated options refuses naming the options", () => { const rec = openDecision(vault, baseInput()); expect(() => From d34d3b1b00416938ee06b74f6e26162a4da90a9f Mon Sep 17 00:00:00 2001 From: Sergey Eroshenkov Date: Sat, 10 Oct 2026 21:18:34 +0200 Subject: [PATCH 56/84] fix(decisions): survive adversarial headings and a failed log mirror - question and context are body prose, not structure: a line whose first non-space character is # would re-partition the record on read-back (a question carrying its own ## Options line made its bullets the record's options), so the writer indents such a line one space and the reader removes exactly that one - a byte-faithful round trip for every input the writer accepts. - the decision-resolved log mirror gets the same non-throwing guard as its sibling mirrors: a throw after the page was minted no longer reports the resolve failed and skips the open_resolved receipt. --- src/core/brain/decisions/open-store.ts | 58 ++++++++++++++----- tests/core/brain/decisions/open-store.test.ts | 44 ++++++++++++++ 2 files changed, 88 insertions(+), 14 deletions(-) diff --git a/src/core/brain/decisions/open-store.ts b/src/core/brain/decisions/open-store.ts index 6aec8c8e..bf28cbf2 100644 --- a/src/core/brain/decisions/open-store.ts +++ b/src/core/brain/decisions/open-store.ts @@ -319,6 +319,32 @@ function sectionText(body: string, heading: string): string { return body.slice(start, end).trim(); } +/** + * A line of free-text body prose whose first non-space character is `#` + * would read back as a section heading: a question carrying its own + * `## Options` line re-partitions the body on parse, and its bullets + * become the record's options. The writer indents such a line by one + * space (breaking the `^## ` anchors the section reader matches); the + * reader dedents exactly the lines the writer could have indented - a + * line the writer left alone never has `#` as its first non-space + * character, because that shape is what the writer escapes - so the + * round trip is byte-faithful for every input the writer accepts, + * including a question line that was already indented. + */ +function escapeBodyProse(text: string): string { + return text + .split("\n") + .map((line) => (/^\s*#/u.test(line) ? ` ${line}` : line)) + .join("\n"); +} + +function unescapeBodyProse(text: string): string { + return text + .split("\n") + .map((line) => (line.startsWith(" ") && /^\s*#/u.test(line) ? line.slice(1) : line)) + .join("\n"); +} + function renderRecord(record: OpenDecisionRecord): string { const lines = [ "---", @@ -343,7 +369,7 @@ function renderRecord(record: OpenDecisionRecord): string { "", "## Question", "", - record.question, + escapeBodyProse(record.question), "", "## Options", "", @@ -351,7 +377,7 @@ function renderRecord(record: OpenDecisionRecord): string { for (const option of record.options) lines.push(`- ${option}`); lines.push(""); if (record.context.length > 0) { - lines.push("## Context", "", record.context, ""); + lines.push("## Context", "", escapeBodyProse(record.context), ""); } return lines.join("\n"); } @@ -395,7 +421,7 @@ function parseOpenRecord(vault: string, fileName: string): OpenDecisionRecord | presentButUnreadable(`not one of ${OPEN_DECISION_STATUSES.join(", ")}`, status), ); } - const question = sectionText(body, "Question"); + const question = unescapeBodyProse(sectionText(body, "Question")); if (!question) { throw new OpenDecisionFieldError( path, @@ -460,7 +486,7 @@ function parseOpenRecord(vault: string, fileName: string): OpenDecisionRecord | title: typeof titleMeta === "string" ? titleMeta : id, question, options: Object.freeze(options), - context: sectionText(body, "Context"), + context: unescapeBodyProse(sectionText(body, "Context")), questionHash, createdAt, resolvedAt: textOrNull(OPEN_KEY.resolvedAt), @@ -818,17 +844,21 @@ export function resolveOpenDecision( }); atomicWriteFileSync(record.path, renderRecord(stamped)); - appendLogEvent(vault, { - timestamp: isoSecond(now), - eventType: BRAIN_LOG_EVENT_KIND.decisionResolved, - agent: actor, - body: { - open: `[[${record.id}]]`, - decision: `[[${decisionId}]]`, - choice, + try { + appendLogEvent(vault, { + timestamp: isoSecond(now), + eventType: BRAIN_LOG_EVENT_KIND.decisionResolved, agent: actor, - }, - }); + body: { + open: `[[${record.id}]]`, + decision: `[[${decisionId}]]`, + choice, + agent: actor, + }, + }); + } catch { + // The record file is authoritative; the timeline mirror is best-effort. + } // Exactly one open_resolved receipt per resolution (the receipts // module's idempotency key makes a replay a no-op). Fail-soft like diff --git a/tests/core/brain/decisions/open-store.test.ts b/tests/core/brain/decisions/open-store.test.ts index e40d67fa..bc0e37e8 100644 --- a/tests/core/brain/decisions/open-store.test.ts +++ b/tests/core/brain/decisions/open-store.test.ts @@ -188,6 +188,50 @@ describe("openDecision", () => { }); }); +describe("heading-shaped free text", () => { + test("a heading-shaped line in the question or context cannot re-partition the body", () => { + const rec = openDecision( + vault, + baseInput({ + question: "Pick one\n## Options\n- evil", + context: "Background\n## Question\n- also evil", + }), + ); + // The writer indented the injected headings, so the real sections stay put. + const raw = readFileSync(rec.path, "utf8"); + expect(raw).toContain("Pick one\n ## Options\n- evil"); + expect(raw).toContain("Background\n ## Question\n- also evil"); + + const back = showOpenDecision(vault, rec.id)!; + expect(back.question).toBe("Pick one\n## Options\n- evil"); + expect(back.context).toBe("Background\n## Question\n- also evil"); + expect([...back.options]).toEqual(["bun's fetch", "undici"]); + + // The record resolves by its REAL options, not the injected ones. + const res = resolveOpenDecision(vault, rec.id, { + choice: "bun's fetch", + actor: "tester", + now: T1, + }); + expect(res.decision).toBe("decision-which-http-client-for-the-ingest-lane"); + }); + + test("a question that begins with a heading round-trips", () => { + const rec = openDecision(vault, baseInput({ question: "# Not a heading\nbody line" })); + const back = showOpenDecision(vault, rec.id)!; + expect(back.question).toBe("# Not a heading\nbody line"); + }); + + test("a heading-shaped line that was already indented round-trips byte-faithfully", () => { + const question = "Pick one\n indented\n ## Sub\n- still prose"; + const rec = openDecision(vault, baseInput({ question })); + const raw = readFileSync(rec.path, "utf8"); + // The writer indents it one more space; the reader removes exactly that one. + expect(raw).toContain("indented\n ## Sub"); + expect(showOpenDecision(vault, rec.id)!.question).toBe(question); + }); +}); + describe("hand-edit tolerance", () => { test("a hand-edited file with YAML-significant characters round-trips", () => { const rec = openDecision( From 42d1fb6db50fb375745f5e546e68c48ddbef8695 Mon Sep 17 00:00:00 2001 From: Sergey Eroshenkov Date: Sat, 10 Oct 2026 21:18:41 +0200 Subject: [PATCH 57/84] fix(mcp): gate decision resolve and discard at the caller's reach rate and outcome answered their writes through the readable-at-reach predicate; resolve and discard skipped it, so a record below the caller's reach leaked its existence through a success. Both transitions now run the same predicate before anything is written and answer a below-reach id exactly like an absent one. The title field description also states the unnamed- fallback the ASCII-locked slug grammar gives a non-Latin title. --- src/mcp/brain/decisions-tools.ts | 21 ++++++++++-- tests/mcp/decision-tool.test.ts | 56 ++++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 2 deletions(-) diff --git a/src/mcp/brain/decisions-tools.ts b/src/mcp/brain/decisions-tools.ts index 4a99fa43..543a7a01 100644 --- a/src/mcp/brain/decisions-tools.ts +++ b/src/mcp/brain/decisions-tools.ts @@ -367,6 +367,12 @@ async function toolBrainDecision( const id = coerceStr(args, "id", true)!; const choice = coerceStr(args, "choice", true)!; const rationale = coerceStr(args, "rationale", false) ?? undefined; + // Reach boundary: a record below the caller's reach is answered + // exactly like an absent one, before anything is written - the + // same predicate rate and outcome answer their reads with. + if (showOpenDecision(ctx.vault, id, reads) === null) { + throw new OpenDecisionError(`no open decision: ${id}`); + } const res = resolveOpenDecision(ctx.vault, id, { choice, ...(rationale ? { rationale } : {}), @@ -377,6 +383,11 @@ async function toolBrainDecision( case "discard": { const id = coerceStr(args, "id", true)!; const reason = coerceStr(args, "reason", true)!; + // Reach boundary: same as resolve - a below-reach id is an + // absent id, with no existence leak. + if (showOpenDecision(ctx.vault, id, reads) === null) { + throw new OpenDecisionError(`no open decision: ${id}`); + } discardOpenDecision(ctx.vault, id, { reason, ...(agent ? { actor: agent } : {}), @@ -421,7 +432,10 @@ export const DECISIONS_TOOLS: ReadonlyArray = Object.freeze([ }, title: { type: "string", - description: "record/similar: the decision question / statement.", + description: + "record/similar: the decision question / statement. open: short label that drives " + + "the open-decision id; a title with no ASCII letters or digits hashes to an " + + "unnamed- id (the slug grammar is ASCII).", }, chosen: { type: "string", @@ -477,7 +491,10 @@ export const DECISIONS_TOOLS: ReadonlyArray = Object.freeze([ prompt: { type: "string", description: "recall: the incoming prompt to match." }, question: { type: "string", - description: "open: the full question being parked. Dedup key across open records.", + description: + "open: the full question being parked. Dedup key across open records: a twin " + + "question with an open record refuses; the same wording may be re-parked after " + + "the twin resolves or discards.", }, options: { type: "array", diff --git a/tests/mcp/decision-tool.test.ts b/tests/mcp/decision-tool.test.ts index 67dc62ef..4e35bbea 100644 --- a/tests/mcp/decision-tool.test.ts +++ b/tests/mcp/decision-tool.test.ts @@ -5,6 +5,8 @@ import { join } from "node:path"; import { JSONRPC_VERSION, MCPServer, PROTOCOL_VERSION } from "../../src/mcp/index.ts"; import { buildToolTable } from "../../src/mcp/tools.ts"; +import { TRANSPORT_REACH } from "../../src/core/graph/transport-reach.ts"; +import { REMOTE_DENY_VISIBILITY_TOKEN } from "../../src/core/graph/visibility.ts"; let vault: string; @@ -46,6 +48,10 @@ function payload(response: { result?: unknown }): Record { return JSON.parse(result.content[0]!.text); } +function errorMessage(response: { error?: unknown }): string { + return (response.error as { message?: string } | undefined)?.message ?? ""; +} + describe("brain_decision tool", () => { test("registered in the full tool table", () => { expect(buildToolTable("full").find((t) => t.name === "brain_decision")).toBeDefined(); @@ -373,6 +379,56 @@ describe("brain_decision open-decision actions", () => { expect(res.error).toBeDefined(); }); + test("resolve and discard answer a below-reach id exactly like an absent one", async () => { + const { readFileSync, writeFileSync } = await import("node:fs"); + const { join } = await import("node:path"); + const local = new MCPServer({ vault, configPath: null }, { reach: TRANSPORT_REACH.local }); + await initialize(local); + const opened = payload( + await call(local, { + action: "open", + title: "Reach-gated question", + question: "May a below-reach caller act on this record?", + options: ["yes", "no"], + }), + ); + // Reserve the record against remote reads, the dead-ends-reach way. + const path = join(vault, "Brain", "decisions", `${opened["id"] as string}.md`); + const text = readFileSync(path, "utf8"); + const close = text.indexOf("\n---\n", "---\n".length); + writeFileSync( + path, + `${text.slice(0, close)}\nvisibility: [${REMOTE_DENY_VISIBILITY_TOKEN}]${text.slice(close)}`, + "utf8", + ); + + // A server with no reach minted is a remote caller: both transitions + // answer with the absent id's error, never a reach distinction. + const remote = new MCPServer({ vault, configPath: null }); + await initialize(remote); + const gatedResolve = await call(remote, { + action: "resolve", + id: opened["id"], + choice: "yes", + }); + expect(gatedResolve.error).toBeDefined(); + expect(errorMessage(gatedResolve)).toBe( + `brain_decision: no open decision: ${opened["id"] as string}`, + ); + const gatedDiscard = await call(remote, { action: "discard", id: opened["id"], reason: "x" }); + expect(errorMessage(gatedDiscard)).toBe( + `brain_decision: no open decision: ${opened["id"] as string}`, + ); + + // The record was not touched, and a local caller can still act on it. + const listed = payload(await call(local, { action: "list_open", status: "open" })); + expect((listed["open_decisions"] as unknown[]).length).toBe(1); + const resolved = payload( + await call(local, { action: "resolve", id: opened["id"], choice: "yes" }), + ); + expect(resolved["decision"]).toBe("decision-reach-gated-question"); + }); + test("no new tool was added: the open actions ride the existing brain_decision tool", async () => { const { buildToolTable } = await import("../../src/mcp/tools.ts"); const table = buildToolTable("full"); From eb8ca682a5d692c55a48d67864fdd5cfb8864f6b Mon Sep 17 00:00:00 2001 From: Sergey Eroshenkov Date: Sat, 10 Oct 2026 21:18:47 +0200 Subject: [PATCH 58/84] fix(cli): say that non-Latin decision titles hash to an unnamed id The slug grammar is ASCII-locked and open hashes a title it cannot spell to an unnamed- id; the open-decision help text now says so instead of leaving the opaque id unexplained. --- src/cli/brain/help-text.ts | 10 ++++++---- tests/cli/brain-decision.test.ts | 9 +++++++++ 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/src/cli/brain/help-text.ts b/src/cli/brain/help-text.ts index 3d7d2974..94804bff 100644 --- a/src/cli/brain/help-text.ts +++ b/src/cli/brain/help-text.ts @@ -378,10 +378,12 @@ export const VERB_HELP: Record = { "Open decisions (parked questions with enumerated options) live beside them as\n" + "open-.md: open --title --question --option [--option ...]\n" + "[--context ] parks one (a duplicate question refuses, naming the existing id);\n" + - "list_open [--status open|resolved|discarded] lists them (unreadable records named);\n" + - "show_open reads one; resolve --choice [--rationale ] mints the real\n" + - "type: decision page and stamps the pointer; discard --reason closes the\n" + - "question without deciding. Terminal records stay in place.\n", + "a title with no ASCII letters or digits hashes to an unnamed- id (the slug\n" + + "grammar is ASCII); list_open [--status open|resolved|discarded] lists them\n" + + "(unreadable records named); show_open reads one; resolve --choice \n" + + "[--rationale ] mints the real type: decision page and stamps the pointer;\n" + + "discard --reason closes the question without deciding. Terminal records\n" + + "stay in place.\n", tension: "usage: o2b brain tension [...] [--vault ] [--json]\n" + "Triage persisted contradictions under Brain/tensions/. detect [--jaccard ] scans\n" + diff --git a/tests/cli/brain-decision.test.ts b/tests/cli/brain-decision.test.ts index c6ebbffa..eba05391 100644 --- a/tests/cli/brain-decision.test.ts +++ b/tests/cli/brain-decision.test.ts @@ -9,6 +9,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { runCli } from "../helpers/run-cli.ts"; +import { VERB_HELP } from "../../src/cli/brain/help-text.ts"; let tmp: string; let configDir: string; @@ -474,6 +475,14 @@ describe("o2b brain decision", () => { expect(dup.stderr).toContain("open-pick-a-queue"); }); + test("the verb help states that non-Latin titles hash to an unnamed id", () => { + // The slug grammar is ASCII-locked; a title it cannot spell must be + // explained at the surface that asks for the title. + expect(VERB_HELP["decision"]).toContain( + "a title with no ASCII letters or digits hashes to an unnamed- id", + ); + }); + test("two same-titled questions both resolve to distinct decision pages", async () => { for (const question of [ "Which database for the core lane?", From 68d19d83497e64195586e180c07ac4284e3513e7 Mon Sep 17 00:00:00 2001 From: Sergey Eroshenkov Date: Sat, 10 Oct 2026 21:27:02 +0200 Subject: [PATCH 59/84] test(decisions): cover the resolved log mirror's non-throwing guard A decision-resolved mirror that throws must not fail the resolve nor skip the open_resolved receipt; the mock throws for that one event kind only, so the mint's own mirrors still land. --- tests/core/brain/decisions/open-store.test.ts | 47 ++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/tests/core/brain/decisions/open-store.test.ts b/tests/core/brain/decisions/open-store.test.ts index bc0e37e8..0eb7b668 100644 --- a/tests/core/brain/decisions/open-store.test.ts +++ b/tests/core/brain/decisions/open-store.test.ts @@ -9,7 +9,7 @@ * stay in place so history is a status filter. */ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -37,6 +37,27 @@ import { showOpenDecision, } from "../../../../src/core/brain/decisions/open-store.ts"; +// The log mirror is best-effort by contract; the mirror-failure test below +// needs the real appendLogEvent to throw for ONE event kind while every +// other event still lands, so the module is wrapped in a forwarding mock +// (captured before the mock is installed, or the forward would recurse). +import * as logModule from "../../../../src/core/brain/log.ts"; + +const realAppendLogEvent = logModule.appendLogEvent; +let failResolvedLogMirror = false; + +mock.module("../../../../src/core/brain/log.ts", () => ({ + ...logModule, + appendLogEvent: ( + ...args: Parameters + ): ReturnType => { + if (failResolvedLogMirror && args[1]!.eventType === BRAIN_LOG_EVENT_KIND.decisionResolved) { + throw new Error("log shard unwritable"); + } + return realAppendLogEvent(...args); + }, +})); + let vault: string; beforeEach(() => { @@ -463,6 +484,30 @@ describe("resolveOpenDecision", () => { expect(listDecisions(vault)).toHaveLength(1); }); + test("a throwing decision-resolved log mirror does not fail the resolve nor skip the receipt", () => { + const rec = openDecision(vault, baseInput()); + failResolvedLogMirror = true; + try { + const res = resolveOpenDecision(vault, rec.id, { + choice: "bun's fetch", + actor: "tester", + now: T1, + }); + expect(res.decision).toBe("decision-which-http-client-for-the-ingest-lane"); + expect(showOpenDecision(vault, rec.id)!.status).toBe("resolved"); + const history = queryDecisionChangeHistory(vault, { subject: rec.id, limit: 50 }); + expect(history.receipts.filter((r) => r.reason_code === "open-resolved")).toHaveLength(1); + } finally { + failResolvedLogMirror = false; + } + // The mirror alone went down: the log day carries the mint's + // decision-record event but no decision_resolved one. + const log = readLogDay(vault, "2026-10-02"); + expect( + log.entries.filter((e) => e.eventType === BRAIN_LOG_EVENT_KIND.decisionResolved), + ).toHaveLength(0); + }); + test("a choice outside the enumerated options refuses naming the options", () => { const rec = openDecision(vault, baseInput()); expect(() => From 891b01166378a407f8404259e3da96c6d0cdd20c Mon Sep 17 00:00:00 2001 From: Sergey Eroshenkov Date: Sat, 10 Oct 2026 21:27:30 +0200 Subject: [PATCH 60/84] feat(trust): thread the request identity into every write-side decision The credential-minted identity a transport resolved now rides the ServerContext as requestIdentity (declared on the tool-contract leaf, re-exported from the server), and every writer tool hands it down as the permission subject the gates answer for: createNote's owner gate and review disposition, ingestSource, writePreference's owner resolution and gate, the write-batch subject covering the batch's create arm, and the feedback signal and force-confirmed consults. When the identity exists it wins over resolveAgentName; stdio, the CLI bridge and hand-built contexts stay config-shaped. A token caller that supplies an agent argument naming somebody else is refused outright (agent-claim-refused): brain_feedback, brain_ingest_source, brain_write_batch's per-op agent attribution, brain_note, brain_expire and brain_apply_evidence all check the claim before any byte moves, while shared-key and config-identity requests keep the historical passthrough. Tests speak HTTP: a document deny for the token's agent refuses the token's create with one ledger row naming the token agent and the deciding rule, a cross-owner claim under the fail gate is refused answering for the token's agent, a foreign agent claim on brain_feedback is refused before any byte, and a staged create's review row names the token agent instead of the config identity. --- src/core/brain/ingest/ingest.ts | 12 +- src/core/brain/notes/create-note.ts | 47 +++++- src/core/brain/preference.ts | 43 ++++-- src/core/brain/write-batch.ts | 18 ++- src/mcp/brain/agent-claim.ts | 43 ++++++ src/mcp/brain/feedback-tools.ts | 92 ++++++++++-- src/mcp/brain/ingest-tools.ts | 14 +- src/mcp/brain/notes-tools.ts | 10 +- src/mcp/brain/write-batch-tools.ts | 21 ++- src/mcp/server.ts | 27 ++-- src/mcp/tool-contract.ts | 29 ++++ tests/mcp/http-write-identity.test.ts | 202 ++++++++++++++++++++++++++ 12 files changed, 503 insertions(+), 55 deletions(-) create mode 100644 src/mcp/brain/agent-claim.ts create mode 100644 tests/mcp/http-write-identity.test.ts diff --git a/src/core/brain/ingest/ingest.ts b/src/core/brain/ingest/ingest.ts index f3a3f2d9..70256cab 100644 --- a/src/core/brain/ingest/ingest.ts +++ b/src/core/brain/ingest/ingest.ts @@ -67,6 +67,7 @@ import { } from "./pre-extract.ts"; import { REVIEW_LANE } from "../write-gate.ts"; import { resolveWriteDisposition, stageForReview } from "../pending/pending-lanes.ts"; +import type { WriteSubject } from "../write-disposition.ts"; /** Frontmatter `kind:` marker of an ingested source summary page. */ export const BRAIN_SOURCE_KIND = "brain-source"; @@ -114,6 +115,15 @@ export interface IngestSourceOptions { * nothing. */ readonly readable?: (rel: string) => boolean; + /** + * The credential-minted subject of the caller (write-side-trust, Task + * 7), when the transport resolved one. It WINS over `agent` as the + * permission subject the review disposition consults - a caller-supplied + * agent is content attribution, never the identity a document's rules + * answer. Absent (stdio, the CLI) the disposition falls back to + * `agent`, then to the config identity. + */ + readonly subject?: WriteSubject; } export interface IngestSourceResult { @@ -228,7 +238,7 @@ export function ingestSource( const disposition = resolveWriteDisposition( vault, REVIEW_LANE.ingest, - opts.agent !== undefined ? { agent: opts.agent, via: "config" } : undefined, + opts.subject ?? (opts.agent !== undefined ? { agent: opts.agent, via: "config" } : undefined), { target: summaryPath, ...(opts.now !== undefined ? { now: opts.now } : {}) }, ); const preExtract = diff --git a/src/core/brain/notes/create-note.ts b/src/core/brain/notes/create-note.ts index 2f3894bc..5500cde4 100644 --- a/src/core/brain/notes/create-note.ts +++ b/src/core/brain/notes/create-note.ts @@ -85,6 +85,7 @@ import { NOTE_WRITE_OP, recordNoteWrite } from "./write-record.ts"; import { ROUTE_STAGE, timeStageSync } from "../../route-scope.ts"; import { REVIEW_LANE } from "../write-gate.ts"; import { resolveWriteDisposition, stageForReview } from "../pending/pending-lanes.ts"; +import { substrateSubject, type WriteSubject } from "../write-disposition.ts"; /** Machine-readable reason a {@link createNote} call was refused. */ export type CreateNoteErrorCode = @@ -164,6 +165,17 @@ export interface CreateNoteInput { * is, not the one the machine default happens to name. */ readonly configPath?: string; + /** + * The credential-minted subject of the caller (write-side-trust, Task + * 7), when the transport resolved one. When present it WINS over the + * config resolution in both gates this primitive consults - the + * owner-write gate's resolved identity and the review disposition's + * permission subject - so a token whose agent a document denies is + * refused, and the decision ledger names the token's agent, whatever + * the process config says. Absent (stdio, the CLI) keeps the + * config-derived subject. + */ + readonly subject?: WriteSubject; } /** What a {@link createNote} call actually did. `staged` means the review gate parked the bytes in the queue. */ @@ -616,13 +628,17 @@ export function noteOwnerGateVerdict( vault: string, frontmatter: FrontmatterMap | undefined, configPath: string | undefined, + subject?: WriteSubject, ): NoteOwnerGateOutcome { const claim = frontmatter?.[NOTE_OWNER_FRONTMATTER_KEY]; if (claim === undefined || claim === null) { return { refused: false, watch: false, named: "", resolvedIdentity: "" }; } const named = typeof claim === "string" ? claim : String(claim); - const resolved = resolveAgentName(configPath); + // The credential-minted subject wins over the config resolution + // (write-side-trust, Task 7): a token's owner gate answers for the + // token's agent, never for whoever the process config names. + const resolved = subject?.agent ?? resolveAgentName(configPath); const gateMode = loadIntegrityConfigSafe(vault).owner_scope_writes; const { document } = loadPermissionsDocument(vault); const verdict = refuseCrossOwnerWrite({ @@ -630,7 +646,7 @@ export function noteOwnerGateVerdict( resolvedIdentity: resolved, gateMode, document, - subject: { agent: resolved, via: "config" }, + subject: substrateSubject(subject ?? { agent: resolved, via: "config" }), }); if (verdict.refused) return { @@ -659,11 +675,18 @@ export function gateNoteOwnerFrontmatter( relPath: string, frontmatter: FrontmatterMap | undefined, configPath: string | undefined, + subject?: WriteSubject, ): NoteOwnerGateResult { - const outcome = noteOwnerGateVerdict(vault, frontmatter, configPath); + const outcome = noteOwnerGateVerdict(vault, frontmatter, configPath, subject); if (outcome.refused) return { refused: true, reason: outcome.reason }; if (outcome.watch) { - logWatchedNoteOwnerWrite(vault, relPath, outcome.named, outcome.resolvedIdentity); + logWatchedNoteOwnerWrite( + vault, + relPath, + outcome.named, + outcome.resolvedIdentity, + subject?.via ?? "config", + ); } return { refused: false }; } @@ -683,6 +706,7 @@ export function logWatchedNoteOwnerWrite( target: string, named: string, resolved: string, + via: WriteSubject["via"] = "config", ): void { const token = normalizeAgentScope(named); const identity = normalizeAgentScope(normalizeAgentArgument(resolved) ?? undefined); @@ -690,7 +714,7 @@ export function logWatchedNoteOwnerWrite( appendDecisionLedger(vault, { ts: new Date().toISOString(), actor: resolved, - via: "config", + via, action: "owner_write", target, verdict: GATE_MODE.warn, @@ -721,7 +745,13 @@ export function createNote(vault: string, input: CreateNoteInput): CreateNoteRes // same whether the target is free, occupied or already staged-for- // review, so a caller probing foreign owners learns nothing about which // paths are real. - const ownerGate = gateNoteOwnerFrontmatter(vault, relPath, callerFrontmatter, input.configPath); + const ownerGate = gateNoteOwnerFrontmatter( + vault, + relPath, + callerFrontmatter, + input.configPath, + input.subject, + ); if (ownerGate.refused) { throw new CreateNoteError( "owner_write_refused", @@ -775,7 +805,10 @@ export function createNote(vault: string, input: CreateNoteInput): CreateNoteRes const disposition = resolveWriteDisposition( vault, REVIEW_LANE.notes, - { agent: resolveAgentName(input.configPath), via: "config" }, + // The credential-minted subject wins over the config resolution + // (write-side-trust, Task 7); resolveAgentName stays the stdio and + // CLI fallback. + input.subject ?? { agent: resolveAgentName(input.configPath), via: "config" }, { target: relPath }, ); if (disposition.verdict === "stage") { diff --git a/src/core/brain/preference.ts b/src/core/brain/preference.ts index 7366e1c8..301c47a3 100644 --- a/src/core/brain/preference.ts +++ b/src/core/brain/preference.ts @@ -65,6 +65,7 @@ import { loadIntegrityConfigForWrite, loadIntegrityConfigSafe } from "./policy.t import { loadPermissionsDocument } from "./permissions/document.ts"; import { appendDecisionLedger } from "./permissions/ledger.ts"; import { OWNER_SCOPE_WRITES_KEY, refuseCrossOwnerWrite } from "./trust/owner-write-gate.ts"; +import { substrateSubject, type WriteSubject } from "./write-disposition.ts"; import { asProvenanceLevel, type ProvenanceLevel } from "./provenance/provenance.ts"; import { sanitisePrinciple } from "./text/sanitize-principle.ts"; import { @@ -306,6 +307,16 @@ export interface WritePreferenceOptions { * the stamped owner is the identity that surface answers under. */ readonly configPath?: string; + /** + * The credential-minted subject of the caller (write-side-trust, Task + * 7), when the transport resolved one. When present it WINS over the + * config resolution everywhere this writer resolves an identity: the + * owner-write gate's resolved identity, the subject the permissions + * document is consulted under, and the owner stamped onto a newly + * created preference. Absent (stdio, the CLI) keeps the + * config-derived identity. + */ + readonly subject?: WriteSubject; } export interface ParsePreferenceOptions { @@ -408,7 +419,7 @@ export function writePreference( // idempotency hash so a write that changes the owner cannot dedupe // against one that did not - the hash already folds `owner` in for // exactly that reason. - const owned = withResolvedOwner(vault, path, input, options.configPath); + const owned = withResolvedOwner(vault, path, input, options.configPath, options.subject); // Idempotency consult (C1): a repeat with the same key + same payload // dedupes; the same key + a different payload throws before any write. @@ -523,8 +534,9 @@ function withResolvedOwner( path: string, input: WritePreferenceInput, configPath: string | undefined, + subject?: WriteSubject, ): WritePreferenceInput { - const owner = resolvedOwnerFor(vault, path, input.owner, configPath); + const owner = resolvedOwnerFor(vault, path, input.owner, configPath, subject); return owner === undefined ? input : { ...input, owner }; } @@ -549,9 +561,10 @@ export function resolvedOwnerFor( path: string, explicit: string | undefined, configPath: string | undefined, + subject?: WriteSubject, ): string | undefined { const given = explicit?.trim(); - if (given) return gateNamedOwner(vault, path, given, configPath); + if (given) return gateNamedOwner(vault, path, given, configPath, subject); let mode: string; try { mode = loadIntegrityConfigForWrite(vault).owner_scope_delivery; @@ -568,7 +581,7 @@ export function resolvedOwnerFor( } if (mode === GATE_MODE.off) return undefined; if (existsSync(path)) return carriedOwner(path).owner; - const agent = resolveAgentName(configPath); + const agent = subject?.agent ?? resolveAgentName(configPath); const stamp = ownerStampFor(agent); if (stamp === null) { throw new Error( @@ -620,8 +633,12 @@ function gateNamedOwner( path: string, given: string, configPath: string | undefined, + subject?: WriteSubject, ): string { - const resolved = resolveAgentName(configPath); + // The credential-minted subject wins over the config resolution + // (write-side-trust, Task 7): a token's owner gate answers for the + // token's agent, never for whoever the process config names. + const resolved = subject?.agent ?? resolveAgentName(configPath); const gateMode = loadIntegrityConfigSafe(vault).owner_scope_writes; const { document } = loadPermissionsDocument(vault); const verdict = refuseCrossOwnerWrite({ @@ -629,12 +646,14 @@ function gateNamedOwner( resolvedIdentity: resolved, gateMode, document, - subject: { agent: resolved, via: "config" }, + subject: substrateSubject(subject ?? { agent: resolved, via: "config" }), }); if (verdict.refused) { throw new Error(`preference write at ${relative(vault, path)}: ${verdict.reason}`); } - if (gateMode === GATE_MODE.warn) logWatchedOwnerWrite(vault, path, given, resolved); + if (gateMode === GATE_MODE.warn) { + logWatchedOwnerWrite(vault, path, given, resolved, subject?.via ?? "config"); + } return given; } @@ -644,14 +663,20 @@ function gateNamedOwner( * one case `fail` would not refuse is the one case there is nothing to * watch). */ -function logWatchedOwnerWrite(vault: string, path: string, given: string, resolved: string): void { +function logWatchedOwnerWrite( + vault: string, + path: string, + given: string, + resolved: string, + via: WriteSubject["via"] = "config", +): void { const named = normalizeAgentScope(given); const identity = normalizeAgentScope(normalizeAgentArgument(resolved) ?? undefined); if (named === null || identity === null || named === identity) return; appendDecisionLedger(vault, { ts: new Date().toISOString(), actor: resolved, - via: "config", + via, action: "owner_write", target: relative(vault, path), verdict: GATE_MODE.warn, diff --git a/src/core/brain/write-batch.ts b/src/core/brain/write-batch.ts index 30036505..ac03f9d7 100644 --- a/src/core/brain/write-batch.ts +++ b/src/core/brain/write-batch.ts @@ -57,6 +57,7 @@ import { import { appendBrainNote, type AppendBrainNoteInput } from "./note.ts"; import { ORIGIN_CHANNEL_FIELD } from "../origin-channel.ts"; import { preferencePath, validateSlug } from "./paths.ts"; +import { type WriteSubject } from "./write-disposition.ts"; import { assertVaultIdentityForWrite } from "./vault-identity.ts"; import { BRAIN_APPLY_RESULT } from "./types.ts"; import { ROUTE_STAGE, timeStageSync } from "../route-scope.ts"; @@ -407,6 +408,17 @@ export interface ApplyWriteBatchOptions { * record names the agent the caller is. */ readonly configPath?: string; + /** + * The credential-minted subject of the caller (write-side-trust, Task + * 7), when the transport resolved one. It WINS over the config + * resolution in every gate the batch consults: the owner-write gate's + * resolved identity, the review disposition's subject on a staged + * create, and the deny-only document consult every mutation runs + * (write-side trust: a denied agent cannot rewrite or append to a note + * by updating what an allowed create once published). Absent (stdio, + * the CLI) keeps the config-derived subject. + */ + readonly subject?: WriteSubject; /** * Client-supplied request ID (t_b34439d9). Supplied, the batch consults * the idempotency ledger BEFORE any validation or write: the same ID with @@ -670,7 +682,7 @@ function projectCreateNote( // consults the same gate again at commit - that arm covers the // single-note callers, and this one exists so a batch never leaves // earlier operations committed behind a refused claim. - const ownerGate = noteOwnerGateVerdict(vault, op.frontmatter, opts.configPath); + const ownerGate = noteOwnerGateVerdict(vault, op.frontmatter, opts.configPath, opts.subject); if (ownerGate.refused) { throw new WriteBatchError( "owner_write_refused", @@ -697,6 +709,7 @@ function projectCreateNote( ...(op.frontmatter !== undefined ? { frontmatter: op.frontmatter } : {}), ...(op.content !== undefined ? { content: op.content } : {}), ...(opts.configPath !== undefined ? { configPath: opts.configPath } : {}), + ...(opts.subject !== undefined ? { subject: opts.subject } : {}), }); if (res.outcome === "staged") { // The review gate staged the create (write-side trust, Task 9). @@ -793,7 +806,7 @@ function projectUpdateNote( // projection must stay row-free so a later operation's refusal never // leaves a row for a write that never happened, and a byte-identical // skip is such a write too. - const ownerGate = noteOwnerGateVerdict(vault, op.frontmatter, opts.configPath); + const ownerGate = noteOwnerGateVerdict(vault, op.frontmatter, opts.configPath, opts.subject); if (ownerGate.refused) { throw new WriteBatchError( "owner_write_refused", @@ -859,6 +872,7 @@ function projectUpdateNote( target.relPath, ownerGate.named, ownerGate.resolvedIdentity, + opts.subject?.via ?? "config", ); } // The flag is the write's own verdict, not a hardcoded success: a diff --git a/src/mcp/brain/agent-claim.ts b/src/mcp/brain/agent-claim.ts new file mode 100644 index 00000000..8ae19c51 --- /dev/null +++ b/src/mcp/brain/agent-claim.ts @@ -0,0 +1,43 @@ +/** + * The caller-supplied `agent` argument a token identity may not override + * (write-side-trust, Task 7). + * + * A credential-minted identity is the one fact a request cannot argue + * with: the token map names its agent, and every write-side gate answers + * for that agent. Letting the same request stamp a DIFFERENT name into + * signals, log rows and evidence would make the credential decorative - + * a ledger that names the wrong actor. When the request arrived on a + * token, a mismatching claim is therefore refused outright, in the same + * shape the owner-write refusal speaks: named, quoting both sides, and + * telling the caller the one move that fixes it. A shared-key or + * config-identity request keeps the historical passthrough - the + * operator master credential has always been allowed to say who it acts + * for. + */ + +import { INVALID_PARAMS, MCPError } from "../protocol.ts"; +import type { RequestIdentity } from "../tool-contract.ts"; +import { normalizeAgentArgument } from "../../core/agent-identity.ts"; +import { normalizeAgentScope } from "../../core/graph/agent-scope.ts"; + +export function refuseAgentClaim( + identity: RequestIdentity | undefined, + claimed: string | null | undefined, + tool: string, +): void { + if (identity === undefined || identity.via !== "token") return; + if (claimed === null || claimed === undefined || claimed.trim() === "") return; + // The same normalised token comparison the owner-write gate makes, so + // a second spelling of "the same agent" is not a second claim rule. + const claimedToken = normalizeAgentScope(normalizeAgentArgument(claimed) ?? undefined); + const identityToken = normalizeAgentScope(identity.agent); + if (claimedToken === null || claimedToken === identityToken) return; + throw new MCPError( + INVALID_PARAMS, + `${tool}: write refused (agent-claim-refused): the caller named agent ` + + `${JSON.stringify(claimed)}, but the presented token resolves to ` + + `${JSON.stringify(identity.agent)}; a token caller writes only under its own ` + + `agent. Drop the 'agent' argument, or present the token minted for ` + + `${JSON.stringify(claimed)}.`, + ); +} diff --git a/src/mcp/brain/feedback-tools.ts b/src/mcp/brain/feedback-tools.ts index 22fa81cb..77e9e44a 100644 --- a/src/mcp/brain/feedback-tools.ts +++ b/src/mcp/brain/feedback-tools.ts @@ -79,12 +79,15 @@ import { INTERNAL_ERROR, INVALID_PARAMS, MCPError } from "../protocol.ts"; import { PENDING_STAGED_DIAGNOSTIC_CODE, WRITE_REFUSAL_CODES, + WriteRefusedError, } from "../../core/brain/pending/pending-lanes.ts"; import { nextCommandField, requireNextStep } from "../../core/brain/next-step.ts"; import { loadPermissionsDocument } from "../../core/brain/permissions/document.ts"; import { resolvePermission } from "../../core/brain/permissions/resolve.ts"; +import { substrateSubject } from "../../core/brain/write-disposition.ts"; import { MCP_PREVIEW_BUDGET } from "../preview-budget.ts"; import type { ServerContext, ToolDefinition } from "../tool-contract.ts"; +import { refuseAgentClaim } from "./agent-claim.ts"; import { codeForError } from "../tool-error-codes.ts"; import { emitObservedUse, @@ -111,6 +114,22 @@ const FORCE_CONFIRMED_EXIT = requireNextStep( WRITE_REFUSAL_CODES.forceConfirmedRequiresAllow, ).nextCommand; +/** + * The MCP mapping of a document-deny refusal (write-side trust, Task + * 12): named code, the rule that decided, the principal it named, and + * the operator exit - the write never landed, so the refusal is the + * whole answer. Shared by the writers on this surface that let the + * disposition layer refuse through a typed error. + */ +function writeRefusedToMcp(tool: string, err: WriteRefusedError): MCPError { + return new MCPError(INVALID_PARAMS, `${tool}: ${err.message}`, { + code: err.code, + rule: err.rule, + agent: err.agent, + next_command: err.nextCommand, + }); +} + /** * Build the slug used in the signal / preference filename. We never let * the agent decide the slug directly — taking `topic` as the slug stem @@ -145,12 +164,17 @@ async function toolBrainFeedback( } = validated.value; const forceConfirmed = force_confirmed ?? false; - // Agent-fallback stays MCP-side: validator just hands back the user- - // supplied value (or undefined); the live path resolves via config - // when absent. - const agent = - normalizeAgentArgument(validated.value.agent ?? null) ?? - resolveAgentName(ctx.configPath ?? undefined); + // Identity, resolved once (write-side trust, Task 7). The + // transport-minted identity WINS over the config resolution, and under + // a token a caller-supplied `agent` that claims somebody else is + // refused outright; resolveAgentName stays the stdio and CLI fallback. + const identity = ctx.requestIdentity; + const claimedAgent = normalizeAgentArgument(validated.value.agent ?? null); + refuseAgentClaim(identity, claimedAgent, "brain_feedback"); + const agent = claimedAgent ?? identity?.agent ?? resolveAgentName(ctx.configPath ?? undefined); + // The subject the write-side gates answer for: the credential-minted + // one when the transport minted it, the config-shaped one otherwise. + const subject = identity ?? { agent, via: "config" as const }; // The force-confirmed rule (write-side trust, Task 12). `force_confirmed` // skips the dream trial window - the one write whose whole point is to @@ -160,11 +184,13 @@ async function toolBrainFeedback( // write. With no document this block never runs, so the document-absent // behavior is byte-identically the pre-Task-12 one. The verdict consult // is the substrate resolver directly - no disposition row - because the - // write it guards has its own gate; this check only polices the bypass. + // write it guards has its own gate; this check only polices the bypass, + // and it answers for the credential-minted subject, never for a name + // the request argued for. if (forceConfirmed) { const { document } = loadPermissionsDocument(ctx.vault); if (document !== null) { - const decision = resolvePermission(document, { agent, via: "config" }, "write"); + const decision = resolvePermission(document, substrateSubject(subject), "write"); if (decision.verdict !== "allow") { throw new MCPError( INVALID_PARAMS, @@ -172,13 +198,13 @@ async function toolBrainFeedback( "force_confirmed skips the dream trial window, which the permissions " + `document reserves for allow-verdict callers. Rule ${decision.source} ` + `(${decision.reason}) decided ${decision.verdict} for agent ` + - `${JSON.stringify(agent)}. The operator can review the policy: ` + + `${JSON.stringify(subject.agent)}. The operator can review the policy: ` + FORCE_CONFIRMED_EXIT, { code: WRITE_REFUSAL_CODES.forceConfirmedRequiresAllow, rule: decision.source, verdict: decision.verdict, - agent, + agent: subject.agent, }, ); } @@ -229,7 +255,10 @@ async function toolBrainFeedback( // 1. Always write the signal to inbox/. Mirrors the CLI handler so the // audit trail in `Brain/log/` and `inbox/processed/` stays consistent // across CLI and MCP entry points. `--force-confirmed` ADDITIONALLY - // creates a confirmed pref below. + // creates a confirmed pref below. The transport-minted identity + // rides to the review gate as its subject (write-side-trust, Task + // 7): a document that denies the token's agent refuses this write + // before any byte exists. const signalInput = { topic, signal: signalRaw as BrainSignalSign, @@ -247,7 +276,22 @@ async function toolBrainFeedback( ...(idempotencyKey ? { idempotency_key: idempotencyKey } : {}), ...(expires !== undefined ? { expiration_date: expires } : {}), }; - const sigResult = writeSignal(ctx.vault, signalInput, writeOpts); + let sigResult; + try { + sigResult = writeSignal(ctx.vault, signalInput, { + ...writeOpts, + // The review disposition always answers for THIS tool's resolved + // identity: the credential-minted subject when the transport minted + // one, the config-shaped subject this tool resolved (config path + // honored, caller claim refused) otherwise. Leaving the subject off + // would make the disposition re-resolve the agent without this + // context's config path and answer for a stranger. + subject, + }); + } catch (err) { + if (err instanceof WriteRefusedError) throw writeRefusedToMcp("brain_feedback", err); + throw err; + } // A deduped signal means this whole feedback call is a retry of one // already recorded — skip the log event, the shared-namespace mirror, // and any force-confirmed pref so the retry stays a true no-op. @@ -353,8 +397,14 @@ async function toolBrainFeedback( // Ownership is resolved by the writer, never echoed from `agent`: // that argument is caller-supplied, and a caller must not be able to // name whose memory this becomes. The server's config path is handed - // over rather than a resolved name for the same reason. - ctx.configPath !== null ? { configPath: ctx.configPath } : {}, + // over rather than a resolved name for the same reason, and the + // transport-minted identity rides as the credential-minted subject + // (write-side-trust, Task 7) so the gate and the stamp answer for + // the token's agent. + { + ...(ctx.configPath !== null ? { configPath: ctx.configPath } : {}), + ...(identity !== undefined ? { subject: identity } : {}), + }, ), ); try { @@ -889,7 +939,13 @@ async function toolBrainApplyEvidence( ); } - const agent = normalizeAgentArgument(agentArg) ?? resolveAgentName(ctx.configPath ?? undefined); + // The evidence row names who applied the rule; a token caller cannot + // file that attribution under another agent's name. + refuseAgentClaim(ctx.requestIdentity, agentArg, "brain_apply_evidence"); + const agent = + normalizeAgentArgument(agentArg) ?? + ctx.requestIdentity?.agent ?? + resolveAgentName(ctx.configPath ?? undefined); const input: AppendApplyEvidenceInput = { pref_id: prefId, @@ -998,6 +1054,9 @@ async function toolBrainNote( ): Promise> { const rawText = coerceStr(args, "text", true)!; const agentArg = coerceStr(args, "agent", false); + // A token caller's log line is attributed to the token's agent; a + // claim on somebody else's name is refused before anything is written. + refuseAgentClaim(ctx.requestIdentity, agentArg, "brain_note"); let res; try { @@ -1044,6 +1103,9 @@ async function toolBrainExpire( const id = coerceStr(args, "id", true)!; const expires = coerceStr(args, "expires", true)!; const agent = coerceStr(args, "agent", false); + // The expiration's audit stamp names who set it; a token caller cannot + // sign another agent's name to it. + refuseAgentClaim(ctx.requestIdentity, agent, "brain_expire"); // A record the caller may not read is refused as an unknown id, before // anything is written. const readable = readableAtContextReachOrUndefined(ctx); diff --git a/src/mcp/brain/ingest-tools.ts b/src/mcp/brain/ingest-tools.ts index 03de5199..6881a364 100644 --- a/src/mcp/brain/ingest-tools.ts +++ b/src/mcp/brain/ingest-tools.ts @@ -46,6 +46,7 @@ import { import { nextCommandField } from "../../core/brain/next-step.ts"; import { enforceCountGuard, readCountGuardArgs, wrapToolErrors } from "./shared.ts"; import type { IngestSourceResult } from "../../core/brain/ingest/ingest.ts"; +import { refuseAgentClaim } from "./agent-claim.ts"; const TOOL = "brain_ingest_source"; const SEARCH_TOOL = "brain_search_by_source"; @@ -82,10 +83,14 @@ async function toolBrainIngestSource( // shared parser building a provenance out of an argument this schema never // declared and this handler never read. const parsed = parseExtractionIntakeArgs(args, TOOL, "absent"); + // The transport-minted identity wins over the config fallback, and a + // token caller claiming another agent is refused outright + // (write-side-trust, Task 7). resolveAgentName stays the stdio/CLI + // fallback. + const claimedAgent = parsed.agent && parsed.agent.trim().length > 0 ? parsed.agent : undefined; + refuseAgentClaim(ctx.requestIdentity, claimedAgent ?? null, TOOL); const agent = - parsed.agent && parsed.agent.trim().length > 0 - ? parsed.agent - : resolveAgentName(ctx.configPath ?? undefined); + claimedAgent ?? ctx.requestIdentity?.agent ?? resolveAgentName(ctx.configPath ?? undefined); return wrapToolErrors(TOOL, [IntakeValidationError], async () => { let res: IngestSourceResult; @@ -98,6 +103,9 @@ async function toolBrainIngestSource( now: new Date(), // A page the caller may not read at its reach answers as an absent one. readable: readableAtContextReach(ctx), + // The review disposition answers for the credential-minted + // subject, never for the caller-named agent. + ...(ctx.requestIdentity !== undefined ? { subject: ctx.requestIdentity } : {}), ...(planId !== undefined ? { planId } : {}), ...(preExtract ? { preExtract: true } : {}), }, diff --git a/src/mcp/brain/notes-tools.ts b/src/mcp/brain/notes-tools.ts index 4ae931d2..910c381b 100644 --- a/src/mcp/brain/notes-tools.ts +++ b/src/mcp/brain/notes-tools.ts @@ -298,6 +298,10 @@ async function toolBrainCreateNote( // is the one THIS config declares - not the one a machine-default // discovery would find (who-wrote-what, Task A). ...(ctx.configPath !== null ? { configPath: ctx.configPath } : {}), + // The transport-minted identity wins as the permission subject and + // the owner gate's resolved identity (write-side-trust, Task 7): a + // token's create answers for the token's agent. + ...(ctx.requestIdentity !== undefined ? { subject: ctx.requestIdentity } : {}), }); // `outcome` is the discriminant; `created` is the boolean this tool // has always returned and stays in lockstep with it, so a skip can @@ -497,10 +501,14 @@ function runSingleWrite( let batch; try { // A target the caller may not read at its reach is refused exactly - // as a missing one. + // as a missing one. The transport-minted identity rides along as the + // batch's subject (write-side-trust, Task 7): a token's update or + // append answers for the token's agent, in the owner gate and the + // deny-only document consult alike. batch = applyWriteBatch(ctx.vault, [op], { readable: readableAtContextReach(ctx), ...(ctx.configPath !== null ? { configPath: ctx.configPath } : {}), + ...(ctx.requestIdentity !== undefined ? { subject: ctx.requestIdentity } : {}), }); } catch (err) { throw writeBatchErrorToMcp(err, tool); diff --git a/src/mcp/brain/write-batch-tools.ts b/src/mcp/brain/write-batch-tools.ts index fc03bd8a..63603925 100644 --- a/src/mcp/brain/write-batch-tools.ts +++ b/src/mcp/brain/write-batch-tools.ts @@ -34,6 +34,7 @@ import { import { INVALID_PARAMS, MCPError } from "../protocol.ts"; import type { ServerContext, ToolDefinition } from "../tool-contract.ts"; import { noteWriteResult, parseFrontmatterArg, writeBatchErrorToMcp } from "./notes-tools.ts"; +import { refuseAgentClaim } from "./agent-claim.ts"; import { readableAtContextReach } from "./reach-readable.ts"; import { vaultRelativeSafe } from "./shared.ts"; import { unknownOperationError } from "../coerce.ts"; @@ -236,16 +237,30 @@ async function toolBrainWriteBatch( if (!Array.isArray(rawOps)) { throw new MCPError(INVALID_PARAMS, "brain_write_batch: 'operations' must be an array"); } - const resolveAgent = (override?: string): string => - normalizeAgentArgument(override ?? null) ?? resolveAgentName(ctx.configPath ?? undefined); + // The log-writing ops stamp their agent attribution from the caller's + // override, then the config. A token caller claims neither: the + // mismatch is refused, and the identity fills the absent override + // (write-side-trust, Task 7). + const resolveAgent = (override?: string): string => { + refuseAgentClaim(ctx.requestIdentity, override ?? null, "brain_write_batch"); + return ( + normalizeAgentArgument(override ?? null) ?? + ctx.requestIdentity?.agent ?? + resolveAgentName(ctx.configPath ?? undefined) + ); + }; const operations = rawOps.map((raw, index) => mapOperation(raw, index, resolveAgent)); const requestId = optionalStr(args, "request_id"); // An update or append whose target the caller may not read at its - // reach is refused exactly as a missing one. + // reach is refused exactly as a missing one. The transport-minted + // identity rides as the batch's subject (write-side-trust, Task 7), + // covering the owner gate and the deny-only document consult on the + // update and append ops. const baseOpts = { readable: readableAtContextReach(ctx), ...(ctx.configPath !== null ? { configPath: ctx.configPath } : {}), + ...(ctx.requestIdentity !== undefined ? { subject: ctx.requestIdentity } : {}), }; let batch: WriteBatchResult; // Set exactly when a request ID was supplied: the core's receipted diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 4b4e7511..534524a1 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -49,6 +49,7 @@ import { PROGRESS_REASON, type ProgressSink } from "../core/brain/progress.ts"; import { listResources, listResourceTemplates, readResource } from "./resources.ts"; import { buildToolTable, findTool } from "./tools.ts"; import type { + RequestIdentity, RuleScopeSources, ServerContext, ToolCapabilityReport, @@ -153,20 +154,12 @@ export interface JsonRpcResponse { /** * The per-request identity a transport resolved from a credential - * (write-side-trust, Task 7). The HTTP transport mints it from the - * presented bearer token or shared key; stdio and the CLI bridge pass - * none and keep the config-derived identity. - * - * It threads as a PARAMETER from `handleRequest` through - * `handleToolsCall` and `invokeToolHandler` into `contextFor`, never as - * instance state: one MCPServer serves concurrent HTTP requests, and an - * identity parked on `this` would let two callers read each other's - * scope. + * (write-side-trust, Task 7). Declared on the tool-contract leaf beside + * {@link ServerContext.requestIdentity} - the context carries it and + * every writer tool reads it there - and re-exported here so the + * transport-facing spelling does not move. */ -export interface RequestIdentity { - readonly agent: string; - readonly via: "token" | "shared-key"; -} +export type { RequestIdentity }; /** * The `error` member of a JSON-RPC answer. `data` is always present and @@ -246,7 +239,9 @@ export class MCPServer { * resolved from the request's credential; when it is absent (stdio, * the CLI bridge, a probe) the context falls back to the process * config identity exactly as the plain getter always did - including - * deferring `resolveAgentName`'s refusal to the point of use. + * deferring `resolveAgentName`'s refusal to the point of use. The + * identity rides the context as `requestIdentity`, so a writer tool + * can answer for the credential's agent rather than the config's. */ private contextFor(identity?: RequestIdentity): ServerContext { const configPath = this.configPath ?? undefined; @@ -258,6 +253,10 @@ export class MCPServer { artifactStore: this.artifactStore, reach: this.reach, ruleScope: this.ruleScope, + // Present only when a transport minted one: the two nulls (no + // credential, no identity) stay spelled as absence, and a + // hand-built context keeps its validity. + ...(identity !== undefined ? { requestIdentity: identity } : {}), // Owner-scope isolation (context-integrity-gates, Unit A): the // only source of identity for `brain_context`, which takes no // arguments. A transport-minted credential wins; otherwise the diff --git a/src/mcp/tool-contract.ts b/src/mcp/tool-contract.ts index e1ef2325..77e23c8c 100644 --- a/src/mcp/tool-contract.ts +++ b/src/mcp/tool-contract.ts @@ -126,6 +126,21 @@ export interface RuleScopeSources { readonly harness: HarnessId | null; } +/** + * The per-request identity a transport resolved from a credential + * (write-side-trust, Task 7). The HTTP transport mints it from the + * presented bearer token or shared key; stdio and the CLI bridge pass + * none and keep the config-derived identity. + * + * It lives on this leaf module because the context carries it and every + * writer tool reads it off the context; `./server.ts` re-exports the + * type so the transport-facing spelling does not move. + */ +export interface RequestIdentity { + readonly agent: string; + readonly via: "token" | "shared-key"; +} + export interface ServerContext { readonly vault: string; readonly configPath: string | null; @@ -167,6 +182,20 @@ export interface ServerContext { * Optional so a manually-built context stays valid and unscoped. */ readonly agentName?: string; + /** + * The credential-minted identity of the ONE request this context was + * built for (write-side-trust, Task 7), when the transport resolved + * one. `undefined` for stdio, the CLI bridge and every hand-built + * context: there, `agentName`'s config resolution is the caller's + * identity and nothing about the request contradicts it. + * + * A property of the per-request context, never of the server: one + * MCPServer serves concurrent HTTP requests, and this is what makes a + * token's write-side decisions - the permission subjects, the + * owner-write gate, the agent a signal or preference is stamped under + * - answer for the token's agent instead of the process config's. + */ + readonly requestIdentity?: RequestIdentity; /** * Where the scoped standing rules look for their scope. Optional so a * manually-built context stays valid; absent resolves no project and diff --git a/tests/mcp/http-write-identity.test.ts b/tests/mcp/http-write-identity.test.ts new file mode 100644 index 00000000..5cfafdad --- /dev/null +++ b/tests/mcp/http-write-identity.test.ts @@ -0,0 +1,202 @@ +/** + * Write-side decisions answer for the credential's agent (write-side + * trust, Task 7 over HTTP). + * + * A token minted for an agent is the identity every write-side gate + * answers for when that token calls: the permissions document is + * consulted under the token's agent, the owner-write gate resolves to + * it, the decision ledger names it, and a caller-supplied `agent` + * argument claiming somebody else is refused outright. The process + * config identity (`operator` throughout this suite) must never appear + * where the token's agent decides. + */ + +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { startHttp, type HttpServerHandle } from "../../src/mcp/index.ts"; +import { bootstrapBrain } from "../../src/core/brain/init.ts"; +import { brainConfigPath } from "../../src/core/brain/paths.ts"; +import { queryDecisionLedger } from "../../src/core/brain/permissions/ledger.ts"; +import { mintAgentToken } from "../../src/core/brain/secrets/token-store.ts"; +import { GATE_MODE } from "../../src/core/integrity/stamp.ts"; +import { JSONRPC_VERSION } from "../../src/mcp/protocol.ts"; + +let vault: string; +let handle: HttpServerHandle | null = null; +const envSaved = new Map(); + +beforeEach(() => { + vault = mkdtempSync(join(tmpdir(), "o2b-http-write-identity-")); + bootstrapBrain(vault, {}); + setEnv("VAULT_AGENT_NAME", "operator"); +}); + +afterEach(async () => { + if (handle !== null) await handle.close(); + handle = null; + rmSync(vault, { recursive: true, force: true }); + for (const [key, value] of envSaved) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + envSaved.clear(); +}); + +function setEnv(key: string, value: string | undefined): void { + if (!envSaved.has(key)) envSaved.set(key, process.env[key]); + if (value === undefined) delete process.env[key]; + else process.env[key] = value; +} + +function rpc(method: string, id: number, params: Record = {}) { + return { jsonrpc: JSONRPC_VERSION, id, method, params }; +} + +async function post(body: unknown, key?: string): Promise { + const headers: Record = { + "content-type": "application/json", + accept: "application/json", + }; + if (key !== undefined) headers.authorization = `Bearer ${key}`; + return fetch(handle!.url, { method: "POST", headers, body: JSON.stringify(body) }); +} + +async function postJson(body: unknown, key?: string): Promise> { + const res = await post(body, key); + expect(res.status).toBe(200); + return (await res.json()) as Record; +} + +async function start(): Promise { + handle = await startHttp({ vault }, { host: "127.0.0.1", port: 0 }); +} + +function writeDoc(text: string): void { + mkdirSync(join(vault, "Brain"), { recursive: true }); + writeFileSync(join(vault, "Brain", "_permissions.yaml"), text, "utf8"); +} + +function setGate(mode: string): void { + mkdirSync(join(vault, "Brain"), { recursive: true }); + writeFileSync( + brainConfigPath(vault), + `schema_version: 1\nintegrity:\n owner_scope_writes: ${mode}\n`, + ); +} + +function inboxEntries(): string[] { + const dir = join(vault, "Brain", "inbox"); + return existsSync(dir) ? readdirSync(dir).filter((n) => n.includes("prefer-tests")) : []; +} + +describe("write-side decisions answer for the token's agent", () => { + test("a document deny for the token's agent refuses its create, naming the rule", async () => { + const { tokenMaterial } = mintAgentToken(vault, "mcp_token_bob", "bob"); + writeDoc("version: 1\ndefault_action: allow\nagents:\n bob:\n write: deny\n"); + await start(); + const body = await postJson( + rpc("tools/call", 1, { + name: "brain_create_note", + arguments: { path: "Notes/Bob Page.md", content: "bob bytes" }, + }), + tokenMaterial, + ); + const error = body.error as Record; + expect(error).toBeDefined(); + expect(error.code).toBe(-32602); + expect(error.data.code).toBe("write-refused"); + expect(error.data.agent).toBe("bob"); + expect(error.data.rule).toBe("agent:bob"); + // Nothing was written, and the ledger names the token agent arriving + // by token - never the process config identity. + expect(existsSync(join(vault, "Notes/Bob Page.md"))).toBe(false); + const rows = queryDecisionLedger(vault, { verdict: "deny" }); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + actor: "bob", + via: "token", + action: "write", + target: "Notes/Bob Page.md", + source: "agent:bob", + }); + }); + + test("a cross-owner claim under the fail gate is refused, answering for the token's agent", async () => { + const { tokenMaterial } = mintAgentToken(vault, "mcp_token_bob", "bob"); + setGate(GATE_MODE.fail); + await start(); + const body = await postJson( + rpc("tools/call", 1, { + name: "brain_create_note", + arguments: { + path: "Notes/Claimed.md", + content: "claimed bytes", + frontmatter: { owner: "alice" }, + }, + }), + tokenMaterial, + ); + const error = body.error as Record; + expect(error).toBeDefined(); + expect(error.data.code).toBe("owner_write_refused"); + // The gate resolved the caller to the token's agent, so the claim on + // alice's name is a cross-owner write and the refusal says whose. + expect(JSON.stringify(error)).toContain("alice"); + expect(JSON.stringify(error)).toContain("bob"); + expect(existsSync(join(vault, "Notes/Claimed.md"))).toBe(false); + }); + + test("a token caller cannot sign another agent's name to a feedback write", async () => { + const { tokenMaterial } = mintAgentToken(vault, "mcp_token_bob", "bob"); + await start(); + const body = await postJson( + rpc("tools/call", 1, { + name: "brain_feedback", + arguments: { + topic: "prefer tests", + signal: "positive", + principle: "always test first", + agent: "alice", + }, + }), + tokenMaterial, + ); + const error = body.error as Record; + expect(error).toBeDefined(); + expect(error.code).toBe(-32602); + expect(error.message).toContain("agent-claim-refused"); + expect(error.message).toContain("alice"); + expect(error.message).toContain("bob"); + // The refusal fired before any byte: no signal, no preference page. + expect(inboxEntries()).toEqual([]); + }); + + test("a staged create's review row names the token agent, not the config identity", async () => { + const { tokenMaterial } = mintAgentToken(vault, "mcp_token_bob", "bob"); + writeDoc("version: 1\ndefault_action: ask\n"); + await start(); + const body = await postJson( + rpc("tools/call", 1, { + name: "brain_create_note", + arguments: { path: "Notes/Bob Ask.md", content: "reviewed bytes" }, + }), + tokenMaterial, + ); + const result = (body.result as Record).structuredContent as Record; + expect(body.error).toBeUndefined(); + expect(result.staged).toBe(true); + expect(result.pending_id).toBeDefined(); + const rows = queryDecisionLedger(vault, { verdict: "ask" }); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + actor: "bob", + via: "token", + action: "write", + target: "Notes/Bob Ask.md", + source: "default", + }); + }); +}); From a458f0c83df6d1d5a30d6673bb37e5923d1789e5 Mon Sep 17 00:00:00 2001 From: Sergey Eroshenkov Date: Sat, 10 Oct 2026 21:33:25 +0200 Subject: [PATCH 61/84] fix(brain): consult the permissions document on the signal lane writeSignal's ungated path resolved only the write-approval toggle, so a permissions document's rules never reached the one write every feedback surface rides through: a signal from an agent the document denies was published, and an ask never staged. The ungated path now resolves through the same disposition resolver every other writer answers to - deny refuses as a typed WriteRefusedError before any byte exists, with exactly one ledger row naming the deciding rule and the inbox path the write intent asked for; ask stages into the signals queue with byte-identical bytes; allow publishes, row only when the document's record_allows asks. An unreadable document fails closed through the loader's own error. The subject is the transport-minted one when the caller threads it, and otherwise the write's own agent field - already resolved against the calling surface's config - rather than a fresh resolution that could answer for an identity no config on this machine declared. The staging path still never re-enters the gate it feeds. --- src/core/brain/signal.ts | 56 +++++-- tests/core/brain/signal-disposition.test.ts | 176 ++++++++++++++++++++ 2 files changed, 220 insertions(+), 12 deletions(-) create mode 100644 tests/core/brain/signal-disposition.test.ts diff --git a/src/core/brain/signal.ts b/src/core/brain/signal.ts index 24bde73b..68774747 100644 --- a/src/core/brain/signal.ts +++ b/src/core/brain/signal.ts @@ -26,6 +26,7 @@ import { join, relative } from "node:path"; +import type { WriteSubject } from "./write-disposition.ts"; import type { FrontmatterMap } from "../types.ts"; import { sanitiseTextField } from "../redactor.ts"; import { @@ -45,9 +46,10 @@ import { import { EXPIRATION_DATE_FIELD, normalizeExpirationDate } from "./expiration.ts"; import { writeFrontmatterAtomic, parseFrontmatter, slugify } from "../vault.ts"; import { requireObsidianTagValue } from "./tag-syntax.ts"; -import { resolveWriteApprovalLane, REVIEW_LANE } from "./write-gate.ts"; +import { REVIEW_LANE } from "./write-gate.ts"; +import { resolveWriteDisposition } from "./write-disposition.ts"; import { compress, expand, CODEC_VERSION } from "./portability/codec.ts"; -import { allocateAndCreate, brainDirsForWrite, validateIsoDate } from "./paths.ts"; +import { allocateAndCreate, brainDirsForWrite, validateIsoDate, BRAIN_INBOX_REL } from "./paths.ts"; import { isKnownSchemaToken, validateSchemaToken, @@ -213,6 +215,16 @@ export interface WriteSignalOptions { * responsible for passing a directory that resolves inside the vault. */ readonly targetDir?: string; + /** + * The credential-minted subject of the caller (write-side-trust, Task + * 7), when the transport resolved one. It is the subject the review + * disposition consults the permissions document under, so a document + * rule that denies the token's agent refuses this write; absent + * (stdio, the CLI) the resolver falls back to the config identity. + * Never consulted when `targetDir` is set: that caller IS the staging + * path, and must not re-enter the gate it feeds. + */ + readonly subject?: WriteSubject; } /** @@ -367,17 +379,37 @@ export function writeSignal( // materializes a mis-resolved root, so it asserts the vault identity // before allocating a filename under it. const dirs = brainDirsForWrite(vault); - // Review gate (write-side trust, Task 6). Resolved ONLY when the caller - // named no targetDir: an explicit target is the staging path itself - // (`stagePendingSignal`), and it must never re-enter the gate it feeds. - // When the signals lane resolves on, the signal stages into - // `Brain/pending/` with byte-identical bytes; every ungated caller - // (MCP and CLI feedback, inline scan, session import, session - // lifecycle, session checkpoint) inherits the gate here at the - // chokepoint with no change of its own. - const gateStaged = - options.targetDir === undefined && resolveWriteApprovalLane(REVIEW_LANE.signals); + // Review disposition (write-side trust, Tasks 6, 9 and 12). Resolved + // ONLY when the caller named no targetDir: an explicit target is the + // staging path itself (`stagePendingSignal`), and it must never + // re-enter the gate it feeds. With no permissions document the signals + // lane's toggle decides, exactly as before; with one, the document is + // the ONLY gate - a deny throws WriteRefusedError here (before any + // byte exists, one ledger row behind it), an ask stages into + // `Brain/pending/` with byte-identical bytes, and an allow publishes. + // The target is the first candidate's inbox path: exact enough for the + // document's target entries, and the write intent the caller asked + // for even where the allocator's collision suffix moves it. The + // subject is the transport-minted one when a caller threaded it; the + // fallback is the write's own agent field, which every surface that + // gets here has already resolved against its config (a token caller's + // foreign claim was refused before this write was attempted) - letting + // the resolver re-resolve instead would answer for an identity no + // config on this machine declared. Every ungated caller (MCP and CLI + // feedback, inline scan, session import, session lifecycle, session + // checkpoint) inherits the gate here at the chokepoint with no change + // of its own. const prefix = signalPrefix(sanitised.date); + let gateStaged = false; + if (options.targetDir === undefined) { + const disposition = resolveWriteDisposition( + vault, + REVIEW_LANE.signals, + options.subject ?? { agent: resolvedInput.agent, via: "config" }, + { target: `${BRAIN_INBOX_REL}/${prefix}-${sanitised.slug}.md` }, + ); + gateStaged = disposition.verdict === "stage"; + } // Allocation and creation are one step (#161): a signal write is the // most contended name in the vault - inline scan, pending drain and diff --git a/tests/core/brain/signal-disposition.test.ts b/tests/core/brain/signal-disposition.test.ts new file mode 100644 index 00000000..6ee8950f --- /dev/null +++ b/tests/core/brain/signal-disposition.test.ts @@ -0,0 +1,176 @@ +/** + * The signal lane's write disposition (write-side trust, Task 12 over + * the signals chokepoint). + * + * `writeSignal`'s ungated path resolves through the same disposition + * resolver every other writer answers to: with no permissions document + * the `write_approval.*` toggle decides exactly as before; with one, the + * document is the ONLY gate - a deny refuses as a typed WriteRefusedError + * before any byte exists, with exactly one ledger row behind it, an ask + * stages into the signals queue with byte-identical bytes, and an allow + * publishes. The credential-minted subject the caller threads wins over + * the config identity, an unreadable document fails closed by name, and + * the staging path (`targetDir` set) never re-enters the gate it feeds. + */ + +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { bootstrapBrain } from "../../../src/core/brain/init.ts"; +import { atomicWriteFileSync } from "../../../src/core/fs-atomic.ts"; +import { PermissionsDocumentError } from "../../../src/core/brain/permissions/document.ts"; +import { queryDecisionLedger } from "../../../src/core/brain/permissions/ledger.ts"; +import { stagePendingSignal } from "../../../src/core/brain/pending.ts"; +import { writeSignal } from "../../../src/core/brain/signal.ts"; +import { WriteRefusedError } from "../../../src/core/brain/write-disposition.ts"; + +let tmp: string; +let vault: string; +let configPath: string; + +beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), "o2b-signal-disposition-")); + vault = join(tmp, "vault"); + configPath = join(tmp, "config.yaml"); + atomicWriteFileSync(configPath, `vault: ${vault}\nagent_name: claude\n`); + bootstrapBrain(vault, { configPath }); +}); + +afterEach(() => { + rmSync(tmp, { recursive: true, force: true }); +}); + +const INPUT = { + topic: "prefer tests", + signal: "positive" as const, + agent: "claude", + principle: "always test first", + created_at: "2026-06-01T10:00:00Z", + date: "2026-06-01", + slug: "prefer-tests", +}; + +const TARGET = "Brain/inbox/sig-2026-06-01-prefer-tests.md"; + +function writeDoc(text: string): void { + mkdirSync(join(vault, "Brain"), { recursive: true }); + writeFileSync(join(vault, "Brain", "_permissions.yaml"), text, "utf8"); +} + +function inboxFiles(): string[] { + const dir = join(vault, "Brain", "inbox"); + return existsSync(dir) ? readdirSync(dir).filter((n) => n.endsWith(".md")) : []; +} + +describe("writeSignal under a permissions document", () => { + test("a deny refuses by name before any byte, one ledger row behind it", () => { + writeDoc("version: 1\ndefault_action: deny\n"); + let refused: WriteRefusedError | undefined; + try { + writeSignal(vault, INPUT); + } catch (err) { + refused = err instanceof WriteRefusedError ? err : undefined; + } + expect(refused).toBeInstanceOf(WriteRefusedError); + expect(refused?.agent).toBe("claude"); + expect(refused?.via).toBe("config"); + expect(refused?.action).toBe("write"); + expect(refused?.rule).toBe("default"); + // The consulted target is the inbox path the write intent asked for. + expect(refused?.target).toBe(TARGET); + expect(inboxFiles()).toEqual([]); + const rows = queryDecisionLedger(vault, { verdict: "deny" }); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + actor: "claude", + via: "config", + action: "write", + target: TARGET, + source: "default", + }); + }); + + test("the threaded subject wins over the config identity", () => { + writeDoc( + "version: 1\ndefault_action: allow\nagents:\n claude:\n write: allow\n bob:\n write: deny\n", + ); + // The config identity is allowed: the signal publishes. + const res = writeSignal(vault, INPUT); + expect(res.staged).toBe(false); + expect(res.path).toContain("Brain/inbox"); + // A credential-minted subject the document denies is refused with the + // same typed refusal, one row naming ITS agent. + let refused: WriteRefusedError | undefined; + try { + writeSignal( + vault, + { ...INPUT, slug: "prefer-tests-bob" }, + { + subject: { agent: "bob", via: "token" }, + }, + ); + } catch (err) { + refused = err instanceof WriteRefusedError ? err : undefined; + } + expect(refused).toBeInstanceOf(WriteRefusedError); + expect(refused?.agent).toBe("bob"); + expect(refused?.via).toBe("token"); + const rows = queryDecisionLedger(vault, { verdict: "deny" }); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ actor: "bob", via: "token", source: "agent:bob" }); + }); + + test("an ask stages into the signals queue with one row naming the publish target", () => { + writeDoc("version: 1\ndefault_action: ask\n"); + const res = writeSignal(vault, INPUT); + expect(res.staged).toBe(true); + expect(res.path).toContain("Brain/pending"); + expect(existsSync(res.path)).toBe(true); + expect(inboxFiles()).toEqual([]); + const rows = queryDecisionLedger(vault, { verdict: "ask" }); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + actor: "claude", + via: "config", + action: "write", + target: TARGET, + source: "default", + }); + }); + + test("an allow publishes with no row unless record_allows asks for one", () => { + writeDoc("version: 1\ndefault_action: allow\n"); + const res = writeSignal(vault, INPUT); + expect(res.staged).toBe(false); + expect(res.path).toContain("Brain/inbox"); + expect(queryDecisionLedger(vault)).toEqual([]); + writeDoc("version: 1\ndefault_action: allow\nledger:\n record_allows: true\n"); + const second = writeSignal(vault, { ...INPUT, slug: "prefer-tests-2" }); + expect(second.staged).toBe(false); + const rows = queryDecisionLedger(vault, { verdict: "allow" }); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + actor: "claude", + action: "write", + target: "Brain/inbox/sig-2026-06-01-prefer-tests-2.md", + source: "default", + }); + }); + + test("an unreadable document fails closed by name", () => { + writeDoc("version: 1\ndefault_action: nonsense\n"); + expect(() => writeSignal(vault, INPUT)).toThrow(PermissionsDocumentError); + expect(inboxFiles()).toEqual([]); + }); + + test("the staging path never re-enters the gate it feeds", () => { + writeDoc("version: 1\ndefault_action: deny\n"); + const res = stagePendingSignal(vault, INPUT); + // The queue's own staging write lands even though the document denies + // every write: the caller that feeds the queue is not re-gated by it. + expect(existsSync(res.path)).toBe(true); + expect(queryDecisionLedger(vault)).toEqual([]); + }); +}); From ad2ae10c6fe27eb60e7b8ad18f6751e4809d752a Mon Sep 17 00:00:00 2001 From: Sergey Eroshenkov Date: Sat, 10 Oct 2026 21:40:03 +0200 Subject: [PATCH 62/84] fix(brain): refuse denied mutations and audit the review door Mutation of an already published note has no entry boundary to stage at, so the review gate never saw it: an agent a permissions document denies could rewrite or append to a note an allowed create once published. The batch's update and append ops now run the deny-only document consult ahead of the existing-note read - a deny refuses as the typed WriteRefusedError with its one ledger row, while an ask and an allow change nothing at all: no row, no stage, the mutation proceeds exactly as before. The review door leaves an audit trail: a real apply appends exactly one decision-ledger resolution row (actor: the resolving credential when one is handed through, else operator; source review-door; target the decoded publish path; verdict resolved), a real reject appends its twin naming the path the bytes will never reach, and a preview records nothing. Staging the same target twice no longer collides or replaces silently: stageForReview refuses a second entry whose decoded target equals an open entry's target, naming the pending id the operator has to apply or reject first. The comparison is on the decoded target, so the two-day case (two ids, one target) refuses exactly as the same-day one does. --- src/core/brain/pending/pending-lanes.ts | 104 +++++++++++++++++++++- src/core/brain/write-batch.ts | 14 ++- tests/core/brain/pending-lanes.test.ts | 110 ++++++++++++++++++++++-- tests/core/brain/write-batch.test.ts | 68 +++++++++++++++ 4 files changed, 284 insertions(+), 12 deletions(-) diff --git a/src/core/brain/pending/pending-lanes.ts b/src/core/brain/pending/pending-lanes.ts index 9a4a476b..32a60818 100644 --- a/src/core/brain/pending/pending-lanes.ts +++ b/src/core/brain/pending/pending-lanes.ts @@ -49,6 +49,7 @@ import { import { ensureInsideVault } from "../../path-safety.ts"; import { parseFrontmatter, writeFrontmatterAtomic } from "../../vault.ts"; import type { FrontmatterMap } from "../../types.ts"; +import { appendDecisionLedger } from "../permissions/ledger.ts"; import type { PermissionSubject } from "../permissions/resolve.ts"; import type { BrainDirs } from "../paths.ts"; import { BRAIN_INBOX_REL, BRAIN_SOURCES_REL, brainDirs, brainDirsForWrite } from "../paths.ts"; @@ -308,12 +309,37 @@ export interface StageForReviewResult { readonly path: string; } +/** + * The publish target of the new stage is already staged for review under + * another OPEN queue entry. Named rather than silently replaced: two + * entries decoding to one target would apply into a conflict (the second + * apply refuses an occupied target the first created), and two same-day + * stagings of one path would otherwise overwrite each other's bytes with + * nothing naming the lost proposal. The refusal names the open entry the + * operator has to apply or reject first. + */ +export class PendingStageConflictError extends Error { + /** The pending id already holding the target. */ + readonly existingId: string; + readonly target: string; + constructor(target: string, existingId: string) { + super( + `publish target ${JSON.stringify(target)} is already staged for review as ` + + `${JSON.stringify(existingId)}; apply or reject that entry before staging ` + + "new bytes for the same target", + ); + this.name = "PendingStageConflictError"; + this.existingId = existingId; + this.target = target; + } +} + /** * Stage one write for review: write `render()`'s bytes VERBATIM into the * lane's pending directory under the vault-identity write guard, named - * by the lane's deterministic pending id. Re-staging the same target - * replaces the staged bytes - the pending document always holds the - * latest proposed bytes, and apply publishes exactly what is on disk. + * by the lane's deterministic pending id. A target already held by an + * open entry refuses with {@link PendingStageConflictError} - the queue + * never silently replaces one proposal with another. */ export function stageForReview( vault: string, @@ -348,6 +374,17 @@ export function stageForReview( publishTarget, ); } + // One open entry per publish target. The comparison is on the DECODED + // target, so it catches the two-day case (two ids, one target) as well + // as the same-day one (one id, silently rewritten bytes); an unreadable + // entry names no target and so holds none. + for (const path of laneDirFiles(laneDir)) { + const entry = readLaneEntry(vault, lane, path); + if (entry.unreadableReason !== undefined) continue; + if (entry.publishTarget === publishTarget) { + throw new PendingStageConflictError(publishTarget, entry.id); + } + } const stagedPath = ensureInsideVault(join(laneDir, `${pendingId}.md`), vault); const composed = `${pendingId}.md`; if (Buffer.byteLength(composed, "utf8") > PENDING_FILENAME_MAX_BYTES) { @@ -495,6 +532,15 @@ export function listPendingLane(vault: string, lane: ReviewLane | "all"): Pendin export interface PendingLaneApplyOptions { /** True previews the move and writes nothing; false performs it. */ readonly dryRun?: boolean; + /** + * The identity resolving the door, for the resolution row the apply + * appends. Absent means the operator surface answered and the row + * records that; a caller that resolved a credential hands its agent + * through (`via` beside it). + */ + readonly actor?: string; + /** The credential path `actor` arrived by. Absent means the operator. */ + readonly via?: PermissionSubject["via"]; } export interface PendingLaneApplyResult { @@ -509,6 +555,10 @@ export interface PendingLaneRejectOptions { readonly dryRun?: boolean; /** Injected clock for a deterministic `retired_at`. Defaults to now. */ readonly now?: Date; + /** The identity resolving the door, for the resolution row. See {@link PendingLaneApplyOptions.actor}. */ + readonly actor?: string; + /** The credential path `actor` arrived by. Absent means the operator. */ + readonly via?: PermissionSubject["via"]; } export interface PendingLaneRejectResult { @@ -533,6 +583,44 @@ function pendingLaneFilePath(vault: string, id: string, forWrite: boolean): stri return ensureInsideVault(join(laneDir, `${id}.md`), vault); } +/** The `source` spelling every resolution row carries. */ +const REVIEW_DOOR_SOURCE = "review-door"; + +/** + * Append the ONE decision-ledger resolution row a real apply or reject + * owes (the audit trail of the approval door). A preview writes nothing, + * so it records nothing either. Never throws: a row that cannot land + * surfaces as a named stderr line, the same treatment the disposition + * rows get - an unrecorded resolution must not read, in every later + * audit, like a publish or retire nobody resolved. + */ +function recordResolutionRow( + vault: string, + id: string, + target: string, + verdict: "resolved" | "rejected", + opts: { readonly actor?: string; readonly via?: PermissionSubject["via"] }, + reason: string, + ts?: Date, +): void { + const appended = appendDecisionLedger(vault, { + ts: (ts ?? new Date()).toISOString(), + actor: opts.actor ?? "operator", + via: opts.via ?? "operator", + action: "resolution", + target, + verdict, + source: REVIEW_DOOR_SOURCE, + reason, + }); + if (!appended.logged) { + process.stderr.write( + `warning: decision-ledger append failed for the ${verdict} row on ` + + `${JSON.stringify(target)}: ${appended.audit_reason ?? "unknown reason"}\n`, + ); + } +} + /** * Apply a staged entry: move it into its decoded publish target * UNCHANGED. The bytes are copied verbatim and the staged copy is @@ -576,6 +664,7 @@ export function applyPendingLane( unlinkSync(src); // The door's audit trail: one resolution row per real apply, naming // the decoded publish path it published into. + recordResolutionRow(vault, id, target, "resolved", opts, `pending ${JSON.stringify(id)} applied`); return { id, path: dest, dryRun: false }; } @@ -636,5 +725,14 @@ export function rejectPendingLane( // The door's audit trail, matching the apply's: one row per real // reject, naming the publish path the bytes will never reach and the // reason the resolver gave. + recordResolutionRow( + vault, + id, + publishTargetForId(vault, id), + "rejected", + opts, + `pending ${JSON.stringify(id)} rejected: ${reason}`, + now, + ); return { id, path: dest }; } diff --git a/src/core/brain/write-batch.ts b/src/core/brain/write-batch.ts index ac03f9d7..5e4965fa 100644 --- a/src/core/brain/write-batch.ts +++ b/src/core/brain/write-batch.ts @@ -57,7 +57,8 @@ import { import { appendBrainNote, type AppendBrainNoteInput } from "./note.ts"; import { ORIGIN_CHANNEL_FIELD } from "../origin-channel.ts"; import { preferencePath, validateSlug } from "./paths.ts"; -import { type WriteSubject } from "./write-disposition.ts"; +import { refuseDocumentDeny, type WriteSubject } from "./write-disposition.ts"; +import { REVIEW_LANE } from "./write-gate.ts"; import { assertVaultIdentityForWrite } from "./vault-identity.ts"; import { BRAIN_APPLY_RESULT } from "./types.ts"; import { ROUTE_STAGE, timeStageSync } from "../route-scope.ts"; @@ -794,6 +795,13 @@ function projectUpdateNote( ); } const target = reserveNoteTarget(vault, op.path, index, noteTargets); + // The document-deny consult rides the same seam ahead of the + // existing-note read (write-side trust): the review boundary is ENTRY, + // so an admitted note is never re-staged, but a caller the document + // denies cannot rewrite it either - mutation is not a door around the + // rule that refused the create. An ask and an allow change nothing + // here: no row, no stage, the update proceeds exactly as before. + refuseDocumentDeny(vault, REVIEW_LANE.notes, opts.subject, { target: target.relPath }); // The owner gate rides the update seam AHEAD of the existing-note read // (write-side-trust, Task 13): a caller-named `owner:` the gate refuses // is refused with the same error whether the target exists or not, so @@ -916,6 +924,10 @@ function projectAppendNote( ); } const target = reserveNoteTarget(vault, op.path, index, noteTargets); + // The same deny-only consult the update runs (write-side trust): an + // append mutates an admitted note, so the document's deny refuses it, + // while an ask and an allow leave the append exactly as it was. + refuseDocumentDeny(vault, REVIEW_LANE.notes, opts.subject, { target: target.relPath }); const state = readExistingNote(target.abs, target.relPath, index, opts.readable); const appended = op.content.trim(); const body = state.body.length > 0 ? `${state.body}${APPEND_SEPARATOR}${appended}` : appended; diff --git a/tests/core/brain/pending-lanes.test.ts b/tests/core/brain/pending-lanes.test.ts index 04dae77a..fa5547a9 100644 --- a/tests/core/brain/pending-lanes.test.ts +++ b/tests/core/brain/pending-lanes.test.ts @@ -31,6 +31,7 @@ import { InvalidPendingIdError, PendingApplyConflictError, PendingSignalNotFoundError, + PendingStageConflictError, PendingTargetPathError, WRITE_APPROVAL_SOURCES, WriteRefusedError, @@ -206,14 +207,6 @@ describe("stageForReview", () => { expect(readFileSync(staged.path, "utf8")).toBe("summary bytes"); }); - test("re-staging the same target replaces the staged bytes", () => { - enableNotes(); - const first = stageForReview(vault, "notes", "Notes/Replace.md", () => "first"); - const second = stageForReview(vault, "notes", "Notes/Replace.md", () => "second"); - expect(second.pendingId).toBe(first.pendingId); - expect(readFileSync(first.path, "utf8")).toBe("second"); - }); - test("a non-lowercase .md target keeps its spelling through the queue", () => { // createNote admits a `.MD` spelling and writes it as given, so the // queue must apply into the caller's exact target - not fold it into @@ -760,3 +753,104 @@ describe("disposition ledger rows that cannot land", () => { expect(queryDecisionLedger(vault)).toEqual([]); }); }); + +describe("stageForReview one open entry per publish target", () => { + test("a second staged entry for one target refuses, naming the open entry", () => { + enableNotes(); + const first = stageForReview(vault, "notes", "Notes/Once.md", () => "first bytes"); + let conflict: PendingStageConflictError | undefined; + try { + stageForReview(vault, "notes", "Notes/Once.md", () => "second bytes"); + } catch (err) { + conflict = err instanceof PendingStageConflictError ? err : undefined; + } + expect(conflict).toBeInstanceOf(PendingStageConflictError); + expect(conflict?.existingId).toBe(first.pendingId); + expect(conflict?.target).toBe("Notes/Once.md"); + // The refusal replaced nothing: the first proposal keeps its bytes and + // the queue holds exactly one open entry for the target. + expect(readFileSync(first.path, "utf8")).toBe("first bytes"); + expect(listPendingLane(vault, "notes").entries).toHaveLength(1); + }); + + test("the decoded comparison catches a later-day id for the same target", () => { + enableNotes(); + // Hand-place a yesteryear entry whose decoded target is the one about + // to be staged: two ids, one target, on different days. + const olderId = "note-2026-01-01-Notes%2FOnce.md"; + const notesDir = join(brainDirs(vault).pending, "notes"); + mkdirSync(notesDir, { recursive: true }); + writeFileSync(join(notesDir, `${olderId}.md`), "older proposal"); + let conflict: PendingStageConflictError | undefined; + try { + stageForReview(vault, "notes", "Notes/Once.md", () => "today bytes"); + } catch (err) { + conflict = err instanceof PendingStageConflictError ? err : undefined; + } + expect(conflict).toBeInstanceOf(PendingStageConflictError); + expect(conflict?.existingId).toBe(olderId); + expect(conflict?.target).toBe("Notes/Once.md"); + expect(readFileSync(join(notesDir, `${olderId}.md`), "utf8")).toBe("older proposal"); + }); +}); + +describe("the review door's resolution rows", () => { + function stageNote(rel: string, bytes: string) { + enableNotes(); + return stageForReview(vault, "notes", rel, () => bytes); + } + + test("a real apply appends one resolution row naming the decoded publish path", () => { + const staged = stageNote("Notes/Rowed.md", "door bytes"); + applyPendingLane(vault, staged.pendingId); + const rows = queryDecisionLedger(vault, { action: "resolution" }); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + actor: "operator", + via: "operator", + action: "resolution", + target: "Notes/Rowed.md", + verdict: "resolved", + source: "review-door", + reason: `pending "${staged.pendingId}" applied`, + }); + }); + + test("a real reject appends one row naming the path the bytes will never reach", () => { + const staged = stageNote("Notes/Refused.md", "retired bytes"); + rejectPendingLane(vault, staged.pendingId, "not wanted", { + now: new Date("2026-07-18T12:00:00Z"), + }); + const rows = queryDecisionLedger(vault, { action: "resolution" }); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + actor: "operator", + via: "operator", + action: "resolution", + target: "Notes/Refused.md", + verdict: "rejected", + source: "review-door", + reason: `pending "${staged.pendingId}" rejected: not wanted`, + }); + }); + + test("a preview of either door moves writes no row", () => { + const staged = stageNote("Notes/Previewed.md", "preview bytes"); + applyPendingLane(vault, staged.pendingId, { dryRun: true }); + rejectPendingLane(vault, staged.pendingId, "not yet", { dryRun: true }); + expect(queryDecisionLedger(vault)).toEqual([]); + }); + + test("a resolving credential names its agent in the row", () => { + const staged = stageNote("Notes/Credited.md", "credited bytes"); + applyPendingLane(vault, staged.pendingId, { actor: "@resolver", via: "token" }); + const rows = queryDecisionLedger(vault, { action: "resolution" }); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + actor: "@resolver", + via: "token", + verdict: "resolved", + source: "review-door", + }); + }); +}); diff --git a/tests/core/brain/write-batch.test.ts b/tests/core/brain/write-batch.test.ts index 5029526f..cf24d11d 100644 --- a/tests/core/brain/write-batch.test.ts +++ b/tests/core/brain/write-batch.test.ts @@ -45,6 +45,8 @@ import { WriteBatchError, type WriteOperation, } from "../../../src/core/brain/write-batch.ts"; +import { WriteRefusedError } from "../../../src/core/brain/pending/pending-lanes.ts"; +import { queryDecisionLedger } from "../../../src/core/brain/permissions/ledger.ts"; import * as ledger from "../../../src/core/brain/idempotency-ledger.ts"; import { IdempotencyKeyError, @@ -839,3 +841,69 @@ describe("applyWriteBatch staged creates (review gate)", () => { ).toThrow(WriteBatchError); }); }); + +describe("applyWriteBatch under a permissions document", () => { + const DOC = () => join(vault, "Brain", "_permissions.yaml"); + + function writeDoc(text: string): void { + mkdirSync(join(vault, "Brain"), { recursive: true }); + writeFileSync(DOC(), text, "utf8"); + } + + test("a deny refuses an update of an admitted note, by name, before any byte", () => { + seedNote("Notes/Admitted.md", "original body"); + writeDoc("version: 1\ndefault_action: deny\n"); + let refused: WriteRefusedError | undefined; + try { + applyWriteBatch(vault, [ + { kind: "update_note", path: "Notes/Admitted.md", body: "rewritten" }, + ]); + } catch (err) { + refused = err instanceof WriteRefusedError ? err : undefined; + } + expect(refused).toBeInstanceOf(WriteRefusedError); + expect(refused?.action).toBe("write"); + expect(refused?.target).toBe("Notes/Admitted.md"); + expect(refused?.rule).toBe("default"); + // Mutation is not a door around the rule: the note keeps its bytes, + // and exactly one deny row records the refusal. + expect(readFileSync(join(vault, "Notes/Admitted.md"), "utf8")).toContain("original body"); + const rows = queryDecisionLedger(vault, { verdict: "deny" }); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ action: "write", target: "Notes/Admitted.md" }); + }); + + test("a deny refuses an append the same way", () => { + seedNote("Notes/Appended.md", "first line"); + writeDoc("version: 1\ndefault_action: deny\n"); + expect(() => + applyWriteBatch(vault, [{ kind: "append_note", path: "Notes/Appended.md", content: "more" }]), + ).toThrow(WriteRefusedError); + expect(readFileSync(join(vault, "Notes/Appended.md"), "utf8")).not.toContain("more"); + expect(queryDecisionLedger(vault, { verdict: "deny" })).toHaveLength(1); + }); + + test("an ask mutates without staging and without a row", () => { + seedNote("Notes/Asked.md", "before"); + writeDoc("version: 1\ndefault_action: ask\n"); + const res = applyWriteBatch(vault, [ + { kind: "update_note", path: "Notes/Asked.md", body: "after" }, + ]); + expect(res.results[0]).toMatchObject({ kind: "update_note", updated: true }); + expect(readFileSync(join(vault, "Notes/Asked.md"), "utf8")).toContain("after"); + // The review boundary is ENTRY: a mutation never re-stages and the + // consult that ran is deny-only, so an ask leaves no ledger row. + expect(queryDecisionLedger(vault)).toEqual([]); + }); + + test("an allow mutates exactly as it did before the consult existed", () => { + seedNote("Notes/Allowed.md", "before"); + writeDoc("version: 1\ndefault_action: allow\n"); + const res = applyWriteBatch(vault, [ + { kind: "append_note", path: "Notes/Allowed.md", content: "tail" }, + ]); + expect(res.results[0]).toMatchObject({ kind: "append_note", appended: true }); + expect(readFileSync(join(vault, "Notes/Allowed.md"), "utf8")).toContain("tail"); + expect(queryDecisionLedger(vault)).toEqual([]); + }); +}); From 12c51e71f1db253a5933f397b45ace380506ae57 Mon Sep 17 00:00:00 2001 From: Sergey Eroshenkov Date: Sat, 10 Oct 2026 21:44:30 +0200 Subject: [PATCH 63/84] fix(mcp): keep the auth gate closed when the token store cannot be read A token store that cannot be READ used to be indistinguishable from one that cannot be satisfied: the per-request probes threw, the exception escaped the dispatch as a 500, and whether the gate then stood open depended on which probe blew up first. Both probes are now failure-netted per request: the failure is named on stderr (the store's own reason, once per read), the presented credential fails the way any unmatched one does - the generic 401, no oracle - and a matching shared key still answers, because a corrupt store must not lock the operator's master credential out of its own server. The exposed-bind posture no longer reads the map's current readability as the requirement either: a key-less non-loopback bind enforces by itself, so a map that turns unreadable (or empties) under a running server cannot re-open anonymous access that bind never promised. A map already corrupt at bind time fails the bind's own credential-source check, closed. --- src/mcp/http.ts | 63 +++++++++++++++++++++++++---- tests/mcp/http-token-auth.test.ts | 67 +++++++++++++++++++++++++++++++ 2 files changed, 123 insertions(+), 7 deletions(-) diff --git a/src/mcp/http.ts b/src/mcp/http.ts index 7eda3bcd..244520a7 100644 --- a/src/mcp/http.ts +++ b/src/mcp/http.ts @@ -81,6 +81,11 @@ export interface ServeHttpOptions { * explicit at the surface that warns about it. */ readonly tokensRequired?: boolean; + /** + * Where the transport writes its named warnings (a token store that + * cannot be read, for one). Defaults to the process stderr; injected + * so tests can pin what a degraded bind says. + */ readonly stderr?: Writable; /** * How long {@link HttpServerHandle.close} waits for in-flight requests. @@ -173,7 +178,17 @@ export async function startHttp( // shutdown then waits for a client that is waiting for it. res.on("close", finish); try { - await handleHttpRequest(mcp, apiKey, tokensRequired, host, drain, opts.faultCounts, req, res); + await handleHttpRequest( + mcp, + apiKey, + tokensRequired, + host, + drain, + opts.faultCounts, + opts.stderr ?? process.stderr, + req, + res, + ); } catch (exc) { // This promise used to be floated. A throw from the dispatch left // the socket open with no response on it and no record anywhere; @@ -283,6 +298,7 @@ async function handleHttpRequest( boundHost: string, drain: RequestDrain, faultCounts: (() => McpFaultCounts) | undefined, + stderr: Writable, req: IncomingMessage, res: ServerResponse, ): Promise { @@ -345,7 +361,7 @@ async function handleHttpRequest( // then the shared key, which keeps the process config identity. The // generic 401 body is unchanged, and no answer distinguishes a revoked // token from an unknown one. - const auth = authenticateHttpRequest(mcp, apiKey, configTokensRequired, boundHost, req); + const auth = authenticateHttpRequest(mcp, apiKey, configTokensRequired, boundHost, stderr, req); if (auth.refused) { res.writeHead(401, { "content-type": "text/plain; charset=utf-8" }); res.end("Unauthorized\n"); @@ -450,8 +466,15 @@ interface HttpAuth { * absence would let a stale or forged bearer ride the loopback's * anonymous posture; * - a credential-less request proceeds anonymous unless tokens are - * required - which is the config key AND a non-empty map, or the - * implicit requirement of a key-less non-loopback bind. + * required - which is the config key AND a non-empty map, or a key-less + * non-loopback bind, where the requirement is the bind itself and + * holds whatever the map holds: revoking the last token must not + * re-open anonymous access an exposed bind never promised. + * + * A token store that cannot be READ never widens this gate: the probe + * answers "no tokens" and the presented-credential check falls through + * to the shared key, with the failure named on stderr - a corrupt store + * must not lock the operator's master credential out of its own server. * * The store probes sit behind the flags that need them: a loopback bind * with no requirement and no presented credential reads nothing. @@ -461,16 +484,34 @@ function authenticateHttpRequest( apiKey: string | null, configTokensRequired: boolean, boundHost: string, + stderr: Writable, req: IncomingMessage, ): HttpAuth { const hasKey = apiKey !== null && apiKey !== ""; const networkBare = !isLoopbackHost(boundHost) && !hasKey; - const mapNonEmpty = configTokensRequired || networkBare ? hasAnyAgentToken(mcp.vault) : false; - const enforced = (configTokensRequired && mapNonEmpty) || (networkBare && mapNonEmpty); + let mapNonEmpty = false; + if (configTokensRequired || networkBare) { + try { + mapNonEmpty = hasAnyAgentToken(mcp.vault); + } catch (err) { + warnTokenStoreUnreadable(stderr, err); + } + } + const enforced = networkBare || (configTokensRequired && mapNonEmpty); const presented = presentedCredential(req); const identity = authenticateRequest(req, { apiKey, - resolveToken: (candidate) => resolveAgentForToken(mcp.vault, candidate), + resolveToken: (candidate) => { + try { + return resolveAgentForToken(mcp.vault, candidate); + } catch (err) { + warnTokenStoreUnreadable(stderr, err); + // Null, not a throw: the presented credential then fails the way + // any unmatched one does (the generic 401, no oracle), and a + // matching shared key still authenticates. + return null; + } + }, sharedKeyAgent: resolveAgentName(mcp.configPath ?? undefined), }); return { @@ -479,6 +520,14 @@ function authenticateHttpRequest( }; } +/** The one named stderr line a failed token-store read produces. */ +function warnTokenStoreUnreadable(stderr: Writable, err: unknown): void { + stderr.write( + `warning: the MCP token store could not be read; token matching is off for this ` + + `request and the shared key still answers: ${(err as Error).message}\n`, + ); +} + export interface AuthenticateRequestOptions { /** * The shared operator key, launch-captured. `null` or `""` means none diff --git a/tests/mcp/http-token-auth.test.ts b/tests/mcp/http-token-auth.test.ts index d80c92af..67b9b8cb 100644 --- a/tests/mcp/http-token-auth.test.ts +++ b/tests/mcp/http-token-auth.test.ts @@ -124,6 +124,13 @@ function makePref(slug: string, owner?: string): void { }); } +/** Replace the token store's bytes with something that cannot parse. */ +function corruptTokenStore(): void { + const dir = join(vault, ".open-second-brain", "secrets"); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "mcp-tokens.json"), "{not json", "utf8"); +} + describe("HTTP token authentication", () => { test("a valid token authenticates with per-caller identity", async () => { const { tokenMaterial } = mintAgentToken(vault, "mcp_token_edge", "edge-agent"); @@ -280,6 +287,66 @@ describe("HTTP token authentication", () => { const res = await post(rpc("ping", 1), { key: tokenMaterial, header: "x-api-key" }); expect(res.status).toBe(200); }); + + test("a corrupt token store never widens the gate: the token fails closed, the key still answers", async () => { + const sharedKey = fakeCredential("shared", "-master-", "77f1"); + const { tokenMaterial } = mintAgentToken(vault, "mcp_token_edge", "edge-agent"); + corruptTokenStore(); + const warnings: string[] = []; + const original = process.stderr.write.bind(process.stderr); + process.stderr.write = ((chunk: string | Uint8Array): boolean => { + warnings.push(String(chunk)); + return true; + }) as typeof process.stderr.write; + try { + await start({ apiKey: sharedKey }); + // The presented token can no longer be matched (the store cannot be + // read), so it fails the way any unmatched credential does: the + // generic 401, no oracle. + const presented = await post(rpc("ping", 1), { key: tokenMaterial }); + expect(presented.status).toBe(401); + // The operator's master credential is not locked out with it. + const keyed = await post(rpc("ping", 2), { key: sharedKey }); + expect(keyed.status).toBe(200); + } finally { + process.stderr.write = original; + } + // The loss is named, never silent: token matching is off and the + // reason is on stderr. + expect(warnings.join("")).toContain("the MCP token store could not be read"); + }); + + test("a corrupt map never opens a key-less exposed bind: the bind refuses to start", async () => { + mintAgentToken(vault, "mcp_token_edge", "edge-agent"); + corruptTokenStore(); + const warnings: string[] = []; + const original = process.stderr.write.bind(process.stderr); + process.stderr.write = ((chunk: string | Uint8Array): boolean => { + warnings.push(String(chunk)); + return true; + }) as typeof process.stderr.write; + try { + // A non-loopback bind requires a credential source by itself; an + // unreadable map is not "no tokens, come on in" - the bind fails + // closed at startup instead of exposing an unauthenticated endpoint. + await expect(startHttp({ vault }, { host: "0.0.0.0", port: 0 })).rejects.toThrow(); + } finally { + process.stderr.write = original; + } + }); + + test("a map that turns unreadable after the bind never re-opens anonymous access", async () => { + // The bind-time probe passes on a healthy map; the store then corrupts + // under the running server. The exposed bind's requirement is the bind + // itself, not the map's current readability, so credential-less calls + // stay refused. + mintAgentToken(vault, "mcp_token_edge", "edge-agent"); + handle = await startHttp({ vault }, { host: "0.0.0.0", port: 0 }); + corruptTokenStore(); + const anonymous = await post(rpc("ping", 1)); + expect(anonymous.status).toBe(401); + expect(await anonymous.text()).toBe("Unauthorized\n"); + }); }); /** A minimal request double: authenticateRequest reads only `headers`. */ From cb8e68236dd493bc08f3d39ed1609e53fd855fd9 Mon Sep 17 00:00:00 2001 From: Sergey Eroshenkov Date: Sat, 10 Oct 2026 21:51:09 +0200 Subject: [PATCH 64/84] fix(brain): resolve the write-refusal exit lazily to break the diagnostics cycle The disposition module resolved its refusal's registered exit at import time, and the module sits inside the diagnostics import cycle: next-step reads the diagnostics registry, which reaches the doctor checks that reach the signals parser and this module back. Depending on which entry point loaded first, the module-scope requireNextStep call ran while the registry was still initializing and crashed with a TDZ ReferenceError - the doctor inbox check and the write-binding suite triggered it, and nothing but import order stood between every other suite and the same crash. The exit now resolves on first refusal, memoized; the registry census still pins the code's registration at test time. --- src/core/brain/write-disposition.ts | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/src/core/brain/write-disposition.ts b/src/core/brain/write-disposition.ts index 135f80cb..61614607 100644 --- a/src/core/brain/write-disposition.ts +++ b/src/core/brain/write-disposition.ts @@ -71,8 +71,25 @@ export function isWriteRefusalCode(value: unknown): value is WriteRefusalCode { ); } -/** The registered exit a document-deny refusal names. Resolved once, at import. */ -const WRITE_REFUSED_EXIT = requireNextStep(WRITE_REFUSAL_CODES.documentDeny).nextCommand; +/** + * The registered exit a document-deny refusal names. Resolved lazily, on + * first refusal: this module sits inside the diagnostics import cycle + * (`next-step.ts` reads `diagnostics.ts`, which reaches the doctor checks + * that reach the signals parser and this module back), so resolving at + * module scope would call into `next-step.ts` while its registry is still + * initializing - a TDZ crash whose trigger depends on which test file or + * entry point loaded first. The registry census pins the code's + * registration at test time, so drift still fails there rather than + * inside this lazy read. + */ +let writeRefusedExit: string | undefined; + +function writeRefusedNextCommand(): string { + if (writeRefusedExit === undefined) { + writeRefusedExit = requireNextStep(WRITE_REFUSAL_CODES.documentDeny).nextCommand; + } + return writeRefusedExit; +} /** The fields a document-deny refusal carries beside its message. */ export interface WriteRefusedFields { @@ -106,7 +123,7 @@ export class WriteRefusedError extends Error { `write refused (${WRITE_REFUSAL_CODES.documentDeny}): agent ` + `${JSON.stringify(fields.agent)} (via ${fields.via}) may not ${fields.action} ` + `${JSON.stringify(fields.target)} - rule ${fields.rule} denied it. ` + - `The operator can review the policy: ${WRITE_REFUSED_EXIT}`, + `The operator can review the policy: ${writeRefusedNextCommand()}`, ); this.name = "WriteRefusedError"; this.code = WRITE_REFUSAL_CODES.documentDeny; @@ -115,7 +132,7 @@ export class WriteRefusedError extends Error { this.action = fields.action; this.rule = fields.rule; this.target = fields.target; - this.nextCommand = WRITE_REFUSED_EXIT; + this.nextCommand = writeRefusedNextCommand(); } } From f0abc258e7ae69cb79769d39b3f10328a579ec72 Mon Sep 17 00:00:00 2001 From: Sergey Eroshenkov Date: Sat, 10 Oct 2026 22:04:56 +0200 Subject: [PATCH 65/84] fix(brain): read the write refusal's exit from a leaf, closing the diagnostics cycle Routing the disposition module's requireNextStep call through the registry closed a cycle the acyclic-dependency ratchet refuses: next-step reads diagnostics, diagnostics reaches the doctor checks, and the doctor's signal checks reach the signals parser and the disposition module back - so the module graph carried a 121-file strongly connected component, and the TDZ crash the lazy resolution had dodged was only postponed. The exit command now lives in a leaf importing nothing, the same cure entities/canonical.ts documents: the registry builds both write-side trust entries from it and the disposition layer reads it directly, keeping the string at exactly one definition, with a census pin holding leaf and registry together. --- src/core/brain/diagnostics.ts | 5 ++-- src/core/brain/write-disposition.ts | 33 ++++++++++---------------- src/core/brain/write-refusal-exit.ts | 16 +++++++++++++ tests/core/brain/pending-lanes.test.ts | 19 +++++++++++++++ 4 files changed, 51 insertions(+), 22 deletions(-) create mode 100644 src/core/brain/write-refusal-exit.ts diff --git a/src/core/brain/diagnostics.ts b/src/core/brain/diagnostics.ts index 4670d97c..3092d8b5 100644 --- a/src/core/brain/diagnostics.ts +++ b/src/core/brain/diagnostics.ts @@ -72,6 +72,7 @@ import { BRAIN_LOG_EVENT_KIND, type DoctorIssue } from "./types.ts"; import { isBrainArtifactId, normaliseWikilinkTarget } from "./wikilink.ts"; import { assertVaultIdentityForWrite } from "./vault-identity.ts"; import { recordRefs } from "./log-events-at-reach.ts"; +import { WRITE_REFUSAL_NEXT_COMMAND } from "./write-refusal-exit.ts"; import { extractId } from "./temporal/period-common.ts"; // ----- Diagnostics-signal model -------------------------------------------- @@ -576,7 +577,7 @@ export const DIAGNOSTIC_SIGNALS: ReadonlyMap = new Map // fixer's - so `autoRepairable` stays false. code: "write-refused", issueClass: "write refused by a permissions document rule", - nextCommand: "o2b brain permissions show", + nextCommand: WRITE_REFUSAL_NEXT_COMMAND, autoRepairable: false, }, { @@ -589,7 +590,7 @@ export const DIAGNOSTIC_SIGNALS: ReadonlyMap = new Map code: "force-confirmed-requires-allow", issueClass: "force_confirmed refused: the permissions document does not allow this caller's write", - nextCommand: "o2b brain permissions show", + nextCommand: WRITE_REFUSAL_NEXT_COMMAND, autoRepairable: false, }, { diff --git a/src/core/brain/write-disposition.ts b/src/core/brain/write-disposition.ts index 61614607..bf5a49ad 100644 --- a/src/core/brain/write-disposition.ts +++ b/src/core/brain/write-disposition.ts @@ -23,12 +23,12 @@ */ import { discoverConfig, resolveAgentName } from "../config.ts"; -import { requireNextStep } from "./next-step.ts"; import type { PermissionAction } from "./permissions/document.ts"; import { loadPermissionsDocument } from "./permissions/document.ts"; import { appendDecisionLedger } from "./permissions/ledger.ts"; import type { PermissionDecision, PermissionSubject } from "./permissions/resolve.ts"; import { resolvePermission } from "./permissions/resolve.ts"; +import { WRITE_REFUSAL_NEXT_COMMAND } from "./write-refusal-exit.ts"; import { WRITE_APPROVAL_ENABLED_CONFIG_KEY, WRITE_APPROVAL_ENABLED_ENV_KEY, @@ -72,24 +72,17 @@ export function isWriteRefusalCode(value: unknown): value is WriteRefusalCode { } /** - * The registered exit a document-deny refusal names. Resolved lazily, on - * first refusal: this module sits inside the diagnostics import cycle - * (`next-step.ts` reads `diagnostics.ts`, which reaches the doctor checks - * that reach the signals parser and this module back), so resolving at - * module scope would call into `next-step.ts` while its registry is still - * initializing - a TDZ crash whose trigger depends on which test file or - * entry point loaded first. The registry census pins the code's - * registration at test time, so drift still fails there rather than - * inside this lazy read. + * The registered exit a document-deny refusal names, read straight from + * the leaf the diagnostics registry builds its own entries from: this + * module sits inside the diagnostics import cycle (`next-step.ts` reads + * `diagnostics.ts`, which reaches the doctor checks that reach the + * signals parser and this module back), so resolving through + * `requireNextStep` - at module scope OR on first refusal - would either + * crash on the registry's initialization order or paper over the cycle + * the graph ratchet refuses. The leaf keeps the string at one + * definition; the census pins the codes against it. */ -let writeRefusedExit: string | undefined; - -function writeRefusedNextCommand(): string { - if (writeRefusedExit === undefined) { - writeRefusedExit = requireNextStep(WRITE_REFUSAL_CODES.documentDeny).nextCommand; - } - return writeRefusedExit; -} +const WRITE_REFUSED_EXIT = WRITE_REFUSAL_NEXT_COMMAND; /** The fields a document-deny refusal carries beside its message. */ export interface WriteRefusedFields { @@ -123,7 +116,7 @@ export class WriteRefusedError extends Error { `write refused (${WRITE_REFUSAL_CODES.documentDeny}): agent ` + `${JSON.stringify(fields.agent)} (via ${fields.via}) may not ${fields.action} ` + `${JSON.stringify(fields.target)} - rule ${fields.rule} denied it. ` + - `The operator can review the policy: ${writeRefusedNextCommand()}`, + `The operator can review the policy: ${WRITE_REFUSED_EXIT}`, ); this.name = "WriteRefusedError"; this.code = WRITE_REFUSAL_CODES.documentDeny; @@ -132,7 +125,7 @@ export class WriteRefusedError extends Error { this.action = fields.action; this.rule = fields.rule; this.target = fields.target; - this.nextCommand = writeRefusedNextCommand(); + this.nextCommand = WRITE_REFUSED_EXIT; } } diff --git a/src/core/brain/write-refusal-exit.ts b/src/core/brain/write-refusal-exit.ts new file mode 100644 index 00000000..a2b018e8 --- /dev/null +++ b/src/core/brain/write-refusal-exit.ts @@ -0,0 +1,16 @@ +/** + * The operator exit both write-side trust refusals name (write-side + * trust, Task 12): the permissions `show` loop, because the fix for a + * refusal is editing the policy, which is the operator's act - never a + * fixer's. + * + * A LEAF importing nothing, for the same reason + * `entities/canonical.ts` is: the registry's next-step resolution + * (`next-step.ts`) reads `diagnostics.ts`, which reaches the doctor + * checks and from there the signals parser and the disposition layer + * that refuse with this exit - so a consumer resolving the command + * through `requireNextStep` at module scope closes an import cycle. The + * registry's entries are built from this constant, keeping the string at + * exactly one definition, and every refusal reads it straight from here. + */ +export const WRITE_REFUSAL_NEXT_COMMAND = "o2b brain permissions show"; diff --git a/tests/core/brain/pending-lanes.test.ts b/tests/core/brain/pending-lanes.test.ts index fa5547a9..6c507a18 100644 --- a/tests/core/brain/pending-lanes.test.ts +++ b/tests/core/brain/pending-lanes.test.ts @@ -26,6 +26,8 @@ import { atomicWriteFileSync, FileAlreadyExistsError } from "../../../src/core/f import { PermissionsDocumentError } from "../../../src/core/brain/permissions/document.ts"; import { queryDecisionLedger } from "../../../src/core/brain/permissions/ledger.ts"; import { freezeVault } from "../../../src/core/brain/freeze.ts"; +import { requireNextStep } from "../../../src/core/brain/next-step.ts"; +import { WRITE_REFUSAL_NEXT_COMMAND } from "../../../src/core/brain/write-refusal-exit.ts"; import { createNote } from "../../../src/core/brain/notes/create-note.ts"; import { InvalidPendingIdError, @@ -34,6 +36,7 @@ import { PendingStageConflictError, PendingTargetPathError, WRITE_APPROVAL_SOURCES, + WRITE_REFUSAL_CODES, WriteRefusedError, applyPendingLane, decodePendingTargetPath, @@ -854,3 +857,19 @@ describe("the review door's resolution rows", () => { }); }); }); + +describe("the write refusal's registered exit", () => { + test("the registry entries and the refusal both name the leaf's one command", () => { + // The exit lives in a leaf the diagnostics registry builds its entries + // from, because resolving it through requireNextStep from the + // disposition layer closes the diagnostics import cycle. This pin is + // what keeps the leaf and the registry from drifting apart. + expect(requireNextStep(WRITE_REFUSAL_CODES.documentDeny).nextCommand).toBe( + WRITE_REFUSAL_NEXT_COMMAND, + ); + expect(requireNextStep(WRITE_REFUSAL_CODES.forceConfirmedRequiresAllow).nextCommand).toBe( + WRITE_REFUSAL_NEXT_COMMAND, + ); + expect(WRITE_REFUSAL_NEXT_COMMAND).toBe("o2b brain permissions show"); + }); +}); From 04c071520125409b6ebd1dda5fe46eac2e1f7079 Mon Sep 17 00:00:00 2001 From: Sergey Eroshenkov Date: Sat, 10 Oct 2026 22:27:31 +0200 Subject: [PATCH 66/84] fix(mcp): trim the brain_decision descriptions under the registry cap --- src/mcp/brain/decisions-tools.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/mcp/brain/decisions-tools.ts b/src/mcp/brain/decisions-tools.ts index 543a7a01..f0d56933 100644 --- a/src/mcp/brain/decisions-tools.ts +++ b/src/mcp/brain/decisions-tools.ts @@ -433,9 +433,8 @@ export const DECISIONS_TOOLS: ReadonlyArray = Object.freeze([ title: { type: "string", description: - "record/similar: the decision question / statement. open: short label that drives " + - "the open-decision id; a title with no ASCII letters or digits hashes to an " + - "unnamed- id (the slug grammar is ASCII).", + "record/similar: the decision question / statement. open: short label driving " + + "the open-decision id; non-ASCII titles hash to unnamed-.", }, chosen: { type: "string", @@ -492,9 +491,8 @@ export const DECISIONS_TOOLS: ReadonlyArray = Object.freeze([ question: { type: "string", description: - "open: the full question being parked. Dedup key across open records: a twin " + - "question with an open record refuses; the same wording may be re-parked after " + - "the twin resolves or discards.", + "open: the full question being parked. Dedup key: a twin with an open record " + + "refuses; the same wording may re-park after it resolves or discards.", }, options: { type: "array", From 66d79add0d92e424f9075eb1c6ce009ee0e28d86 Mon Sep 17 00:00:00 2001 From: Sergey Eroshenkov Date: Sat, 10 Oct 2026 22:27:42 +0200 Subject: [PATCH 67/84] test(brain): point the tag-grammar refusals at purely numeric tokens --- tests/cli/brain-feedback-routing-hint.test.ts | 2 +- tests/core/brain.preference.test.ts | 2 +- tests/core/brain.signal.test.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/cli/brain-feedback-routing-hint.test.ts b/tests/cli/brain-feedback-routing-hint.test.ts index 6b9f880c..9bcad3ec 100644 --- a/tests/cli/brain-feedback-routing-hint.test.ts +++ b/tests/cli/brain-feedback-routing-hint.test.ts @@ -210,7 +210,7 @@ describe("o2b brain feedback - success line and refusal envelope", () => { "--principle", "recorded where it belongs", "--scope", - "1-not-a-scope", + "42", "--json", ], { env: env() }, diff --git a/tests/core/brain.preference.test.ts b/tests/core/brain.preference.test.ts index 3f9ccf48..349f1754 100644 --- a/tests/core/brain.preference.test.ts +++ b/tests/core/brain.preference.test.ts @@ -514,7 +514,7 @@ describe("writePreference — tag syntax (t_11ee559f)", () => { test("a topic that even slugified fails the rule refuses, naming the field", () => { let thrown: unknown; try { - writePreference(tmp, basePrefInput({ topic: "2024 retrospective", slug: "numeric-topic" })); + writePreference(tmp, basePrefInput({ topic: "42", slug: "numeric-topic" })); } catch (err) { thrown = err; } diff --git a/tests/core/brain.signal.test.ts b/tests/core/brain.signal.test.ts index 781a3e48..fa3588b7 100644 --- a/tests/core/brain.signal.test.ts +++ b/tests/core/brain.signal.test.ts @@ -456,7 +456,7 @@ describe("writeSignal — tag syntax (t_11ee559f)", () => { test("a topic that even slugified fails the rule refuses, naming the field", () => { let thrown: unknown; try { - writeSignal(tmp, baseInput({ topic: "2024 retrospective", slug: "numeric-topic" })); + writeSignal(tmp, baseInput({ topic: "42", slug: "numeric-topic" })); } catch (err) { thrown = err; } From 9208cb14d8daf9ab6e701a5e530a3c757c385376 Mon Sep 17 00:00:00 2001 From: Sergey Eroshenkov Date: Sat, 10 Oct 2026 22:27:42 +0200 Subject: [PATCH 68/84] test(architecture): re-measure the write-site census for the envelope unwrap --- .../architecture/write-site-census.test.ts | 23 ++++++++++++------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/tests/core/architecture/write-site-census.test.ts b/tests/core/architecture/write-site-census.test.ts index ed2f937a..57a74b50 100644 --- a/tests/core/architecture/write-site-census.test.ts +++ b/tests/core/architecture/write-site-census.test.ts @@ -820,12 +820,12 @@ const DIRECT_WRITE_EXCLUSIONS: Readonly> = Object }, "src/core/brain/secrets/envelope.ts": { categories: [C.machineArtifact], - calls: ["chmodSync", "renameSync", "unlinkSync", "writeFileSync"], + calls: ["chmodSync", "renameSync", "unlinkSync", "writeSync"], reason: - "replaces the raw keyfile with the passphrase-wrapped envelope via " + - "tmp-plus-rename at mode 0600, the custody-boundary twin of the store " + - "row beside it: a torn wrap would destroy the only copy of the key, so " + - "the swap is atomic and the shared writer's 0644 default is wrong here. " + + "restores the raw keyfile bytes when the written envelope fails its read-back " + + "unwrap: a partial-write-safe `writeSync` loop into a tmp file renamed over " + + "the only copy of the key at 0600 - custody bytes the shared writer must not " + + "own (the envelope write itself now routes through the shared atomic writer). " + "The unlink clears the tmp when the rename is refused; `chmodSync` " + "re-asserts the 0600 on the envelope the keyfile it replaced carried.", }, @@ -1362,8 +1362,13 @@ const DIRECT_WRITE_ROWS = 80; * * 113 -> 114: the decision ledger appends its month/device JSONL shard * through the shared atomic writer (write-side-trust Task 2). + * + * 114 -> 115: `src/core/brain/secrets/envelope.ts` writes the unwrapped + * envelope through `atomicWriteText` at 0600 (the unwrap round-trip gained a + * second-process path), moving it into BOTH classes - the raw-keyfile + * restore beside it stays direct and keeps its exclusion. */ -const SHARED_HELPER_ROWS = 114; +const SHARED_HELPER_ROWS = 115; // ----- Origin-channel coverage boundary (Unit C) ---------------------------- @@ -1461,9 +1466,11 @@ const UNSTAMPED_DIRECT_ROWS = 79; * bootstrap receipt writes `/.open-second-brain/bootstrap.lock.json` * through the shared atomic writer (custody state, not notes). 110 -> 111: * the decision ledger appends its month/device JSONL shard through the shared - * atomic writer (write-side-trust Task 2). + * atomic writer (write-side-trust Task 2). 111 -> 112: the envelope swap + * writes its custody bytes through the shared atomic writer (custody state, + * not notes). */ -const UNSTAMPED_SHARED_ROWS = 111; +const UNSTAMPED_SHARED_ROWS = 112; describe("in-vault write-site census", () => { test("every direct-fs write site carries a written exclusion", () => { From 9172e25483a179fb09954f1b713fcef929affba8 Mon Sep 17 00:00:00 2001 From: Sergey Eroshenkov Date: Sat, 10 Oct 2026 22:27:46 +0200 Subject: [PATCH 69/84] test(cli): sanction the bootstrap receipt's parameterized pointers --- tests/cli/forward-pointer-rail.test.ts | 44 +++++++++++++++++++++++--- 1 file changed, 39 insertions(+), 5 deletions(-) diff --git a/tests/cli/forward-pointer-rail.test.ts b/tests/cli/forward-pointer-rail.test.ts index 55050462..e61b6edf 100644 --- a/tests/cli/forward-pointer-rail.test.ts +++ b/tests/cli/forward-pointer-rail.test.ts @@ -133,6 +133,38 @@ const SANCTIONED: Readonly> = Object.freeze({ "command that does not run. It sits beside the manifest path in the same receipt for the " + "same reason: both are facts about the move that just happened.", }, + "src/cli/bootstrap/run.ts :: o2b bootstrap": { + count: 1, + reason: + "the failed-registration receipt beside a mint that already landed: `re-run o2b bootstrap " + + "--target `. The target is runtime state, the rail resolves " + + "one structural command per code with no parameter channel, so a registered signal could " + + "only advise bootstrapping a target other than the one that just failed.", + }, + "src/cli/bootstrap/run.ts :: o2b mcp token revoke": { + count: 1, + reason: + "the cleanup half of that same failed-registration receipt: `o2b mcp token revoke --name " + + "`. The name exists only in this run and is printed once, in " + + "the line above; the rail's structural registry cannot carry it, and an unparameterized " + + "revoke could only name the wrong credential.", + }, + "src/cli/bootstrap/run.ts :: o2b install is removed with": { + count: 1, + reason: + "prose on the nothing-to-remove receipt: `a registration written by o2b install is removed " + + "with: o2b uninstall --target --apply`. The detector reads the sentence's first `o2b` " + + "token run as the invocation; the pointer it frames is the runtime-parameterized uninstall " + + "named in the sibling entry, which the rail cannot carry.", + }, + "src/cli/bootstrap/run.ts :: o2b uninstall": { + count: 1, + reason: + "the command half of that same nothing-to-remove receipt line: `o2b uninstall --target " + + " --apply`. Runtime-parameterized like its prose sibling and counted separately, so " + + "a new hand-written pointer naming this verb in this module cannot hide behind the prose " + + "entry.", + }, }); /** A reason has to say something; a placeholder cannot reach this. */ @@ -293,11 +325,13 @@ describe("the rail is the only forward-pointer mechanism", () => { test("the sanctioned table stays an exception", () => { // Every measured site is sanctioned (test one), so the table's size - // IS the number of unmigrated sites. Four was the ceiling that change - // left behind; the fifth is `state migrate`'s undo line, which the - // rail structurally cannot carry because it interpolates the - // destination the operator just named. A sixth needs its own argument. - expect(Object.keys(SANCTIONED).length).toBeLessThanOrEqual(5); + // IS the number of unmigrated sites. Five was the ceiling this change + // inherited; 6-9 are the bootstrap receipt's four measured literals, + // each interpolating runtime state (`--target`, `--name`) the rail + // structurally cannot carry because it resolves one structural command + // per code with no parameter channel - the same argument the `state + // migrate` undo line makes. A tenth needs its own argument. + expect(Object.keys(SANCTIONED).length).toBeLessThanOrEqual(9); }); }); From dd1a7dd56ca3aea52043c13d4d436b5521d79605 Mon Sep 17 00:00:00 2001 From: Sergey Eroshenkov Date: Sat, 10 Oct 2026 22:27:46 +0200 Subject: [PATCH 70/84] test(maintenance): apply the reviewed plan digest in the self-heal surface test --- tests/core/maintenance/self-heal-upgrade.test.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/tests/core/maintenance/self-heal-upgrade.test.ts b/tests/core/maintenance/self-heal-upgrade.test.ts index f9eba519..eabbc1e0 100644 --- a/tests/core/maintenance/self-heal-upgrade.test.ts +++ b/tests/core/maintenance/self-heal-upgrade.test.ts @@ -305,9 +305,21 @@ describe("operator surfaces after a failure", () => { failOnce(); repairSnapshots(); - const r = await runCli(["brain", "upgrade", "--vault", vault, "--apply", "--yes"], { + // t_18fda844: a non-interactive apply must carry the plan digest a + // prior --dry-run printed, so the operator surface under test is the + // two-step one: review, then apply the reviewed plan. + const dry = await runCli(["brain", "upgrade", "--vault", vault, "--dry-run", "--json"], { env: { OPEN_SECOND_BRAIN_CONFIG: configPath }, }); + expect(dry.returncode).toBe(0); + const { digest } = JSON.parse(dry.stdout) as { digest: string }; + + const r = await runCli( + ["brain", "upgrade", "--vault", vault, "--apply", "--yes", "--approval-digest", digest], + { + env: { OPEN_SECOND_BRAIN_CONFIG: configPath }, + }, + ); expect(r.returncode).toBe(0); expect(readSelfHealUpgradeFailure(vault)).toBeNull(); From 254c5e23c9755c0b2bdfac8fbc80569f9b77049d Mon Sep 17 00:00:00 2001 From: Sergey Eroshenkov Date: Sat, 10 Oct 2026 22:33:36 +0200 Subject: [PATCH 71/84] docs: fold the review-round repairs into the 1.79.0 changelog --- CHANGELOG.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c5e3d2e..6ba6a1be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,24 @@ Open Second Brain 1.79.0 gives an operator who lets agents write into a vault th - **Document-backed dispositions close the `force_confirmed` gap.** With a permissions document present, `force_confirmed` writes a confirmed preference only when the caller's `write` verdict resolves to `allow`; otherwise the refusal answers with the `force-confirmed-requires-allow` token and the `o2b brain permissions show` exit, and document-absent behavior is unchanged. A document `deny` refuses the write before any filesystem effect with the typed `write-refused` error naming the principal, the action, the deciding rule and the next command, the lane keys cannot bypass a document in either direction, and each stage or refuse disposition appends exactly one ledger row naming the rule that decided. - **Docs:** `docs/cli-reference.md` documents `o2b bootstrap`, `o2b mcp token`, `o2b brain permissions`, the widened `o2b brain pending` and the open-decision actions; `docs/mcp.md` documents the per-agent token authentication, the new refusal tokens with their next commands and the open-decision actions on `brain_decision` (no new MCP tool - the advertised surface is unchanged); `docs/observability.md` gains the decision-ledger section and the new Brain log kinds; the README control section states every new gate with its config key and default-off posture. +### Fixed + +- **The passphrase-wrapped store is reachable again.** Every key-bearing verb (`set`, `rm`, `run`, `export`, `import`) ingests the passphrase through `--passphrase-from-env` or stdin exactly as `unlock` does; the MCP server unlocks once from `OPEN_SECOND_BRAIN_SECRETS_PASSPHRASE` at its first key use and drops the variable; `o2b brain secret unwrap` restores the raw keyfile; and the wrap path verifies the envelope against the key and writes it durably before replacing the only copy. +- **Credential bundles authenticate their metadata.** Each entry's value is sealed against its name, env mapping, allow patterns, the bundle version and the KDF parameters, so an edited or swapped bundle refuses on import before any write; the bundle schema moves to 2 and older bundles refuse naming the re-export remedy. +- **Approval digests bind the content that lands.** The import plan seals each row's source and rendered-body hashes, so a file edited after the dry run fails the apply; `brain upgrade --apply` accepts `--approval-digest`, required when non-interactive and verified against the freshly computed plan. +- **The ingest manifest carries the extraction contract per entry.** Ingesting one source no longer cancels the owed reprocess of the others; a manifest without per-entry contracts degrades toward reprocessing, never toward skipping. +- **Tags accept the Obsidian grammar.** Unicode letters and digit-led tags (`2fa`, `2024-notes`) pass; only bare numbers refuse; legacy scopes carrying such tags stop breaking dream rewrites and preference writes. +- **A refused capture no longer crash-loops the Telegram daemon.** Contract refusals are recorded per update, the offset advances, and the run continues with the messages behind the refusal. +- **Session-summary divergence is a signal again.** Records dedupe by content hash on read, `divergent` fires only when distinct hashes coexist at one instant, and the revision count is reported separately. +- **Secret references resolve the names the store holds.** Hyphenated and mixed-case names resolve through the store leg while the env fallback keeps its case rules, and redaction covers env-resolved values without blanking error text on one- or two-character values. +- **The HTTP transport survives a corrupt token store.** A store read error falls through to the shared-key compare with a named warning; a presented credential matching nothing is refused outright rather than read as anonymous; and revoking the last token on a keyless network bind no longer re-opens anonymous access. +- **Request identity reaches every write decision.** A token caller's writes are judged as the token's agent: document deny entries refuse them, cross-owner claims are checked against the credential identity, a caller-supplied agent that differs from the token refuses by name, and the decision ledger names the true actor. The signal lane consults the permissions document like every other writer, denied agents cannot update or append to published notes, and the review door appends one resolution row per apply and reject. +- **Staging the same target twice refuses by name** instead of silently replacing the earlier entry's bytes, and a failed decision-ledger append surfaces its audit reason instead of vanishing. +- **Open decisions stay resolvable.** Same-titled questions resolve into distinct decision pages, a resolved or discarded question can be parked again with the same wording, adversarial headings in question text round-trip byte-faithfully, callers below the record's reach cannot resolve or discard what they cannot list, and the CLI help says non-Latin titles hash to an unnamed id. +- **Bootstrap tells the truth about tokens.** The minted token authenticates hand-configured HTTP clients while the registered harness keeps its config-derived identity (stated in the output and the docs); a failed apply no longer loses the minted material; placeholder agent names refuse at mint; rotation keeps a ten-minute grace window for the previous material; and `o2b bootstrap --remove` tears a provisioning down through the receipt. +- **The doctor sees a document that denies the locally configured agent**, and sessions import reports the ambient-withheld count when consent suppresses capture. +- **Expired transient facts cannot consolidate.** The dream topic and promotion planning readers drop signals past their ambient time-to-live before clustering, matching the recall path. + ## [1.78.0] - 2026-10-09 Open Second Brain now puts credential custody under an operator passphrase and routes it by name: the vault's secrets keyfile wraps into an opt-in scrypt envelope behind a lock/unlock lifecycle, `$secret:NAME` references resolve through the custody store at every credential use site, one passphrase-encrypted bundle moves the store between installs, and every resolved credential literal is redacted at the error and config-mapping boundaries. The vault's own declarations bind its writers - composed tags must parse as Obsidian tags and a declared page vocabulary gates capture writes - and the plans an operator approves are sealed: a Claude-memory import or a brain upgrade applies exactly the approved plan or nothing. Reads name divergent session summaries, the model-answer verbs strip and name a leading think block before parsing, and a changed extraction contract reprocesses the sources it covers instead of absorbing the change as unchanged. From 4aac3c996c23012cf30357ef1ead9db91d13fc1c Mon Sep 17 00:00:00 2001 From: Sergey Eroshenkov Date: Sat, 10 Oct 2026 23:01:16 +0200 Subject: [PATCH 72/84] test: pin the platform-dependent halves of the new suites Windows reports every file mode as 666 and resolves environment names case-insensitively at the OS level, and absolute paths carry the platform separator, so the restore-mode, env-case and staged-path assertions now pin their POSIX halves where the platform honours them and normalize separators where they compare paths. --- tests/core/brain/secrets/envelope.test.ts | 7 ++++++- tests/core/brain/signal-disposition.test.ts | 4 ++-- tests/core/secret-resolver.test.ts | 12 +++++++++++- 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/tests/core/brain/secrets/envelope.test.ts b/tests/core/brain/secrets/envelope.test.ts index bbf1ef5f..358a902e 100644 --- a/tests/core/brain/secrets/envelope.test.ts +++ b/tests/core/brain/secrets/envelope.test.ts @@ -241,7 +241,12 @@ describe("the keyfile envelope", () => { expect(readFileSync(keyPath).equals(dek)).toBe(true); expect(isEnvelopeFile(keyPath)).toBe(false); expect(loadOrCreateKey(keyPath).equals(dek)).toBe(true); - expect((statSync(keyPath).mode & 0o777).toString(8)).toBe("600"); + // Windows reports every mode as 666 and enforces none: the owner-only + // mode is a POSIX property of the restore, pinned only where the + // platform honours file modes. + if (process.platform !== "win32") { + expect((statSync(keyPath).mode & 0o777).toString(8)).toBe("600"); + } }); test("a wrapped store unlocks from the environment once and the variable is consumed", () => { diff --git a/tests/core/brain/signal-disposition.test.ts b/tests/core/brain/signal-disposition.test.ts index 6ee8950f..809312c2 100644 --- a/tests/core/brain/signal-disposition.test.ts +++ b/tests/core/brain/signal-disposition.test.ts @@ -99,7 +99,7 @@ describe("writeSignal under a permissions document", () => { // The config identity is allowed: the signal publishes. const res = writeSignal(vault, INPUT); expect(res.staged).toBe(false); - expect(res.path).toContain("Brain/inbox"); + expect(res.path.replaceAll("\\", "/")).toContain("Brain/inbox"); // A credential-minted subject the document denies is refused with the // same typed refusal, one row naming ITS agent. let refused: WriteRefusedError | undefined; @@ -144,7 +144,7 @@ describe("writeSignal under a permissions document", () => { writeDoc("version: 1\ndefault_action: allow\n"); const res = writeSignal(vault, INPUT); expect(res.staged).toBe(false); - expect(res.path).toContain("Brain/inbox"); + expect(res.path.replaceAll("\\", "/")).toContain("Brain/inbox"); expect(queryDecisionLedger(vault)).toEqual([]); writeDoc("version: 1\ndefault_action: allow\nledger:\n record_allows: true\n"); const second = writeSignal(vault, { ...INPUT, slug: "prefer-tests-2" }); diff --git a/tests/core/secret-resolver.test.ts b/tests/core/secret-resolver.test.ts index 19ddf070..17479c73 100644 --- a/tests/core/secret-resolver.test.ts +++ b/tests/core/secret-resolver.test.ts @@ -26,6 +26,8 @@ import { writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; + +const IS_WINDOWS = process.platform === "win32"; import { join } from "node:path"; import { listSecrets, secretsDir, setSecret } from "../../src/core/brain/secrets/store.ts"; @@ -139,7 +141,15 @@ describe("resolveNamedSecret", () => { expect(resolveNamedSecret(vault, "$secret:ENV_CASE")).toBe(ENV_VALUE); delete process.env["ENV_CASE"]; process.env["env_case"] = ENV_VALUE; - expect(() => resolveNamedSecret(vault, "$secret:ENV_CASE")).toThrow(SecretReferenceError); + // Windows resolves environment names case-insensitively at the OS level, + // so the lower-case variable answers the upper-case reference there; the + // case-sensitive contract is a POSIX property and is pinned only where + // the platform honours it. + if (!IS_WINDOWS) { + expect(() => resolveNamedSecret(vault, "$secret:ENV_CASE")).toThrow(SecretReferenceError); + } else { + expect(resolveNamedSecret(vault, "$secret:ENV_CASE")).toBe(ENV_VALUE); + } }); test("a locked store surfaces the named locked error, never the env value", () => { From 7a5237b707a23312f340ba9ece704a1e3b1c0523 Mon Sep 17 00:00:00 2001 From: Sergey Eroshenkov Date: Sat, 10 Oct 2026 23:01:16 +0200 Subject: [PATCH 73/84] chore(openclaw): rebuild the bundle --- openclaw/index.js | 1039 +++++++++++++++++++++++++++++++++++++-------- 1 file changed, 853 insertions(+), 186 deletions(-) diff --git a/openclaw/index.js b/openclaw/index.js index fc7848bb..f4b3482a 100644 --- a/openclaw/index.js +++ b/openclaw/index.js @@ -1673,6 +1673,15 @@ function assertExpectedBefore(target, expected) { if (!fileMatchesExpected(target, expected)) throw new FileDriftError(target); } +function atomicWriteText(targetPath, candidate, opts = {}) { + opts.validate?.(candidate); + if (opts.skipIfUnchanged && isUnchanged(targetPath, candidate)) + return false; + withTempFile(targetPath, candidate, (tmpPath) => { + renameWithRetry(tmpPath, targetPath); + }, opts.mode ?? 384); + return true; +} function withTempFile(target, contents, commit, mode = 420) { const dir = dirname(target); mkdirSync(dir, { recursive: true }); @@ -1729,6 +1738,9 @@ var init_fs_atomic = __esm(() => { }); // src/core/secret-ref.ts +function storeSecretName(name) { + return name.trim().toLowerCase(); +} function parseSecretReference(value) { if (typeof value !== "string") return null; @@ -1752,9 +1764,9 @@ function resolveSecretReference(value, provider = process.env) { return resolved; } function sortedDistinctLiterals(values) { - return [...new Set(values)].filter((value) => value.length > 0).sort((a, b) => b.length - a.length); + return [...new Set(values)].filter((value) => value.length >= MIN_REDACTED_LITERAL_LENGTH).sort((a, b) => b.length - a.length); } -var SecretReferenceError, SECRET_REFERENCE_RE, REFERENCE_PREFIX = "$secret:"; +var SecretReferenceError, SECRET_REFERENCE_RE, REFERENCE_PREFIX = "$secret:", MIN_REDACTED_LITERAL_LENGTH = 8; var init_secret_ref = __esm(() => { SecretReferenceError = class SecretReferenceError extends Error { nameValue; @@ -1764,7 +1776,7 @@ var init_secret_ref = __esm(() => { this.nameValue = nameValue; } }; - SECRET_REFERENCE_RE = /^\$secret:([A-Za-z_][A-Za-z0-9_]*)$/; + SECRET_REFERENCE_RE = /^\$secret:([A-Za-z0-9_][A-Za-z0-9_-]*)$/; }); // src/core/platform-dirs.ts @@ -1810,16 +1822,50 @@ var init_platform_dirs = __esm(() => { }); // src/core/brain/portability/profiles.ts -var import_proper_lockfile; +import { existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync2, statSync } from "node:fs"; +import { dirname as dirname2, join as join3 } from "node:path"; +function profilesPath(configPath) { + return join3(dirname2(configPath), "profiles.json"); +} +function load(configPath, opts = {}) { + const path = profilesPath(configPath); + if (!existsSync2(path)) + return { active: null, profiles: {} }; + try { + const raw = JSON.parse(readFileSync2(path, "utf8")); + const profiles = {}; + if (raw.profiles && typeof raw.profiles === "object") { + for (const [name, entry] of Object.entries(raw.profiles)) { + if (entry && typeof entry === "object" && typeof entry.vault === "string") { + profiles[name] = { vault: entry.vault }; + } + } + } + return { active: typeof raw.active === "string" ? raw.active : null, profiles }; + } catch (exc) { + if (!opts.tolerateParseError) { + throw new Error(`profiles registry is malformed: ${path}`, { cause: exc }); + } + return { ...EMPTY }; + } +} +function resolveActiveProfileVault(configPath) { + const data = load(configPath, { tolerateParseError: true }); + if (data.active === null) + return null; + return data.profiles[data.active]?.vault ?? null; +} +var import_proper_lockfile, EMPTY; var init_profiles = __esm(() => { init_fs_atomic(); import_proper_lockfile = __toESM(require_proper_lockfile(), 1); + EMPTY = { active: null, profiles: {} }; }); // src/core/fs-utils.ts -import { statSync } from "node:fs"; +import { statSync as statSync2 } from "node:fs"; function statOrAbsent(p) { - return statSync(p, { throwIfNoEntry: false }); + return statSync2(p, { throwIfNoEntry: false }); } function isDir(p) { try { @@ -1835,6 +1881,55 @@ function stem(filename) { var init_fs_utils = () => {}; // src/core/brain/portability/pointer.ts +import { existsSync as existsSync3, mkdirSync as mkdirSync3, readFileSync as readFileSync3, rmSync } from "node:fs"; +import { dirname as dirname3, join as join4, resolve as resolve2, sep } from "node:path"; +function probeAt(dir) { + const path = join4(dir, VAULT_POINTER_FILE); + if (!existsSync3(path)) + return null; + try { + const raw = JSON.parse(readFileSync3(path, "utf8")); + const vault = raw["vault"]; + if (typeof vault !== "string" || vault.trim() === "") { + return Object.freeze({ path, dir, pointer: null, error: "pointer has no vault field" }); + } + const linkedAt = typeof raw["linked_at"] === "string" ? raw["linked_at"] : null; + return Object.freeze({ + path, + dir, + pointer: Object.freeze({ vault, linkedAt }), + error: null + }); + } catch (exc) { + return Object.freeze({ + path, + dir, + pointer: null, + error: `pointer is not valid JSON: ${exc.message}` + }); + } +} +function findVaultPointer(startDir) { + let dir = resolve2(startDir); + for (;; ) { + const probe = probeAt(dir); + if (probe !== null) + return probe; + const parent = dirname3(dir); + if (parent === dir) + return null; + dir = parent; + } +} +function resolvePointerVault(startDir) { + const probe = findVaultPointer(startDir); + if (probe === null || probe.pointer === null) + return null; + if (!isDir(probe.pointer.vault)) + return null; + return probe.pointer.vault; +} +var VAULT_POINTER_FILE = ".o2b-vault.json"; var init_pointer = __esm(() => { init_fs_atomic(); init_fs_utils(); @@ -1844,6 +1939,9 @@ var init_pointer = __esm(() => { var init_wikilink = () => {}; // src/core/brain/link-graph/format-wikilink.ts +function isWikiLinkFormat(value) { + return WIKI_LINK_FORMATS.includes(value); +} var WIKI_LINK_FORMATS, SUFFIX_INDEX_MEMO; var init_format_wikilink = __esm(() => { init_wikilink(); @@ -1856,10 +1954,91 @@ var init_format_wikilink = __esm(() => { }); // src/core/config.ts -import { mkdirSync as mkdirSync2, readFileSync as readFileSync2, statSync as statSync2 } from "node:fs"; +var exports_config = {}; +__export(exports_config, { + ConfigReadError: () => ConfigReadError, + DECISION_RECALL_MAX_PER_SESSION_CONFIG_KEY: () => DECISION_RECALL_MAX_PER_SESSION_CONFIG_KEY, + DECISION_RECALL_MAX_PER_SESSION_ENV_KEY: () => DECISION_RECALL_MAX_PER_SESSION_ENV_KEY, + DECISION_RECALL_MIN_SPACING_TURNS_CONFIG_KEY: () => DECISION_RECALL_MIN_SPACING_TURNS_CONFIG_KEY, + DECISION_RECALL_MIN_SPACING_TURNS_ENV_KEY: () => DECISION_RECALL_MIN_SPACING_TURNS_ENV_KEY, + DEVICE_ID_RE: () => DEVICE_ID_RE, + INSTALLATION_SECRET_CONFIG_KEY: () => INSTALLATION_SECRET_CONFIG_KEY, + INSTALLATION_SECRET_ENV_KEY: () => INSTALLATION_SECRET_ENV_KEY, + INSTALLATION_SECRET_RE: () => INSTALLATION_SECRET_RE, + MAINTENANCE_CUSTOM_TASKS_CONFIG_KEY: () => MAINTENANCE_CUSTOM_TASKS_CONFIG_KEY, + MAINTENANCE_CUSTOM_TASKS_ENV: () => MAINTENANCE_CUSTOM_TASKS_ENV, + MAINTENANCE_EMBEDDINGS_CONFIG_KEY: () => MAINTENANCE_EMBEDDINGS_CONFIG_KEY, + MAINTENANCE_EMBEDDINGS_ENV: () => MAINTENANCE_EMBEDDINGS_ENV, + PARTNER_CODEGRAPH_DISABLED_CONFIG_KEY: () => PARTNER_CODEGRAPH_DISABLED_CONFIG_KEY, + PARTNER_CODEGRAPH_DISABLED_ENV: () => PARTNER_CODEGRAPH_DISABLED_ENV, + REGROUND_PART_CHARS_DEFAULT: () => REGROUND_PART_CHARS_DEFAULT, + SESSION_CAPTURE_ROLES: () => SESSION_CAPTURE_ROLES, + UNCONFIGURED_AGENT_NAME: () => UNCONFIGURED_AGENT_NAME, + UnsupportedPlatformError: () => UnsupportedPlatformError, + VAULT_STORE_REF_PREFIX: () => VAULT_STORE_REF_PREFIX, + defaultConfigPath: () => defaultConfigPath, + discoverConfig: () => discoverConfig, + installNamedSecretResolver: () => installNamedSecretResolver, + isConfiguredAgentName: () => isConfiguredAgentName, + isValidDeviceId: () => isValidDeviceId, + isValidInstallationSecret: () => isValidInstallationSecret, + parseSimpleYaml: () => parseSimpleYaml, + resolveAgentName: () => resolveAgentName, + resolveBenchJudgeCmd: () => resolveBenchJudgeCmd, + resolveContextPackOutcomeEnabled: () => resolveContextPackOutcomeEnabled, + resolveDecisionRecallMaxPerSession: () => resolveDecisionRecallMaxPerSession, + resolveDecisionRecallMinSpacingTurns: () => resolveDecisionRecallMinSpacingTurns, + resolveDefaultConfigPath: () => resolveDefaultConfigPath, + resolveDensityRankingContextPack: () => resolveDensityRankingContextPack, + resolveDeviceId: () => resolveDeviceId, + resolveExposeHostPaths: () => resolveExposeHostPaths, + resolveGapLoopEnabled: () => resolveGapLoopEnabled, + resolveGapLoopThreshold: () => resolveGapLoopThreshold, + resolveGenerationTraceEnabled: () => resolveGenerationTraceEnabled, + resolveHookStrictEnabled: () => resolveHookStrictEnabled, + resolveHygieneDigestEnabled: () => resolveHygieneDigestEnabled, + resolveInstallationSecret: () => resolveInstallationSecret, + resolveLinkOutputFormat: () => resolveLinkOutputFormat, + resolveMaintenanceCustomTasksSwitch: () => resolveMaintenanceCustomTasksSwitch, + resolveMaintenanceEmbeddings: () => resolveMaintenanceEmbeddings, + resolveMcpRouteMetricsEnabled: () => resolveMcpRouteMetricsEnabled, + resolveMcpToolProfile: () => resolveMcpToolProfile, + resolveNavTierCadenceMinutes: () => resolveNavTierCadenceMinutes, + resolveNavTierEnabled: () => resolveNavTierEnabled, + resolveNearDuplicateRetireSiblingsEnabled: () => resolveNearDuplicateRetireSiblingsEnabled, + resolveNearDuplicateWriteWideningEnabled: () => resolveNearDuplicateWriteWideningEnabled, + resolvePartnerCodegraphDisabled: () => resolvePartnerCodegraphDisabled, + resolvePostCompactSurvivalAudit: () => resolvePostCompactSurvivalAudit, + resolveRecallAdequacyThresholds: () => resolveRecallAdequacyThresholds, + resolveRecallGateTelemetry: () => resolveRecallGateTelemetry, + resolveRecallInjectCaps: () => resolveRecallInjectCaps, + resolveRecallInjectDedupe: () => resolveRecallInjectDedupe, + resolveRecallInjectEnabled: () => resolveRecallInjectEnabled, + resolveRecentTurnsResurface: () => resolveRecentTurnsResurface, + resolveRegroundPartChars: () => resolveRegroundPartChars, + resolveRegroundPartsEnabled: () => resolveRegroundPartsEnabled, + resolveSearchFocusContextPack: () => resolveSearchFocusContextPack, + resolveSessionCaptureRoles: () => resolveSessionCaptureRoles, + resolveSessionHandoff: () => resolveSessionHandoff, + resolveSharedNamespace: () => resolveSharedNamespace, + resolveSkillAutoAttach: () => resolveSkillAutoAttach, + resolveSkillsAttachTriggers: () => resolveSkillsAttachTriggers, + resolveSkillsDir: () => resolveSkillsDir, + resolveTelegramBotToken: () => resolveTelegramBotToken, + resolveTelegramCaptureAllowlist: () => resolveTelegramCaptureAllowlist, + resolveTimezone: () => resolveTimezone, + resolveTokenImpactLedgerEnabled: () => resolveTokenImpactLedgerEnabled, + resolveTriggerCooldownDays: () => resolveTriggerCooldownDays, + resolveVault: () => resolveVault, + resolveWikiLinkFormat: () => resolveWikiLinkFormat, + setConfigValue: () => setConfigValue, + validateTimezoneName: () => validateTimezoneName, + vaultStoreReference: () => vaultStoreReference +}); +import { mkdirSync as mkdirSync4, readFileSync as readFileSync4, statSync as statSync3 } from "node:fs"; import { createHmac, randomBytes } from "node:crypto"; import { homedir as homedir2 } from "node:os"; -import { dirname as dirname2, isAbsolute, join as join3, resolve as resolve2 } from "node:path"; +import { dirname as dirname4, isAbsolute, join as join5, resolve as resolve3 } from "node:path"; function installNamedSecretResolver(resolver) { namedSecretResolver = resolver; } @@ -1876,11 +2055,11 @@ function resolveDefaultConfigPath(source) { return expandTilde(override, source.platform, source.home); const xdg = source.env["XDG_CONFIG_HOME"]; if (xdg) - return join3(expandTilde(xdg, source.platform, source.home), APP_DIR_NAME, "config.yaml"); + return join5(expandTilde(xdg, source.platform, source.home), APP_DIR_NAME, "config.yaml"); if (UNSUPPORTED_CONFIG_PLATFORMS.includes(source.platform)) { throw new UnsupportedPlatformError(source.platform); } - return join3(configBaseDir(source), APP_DIR_NAME, "config.yaml"); + return join5(configBaseDir(source), APP_DIR_NAME, "config.yaml"); } function defaultConfigPath() { return resolveDefaultConfigPath({ @@ -1922,7 +2101,7 @@ function discoverConfig(path) { } function statConfigPath(resolved) { try { - return statSync2(resolved, { throwIfNoEntry: false }); + return statSync3(resolved, { throwIfNoEntry: false }); } catch (err) { throw new ConfigReadError(resolved, err.message ?? String(err)); } @@ -1930,7 +2109,7 @@ function statConfigPath(resolved) { function readConfigText(resolved) { let bytes; try { - bytes = readFileSync2(resolved); + bytes = readFileSync4(resolved); } catch (err) { throw new ConfigReadError(resolved, err.message ?? String(err)); } @@ -1958,6 +2137,43 @@ function setConfigValue(key, value, path) { atomicWriteFileSync(resolved, body); return resolved; } +function validateTimezoneName(name) { + try { + Intl.DateTimeFormat("en-US", { timeZone: name }); + return { ok: true, error: null }; + } catch (exc) { + return { ok: false, error: exc.message ?? String(exc) }; + } +} +function resolveTimezone(configPath) { + let name = process.env["VAULT_TIMEZONE"]; + if (!name) { + name = discoverConfig(configPath).data["timezone"]; + } + if (!name) + return null; + return validateTimezoneName(name).ok ? name : null; +} +function resolveVault(configPath, opts = {}) { + const env = process.env["VAULT_DIR"]; + if (env) + return expandTilde(env); + const pointerVault = resolvePointerVault(opts.cwd ?? process.cwd()); + if (pointerVault !== null) + return expandTilde(pointerVault); + const discovery = discoverConfig(configPath); + const profileVault = resolveActiveProfileVault(discovery.path); + if (profileVault) + return expandTilde(profileVault); + const cfg = discovery.data["vault"]; + if (cfg) + return expandTilde(cfg); + return null; +} +function isConfiguredAgentName(name) { + const trimmed = name.trim(); + return trimmed.length > 0 && trimmed !== UNCONFIGURED_AGENT_NAME; +} function resolveAgentName(configPath) { const env = process.env["VAULT_AGENT_NAME"]; if (env) @@ -1968,6 +2184,11 @@ function resolveAgentName(configPath) { return value; return UNCONFIGURED_AGENT_NAME; } +function resolveSharedNamespace(configPath) { + const discovery = discoverConfig(configPath ?? undefined); + const value = discovery.data[SHARED_NAMESPACE_KEY]?.trim(); + return value ? value : null; +} function isValidDeviceId(value) { return DEVICE_ID_RE.test(value) && !value.startsWith("sync-conflict"); } @@ -1983,8 +2204,8 @@ function resolveDeviceId(configPath) { const existing = read(); if (existing !== null) return existing; - const dir = dirname2(resolved); - mkdirSync2(dir, { recursive: true }); + const dir = dirname4(resolved); + mkdirSync4(dir, { recursive: true }); let release; try { for (let attempt = 0;attempt < 10; attempt++) { @@ -2036,8 +2257,8 @@ function resolveInstallationSecret(configPath, secretsVault) { const existing = read(); if (existing !== null) return existing; - const dir = dirname2(resolved); - mkdirSync2(dir, { recursive: true }); + const dir = dirname4(resolved); + mkdirSync4(dir, { recursive: true }); let release; try { for (let attempt = 0;attempt < 10; attempt++) { @@ -2062,9 +2283,31 @@ function resolveInstallationSecret(configPath, secretsVault) { } function vaultStoreReference(vaultPath, configPath) { const key = resolveInstallationSecret(configPath, vaultPath); - const digest = createHmac("sha256", key).update(resolve2(vaultPath)).digest("hex").slice(0, VAULT_STORE_REF_HEX_LEN); + const digest = createHmac("sha256", key).update(resolve3(vaultPath)).digest("hex").slice(0, VAULT_STORE_REF_HEX_LEN); return `${VAULT_STORE_REF_PREFIX}${digest}`; } +function resolveLinkOutputFormat(configPath) { + const env = process.env["OBSIDIAN_LINK_FORMAT"]?.trim(); + const data = env ? {} : discoverConfig(configPath).data; + const raw = env || data["link_output_format"] || data["linkOutputFormat"]; + return raw === "markdown" ? "markdown" : "wikilink"; +} +function resolveMcpToolProfile(configPath) { + const env = process.env["OPEN_SECOND_BRAIN_MCP_TOOL_PROFILE"]?.trim(); + if (env) + return env; + const raw = discoverConfig(configPath).data["mcp_tool_profile"]?.trim(); + return raw ? raw : null; +} +function resolveSkillsDir(configPath) { + const discovery = discoverConfig(configPath); + const env = process.env["OPEN_SECOND_BRAIN_SKILLS_DIR"]?.trim(); + const raw = env || discovery.data["skills_dir"]?.trim(); + if (!raw || raw.length === 0) + return null; + const expanded = expandTilde(raw); + return isAbsolute(expanded) ? expanded : resolve3(dirname4(discovery.path), expanded); +} function readSetting(envKey, configKey, data) { const env = process.env[envKey]?.trim(); if (env) @@ -2072,25 +2315,284 @@ function readSetting(envKey, configKey, data) { const raw = (typeof data === "function" ? data() : data)[configKey]?.trim(); return raw ? raw : undefined; } +function rejectedSettingName(envKey, configKey) { + return process.env[envKey]?.trim() ? envKey : configKey; +} function resolveConfigFlag(envKey, configKey, configPath) { return isFlagOn(readSetting(envKey, configKey, () => discoverConfig(configPath).data)); } function isFlagOn(raw) { return raw === "true" || raw === "1"; } +function resolveSkillsAttachTriggers(configPath) { + return resolveConfigFlag("OPEN_SECOND_BRAIN_SKILLS_ATTACH_TRIGGERS", "skills_attach_triggers", configPath); +} +function resolveSkillAutoAttach(configPath) { + return resolveConfigFlag("OPEN_SECOND_BRAIN_SKILL_AUTO_ATTACH", "skill_auto_attach", configPath); +} +function resolveSearchFocusContextPack(configPath) { + return resolveConfigFlag("OPEN_SECOND_BRAIN_SEARCH_FOCUS_CONTEXT_PACK", "search_focus_context_pack", configPath); +} +function resolveDensityRankingContextPack(configPath) { + return resolveConfigFlag("OPEN_SECOND_BRAIN_DENSITY_RANKING_CONTEXT_PACK", "density_ranking_context_pack", configPath); +} +function resolvePostCompactSurvivalAudit(configPath) { + return resolveConfigFlag("OPEN_SECOND_BRAIN_POST_COMPACT_SURVIVAL_AUDIT", "post_compact_survival_audit", configPath); +} +function resolveRecentTurnsResurface(configPath) { + return resolveConfigFlag("OPEN_SECOND_BRAIN_RECENT_TURNS_RESURFACE", "recent_turns_resurface", configPath); +} +function resolveNearDuplicateRetireSiblingsEnabled(configPath) { + return resolveConfigFlag("OPEN_SECOND_BRAIN_NEAR_DUPLICATE_RETIRE_SIBLINGS_ENABLED", "near_duplicate_retire_siblings_enabled", configPath); +} +function resolveNearDuplicateWriteWideningEnabled(configPath) { + return resolveConfigFlag("OPEN_SECOND_BRAIN_NEAR_DUPLICATE_WRITE_WIDENING_ENABLED", "near_duplicate_write_widening_enabled", configPath); +} +function resolveSessionHandoff(configPath) { + return resolveConfigFlag("OPEN_SECOND_BRAIN_SESSION_HANDOFF", "session_handoff", configPath); +} +function resolveConfigNonNegativeInt(envKey, configKey, configPath) { + const env = process.env[envKey]?.trim(); + const raw = env || discoverConfig(configPath).data[configKey]?.trim(); + if (!raw || raw.length === 0) + return null; + const n = Number(raw); + if (!Number.isInteger(n) || n < 0) + return null; + return n; +} +function resolveDecisionRecallMaxPerSession(configPath) { + return resolveConfigNonNegativeInt(DECISION_RECALL_MAX_PER_SESSION_ENV_KEY, DECISION_RECALL_MAX_PER_SESSION_CONFIG_KEY, configPath); +} +function resolveDecisionRecallMinSpacingTurns(configPath) { + return resolveConfigNonNegativeInt(DECISION_RECALL_MIN_SPACING_TURNS_ENV_KEY, DECISION_RECALL_MIN_SPACING_TURNS_CONFIG_KEY, configPath); +} +function resolveWikiLinkFormat(configPath) { + const env = process.env["OPEN_SECOND_BRAIN_WIKI_LINK_FORMAT"]?.trim(); + const raw = env || discoverConfig(configPath).data["wiki_link_format"]?.trim(); + if (raw === undefined || raw === "") + return "preserve"; + if (!isWikiLinkFormat(raw)) { + throw new Error(`wiki_link_format must be one of ${WIKI_LINK_FORMATS.join(", ")}; got '${raw}'`); + } + return raw; +} +function resolveTriggerCooldownDays(configPath) { + const env = process.env["OPEN_SECOND_BRAIN_TRIGGER_COOLDOWN_DAYS"]?.trim(); + const raw = env || discoverConfig(configPath).data["trigger_cooldown_days"]?.trim(); + if (raw === undefined || raw === "") + return 7; + const days = Number(raw); + if (!Number.isInteger(days) || days < 0) { + throw new Error(`trigger_cooldown_days must be a non-negative integer; got '${raw}'`); + } + return days; +} +function resolveRecallGateTelemetry(configPath) { + return resolveConfigFlag("OPEN_SECOND_BRAIN_RECALL_GATE_TELEMETRY", "recall_gate_telemetry", configPath); +} +function resolveRecallAdequacyThresholds(configPath) { + const data = discoverConfig(configPath).data; + const sufficient = resolveAdequacyFloor("recall_adequacy_sufficient", process.env["OPEN_SECOND_BRAIN_RECALL_ADEQUACY_SUFFICIENT"], data["recall_adequacy_sufficient"], 0.6); + const weak = resolveAdequacyFloor("recall_adequacy_weak", process.env["OPEN_SECOND_BRAIN_RECALL_ADEQUACY_WEAK"], data["recall_adequacy_weak"], 0.3); + if (weak > sufficient) { + throw new Error(`recall_adequacy_weak (${weak}) must not exceed recall_adequacy_sufficient (${sufficient})`); + } + const minResults = resolveAdequacyMinResults(process.env["OPEN_SECOND_BRAIN_RECALL_ADEQUACY_MIN_RESULTS"], data["recall_adequacy_min_results"]); + return { sufficient, weak, minResults }; +} +function resolveAdequacyFloor(key, env, fileValue, fallback) { + const raw = (env?.trim() || fileValue?.trim()) ?? ""; + if (raw === "") + return fallback; + const value = Number(raw); + if (!Number.isFinite(value) || value < 0 || value > 1) { + throw new Error(`${key} must be a number in [0,1]; got '${raw}'`); + } + return value; +} +function resolveAdequacyMinResults(env, fileValue) { + const raw = (env?.trim() || fileValue?.trim()) ?? ""; + if (raw === "") + return 1; + const value = Number(raw); + if (!Number.isInteger(value) || value < 1) { + throw new Error(`recall_adequacy_min_results must be a positive integer; got '${raw}'`); + } + return value; +} +function resolveGenerationTraceEnabled(configPath) { + return resolveConfigFlag("OPEN_SECOND_BRAIN_GENERATION_TRACE_ENABLED", "generation_trace_enabled", configPath); +} +function resolveMcpRouteMetricsEnabled(configPath) { + return resolveConfigFlag("OPEN_SECOND_BRAIN_MCP_ROUTE_METRICS_ENABLED", "mcp_route_metrics_enabled", configPath); +} +function resolveTokenImpactLedgerEnabled(configPath) { + return resolveConfigFlag("OPEN_SECOND_BRAIN_TOKEN_IMPACT_LEDGER_ENABLED", "token_impact_ledger_enabled", configPath); +} +function resolveContextPackOutcomeEnabled(configPath) { + return resolveConfigFlag("OPEN_SECOND_BRAIN_CONTEXT_PACK_OUTCOME_ENABLED", "context_pack_outcome_enabled", configPath); +} +function resolveRecallInjectEnabled(configPath) { + return resolveConfigFlag("OPEN_SECOND_BRAIN_RECALL_INJECT_ENABLED", "recall_inject_enabled", configPath); +} +function parseBounded(raw, min, max, integer) { + const value = Number(raw); + if (!Number.isFinite(value) || integer && !Number.isInteger(value)) + return; + if (value < min || value > max) + return; + return value; +} +function resolveRecallInjectCaps(configPath) { + const data = discoverConfig(configPath).data; + const caps = {}; + const invalid = []; + for (const spec of RECALL_INJECT_CAP_SPECS) { + const raw = readSetting(spec.env, spec.key, data); + if (raw === undefined) + continue; + const value = parseBounded(raw, spec.min, spec.max, spec.integer); + if (value === undefined) + invalid.push(rejectedSettingName(spec.env, spec.key)); + else + caps[spec.field] = value; + } + return { caps, invalid }; +} +function resolveRecallInjectDedupe(configPath) { + const raw = readSetting("OPEN_SECOND_BRAIN_RECALL_INJECT_DEDUPE", "recall_inject_dedupe", discoverConfig(configPath).data); + return raw !== "false" && raw !== "0"; +} +function resolveRegroundPartsEnabled(configPath) { + return resolveConfigFlag("OPEN_SECOND_BRAIN_REGROUND_PARTS_ENABLED", "reground_parts_enabled", configPath); +} +function resolveRegroundPartChars(runtime, configPath) { + const data = discoverConfig(configPath).data; + const levels = [ + [ + `OPEN_SECOND_BRAIN_REGROUND_PART_CHARS_${runtime.toUpperCase()}`, + `reground_part_chars_${runtime}` + ], + ["OPEN_SECOND_BRAIN_REGROUND_PART_CHARS", "reground_part_chars"] + ]; + const invalid = []; + for (const [envKey, configKey] of levels) { + const raw = readSetting(envKey, configKey, data); + if (raw === undefined) + continue; + const value = parseBounded(raw, 2000, 1e5, true); + if (value !== undefined) + return { chars: value, invalid }; + invalid.push(rejectedSettingName(envKey, configKey)); + } + return { chars: REGROUND_PART_CHARS_DEFAULT, invalid }; +} +function resolveHygieneDigestEnabled(configPath) { + return resolveConfigFlag("OPEN_SECOND_BRAIN_HYGIENE_DIGEST_ENABLED", "hygiene_digest_enabled", configPath); +} +function resolveNavTierEnabled(configPath) { + return resolveConfigFlag("OPEN_SECOND_BRAIN_NAV_TIER_ENABLED", "nav_tier_enabled", configPath); +} +function resolveNavTierCadenceMinutes(configPath) { + const env = process.env["OPEN_SECOND_BRAIN_NAV_TIER_CADENCE_MINUTES"]?.trim(); + const raw = env || discoverConfig(configPath).data["nav_tier_cadence_minutes"]?.trim(); + if (!raw) + return; + const value = Number(raw); + if (!Number.isInteger(value) || value < 1) + return; + return value; +} +function resolveHookStrictEnabled(configPath) { + return resolveConfigFlag("OPEN_SECOND_BRAIN_HOOK_STRICT_ENABLED", "hook_strict_enabled", configPath); +} +function resolveMaintenanceEmbeddings(configPath) { + return resolveConfigFlag(MAINTENANCE_EMBEDDINGS_ENV, MAINTENANCE_EMBEDDINGS_CONFIG_KEY, configPath); +} +function resolveMaintenanceCustomTasksSwitch(configPath, data = discoverConfig(configPath).data) { + const env = process.env[MAINTENANCE_CUSTOM_TASKS_ENV]?.trim(); + const fromConfig = data[MAINTENANCE_CUSTOM_TASKS_CONFIG_KEY]?.trim(); + return { + enabled: isFlagOn(env || fromConfig), + source: env ? "env" : fromConfig ? "config" : "unset" + }; +} function resolvePartnerCodegraphDisabled(configPath) { return resolveConfigFlag(PARTNER_CODEGRAPH_DISABLED_ENV, PARTNER_CODEGRAPH_DISABLED_CONFIG_KEY, configPath); } +function resolveGapLoopEnabled(configPath) { + return resolveConfigFlag("OPEN_SECOND_BRAIN_GAP_LOOP_ENABLED", "gap_loop_enabled", configPath); +} +function resolveGapLoopThreshold(configPath) { + const env = process.env["OPEN_SECOND_BRAIN_GAP_LOOP_THRESHOLD"]?.trim(); + const raw = env || discoverConfig(configPath).data["gap_loop_threshold"]?.trim(); + if (!raw) + return; + const value = Number(raw); + if (!Number.isInteger(value) || value < 1) + return; + return value; +} +function resolveBenchJudgeCmd(configPath) { + const env = process.env["OPEN_SECOND_BRAIN_BENCH_JUDGE_CMD"]?.trim(); + const raw = env || discoverConfig(configPath).data["bench_judge_cmd"]?.trim(); + return raw !== undefined && raw !== "" ? raw : undefined; +} +function isSessionCaptureRole(value) { + return SESSION_CAPTURE_ROLES.includes(value); +} +function resolveSessionCaptureRoles(configPath) { + const env = process.env["OPEN_SECOND_BRAIN_SESSION_CAPTURE_ROLES"]?.trim(); + const raw = env || discoverConfig(configPath).data["session_capture_roles"]?.trim(); + if (!raw) + return null; + const roles = []; + for (const part of raw.split(",")) { + const role = part.trim().toLowerCase(); + if (role.length === 0) + continue; + if (!isSessionCaptureRole(role)) { + throw new Error(`session_capture_roles: unknown role "${role}" (expected a subset of ${SESSION_CAPTURE_ROLES.join(", ")})`); + } + if (!roles.includes(role)) + roles.push(role); + } + return roles.length > 0 ? roles : null; +} +function resolveTelegramBotToken(configPath, secretsVault) { + const env = process.env["TELEGRAM_BOT_TOKEN"]?.trim(); + const raw = env || discoverConfig(configPath).data["telegram_bot_token"]?.trim(); + if (raw === undefined || raw.length === 0) + return null; + if (secretsVault !== undefined && isSecretReferenceValue(raw)) { + return resolveThroughNamedSecretResolver(secretsVault, raw); + } + return raw; +} +function resolveTelegramCaptureAllowlist(configPath) { + const env = process.env["TELEGRAM_CHAT_ALLOWLIST"]?.trim(); + const raw = env || discoverConfig(configPath).data["telegram_chat_allowlist"]?.trim(); + if (!raw) + return []; + const ids = []; + for (const part of raw.split(",")) { + const id = part.trim(); + if (id.length > 0 && !ids.includes(id)) + ids.push(id); + } + return ids; +} function expandTilde(p, platform = process.platform, home = homedir2()) { if (p === "~") return home; if (p.startsWith("~/")) - return join3(home, p.slice(2)); + return join5(home, p.slice(2)); if (platform === "win32" && p.startsWith("~\\")) - return join3(home, p.slice(2)); + return join5(home, p.slice(2)); return p; } -var import_proper_lockfile2, namedSecretResolver, CONFIG_VALUE_REJECTED_CHARS, UNSUPPORTED_CONFIG_PLATFORMS, UnsupportedPlatformError, ConfigReadError, UNCONFIGURED_AGENT_NAME = "agent", DEVICE_ID_RE, INSTALLATION_SECRET_CONFIG_KEY = "installation_secret", INSTALLATION_SECRET_ENV_KEY = "O2B_INSTALLATION_SECRET", INSTALLATION_SECRET_RE, VAULT_STORE_REF_PREFIX = "vault://", VAULT_STORE_REF_HEX_LEN = 32, PARTNER_CODEGRAPH_DISABLED_ENV = "OPEN_SECOND_BRAIN_PARTNER_CODEGRAPH_DISABLED", PARTNER_CODEGRAPH_DISABLED_CONFIG_KEY = "partner_codegraph_disabled"; +var import_proper_lockfile2, namedSecretResolver, CONFIG_VALUE_REJECTED_CHARS, UNSUPPORTED_CONFIG_PLATFORMS, UnsupportedPlatformError, ConfigReadError, UNCONFIGURED_AGENT_NAME = "agent", SHARED_NAMESPACE_KEY = "shared_namespace", DEVICE_ID_RE, INSTALLATION_SECRET_CONFIG_KEY = "installation_secret", INSTALLATION_SECRET_ENV_KEY = "O2B_INSTALLATION_SECRET", INSTALLATION_SECRET_RE, VAULT_STORE_REF_PREFIX = "vault://", VAULT_STORE_REF_HEX_LEN = 32, DECISION_RECALL_MAX_PER_SESSION_CONFIG_KEY = "decision_recall.max_per_session", DECISION_RECALL_MAX_PER_SESSION_ENV_KEY = "OPEN_SECOND_BRAIN_DECISION_RECALL_MAX_PER_SESSION", DECISION_RECALL_MIN_SPACING_TURNS_CONFIG_KEY = "decision_recall.min_spacing_turns", DECISION_RECALL_MIN_SPACING_TURNS_ENV_KEY = "OPEN_SECOND_BRAIN_DECISION_RECALL_MIN_SPACING_TURNS", RECALL_INJECT_CAP_SPECS, REGROUND_PART_CHARS_DEFAULT = 9000, MAINTENANCE_EMBEDDINGS_ENV = "OPEN_SECOND_BRAIN_MAINTENANCE_EMBEDDINGS", MAINTENANCE_EMBEDDINGS_CONFIG_KEY = "maintenance_embeddings", MAINTENANCE_CUSTOM_TASKS_ENV = "OPEN_SECOND_BRAIN_MAINTENANCE_CUSTOM_TASKS", MAINTENANCE_CUSTOM_TASKS_CONFIG_KEY = "maintenance_custom_tasks", PARTNER_CODEGRAPH_DISABLED_ENV = "OPEN_SECOND_BRAIN_PARTNER_CODEGRAPH_DISABLED", PARTNER_CODEGRAPH_DISABLED_CONFIG_KEY = "partner_codegraph_disabled", SESSION_CAPTURE_ROLES; var init_config = __esm(() => { init_fs_atomic(); init_secret_ref(); @@ -2120,6 +2622,41 @@ var init_config = __esm(() => { }; DEVICE_ID_RE = /^[a-z0-9][a-z0-9-]{0,31}$/; INSTALLATION_SECRET_RE = /^[0-9a-f]{32}$/; + RECALL_INJECT_CAP_SPECS = [ + { + field: "maxNotes", + env: "OPEN_SECOND_BRAIN_RECALL_INJECT_MAX_NOTES", + key: "recall_inject_max_notes", + min: 1, + max: 10, + integer: true + }, + { + field: "maxChars", + env: "OPEN_SECOND_BRAIN_RECALL_INJECT_MAX_CHARS", + key: "recall_inject_max_chars", + min: 200, + max: 8000, + integer: true + }, + { + field: "timeBudgetMs", + env: "OPEN_SECOND_BRAIN_RECALL_INJECT_TIME_BUDGET_MS", + key: "recall_inject_time_budget_ms", + min: 250, + max: 6000, + integer: true + }, + { + field: "confidenceFloor", + env: "OPEN_SECOND_BRAIN_RECALL_INJECT_CONFIDENCE_FLOOR", + key: "recall_inject_confidence_floor", + min: 0, + max: 1, + integer: false + } + ]; + SESSION_CAPTURE_ROLES = ["user", "assistant", "system", "tool", "meta"]; }); // src/core/redactor.ts @@ -2255,23 +2792,23 @@ function scanRawOutput(text, opts = {}) { return `${keyPart}"${PLACEHOLDER}"`; return `${keyPart}${PLACEHOLDER}`; }); - out = out.replace(ENV_RE, (_match, key, sep) => { - return `${key}${sep}${PLACEHOLDER}`; + out = out.replace(ENV_RE, (_match, key, sep2) => { + return `${key}${sep2}${PLACEHOLDER}`; }); out = out.replace(BEARER_RE, (_match, prefix) => `${prefix}${PLACEHOLDER}`); out = out.replace(JWT_RE, PLACEHOLDER); out = out.replace(YAML_SECRET_BLOCK_RE, (_match, indent, key) => `${indent}${key}: "${PLACEHOLDER}" `); - out = out.replace(COLON_VALUE_RE, (match, key, sep, value) => { + out = out.replace(COLON_VALUE_RE, (match, key, sep2, value) => { if (value.includes(PLACEHOLDER)) return match; if (value.startsWith('"') && value.endsWith('"')) { - return `${key}${sep}"${PLACEHOLDER}"`; + return `${key}${sep2}"${PLACEHOLDER}"`; } if (value.startsWith("'") && value.endsWith("'")) { - return `${key}${sep}'${PLACEHOLDER}'`; + return `${key}${sep2}'${PLACEHOLDER}'`; } - return `${key}${sep}"${PLACEHOLDER}"`; + return `${key}${sep2}"${PLACEHOLDER}"`; }); if (opts.redactTokens) out = redactBareTokens(out); @@ -2564,15 +3101,15 @@ var init_ledger_shards = __esm(() => { }); // src/core/reliability/audit.ts -import { closeSync as closeSync2, fsyncSync as fsyncSync2, mkdirSync as mkdirSync3, openSync as openSync2, writeFileSync } from "node:fs"; -import { join as join4 } from "node:path"; +import { closeSync as closeSync2, fsyncSync as fsyncSync2, mkdirSync as mkdirSync5, openSync as openSync2, writeFileSync } from "node:fs"; +import { join as join6 } from "node:path"; function appendAuditRecord(auditRoot, record) { const timestamp = new Date(record.timestamp); if (!Number.isFinite(timestamp.getTime())) { throw new Error(`invalid audit timestamp: ${record.timestamp}`); } - mkdirSync3(auditRoot, { recursive: true }); - const path = join4(auditRoot, shardedFileName(isoWeekLabel(timestamp), resolveAppendShardId(), JSONL_LEDGER_EXT)); + mkdirSync5(auditRoot, { recursive: true }); + const path = join6(auditRoot, shardedFileName(isoWeekLabel(timestamp), resolveAppendShardId(), JSONL_LEDGER_EXT)); const line = redactRawOutput(JSON.stringify(record), { maxInput: Number.POSITIVE_INFINITY }); @@ -2617,36 +3154,36 @@ var init_audit_dirs = __esm(() => { }); // src/core/path-safety.ts -import { existsSync as existsSync2, realpathSync, statSync as statSync3 } from "node:fs"; -import { basename as basename2, dirname as dirname3, join as join5, posix as posix2, relative, resolve as resolve3, sep } from "node:path"; +import { existsSync as existsSync4, realpathSync, statSync as statSync4 } from "node:fs"; +import { basename as basename2, dirname as dirname5, join as join7, posix as posix2, relative, resolve as resolve4, sep as sep2 } from "node:path"; function ensureInsideVault(target, vault) { - const resolvedTarget = resolve3(target); - const resolvedVault = resolve3(vault); + const resolvedTarget = resolve4(target); + const resolvedVault = resolve4(vault); if (!isLexicallyInside(resolvedTarget, resolvedVault)) { throw new VaultEscapeError(`path escapes vault: ${target}`); } - if (existsSync2(resolvedVault) && !realpathInsideVault(resolvedTarget, resolvedVault)) { + if (existsSync4(resolvedVault) && !realpathInsideVault(resolvedTarget, resolvedVault)) { throw new VaultEscapeError(`path escapes vault via symlink: ${target}`); } return resolvedTarget; } function realpathInsideVault(target, vault) { - const resolvedVault = resolve3(vault); - if (!existsSync2(resolvedVault)) + const resolvedVault = resolve4(vault); + if (!existsSync4(resolvedVault)) return true; const realVault = safeRealpath(resolvedVault); - const realAncestor = safeRealpath(deepestExistingAncestor(resolve3(target))); + const realAncestor = safeRealpath(deepestExistingAncestor(resolve4(target))); return isLexicallyInside(realAncestor, realVault); } function isLexicallyInside(target, root) { const t = process.platform === "win32" ? target.toLowerCase() : target; const r = process.platform === "win32" ? root.toLowerCase() : root; - return t === r || t.startsWith(r + sep); + return t === r || t.startsWith(r + sep2); } function deepestExistingAncestor(target) { let cur = target; - while (!existsSync2(cur)) { - const parent = dirname3(cur); + while (!existsSync4(cur)) { + const parent = dirname5(cur); if (parent === cur) return cur; cur = parent; @@ -2663,7 +3200,7 @@ function safeRealpath(p) { } } function vaultRelative(target, vault) { - const rel = relative(resolve3(vault), resolve3(target)); + const rel = relative(resolve4(vault), resolve4(target)); return rel.split(/[\\/]/).filter((p) => p.length > 0).join(posix2.sep); } var VAULT_ESCAPE_CODE = "ESCAPE", VaultEscapeError; @@ -2782,16 +3319,16 @@ var init_stamp = __esm(() => { }); // src/core/brain/freeze-marker.ts -import { existsSync as existsSync3, readFileSync as readFileSync3, statSync as statSync4 } from "node:fs"; -import { join as join6, resolve as resolve4 } from "node:path"; +import { existsSync as existsSync5, readFileSync as readFileSync5, statSync as statSync5 } from "node:fs"; +import { join as join8, resolve as resolve5 } from "node:path"; function frozenMarkerPath(vault) { - return ensureInsideVault(join6(vault, BRAIN_INTERNAL_STATE_REL, FROZEN_MARKER_FILE), vault); + return ensureInsideVault(join8(vault, BRAIN_INTERNAL_STATE_REL, FROZEN_MARKER_FILE), vault); } function parseMarker(path) { reloadCount += 1; let parsed; try { - parsed = JSON.parse(readFileSync3(path, "utf8")); + parsed = JSON.parse(readFileSync5(path, "utf8")); } catch { return UNREADABLE_MARKER; } @@ -2808,7 +3345,7 @@ function parseMarker(path) { }); } function readFreezeMarker(vault) { - const root = resolve4(vault); + const root = resolve5(vault); let path = MARKER_PATHS.get(root); if (path === undefined) { path = frozenMarkerPath(root); @@ -2816,7 +3353,7 @@ function readFreezeMarker(vault) { } let stat; try { - stat = statSync4(path, { throwIfNoEntry: false }); + stat = statSync5(path, { throwIfNoEntry: false }); } catch { stat = undefined; } @@ -2842,7 +3379,7 @@ function vaultFrozenNotice(vault, marker) { return degradationNotice({ code: DEGRADATION_CODE.vaultFrozen, site: SITE, - path: resolve4(vault), + path: resolve5(vault), detail: `refusing to write: this vault was frozen at ${marker.frozen_at} by ` + `${marker.by === "" ? "an unnamed agent" : marker.by} (${why}). ` + `Run \`${FREEZE_NEXT_COMMAND}\` to lift it` }); } @@ -2889,17 +3426,17 @@ var init_freeze_marker = __esm(() => { }); // src/core/brain/vault-identity.ts -import { existsSync as existsSync4, readFileSync as readFileSync4, statSync as statSync5 } from "node:fs"; -import { join as join7, resolve as resolve5 } from "node:path"; +import { existsSync as existsSync6, readFileSync as readFileSync6, statSync as statSync6 } from "node:fs"; +import { join as join9, resolve as resolve6 } from "node:path"; function vaultIdentityPath(vault) { - return ensureInsideVault(join7(vault, BRAIN_ROOT_REL, VAULT_IDENTITY_FILE), vault); + return ensureInsideVault(join9(vault, BRAIN_ROOT_REL, VAULT_IDENTITY_FILE), vault); } function readVaultIdentity(vault) { const path = vaultIdentityPath(vault); - if (!existsSync4(path)) + if (!existsSync6(path)) return null; try { - const parsed = JSON.parse(readFileSync4(path, "utf8")); + const parsed = JSON.parse(readFileSync6(path, "utf8")); if (typeof parsed.vault_id !== "string" || parsed.vault_id.length === 0) return null; return Object.freeze({ @@ -2919,7 +3456,7 @@ function currentVaultId(root) { } let stat; try { - stat = statSync5(path, { throwIfNoEntry: false }); + stat = statSync6(path, { throwIfNoEntry: false }); } catch { stat = undefined; } @@ -2945,7 +3482,7 @@ function currentVaultId(root) { return identity.vault_id; } function vaultMarkerAbsentNotice(vault) { - const root = resolve5(vault); + const root = resolve6(vault); if (currentVaultId(root) !== null) return null; return degradationNotice({ @@ -2956,7 +3493,7 @@ function vaultMarkerAbsentNotice(vault) { }); } function assertVaultIdentityForWrite(vault, sink, lane = WRITE_LANE.content) { - const root = resolve5(vault); + const root = resolve6(vault); if (lane === WRITE_LANE.content) assertVaultNotFrozen(root); const vaultId = currentVaultId(root); @@ -3008,21 +3545,21 @@ var init_vault_identity = __esm(() => { }); // src/core/brain/paths.ts -import { join as join8 } from "node:path"; +import { join as join10 } from "node:path"; function brainDirs(vault) { - const brain = ensureInsideVault(join8(vault, BRAIN_ROOT_REL), vault); + const brain = ensureInsideVault(join10(vault, BRAIN_ROOT_REL), vault); return { brain, - inbox: ensureInsideVault(join8(vault, BRAIN_INBOX_REL), vault), - processed: ensureInsideVault(join8(vault, BRAIN_PROCESSED_REL), vault), - archived: ensureInsideVault(join8(vault, BRAIN_ARCHIVED_SIGNALS_REL), vault), - pending: ensureInsideVault(join8(vault, BRAIN_PENDING_REL), vault), - preferences: ensureInsideVault(join8(vault, BRAIN_PREFERENCES_REL), vault), - retired: ensureInsideVault(join8(vault, BRAIN_RETIRED_REL), vault), - log: ensureInsideVault(join8(vault, BRAIN_LOG_REL), vault), - entities: ensureInsideVault(join8(vault, BRAIN_ENTITIES_REL), vault), - bases: ensureInsideVault(join8(vault, BRAIN_BASES_REL), vault), - snapshots: ensureInsideVault(join8(vault, BRAIN_SNAPSHOTS_REL), vault) + inbox: ensureInsideVault(join10(vault, BRAIN_INBOX_REL), vault), + processed: ensureInsideVault(join10(vault, BRAIN_PROCESSED_REL), vault), + archived: ensureInsideVault(join10(vault, BRAIN_ARCHIVED_SIGNALS_REL), vault), + pending: ensureInsideVault(join10(vault, BRAIN_PENDING_REL), vault), + preferences: ensureInsideVault(join10(vault, BRAIN_PREFERENCES_REL), vault), + retired: ensureInsideVault(join10(vault, BRAIN_RETIRED_REL), vault), + log: ensureInsideVault(join10(vault, BRAIN_LOG_REL), vault), + entities: ensureInsideVault(join10(vault, BRAIN_ENTITIES_REL), vault), + bases: ensureInsideVault(join10(vault, BRAIN_BASES_REL), vault), + snapshots: ensureInsideVault(join10(vault, BRAIN_SNAPSHOTS_REL), vault) }; } function brainDirsForWrite(vault, notices, lane) { @@ -3048,9 +3585,11 @@ var init_time = () => {}; // src/core/brain/secrets/value-cipher.ts import { createCipheriv, createDecipheriv, randomBytes as randomBytes2 } from "node:crypto"; -function encryptValue(key, plaintext) { +function encryptValue(key, plaintext, aad) { const iv = randomBytes2(IV_BYTES); const cipher = createCipheriv(ALGORITHM, key, iv); + if (aad !== undefined) + cipher.setAAD(Buffer.from(aad, "utf8")); const ciphertext = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]); return { ciphertext: ciphertext.toString("base64"), @@ -3058,9 +3597,11 @@ function encryptValue(key, plaintext) { tag: cipher.getAuthTag().toString("base64") }; } -function decryptValue(key, encrypted) { +function decryptValue(key, encrypted, aad) { const decipher = createDecipheriv(ALGORITHM, key, Buffer.from(encrypted.iv, "base64")); decipher.setAuthTag(Buffer.from(encrypted.tag, "base64")); + if (aad !== undefined) + decipher.setAAD(Buffer.from(aad, "utf8")); const plaintext = Buffer.concat([ decipher.update(Buffer.from(encrypted.ciphertext, "base64")), decipher.final() @@ -3072,7 +3613,7 @@ var init_value_cipher = () => {}; // src/core/brain/secrets/owner-acl.ts import { spawnSync } from "node:child_process"; -import { resolve as resolve6, win32 as win322 } from "node:path"; +import { resolve as resolve7, win32 as win322 } from "node:path"; function system32Tool(name, env = process.env) { const root = env["SystemRoot"] || env["windir"] || "C:\\Windows"; return win322.join(root, "System32", name); @@ -3103,7 +3644,7 @@ function ownerOnlyAclArgv(path, sid, kind) { function restrictToOwner(path, kind, platform = process.platform, force = false) { if (platform !== "win32") return true; - const key = `${kind}:${resolve6(path).toLowerCase()}`; + const key = `${kind}:${resolve7(path).toLowerCase()}`; if (!force && restricted.has(key)) return true; let detail; @@ -3140,8 +3681,16 @@ var init_owner_acl = __esm(() => { // src/core/brain/secrets/envelope.ts import { randomBytes as randomBytes3, scryptSync, timingSafeEqual } from "node:crypto"; -import { chmodSync, readFileSync as readFileSync5, unlinkSync as unlinkSync2, writeFileSync as writeFileSync2 } from "node:fs"; -import { resolve as resolve7 } from "node:path"; +import { + chmodSync, + closeSync as closeSync3, + fsyncSync as fsyncSync3, + openSync as openSync3, + readFileSync as readFileSync7, + unlinkSync as unlinkSync2, + writeSync as writeSync2 +} from "node:fs"; +import { dirname as dirname6, resolve as resolve8 } from "node:path"; function kdfCostCurveRefusal(kdf) { if (kdf.n > SCRYPT_N_MAX) { return `kdf n ${String(kdf.n)} exceeds this build's ceiling ${String(SCRYPT_N_MAX)}`; @@ -3162,14 +3711,36 @@ function kdfCostCurveRefusal(kdf) { return null; } function holderSlot(keyPath) { - const resolved = resolve7(keyPath); + const resolved = resolve8(keyPath); return process.platform === "win32" ? resolved.toLowerCase() : resolved; } +function heldUnlockedKey(keyPath) { + return HELD_KEYS.get(holderSlot(keyPath)) ?? null; +} function heldKeyOrRefusal(keyPath) { const held = HELD_KEYS.get(holderSlot(keyPath)); - if (held === undefined) - throw new SecretStoreLockedError(keyPath); - return held; + if (held !== undefined) + return held; + const fromEnvironment = unlockFromEnvironmentOffer(keyPath); + if (fromEnvironment !== null) + return fromEnvironment; + throw new SecretStoreLockedError(keyPath); +} +function unlockFromEnvironmentOffer(keyPath) { + const offered = process.env[SECRET_STORE_PASSPHRASE_ENV]; + if (offered === undefined) + return null; + delete process.env[SECRET_STORE_PASSPHRASE_ENV]; + if (offered.length === 0) + return null; + try { + return unlockKeyfile(keyPath, offered); + } catch (err) { + if (err instanceof SecretEnvelopeError && err.code === ENVELOPE_REFUSAL_CODES.passphrase) { + throw new SecretEnvelopeError(ENVELOPE_REFUSAL_CODES.passphrase, keyPath, `the passphrase in ${SECRET_STORE_PASSPHRASE_ENV} does not unwrap this envelope ` + "(wrong passphrase, or the envelope is corrupt)"); + } + throw err; + } } function clearHeldKey(keyPath) { const slot = holderSlot(keyPath); @@ -3199,7 +3770,7 @@ function hasEnvelopeShape(parsed) { function isEnvelopeFile(keyPath) { let bytes; try { - bytes = readFileSync5(keyPath); + bytes = readFileSync7(keyPath); } catch { return false; } @@ -3215,7 +3786,7 @@ function isEnvelopeBytes(bytes) { function readEnvelope(keyPath) { let parsed; try { - parsed = JSON.parse(readFileSync5(keyPath, "utf8")); + parsed = JSON.parse(readFileSync7(keyPath, "utf8")); } catch (err) { throw new SecretEnvelopeError(ENVELOPE_REFUSAL_CODES.malformed, keyPath, `not parseable as an envelope: ${err instanceof Error ? err.message : String(err)}`); } @@ -3268,7 +3839,7 @@ function unwrapWith(derived, envelope, keyPath) { } return dek; } -function wrapKeyfile(keyPath, passphrase, dek) { +function wrapKeyfile(keyPath, passphrase, dek, seams = {}) { if (passphrase.length === 0) { throw new SecretEnvelopeError(ENVELOPE_REFUSAL_CODES.passphrase, keyPath, "a wrap passphrase must not be empty"); } @@ -3276,23 +3847,31 @@ function wrapKeyfile(keyPath, passphrase, dek) { throw new SecretEnvelopeError(ENVELOPE_REFUSAL_CODES.malformed, keyPath, `refusing to wrap ${String(dek.length)} bytes of key material, expected ${String(DEK_BYTES)}`); } const kdf = freshWrapKdfParams(); + const derived = deriveWrapKey(passphrase, kdf); + const seal = seams.seal ?? ((key, plaintext) => encryptValue(key, plaintext)); const envelope = { version: KEYFILE_ENVELOPE_SCHEMA_VERSION, kdf, - wrapped: encryptValue(deriveWrapKey(passphrase, kdf), dek.toString("base64")) + wrapped: seal(derived, dek.toString("base64")) }; - const tmp = `${keyPath}.wrap-tmp`; + const unwrapped = unwrapWith(derived, envelope, keyPath); + if (!timingSafeEqual(unwrapped, dek)) { + throw new SecretEnvelopeError(ENVELOPE_REFUSAL_CODES.malformed, keyPath, "the produced envelope does not unwrap to the key it wraps; the raw keyfile was left untouched"); + } + const envelopeText = `${JSON.stringify(envelope, null, 2)} +`; + const write = seams.write ?? ((target, text) => { + atomicWriteText(target, text, { mode: 384 }); + }); + write(keyPath, envelopeText); try { - writeFileSync2(tmp, `${JSON.stringify(envelope, null, 2)} -`, { - encoding: "utf8", - mode: 384 - }); - renameWithRetry(tmp, keyPath); + const landed = readEnvelope(keyPath); + const restored = unwrapWith(derived, landed, keyPath); + if (!timingSafeEqual(restored, dek)) { + throw new SecretEnvelopeError(ENVELOPE_REFUSAL_CODES.malformed, keyPath, "the written envelope does not unwrap to the key it wraps"); + } } catch (err) { - try { - unlinkSync2(tmp); - } catch {} + writeRawKeyfileBytes(keyPath, dek); throw err; } restrictToOwner(keyPath, "file", process.platform, true); @@ -3306,6 +3885,48 @@ function wrapKeyfile(keyPath, passphrase, dek) { } return envelope; } +function writeRawKeyfileBytes(keyPath, dek) { + if (dek.length !== DEK_BYTES) { + throw new SecretEnvelopeError(ENVELOPE_REFUSAL_CODES.malformed, keyPath, `refusing to write ${String(dek.length)} bytes of key material, expected ${String(DEK_BYTES)}`); + } + const tmp = `${keyPath}.raw-tmp`; + let fd = null; + try { + fd = openSync3(tmp, "wx", 384); + let written = 0; + while (written < dek.byteLength) { + written += writeSync2(fd, dek, written, dek.byteLength - written); + } + fsyncSync3(fd); + closeSync3(fd); + fd = null; + renameWithRetry(tmp, keyPath); + } catch (err) { + if (fd !== null) { + try { + closeSync3(fd); + } catch {} + } + try { + unlinkSync2(tmp); + } catch {} + throw err; + } + try { + const dfd = openSync3(dirname6(keyPath), "r"); + try { + fsyncSync3(dfd); + } finally { + closeSync3(dfd); + } + } catch {} +} +function unwrapKeyfileToRaw(keyPath, passphrase) { + const envelope = readEnvelope(keyPath); + const dek = unwrapWith(deriveWrapKey(passphrase, envelope.kdf), envelope, keyPath); + writeRawKeyfileBytes(keyPath, dek); + return dek; +} function unlockKeyfile(keyPath, passphrase) { const envelope = readEnvelope(keyPath); const dek = unwrapWith(deriveWrapKey(passphrase, envelope.kdf), envelope, keyPath); @@ -3317,7 +3938,7 @@ function unlockKeyfile(keyPath, passphrase) { HELD_KEYS.set(slot, dek); return dek; } -var KEYFILE_ENVELOPE_SCHEMA_VERSION = 1, KDF_ALGO = "scrypt", WRAP_KEY_BYTES = 32, DEK_BYTES = 32, SALT_BYTES = 16, SCRYPT_N, SCRYPT_R = 8, SCRYPT_P = 1, SCRYPT_MAXMEM, SCRYPT_N_MAX, SCRYPT_R_MAX = 16, SCRYPT_P_MAX = 8, SCRYPT_MAXMEM_MAX, ENVELOPE_REFUSAL_CODES, SecretEnvelopeError, SECRET_STORE_LOCKED_CODE = "secret_store_locked", SecretStoreLockedError, SECRET_STORE_KEYFILE_MISSING_CODE = "secret_store_keyfile_missing", SecretStoreKeyfileMissingError, HELD_KEYS; +var KEYFILE_ENVELOPE_SCHEMA_VERSION = 1, KDF_ALGO = "scrypt", WRAP_KEY_BYTES = 32, DEK_BYTES = 32, SALT_BYTES = 16, SCRYPT_N, SCRYPT_R = 8, SCRYPT_P = 1, SCRYPT_MAXMEM, SCRYPT_N_MAX, SCRYPT_R_MAX = 16, SCRYPT_P_MAX = 8, SCRYPT_MAXMEM_MAX, ENVELOPE_REFUSAL_CODES, SecretEnvelopeError, SECRET_STORE_LOCKED_CODE = "secret_store_locked", SECRET_STORE_PASSPHRASE_ENV = "OPEN_SECOND_BRAIN_SECRETS_PASSPHRASE", SecretStoreLockedError, SECRET_STORE_KEYFILE_MISSING_CODE = "secret_store_keyfile_missing", SecretStoreKeyfileMissingError, HELD_KEYS; var init_envelope = __esm(() => { init_fs_atomic(); init_value_cipher(); @@ -3347,7 +3968,7 @@ var init_envelope = __esm(() => { code = SECRET_STORE_LOCKED_CODE; keyPath; constructor(keyPath) { - super(`the secret store is locked (the keyfile is passphrase-wrapped): run ` + `"o2b brain secret unlock" to unwrap it for this process - the unlock ` + `applies to this process only and the passphrase is never persisted, ` + `so a key-bearing command must run in the same process that unlocked it`); + super(`the secret store is locked (the keyfile is passphrase-wrapped): pass the ` + `passphrase to this command via --passphrase-from-env or stdin, or set ` + `${SECRET_STORE_PASSPHRASE_ENV} for this process (a non-interactive host such ` + `as the MCP server reads it once and drops it), or run ` + `"o2b brain secret unlock" to unlock this process only - the passphrase is ` + `never persisted; "o2b brain secret unwrap" writes the raw keyfile back`); this.name = "SecretStoreLockedError"; this.keyPath = keyPath; } @@ -3368,21 +3989,21 @@ var init_envelope = __esm(() => { import { randomBytes as randomBytes4 } from "node:crypto"; import { chmodSync as chmodSync2, - closeSync as closeSync3, - existsSync as existsSync5, - mkdirSync as mkdirSync4, - openSync as openSync3, - readFileSync as readFileSync6, - writeFileSync as writeFileSync3, - writeSync as writeSync2 + closeSync as closeSync4, + existsSync as existsSync7, + mkdirSync as mkdirSync6, + openSync as openSync4, + readFileSync as readFileSync8, + writeFileSync as writeFileSync2, + writeSync as writeSync3 } from "node:fs"; -import { dirname as dirname4, join as join9 } from "node:path"; +import { dirname as dirname7, join as join11 } from "node:path"; function ensureSyncExclusionMarker(dir) { - const marker = join9(dir, ".gitignore"); - if (existsSync5(marker)) + const marker = join11(dir, ".gitignore"); + if (existsSync7(marker)) return; try { - writeFileSync3(marker, SYNC_EXCLUSION_CONTENT, { encoding: "utf8", flag: "wx", mode: 384 }); + writeFileSync2(marker, SYNC_EXCLUSION_CONTENT, { encoding: "utf8", flag: "wx", mode: 384 }); } catch (err) { if (err.code !== "EEXIST") { process.stderr.write(`warning: could not write the sync-exclusion marker for the secrets directory: ` + `${marker}: ${err instanceof Error ? err.message : String(err)} @@ -3391,8 +4012,8 @@ function ensureSyncExclusionMarker(dir) { } } function loadOrCreateKey(keyPath) { - const keyDir = dirname4(keyPath); - if (existsSync5(keyPath)) { + const keyDir = dirname7(keyPath); + if (existsSync7(keyPath)) { restrictToOwner(keyDir, "directory"); restrictToOwner(keyPath, "file"); if (process.platform !== "win32") { @@ -3405,7 +4026,7 @@ function loadOrCreateKey(keyPath) { } } ensureSyncExclusionMarker(keyDir); - const key2 = readFileSync6(keyPath); + const key2 = readFileSync8(keyPath); if (isEnvelopeBytes(key2)) return heldKeyOrRefusal(keyPath); if (key2.length !== KEY_BYTES) { @@ -3413,22 +4034,22 @@ function loadOrCreateKey(keyPath) { } return key2; } - mkdirSync4(keyDir, { recursive: true, mode: 448 }); + mkdirSync6(keyDir, { recursive: true, mode: 448 }); restrictToOwner(keyDir, "directory"); ensureSyncExclusionMarker(keyDir); const key = randomBytes4(KEY_BYTES); let fd; try { - fd = openSync3(keyPath, "wx", 384); + fd = openSync4(keyPath, "wx", 384); } catch (exc) { if (exc.code === "EEXIST") return loadOrCreateKey(keyPath); throw exc; } try { - writeSync2(fd, key); + writeSync3(fd, key); } finally { - closeSync3(fd); + closeSync4(fd); } restrictToOwner(keyPath, "file"); return key; @@ -3459,24 +4080,26 @@ __export(exports_store, { resolveSecretReadOnly: () => resolveSecretReadOnly, secretsDir: () => secretsDir, setSecret: () => setSecret, + storeLockedForThisProcess: () => storeLockedForThisProcess, tokenStorePath: () => tokenStorePath, unlockSecretKeyfile: () => unlockSecretKeyfile, + unwrapSecretKeyfile: () => unwrapSecretKeyfile, withSecretsLock: () => withSecretsLock, writeStore: () => writeStore }); -import { chmodSync as chmodSync3, existsSync as existsSync6, readFileSync as readFileSync7, statSync as statSync6, writeFileSync as writeFileSync4 } from "node:fs"; -import { join as join10 } from "node:path"; +import { chmodSync as chmodSync3, existsSync as existsSync8, readFileSync as readFileSync9, statSync as statSync7, writeFileSync as writeFileSync3 } from "node:fs"; +import { join as join12 } from "node:path"; function secretsDir(vault) { - return join10(vault, ".open-second-brain", "secrets"); + return join12(vault, ".open-second-brain", "secrets"); } function storePath(vault) { - return join10(secretsDir(vault), "secrets.json"); + return join12(secretsDir(vault), "secrets.json"); } function keyPath(vault) { - return join10(secretsDir(vault), "keyfile"); + return join12(secretsDir(vault), "keyfile"); } function tokenStorePath(vault) { - return join10(secretsDir(vault), "mcp-tokens.json"); + return join12(secretsDir(vault), "mcp-tokens.json"); } function isValidSecretName(name) { return NAME_RE.test(name); @@ -3607,7 +4230,7 @@ function resolveSecretReadOnly(vault, name) { throw new Error(`unknown secret "${normalized}"`); } const kp = keyPath(vault); - if (!existsSync6(kp)) + if (!existsSync8(kp)) throw new SecretStoreKeyfileMissingError(kp); const key = loadOrCreateKey(kp); return { @@ -3641,14 +4264,28 @@ function lockSecretKeyfile(vault, ctx) { clearHeldKey(kp); audit(vault, ctx, "secret_locked", "keyfile", {}); } +function storeLockedForThisProcess(vault) { + const kp = keyPath(vault); + return isEnvelopeFile(kp) && heldUnlockedKey(kp) === null; +} +function unwrapSecretKeyfile(vault, passphrase, ctx) { + assertVaultIdentityForWrite(vault); + const kp = keyPath(vault); + if (!isEnvelopeFile(kp)) { + throw new Error(`secret unwrap: the keyfile is not passphrase-wrapped, nothing to unwrap: ${kp}`); + } + unwrapKeyfileToRaw(kp, passphrase); + clearHeldKey(kp); + audit(vault, ctx, "secret_unwrapped", "keyfile", { keyfile_was_wrapped: true }); +} function custodyTargets(vault) { const targets = [ [secretsDir(vault), "directory"], [keyPath(vault), "file"] ]; - if (existsSync6(storePath(vault))) + if (existsSync8(storePath(vault))) targets.push([storePath(vault), "file"]); - if (existsSync6(tokenStorePath(vault))) + if (existsSync8(tokenStorePath(vault))) targets.push([tokenStorePath(vault), "file"]); return targets; } @@ -3663,19 +4300,19 @@ function toMetadata(name, stored) { } function readStore(vault) { const path = storePath(vault); - if (!existsSync6(path)) + if (!existsSync8(path)) return { version: SECRETS_SCHEMA_VERSION, secrets: {} }; restrictToOwner(path, "file"); if (process.platform !== "win32") { try { - if ((statSync6(path).mode & 511) !== 384) + if ((statSync7(path).mode & 511) !== 384) chmodSync3(path, 384); } catch (err) { process.stderr.write(`warning: could not re-apply owner-only mode to the secrets store: ` + `${path}: ${err instanceof Error ? err.message : String(err)} `); } } - const parsed = JSON.parse(readFileSync7(path, "utf8")); + const parsed = JSON.parse(readFileSync9(path, "utf8")); if (parsed === null || typeof parsed !== "object" || parsed.version !== SECRETS_SCHEMA_VERSION) { throw new Error(`secrets store is corrupt or from a newer version: ${path}`); } @@ -3692,7 +4329,7 @@ function writeStore(vault, file) { loadOrCreateKey(keyPath(vault)); const path = storePath(vault); const tmp = `${path}.tmp`; - writeFileSync4(tmp, JSON.stringify(file, null, 2) + ` + writeFileSync3(tmp, JSON.stringify(file, null, 2) + ` `, { mode: 384 }); renameWithRetry(tmp, path); } @@ -3709,7 +4346,7 @@ function touchLastUsed(vault, name, now) { }); } function audit(vault, ctx, action, name, details) { - appendAuditRecord(join10(brainDirsForWrite(vault).log, SECRET_CUSTODY_AUDIT_DIR), { + appendAuditRecord(join12(brainDirsForWrite(vault).log, SECRET_CUSTODY_AUDIT_DIR), { timestamp: ctx.now.toISOString(), actor: ctx.agent, action, @@ -3907,7 +4544,7 @@ function redactConfigMapping(data, policy = {}) { init_config(); init_secret_ref(); init_secret_ref(); -import { existsSync as existsSync7 } from "node:fs"; +import { existsSync as existsSync9 } from "node:fs"; var custodyStoreModule; function custodyStore() { if (custodyStoreModule === undefined) { @@ -3915,6 +4552,13 @@ function custodyStore() { } return custodyStoreModule; } +var configModule; +function discoverDeviceConfig() { + if (configModule === undefined) { + configModule = (init_config(), __toCommonJS(exports_config)); + } + return configModule.discoverConfig(); +} function storeValue(vault, name) { const held = custodyStore().listSecrets(vault).some((meta) => meta.name === name); if (!held) @@ -3927,12 +4571,12 @@ function secretProvider(vault) { get(_target, prop) { if (typeof prop !== "string") return; - return storeValue(vault, prop) ?? env[prop]; + return storeValue(vault, storeSecretName(prop)) ?? env[prop]; }, has(_target, prop) { if (typeof prop !== "string") return Reflect.has(env, prop); - return storeValue(vault, prop) !== undefined || Reflect.has(env, prop); + return storeValue(vault, storeSecretName(prop)) !== undefined || Reflect.has(env, prop); } }); } @@ -3949,15 +4593,38 @@ function resolvedSecretLiterals(vault) { } catch { return []; } - if (held.length === 0) - return []; - if (!existsSync7(store.keyPath(vault))) + const heldNames = new Set(held.map((meta) => meta.name)); + const out = []; + if (held.length > 0 && existsSync9(store.keyPath(vault))) { + for (const meta of held) { + try { + out.push(store.resolveSecretReadOnly(vault, meta.name).value); + } catch {} + } + } + out.push(...envReferenceLiterals(vault, heldNames)); + return out; +} +function envReferenceLiterals(vault, heldNames) { + let data; + try { + const discovery = discoverDeviceConfig(); + if (!discovery.exists) + return []; + data = discovery.data; + } catch { return []; + } const out = []; - for (const meta of held) { - try { - out.push(store.resolveSecretReadOnly(vault, meta.name).value); - } catch {} + for (const value of Object.values(data)) { + const ref = parseSecretReference(value); + if (!ref) + continue; + if (heldNames.has(storeSecretName(ref.name))) + continue; + const envValue = process.env[ref.name]; + if (envValue !== undefined) + out.push(envValue); } return out; } @@ -3983,21 +4650,21 @@ function probeVaultDirectory(vault) { init_config(); init_fs_utils(); import { - existsSync as existsSync9, - mkdirSync as mkdirSync5, - openSync as openSync4, - readFileSync as readFileSync8, - rmSync, - writeSync as writeSync3, - closeSync as closeSync4 + existsSync as existsSync11, + mkdirSync as mkdirSync7, + openSync as openSync5, + readFileSync as readFileSync10, + rmSync as rmSync2, + writeSync as writeSync4, + closeSync as closeSync5 } from "node:fs"; -import { dirname as dirname6, join as join12 } from "node:path"; +import { dirname as dirname9, join as join14 } from "node:path"; // src/core/partner/codegraph.ts init_config(); init_fs_utils(); -import { existsSync as existsSync8, readdirSync, realpathSync as realpathSync2 } from "node:fs"; -import { dirname as dirname5, join as join11, resolve as resolve8 } from "node:path"; +import { existsSync as existsSync10, readdirSync, realpathSync as realpathSync2 } from "node:fs"; +import { dirname as dirname8, join as join13, resolve as resolve9 } from "node:path"; // src/core/project-manifests.ts var MANIFEST_ECOSYSTEM = Object.freeze({ @@ -4100,11 +4767,11 @@ function codegraphInitCommand(projectPath) { } function isCodeProject(dir) { try { - if (!existsSync8(dir)) + if (!existsSync10(dir)) return false; - if (!isDir(join11(dir, ".git"))) + if (!isDir(join13(dir, ".git"))) return false; - return CODE_MANIFEST_FILES.some((m) => existsSync8(join11(dir, m))); + return CODE_MANIFEST_FILES.some((m) => existsSync10(join13(dir, m))); } catch { return false; } @@ -4117,7 +4784,7 @@ function findCodeProjects(opts) { const consider = (raw) => { if (scanned >= limit) return; - const path = resolve8(raw); + const path = resolve9(raw); if (seen.has(path)) return; seen.add(path); @@ -4128,7 +4795,7 @@ function findCodeProjects(opts) { found.push(path); }; consider(opts.cwd); - const vaultParent = dirname5(resolve8(opts.vault)); + const vaultParent = dirname8(resolve9(opts.vault)); if (isDir(vaultParent)) { let entries = []; try { @@ -4140,7 +4807,7 @@ function findCodeProjects(opts) { for (const name of entries) { if (scanned >= limit) break; - consider(join11(vaultParent, name)); + consider(join13(vaultParent, name)); } } for (const extra of opts.scanExtraPaths ?? []) { @@ -4282,7 +4949,7 @@ function codegraphDisabledResult() { }; } function evaluateProjectStatus(project, deps) { - const indexDir = join11(project, ".codegraph"); + const indexDir = join13(project, ".codegraph"); let indexed; try { indexed = statOrAbsent(indexDir)?.isDirectory() === true; @@ -4361,7 +5028,7 @@ function resolveRealpath(value) { // src/core/doctor.ts var MANIFEST_FIX = "o2b update"; function checkVaultWriteable(vault) { - if (!existsSync9(vault)) { + if (!existsSync11(vault)) { return { name: "vault_writeable", ok: false, @@ -4369,11 +5036,11 @@ function checkVaultWriteable(vault) { fix: `mkdir -p "${vault}"` }; } - const probe = join12(vault, ".open-second-brain-doctor-test"); + const probe = join14(vault, ".open-second-brain-doctor-test"); try { - const fd = openSync4(probe, "w"); - closeSync4(fd); - rmSync(probe); + const fd = openSync5(probe, "w"); + closeSync5(fd); + rmSync2(probe); } catch (exc) { return { name: "vault_writeable", @@ -4387,20 +5054,20 @@ function checkVaultWriteable(vault) { function checkConfigWriteable(config) { let createdForCheck = false; try { - mkdirSync5(dirname6(config), { recursive: true }); - if (!existsSync9(config)) + mkdirSync7(dirname9(config), { recursive: true }); + if (!existsSync11(config)) createdForCheck = true; - const fd = openSync4(config, "a"); - writeSync3(fd, ""); - closeSync4(fd); + const fd = openSync5(config, "a"); + writeSync4(fd, ""); + closeSync5(fd); if (createdForCheck) - rmSync(config); + rmSync2(config); } catch (exc) { return { name: "config_writeable", ok: false, message: `cannot write config ${config}: ${exc.message ?? exc}`, - fix: `mkdir -p "${dirname6(config)}" && chmod u+rwx "${dirname6(config)}"` + fix: `mkdir -p "${dirname9(config)}" && chmod u+rwx "${dirname9(config)}"` }; } return { name: "config_writeable", ok: true, message: `config writable: ${config}` }; @@ -4430,7 +5097,7 @@ function loadJsonManifest(path, name) { } let data; try { - data = JSON.parse(readFileSync8(path, "utf8")); + data = JSON.parse(readFileSync10(path, "utf8")); } catch (exc) { return { result: { @@ -4556,7 +5223,7 @@ function checkHermesManifest(path) { } let text; try { - text = readFileSync8(path, "utf8"); + text = readFileSync10(path, "utf8"); } catch (exc) { return { name: "hermes_manifest", @@ -4605,7 +5272,7 @@ function checkOpenclawManifest(path) { } function checkOpenclawInstallability(repoRoot) { const results = []; - const pkgPath = join12(repoRoot, "package.json"); + const pkgPath = join14(repoRoot, "package.json"); const { result, data } = loadJsonManifest(pkgPath, "openclaw_package_json"); results.push(result); if (!data) @@ -4636,7 +5303,7 @@ function checkOpenclawInstallability(repoRoot) { }); continue; } - const entryPath = join12(repoRoot, entry); + const entryPath = join14(repoRoot, entry); const problem = manifestFileProblem(entryPath); if (problem === null) { results.push({ @@ -4672,10 +5339,10 @@ function doctor(opts) { results.push(checkConfigWriteable(opts.config)); if (opts.repoRoot) { const root = opts.repoRoot; - results.push(checkClaudeManifest(join12(root, ".claude-plugin", "plugin.json"))); - results.push(checkCodexManifest(join12(root, ".codex-plugin", "plugin.json"))); - results.push(checkHermesManifest(join12(root, "plugins", "hermes", "plugin.yaml"))); - results.push(checkOpenclawManifest(join12(root, "openclaw.plugin.json"))); + results.push(checkClaudeManifest(join14(root, ".claude-plugin", "plugin.json"))); + results.push(checkCodexManifest(join14(root, ".codex-plugin", "plugin.json"))); + results.push(checkHermesManifest(join14(root, "plugins", "hermes", "plugin.yaml"))); + results.push(checkOpenclawManifest(join14(root, "openclaw.plugin.json"))); results.push(...checkOpenclawInstallability(root)); } const cg = checkCodegraph({ @@ -4690,10 +5357,10 @@ function doctor(opts) { } // src/core/identity-reminder.ts -import { readFileSync as readFileSync9 } from "node:fs"; -import { dirname as dirname7, resolve as resolve9 } from "node:path"; +import { readFileSync as readFileSync11 } from "node:fs"; +import { dirname as dirname10, resolve as resolve10 } from "node:path"; import { fileURLToPath } from "node:url"; -var TEMPLATE_PATH = resolve9(dirname7(fileURLToPath(import.meta.url)), "..", "..", "templates", "identity-reminder.txt"); +var TEMPLATE_PATH = resolve10(dirname10(fileURLToPath(import.meta.url)), "..", "..", "templates", "identity-reminder.txt"); var RUNTIME_TARGET = Object.freeze({ hermes: "hermes", openclaw: "openclaw" @@ -4710,7 +5377,7 @@ function loadReminderTemplate() { if (commonTemplateCache !== undefined) return commonTemplateCache; try { - commonTemplateCache = readFileSync9(TEMPLATE_PATH, "utf8").trimEnd(); + commonTemplateCache = readFileSync11(TEMPLATE_PATH, "utf8").trimEnd(); return commonTemplateCache; } catch (err) { const message = err instanceof Error ? err.message : String(err); @@ -4719,8 +5386,8 @@ function loadReminderTemplate() { }); } } -var TEMPLATES_DIR = resolve9(dirname7(fileURLToPath(import.meta.url)), "..", "..", "templates"); -var PER_TARGET_PATHS = Object.freeze(Object.fromEntries(KNOWN_RUNTIME_TARGETS.map((t) => [t, resolve9(TEMPLATES_DIR, `identity-reminder.${t}.txt`)]))); +var TEMPLATES_DIR = resolve10(dirname10(fileURLToPath(import.meta.url)), "..", "..", "templates"); +var PER_TARGET_PATHS = Object.freeze(Object.fromEntries(KNOWN_RUNTIME_TARGETS.map((t) => [t, resolve10(TEMPLATES_DIR, `identity-reminder.${t}.txt`)]))); var TEMPLATE_CACHE = new Map; function tryReadTargetTemplate(target) { const cached = TEMPLATE_CACHE.get(target); @@ -4728,7 +5395,7 @@ function tryReadTargetTemplate(target) { return cached; let body; try { - body = readFileSync9(PER_TARGET_PATHS[target], "utf8").trimEnd(); + body = readFileSync11(PER_TARGET_PATHS[target], "utf8").trimEnd(); } catch (err) { if (err.code !== "ENOENT") throw err; @@ -4767,8 +5434,8 @@ init_fs_atomic(); init_fs_utils(); init_degradation(); init_path_safety(); -import { mkdirSync as mkdirSync6, readFileSync as readFileSync10, readdirSync as readdirSync2, writeFileSync as writeFileSync5 } from "node:fs"; -import { dirname as dirname8, join as join13, relative as relative2 } from "node:path"; +import { mkdirSync as mkdirSync8, readFileSync as readFileSync12, readdirSync as readdirSync2, writeFileSync as writeFileSync4 } from "node:fs"; +import { dirname as dirname11, join as join15, relative as relative2 } from "node:path"; // src/core/graph/transport-reach.ts var TRANSPORT_REACH = Object.freeze({ @@ -4837,7 +5504,7 @@ function parseFrontmatterWithNotices(path, opts = {}) { const site = opts.site ?? FRONTMATTER_SITE; let text; try { - text = readFileSync10(path, "utf8"); + text = readFileSync12(path, "utf8"); } catch (err) { const notices = []; emitDegradationNotice(notices, { @@ -4952,7 +5619,7 @@ function walk(root, dir, skipDirs, skipFiles, out, notices) { return; } for (const entry of entries) { - const full = join13(dir, entry.name); + const full = join15(dir, entry.name); if (entry.isDirectory()) { if (skipDirs.has(entry.name)) continue; @@ -5125,7 +5792,7 @@ init_envelope(); init_secret_ref(); var VAULT_PATH_OUTPUT_SCHEMA = Object.freeze({}); var CONFIG_UNREADABLE_REASON = "the device-local config could not be read, so this reference cannot be " + "resolved; call second_brain_status for the file and the remedy"; -var SECRET_STORE_LOCKED_REASON = "the vault's credential store is locked, so the installation secret " + 'reference cannot be resolved; run "o2b brain secret unlock" and retry'; +var SECRET_STORE_LOCKED_REASON = "the vault's credential store is locked, so the installation secret " + "reference cannot be resolved; set OPEN_SECOND_BRAIN_SECRETS_PASSPHRASE " + "for the server process, pass --passphrase-from-env or the passphrase on " + "stdin to any key-bearing verb, or run `o2b brain secret unwrap` to " + "return the store to its raw state"; var SECRET_STORE_KEYFILE_MISSING_REASON = "the vault's credential store is missing its keyfile, so the installation " + "secret reference cannot be resolved; restore the keyfile and retry"; var SECRET_REFERENCE_UNRESOLVED_REASON = "the installation secret is a $secret: reference the vault's credential " + "store cannot resolve; inspect the device config and the store with " + "`o2b secrets list`"; function hostPathReference(path, source) { From 3adc821a03918afcf4946c758b475690d2da61f0 Mon Sep 17 00:00:00 2001 From: Sergey Eroshenkov Date: Sat, 10 Oct 2026 23:28:07 +0200 Subject: [PATCH 74/84] test: normalize the staged-path separator in the ask-arm assertion --- tests/core/brain/signal-disposition.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/core/brain/signal-disposition.test.ts b/tests/core/brain/signal-disposition.test.ts index 809312c2..fe5eabb9 100644 --- a/tests/core/brain/signal-disposition.test.ts +++ b/tests/core/brain/signal-disposition.test.ts @@ -126,7 +126,7 @@ describe("writeSignal under a permissions document", () => { writeDoc("version: 1\ndefault_action: ask\n"); const res = writeSignal(vault, INPUT); expect(res.staged).toBe(true); - expect(res.path).toContain("Brain/pending"); + expect(res.path.replaceAll("\\", "/")).toContain("Brain/pending"); expect(existsSync(res.path)).toBe(true); expect(inboxFiles()).toEqual([]); const rows = queryDecisionLedger(vault, { verdict: "ask" }); From 9e7244b03c33c71a9a9d7d0e627a7b8ca8430d84 Mon Sep 17 00:00:00 2001 From: Sergey Eroshenkov Date: Sun, 11 Oct 2026 00:51:35 +0200 Subject: [PATCH 75/84] fix(decisions): keep the resolve mint candidates inside the slug cap A title whose slug fills the 64-character cap made every derived mint candidate slugify identically: derivedMintTitle appended the distinguishing record id at the END of the title, while slugify truncates from the START, so the tail was silently dropped. When the bare slug was occupied by a foreign decision page, the mint walk spun forever holding the decisions-directory lock, wedging every later transition. The derived candidates are now built on a mint head - the longest word-prefix of the title whose slug stays within 40 characters - with the record's own id pre-shaved to ride INSIDE the cap, and the numbered candidates keep their counter inside the cap too, so successive candidates slugify distinctly and the walk terminates by construction. SLUG_MAX_LEN is exported from vault.ts so the budget reads the cap it budgets against. The provenance convergence check is unchanged on every candidate. --- src/core/brain/decisions/open-store.ts | 82 +++++++++++++---- src/core/vault.ts | 9 +- tests/core/brain/decisions/open-store.test.ts | 87 ++++++++++++++++++- 3 files changed, 158 insertions(+), 20 deletions(-) diff --git a/src/core/brain/decisions/open-store.ts b/src/core/brain/decisions/open-store.ts index bf28cbf2..0c89a20d 100644 --- a/src/core/brain/decisions/open-store.ts +++ b/src/core/brain/decisions/open-store.ts @@ -46,7 +46,7 @@ import { normalizeAgentArgument } from "../../agent-identity.ts"; import { resolveAgentName } from "../../config.ts"; import { sanitiseTextField } from "../../redactor.ts"; import { atomicWriteFileSync } from "../../fs-atomic.ts"; -import { parseFrontmatterText, slugify } from "../../vault.ts"; +import { parseFrontmatterText, SLUG_MAX_LEN, slugify } from "../../vault.ts"; import { appendLogEvent } from "../log.ts"; import { BRAIN_DECISIONS_REL } from "../path-constants.ts"; import { decisionsDir, decisionPath } from "../paths.ts"; @@ -713,23 +713,65 @@ function mintNotes(record: OpenDecisionRecord): string { return [record.context, mintProvenance(record)].filter((part) => part !== "").join("\n\n"); } -/** The title exactly as `recordDecision` will sanitise it, so the - * occupy-check below tests the slug the mint will actually write. */ +/** The minted page's title exactly as `recordDecision` will sanitise it, so + * the occupy-check below tests the slug the mint will actually write. */ function sanitiseMintTitle(value: string): string { return sanitiseTextField(value, { maxLen: TITLE_MAX_LEN, singleLine: true }).trim(); } /** - * A fallback mint title deriving from the open record's OWN id (unique - * among records, so two parked questions under one title can never fight - * for one page). `suffix` 0 appends just the id; 2, 3, ... number it - * further. The base is pre-shaved so the tail survives the field cap and - * each candidate slug stays distinct. + * The slug budget the mint head keeps to: long enough to carry the + * question's opening words, short enough that the per-record tail that + * follows it - the record's own id, and from the second candidate on a + * counter - fits inside {@link SLUG_MAX_LEN} beside it. */ -function derivedMintTitle(record: OpenDecisionRecord, suffix: number): string { - const tail = suffix === 0 ? ` (${record.id})` : ` (${record.id} ${suffix})`; - const base = record.title.slice(0, Math.max(0, TITLE_MAX_LEN - tail.length)); - return `${base}${tail}`; +const MINT_HEAD_MAX = 40; + +/** + * The head every derived mint candidate is built on: the longest + * word-prefix of the title whose slug stays within {@link MINT_HEAD_MAX}. + * Working word by word keeps the head on a word boundary; a single word + * longer than the budget yields an empty head, and the derived candidates + * then carry the id alone. + */ +function mintHead(title: string): string { + let head = ""; + for (const word of title.split(/\s+/u)) { + if (word === "") continue; + const next = head === "" ? word : `${head} ${word}`; + if (slugify(next).length > MINT_HEAD_MAX) break; + head = next; + } + return head; +} + +/** + * A derived mint title: ` ()`, numbered ` ( N)` from + * the second candidate on. The id - unique among records - is PRE-SHAVED + * so the whole tail sits INSIDE the slug cap: `slugify` truncates from the + * START of the slug, so a tail appended past the cap is silently dropped + * and every candidate would slugify identically - an occupied slug then + * wedges the mint walk forever. With the tail budgeted inside the cap the + * numbered candidates keep their counter on the slug too (the id's slice + * shrinks as the counter grows), so successive candidates slugify + * distinctly and the walk terminates by construction. + */ +function derivedMintTitle(sanitisedTitle: string, id: string, suffix: number): string { + const counter = suffix === 0 ? "" : ` ${suffix}`; + const counterLen = suffix === 0 ? 0 : 1 + String(suffix).length; + const head = mintHead(sanitisedTitle); + const headLen = head === "" ? 0 : slugify(head).length + 1; + let budget = SLUG_MAX_LEN - headLen - counterLen; + let effectiveHead = head; + if (budget < 1) { + // The head leaves no room for even a one-character id slice: the + // candidate drops it rather than pushing the tail past the cap. + effectiveHead = ""; + budget = SLUG_MAX_LEN - counterLen; + } + const tailId = id.slice(0, Math.max(1, budget)).replace(/-+$/u, ""); + const tail = `(${tailId}${counter})`; + return effectiveHead === "" ? tail : `${effectiveHead} ${tail}`; } interface ResolveMintTarget { @@ -747,23 +789,27 @@ interface ResolveMintTarget { /** * Plan the page one resolve mints. The bare title slug first; if that is - * occupied by a page that does not reference this record, fall through - * the per-record derived titles, numbered on collision - the same - * occupy-check `openDecision` runs for the record filename. Never the - * bare title alone: `recordDecision` refuses an existing slug, so a + * occupied by a page that does not reference this record, fall through the + * per-record derived titles (head plus id, numbered on collision) - the + * same occupy-check `openDecision` runs for the record filename. The + * derived candidates keep their distinguishing tail INSIDE the slug cap + * (see {@link derivedMintTitle}), so a title whose slug fills the cap + * still walks onto fresh slugs instead of spinning on the same one. Never + * the bare title alone: `recordDecision` refuses an existing slug, so a * second "Pick DB" question would otherwise be wedged open forever (and * a crash between minting and stamping would wedge the first one too). */ function resolveMintTarget(vault: string, record: OpenDecisionRecord): ResolveMintTarget { const reference = `[[${record.id}]]`; - let title = sanitiseMintTitle(record.title); + const sanitised = sanitiseMintTitle(record.title); + let title = sanitised; let slug = slugify(title); let suffix = 0; for (;;) { const path = decisionPath(vault, slug); if (!existsSync(path)) return { title, slug, ours: false }; if (readFileSync(path, "utf8").includes(reference)) return { title, slug, ours: true }; - title = sanitiseMintTitle(derivedMintTitle(record, suffix)); + title = sanitiseMintTitle(derivedMintTitle(sanitised, record.id, suffix)); slug = slugify(title); suffix = suffix === 0 ? 2 : suffix + 1; } diff --git a/src/core/vault.ts b/src/core/vault.ts index 3944e9eb..f2e58c70 100644 --- a/src/core/vault.ts +++ b/src/core/vault.ts @@ -101,7 +101,14 @@ const KEY_VALUE_RE = new RegExp(`^(${FRONTMATTER_KEY_PATTERN})\\s*:\\s*(.*?)\\s* const DASH_ITEM_RE = /^-(?:\s+(.*))?$/; const PLAIN_SCALAR_RE = /^[A-Za-z0-9_./-](?:[A-Za-z0-9_./ -]*[A-Za-z0-9_./-])?$/; const SLUG_INVALID_RE = /[^a-z0-9]+/g; -const SLUG_MAX_LEN = 64; + +/** + * Hard cap on one slug, enforced by {@link slugify} by truncating from the + * START of the string. Exported so slug-deriving callers can budget a tail + * (a collision suffix, a per-record id) to sit INSIDE the cap instead of + * after it, where truncation would silently drop it. + */ +export const SLUG_MAX_LEN = 64; const MEDIA_EXTENSIONS = new Set([ ".png", diff --git a/tests/core/brain/decisions/open-store.test.ts b/tests/core/brain/decisions/open-store.test.ts index 0eb7b668..6b0cbcb1 100644 --- a/tests/core/brain/decisions/open-store.test.ts +++ b/tests/core/brain/decisions/open-store.test.ts @@ -19,7 +19,12 @@ import lockfile from "proper-lockfile"; import { readLogDay } from "../../../../src/core/brain/log-jsonl.ts"; import { BRAIN_LOG_EVENT_KIND } from "../../../../src/core/brain/types.ts"; import { queryDecisionChangeHistory } from "../../../../src/core/brain/decisions/receipts.ts"; -import { listDecisions, showDecision } from "../../../../src/core/brain/decisions/record.ts"; +import { + listDecisions, + recordDecision, + showDecision, +} from "../../../../src/core/brain/decisions/record.ts"; +import { slugify } from "../../../../src/core/vault.ts"; import { BRAIN_DECISIONS_REL } from "../../../../src/core/brain/path-constants.ts"; import { decisionsDir } from "../../../../src/core/brain/paths.ts"; import { @@ -450,6 +455,86 @@ describe("resolveOpenDecision", () => { expect(listDecisions(vault)).toHaveLength(2); }); + test("a cap-length title with a foreign page on its slug still resolves, minting at the head-plus-id slug", () => { + // A title whose slug fills the cap exactly: the derived candidates' + // distinguishing tail must sit INSIDE that cap, or every candidate + // slugifies identically and the mint walk never terminates. + const longTitle = + "Decide the retention window for transient session checkpoints across " + + "every managed device and offline co-editor before the next quarterly storage review"; + expect(slugify(longTitle).length).toBe(64); + // A foreign decision page (no provenance of this record) occupies the + // bare title slug the mint walk tries first. + recordDecision(vault, { + title: longTitle, + chosen: "keep", + assumption: "prior art", + reviewDate: "2026-10-01", + agent: "tester", + now: T0, + }); + const rec = openDecision( + vault, + baseInput({ title: longTitle, question: "A different long question entirely?" }), + ); + const res = resolveOpenDecision(vault, rec.id, { + choice: "bun's fetch", + actor: "tester", + now: T2, + }); + // The mint landed beside the foreign page, not on it, with the record's + // own id riding inside the cap: head slug first, then the open id. + const mintedSlug = res.decision.slice("decision-".length); + expect(mintedSlug).not.toBe(slugify(longTitle)); + expect(mintedSlug.length).toBeLessThanOrEqual(64); + expect(mintedSlug).toContain("open-"); + expect(mintedSlug.startsWith("decide-the-retention-window-for-")).toBe(true); + const page = showDecision(vault, mintedSlug); + expect(page).not.toBeNull(); + expect(page!.chosen).toBe("bun's fetch"); + expect(page!.notes).toContain(`[[${rec.id}]]`); + const stamped = showOpenDecision(vault, rec.id)!; + expect(stamped.status).toBe("resolved"); + expect(stamped.decision).toBe(`[[${res.decision}]]`); + }, 20000); + + test("two same-titled cap-length questions both resolve, each minting its own page", () => { + const longTitle = + "Decide the retention window for transient session checkpoints across " + + "every managed device and offline co-editor before the next quarterly storage review"; + const first = openDecision( + vault, + baseInput({ title: longTitle, question: "Question one under the long title?" }), + ); + const second = openDecision( + vault, + baseInput({ + title: longTitle, + question: "Question two under the long title?", + now: T1, + }), + ); + const r1 = resolveOpenDecision(vault, first.id, { + choice: "bun's fetch", + actor: "tester", + now: T1, + }); + // The bare title slug is taken by the first record's page, so the + // second mints from a candidate carrying its own record id inside the + // slug cap - never a refusal, never a hang. + const r2 = resolveOpenDecision(vault, second.id, { + choice: "undici", + actor: "tester", + now: T2, + }); + expect(r1.decision).not.toBe(r2.decision); + expect(showOpenDecision(vault, first.id)!.status).toBe("resolved"); + expect(showOpenDecision(vault, second.id)!.status).toBe("resolved"); + expect(showDecision(vault, r1.decision.slice("decision-".length))!.chosen).toBe("bun's fetch"); + expect(showDecision(vault, r2.decision.slice("decision-".length))!.chosen).toBe("undici"); + expect(listDecisions(vault)).toHaveLength(2); + }, 20000); + test("a resolve that crashed after minting the page converges on the next attempt", () => { const rec = openDecision(vault, baseInput()); resolveOpenDecision(vault, rec.id, { From 1eb71cf10820d71c9a2d6e2aa52825849a03f3ab Mon Sep 17 00:00:00 2001 From: Sergey Eroshenkov Date: Sun, 11 Oct 2026 00:53:30 +0200 Subject: [PATCH 76/84] fix(bootstrap): rescue a minted token when the registration half fails for any reason The shown-once rescue print fired only in the InstallError branch, so a PayloadError from the payload build or a non-InstallError throw after a mint still lost the material forever: the credential stayed live in the store while nothing ever showed it again (a re-run mints nothing). The payload build and the apply now sit in one try/catch whose every exit prints the material exactly once, on stdout, beside a failure notice naming the retry and revoke paths, before the usage refusal or the rethrow. --- src/cli/bootstrap/run.ts | 100 ++++++++++++++++++++++++++---------- tests/cli/bootstrap.test.ts | 58 ++++++++++++++++++++- 2 files changed, 129 insertions(+), 29 deletions(-) diff --git a/src/cli/bootstrap/run.ts b/src/cli/bootstrap/run.ts index 5ea1b596..b14051b5 100644 --- a/src/cli/bootstrap/run.ts +++ b/src/cli/bootstrap/run.ts @@ -26,8 +26,9 @@ * Idempotency contract: a second identical run is a byte-identical * no-op. When the token already exists and the adapter verifies clean, * bootstrap writes nothing at all - not the config, not the install - * manifest, not the receipt - and says so. When the apply fails after a - * mint or rotation, the material still prints exactly once beside the + * manifest, not the receipt - and says so. When the run fails after a + * mint or rotation - the payload build, the apply, anything in the + * registration half - the material still prints exactly once beside the * failure: the credential is live in the store, a re-run would mint * nothing, and the output names the retry and revoke paths. * @@ -380,6 +381,34 @@ function runCheck(input: CheckInput): number { return BOOTSTRAP_EXIT.ok; } +/** + * The rescue print for a token that was minted or rotated and then lost + * its showing because the run exited without its success report. The + * material is live in the store and will never be shown again by a re-run + * (the name now exists, so nothing is minted), so it prints here, exactly + * once, on stdout, beside the failure notice and the retry and revoke + * paths - returning or rethrowing without it would orphan a live + * credential behind a failed run. + */ +function printOrphanedTokenMaterial(input: { + readonly target: string; + readonly name: string; + readonly agent: string; + readonly event: "minted" | "rotated"; + readonly material: string; + readonly failure: string; +}): void { + process.stdout.write( + `bootstrap: ${input.target}\n` + + ` token: ${input.name} (agent ${JSON.stringify(input.agent)}) ${input.event} - ` + + "shown exactly once, stored only as a hash\n" + + ` ${input.material}\n` + + ` ${SHOWN_ONCE_NOTICE}\n` + + ` ${HTTP_BOUNDARY_NOTICE}\n` + + ` ${input.failure}\n`, + ); +} + interface ProvisionInput { readonly args: ParsedBootstrapArgs; readonly target: string; @@ -457,22 +486,35 @@ function runProvision(input: ProvisionInput): number { } } - let payload; - try { - payload = buildBootstrapPayload(vault, args.config); - } catch (e) { - if (e instanceof PayloadError) return usageRefusal(e.message); - throw e; - } - const capture = captureStream(); - const opts: ApplyOpts = { - dryRun: false, - force: args.force, - stdout: capture.stream, - stderr: process.stderr, + // The rescue print for a mint whose run then exits without its + // success report: every failure below - a refused payload build, a + // failed apply, a throw from either - leaves the material live in + // the store and unshown by any re-run, so it prints here, exactly + // once, before the run returns or rethrows. + const rescueMaterial = (failure: string): void => { + if (tokenMaterial === null) return; + printOrphanedTokenMaterial({ + target, + name, + agent, + event: tokenEvent ?? "minted", + material: tokenMaterial, + failure, + }); }; + + let payload; + let capture; let result; try { + payload = buildBootstrapPayload(vault, args.config); + capture = captureStream(); + const opts: ApplyOpts = { + dryRun: false, + force: args.force, + stdout: capture.stream, + stderr: process.stderr, + }; result = adapter.apply(adapter.plan(payload, env), payload, env, opts); } catch (e) { if (e instanceof InstallError) { @@ -483,23 +525,25 @@ function runProvision(input: ProvisionInput): number { // exists, so nothing is minted). Returning without it orphans a // live credential behind a failed registration - so it prints // here, exactly once, beside the failure and the retry path. - if (tokenMaterial !== null) { - process.stdout.write( - `bootstrap: ${target}\n` + - ` token: ${name} (agent ${JSON.stringify(agent)}) ${tokenEvent} - ` + - "shown exactly once, stored only as a hash\n" + - ` ${tokenMaterial}\n` + - ` ${SHOWN_ONCE_NOTICE}\n` + - ` ${HTTP_BOUNDARY_NOTICE}\n` + - ` The registration half failed (the error above); the token is live in the store. ` + - `Fix the cause and re-run o2b bootstrap --target ${target} to apply the ` + - `registration alone, or revoke with: o2b mcp token revoke --name ${name}\n`, - ); - } + rescueMaterial( + `The registration half failed (the error above); the token is live in the store. ` + + `Fix the cause and re-run o2b bootstrap --target ${target} to apply the ` + + `registration alone, or revoke with: o2b mcp token revoke --name ${name}`, + ); return e.kind === "user-modified-block" ? BOOTSTRAP_EXIT.userModifiedBlock : BOOTSTRAP_EXIT.runtimeError; } + // The payload build refused, or the registration half threw + // something else entirely: the same orphan problem as the failed + // apply above, so the material prints here too - before the usage + // refusal or the rethrow. + rescueMaterial( + `The run failed before its registration could report; the token is live in the store. ` + + `Re-run o2b bootstrap --target ${target} to apply the registration alone, ` + + `or revoke with: o2b mcp token revoke --name ${name}`, + ); + if (e instanceof PayloadError) return usageRefusal(e.message); throw e; } manifest = result.manifest; diff --git a/tests/cli/bootstrap.test.ts b/tests/cli/bootstrap.test.ts index 716f12b5..3316ab09 100644 --- a/tests/cli/bootstrap.test.ts +++ b/tests/cli/bootstrap.test.ts @@ -17,7 +17,7 @@ * that shells out on a real host. */ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; import { existsSync, mkdirSync, @@ -47,6 +47,28 @@ import { import { startHttp, type HttpServerHandle } from "../../src/mcp/index.ts"; import { JSONRPC_VERSION } from "../../src/mcp/protocol.ts"; +// The payload-refusal test below needs buildPayload to throw a PayloadError +// AFTER the mint, on demand and portably (the real builder refuses only on +// an empty vault or a Windows-metacharacter path), so the module is wrapped +// in a forwarding mock (captured before the mock is installed, or the +// forward would recurse) that fails only while a flag is set. +import * as payloadModule from "../../src/core/install/payload.ts"; + +const realBuildPayload = payloadModule.buildPayload; +let failPayloadBuild = false; + +mock.module("../../src/core/install/payload.ts", () => ({ + ...payloadModule, + buildPayload: ( + ...args: Parameters + ): ReturnType => { + if (failPayloadBuild) { + throw new payloadModule.PayloadError("injected: the payload build refused"); + } + return realBuildPayload(...args); + }, +})); + let tempRoot: string; let vault: string; let codexHome: string; @@ -745,3 +767,37 @@ describe("o2b bootstrap when the adapter apply fails after a mint (opencode)", ( expect(entry["agent"]).toBe(OPENCODE_AGENT); }, 20000); }); + +/** + * The payload build refusing AFTER the mint (the run's other pre-apply + * exit): the material is live in the store and a re-run would never show + * it, so the rescue print must fire here too - exactly once, on stdout, + * beside the failure notice - instead of orphaning the credential. + */ +describe("o2b bootstrap when the payload build refuses after a mint (generic)", () => { + test("a payload refusal after a mint still shows the material exactly once, on stdout only", async () => { + failPayloadBuild = true; + try { + const r = await runCli( + bootstrapArgs(["--target", "generic", "--agent", OPENCODE_AGENT, "--token"]), + {}, + ); + expect(r.returncode).toBe(2); + expect(r.stderr).toContain("injected: the payload build refused"); + // The mint happened, so the material prints - exactly once, on + // stdout, beside the failure notice naming the retry and revoke + // paths, with stderr carrying none of it. + const material = printedMaterial(r.stdout); + expect(countOccurrences(r.stdout, material)).toBe(1); + expect(r.stdout).toContain("live in the store"); + expect(r.stdout).toContain("o2b mcp token revoke --name mcp_token_generic"); + expect(r.stderr).not.toContain(material); + expect(resolveAgentForToken(vault, material)).toEqual({ + agent: OPENCODE_AGENT, + name: "mcp_token_generic", + }); + } finally { + failPayloadBuild = false; + } + }, 20000); +}); From 6e5db12ceb971fe8af2481c1222516b63d7f011c Mon Sep 17 00:00:00 2001 From: Sergey Eroshenkov Date: Sun, 11 Oct 2026 00:57:55 +0200 Subject: [PATCH 77/84] fix(mcp): answer a retried write over a pending stage with its named code PendingStageConflictError - a create or ingest retried while its first run is still staged - was unmapped at the MCP surface, so the retry surfaced as a generic internal error a caller would retry into the same wall. The notes and ingest tools now answer it beside their WriteRefusedError handlers: the pending-stage-conflict code carrying the queue entry holding the target and the target itself, registered in the error registry, its test oracle and the docs catalog. The error carries its code like WriteRefusedError does. --- docs/mcp.md | 5 ++ src/core/brain/pending/pending-lanes.ts | 12 ++++ src/mcp/brain/ingest-tools.ts | 12 ++++ src/mcp/brain/notes-tools.ts | 21 +++++++ src/mcp/tool-error-codes.ts | 9 ++- tests/mcp/brain-write-staged-receipt.test.ts | 61 +++++++++++++++++++- tests/mcp/tool-error-codes.test.ts | 6 +- 7 files changed, 123 insertions(+), 3 deletions(-) diff --git a/docs/mcp.md b/docs/mcp.md index fb1cb805..ebab4415 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -301,6 +301,11 @@ through unchanged: uses. The owner-write gate refuses with `owner_write_refused` (below) and names the fix in its message: name the caller's own resolved identity, or write without an explicit owner; +- the staged-target conflict `pending-stage-conflict` (since 1.79.0): a + write retried while its first run is still staged, answered with the + queue entry holding the target (`existing_id`) and the `target` itself, + so a caller applies or rejects that entry instead of retrying into the + same wall; - the codes of write batches (`budget_exceeded`, `invalid_action`, `invalid_target`, ..., and since 1.79.0 the frontmatter `owner_write_refused` guard), note creation (which carries the same diff --git a/src/core/brain/pending/pending-lanes.ts b/src/core/brain/pending/pending-lanes.ts index 32a60818..4a346a1c 100644 --- a/src/core/brain/pending/pending-lanes.ts +++ b/src/core/brain/pending/pending-lanes.ts @@ -309,6 +309,15 @@ export interface StageForReviewResult { readonly path: string; } +/** + * The MCP-boundary code a staged-target conflict answers under (see + * {@link PendingStageConflictError}): a write retried while its first run + * is still pending names the queue entry holding the target, never a + * generic internal error a caller would retry into the same wall. One + * spelling here so the tools and the error registry cannot drift. + */ +export const PENDING_STAGE_CONFLICT_CODE = "pending-stage-conflict"; + /** * The publish target of the new stage is already staged for review under * another OPEN queue entry. Named rather than silently replaced: two @@ -319,6 +328,8 @@ export interface StageForReviewResult { * operator has to apply or reject first. */ export class PendingStageConflictError extends Error { + /** Always {@link PENDING_STAGE_CONFLICT_CODE}. */ + readonly code: string; /** The pending id already holding the target. */ readonly existingId: string; readonly target: string; @@ -329,6 +340,7 @@ export class PendingStageConflictError extends Error { "new bytes for the same target", ); this.name = "PendingStageConflictError"; + this.code = PENDING_STAGE_CONFLICT_CODE; this.existingId = existingId; this.target = target; } diff --git a/src/mcp/brain/ingest-tools.ts b/src/mcp/brain/ingest-tools.ts index 6881a364..f1894434 100644 --- a/src/mcp/brain/ingest-tools.ts +++ b/src/mcp/brain/ingest-tools.ts @@ -41,6 +41,7 @@ import { parseExtractionIntakeArgs } from "./intake-args.ts"; import { readableAtContextReach } from "./reach-readable.ts"; import { PENDING_STAGED_DIAGNOSTIC_CODE, + PendingStageConflictError, WriteRefusedError, } from "../../core/brain/pending/pending-lanes.ts"; import { nextCommandField } from "../../core/brain/next-step.ts"; @@ -122,6 +123,17 @@ async function toolBrainIngestSource( next_command: err.nextCommand, }); } + // An ingest retried while its first run is still staged: the queue + // refuses by name, so the answer names the queue entry holding the + // summary target instead of an internal error a caller would retry + // into the same wall. + if (err instanceof PendingStageConflictError) { + throw new MCPError(INVALID_PARAMS, `${TOOL}: ${err.message}`, { + code: err.code, + existing_id: err.existingId, + target: err.target, + }); + } throw err; } return { diff --git a/src/mcp/brain/notes-tools.ts b/src/mcp/brain/notes-tools.ts index 910c381b..1941e6b1 100644 --- a/src/mcp/brain/notes-tools.ts +++ b/src/mcp/brain/notes-tools.ts @@ -44,6 +44,7 @@ import type { ReadableRef } from "../../core/brain/near-duplicate.ts"; import { nextCommandField } from "../../core/brain/next-step.ts"; import { PENDING_STAGED_DIAGNOSTIC_CODE, + PendingStageConflictError, WriteRefusedError, } from "../../core/brain/pending/pending-lanes.ts"; import { @@ -246,6 +247,16 @@ export function writeBatchErrorToMcp(err: unknown, tool: string): MCPError { next_command: err.nextCommand, }); } + // A staged op retried while its first run is still pending: the queue + // refuses by name, so the answer names the queue entry holding the + // target instead of an internal error a caller would retry into. + if (err instanceof PendingStageConflictError) { + return new MCPError(INVALID_PARAMS, `${tool}: ${err.message}`, { + code: err.code, + existing_id: err.existingId, + target: err.target, + }); + } return new MCPError(INTERNAL_ERROR, err instanceof Error ? err.message : String(err)); } @@ -356,6 +367,16 @@ async function toolBrainCreateNote( next_command: err.nextCommand, }); } + // A create retried while its first run is still staged: the queue + // refuses by name, so the answer names the queue entry holding the + // target instead of an internal error a caller would retry into. + if (err instanceof PendingStageConflictError) { + throw new MCPError(INVALID_PARAMS, `brain_create_note: ${err.message}`, { + code: err.code, + existing_id: err.existingId, + target: err.target, + }); + } rethrowVaultFrozen(err); throw new MCPError(INTERNAL_ERROR, err instanceof Error ? err.message : String(err)); } diff --git a/src/mcp/tool-error-codes.ts b/src/mcp/tool-error-codes.ts index 0d2e7372..c389c5e5 100644 --- a/src/mcp/tool-error-codes.ts +++ b/src/mcp/tool-error-codes.ts @@ -63,7 +63,10 @@ import { WriteBatchError } from "../core/brain/write-batch.ts"; import { ConfigReadError } from "../core/config.ts"; import { SEARCH_ERROR_CODES, SearchError } from "../core/search/search-error.ts"; import { WRITE_BINDING_REFUSED_CODE } from "../core/write-binding/index.ts"; -import { WRITE_REFUSAL_CODES } from "../core/brain/pending/pending-lanes.ts"; +import { + WRITE_REFUSAL_CODES, + PENDING_STAGE_CONFLICT_CODE, +} from "../core/brain/pending/pending-lanes.ts"; import { VAULT_FROZEN_REFUSAL } from "./frozen-refusal.ts"; import { OWNER_SCOPE_REFUSALS } from "./owner-scope-refusal.ts"; import { @@ -272,6 +275,10 @@ export const TOOL_ERROR_CODES = Object.freeze([ VAULT_FROZEN_REFUSAL, WRITE_BINDING_REFUSED_CODE, ...Object.values(WRITE_REFUSAL_CODES), + // A write retried while its first run is still staged: the queue's + // own refusal code, carried by `PendingStageConflictError` beside the + // `WriteRefusedError` handlers that answer it. + PENDING_STAGE_CONFLICT_CODE, REACH_REFUSAL, ...OWNER_SCOPE_REFUSALS, ...WRITE_BATCH_CODES, diff --git a/tests/mcp/brain-write-staged-receipt.test.ts b/tests/mcp/brain-write-staged-receipt.test.ts index 24081e97..62e87ce9 100644 --- a/tests/mcp/brain-write-staged-receipt.test.ts +++ b/tests/mcp/brain-write-staged-receipt.test.ts @@ -29,7 +29,7 @@ import { atomicWriteFileSync } from "../../src/core/fs-atomic.ts"; import { NOTES_TOOLS } from "../../src/mcp/brain/notes-tools.ts"; import { WRITE_BATCH_TOOLS } from "../../src/mcp/brain/write-batch-tools.ts"; import { INGEST_TOOLS } from "../../src/mcp/brain/ingest-tools.ts"; -import { MCPError } from "../../src/mcp/protocol.ts"; +import { INVALID_PARAMS, MCPError } from "../../src/mcp/protocol.ts"; import type { ServerContext } from "../../src/mcp/tool-contract.ts"; const NOTES_ENV = "OPEN_SECOND_BRAIN_WRITE_APPROVAL_NOTES_ENABLED"; @@ -194,3 +194,62 @@ describe("document-denied writes answer as named refusals", () => { expect(existsSync(join(vault, "Brain/pending/ingest"))).toBe(false); }); }); + +/** + * A write RETRIED while its first run is still pending: the queue refuses + * the second stage by name (PendingStageConflictError), and the tool + * answers with that named code - the pending id already holding the + * target, and the target itself - never as an internal error a caller + * would retry into the same wall. + */ +describe("a retried write over a still-pending stage answers the named code", () => { + test("brain_create_note carries pending-stage-conflict with the holding pending id", async () => { + const first = (await createNote(ctx, { + path: "Notes/Retried.md", + content: "first", + })) as Record; + expect(first["staged"]).toBe(true); + const holdingId = first["pending_id"] as string; + let refused: MCPError | undefined; + try { + await createNote(ctx, { path: "Notes/Retried.md", content: "second" }); + } catch (err) { + refused = err instanceof MCPError ? err : undefined; + } + expect(refused).toBeInstanceOf(MCPError); + expect(refused?.code).toBe(INVALID_PARAMS); + const data = (refused?.data ?? {}) as Record; + expect(data["code"]).toBe("pending-stage-conflict"); + expect(data["existing_id"]).toBe(holdingId); + expect(data["target"]).toBe("Notes/Retried.md"); + expect(String(refused?.message)).toContain("already staged for review"); + }); + + test("brain_ingest_source carries pending-stage-conflict for a still-pending summary", async () => { + mkdirSync(join(vault, "Articles"), { recursive: true }); + writeFileSync(join(vault, "Articles/dup.md"), "source bytes\n", "utf8"); + const first = (await ingestSource(ctx, { + source_path: "Articles/dup.md", + summary: "First ingest summary.", + entities: [{ category: "concept", name: "Rollups" }], + })) as Record; + expect(first["staged"]).toBe(true); + const holdingId = first["pending_id"] as string; + let refused: MCPError | undefined; + try { + await ingestSource(ctx, { + source_path: "Articles/dup.md", + summary: "Second ingest summary.", + entities: [{ category: "concept", name: "Rollups" }], + }); + } catch (err) { + refused = err instanceof MCPError ? err : undefined; + } + expect(refused).toBeInstanceOf(MCPError); + expect(refused?.code).toBe(INVALID_PARAMS); + const data = (refused?.data ?? {}) as Record; + expect(data["code"]).toBe("pending-stage-conflict"); + expect(data["existing_id"]).toBe(holdingId); + expect(data["target"]).toBe(first["summary_path"]); + }); +}); diff --git a/tests/mcp/tool-error-codes.test.ts b/tests/mcp/tool-error-codes.test.ts index 9d7d3b39..353920ab 100644 --- a/tests/mcp/tool-error-codes.test.ts +++ b/tests/mcp/tool-error-codes.test.ts @@ -36,7 +36,10 @@ import { WriteBatchError } from "../../src/core/brain/write-batch.ts"; import { ConfigReadError } from "../../src/core/config.ts"; import { SEARCH_ERROR_CODES, SearchError } from "../../src/core/search/search-error.ts"; import { WRITE_BINDING_REFUSED_CODE } from "../../src/core/write-binding/index.ts"; -import { WRITE_REFUSAL_CODES } from "../../src/core/brain/pending/pending-lanes.ts"; +import { + PENDING_STAGE_CONFLICT_CODE, + WRITE_REFUSAL_CODES, +} from "../../src/core/brain/pending/pending-lanes.ts"; import { VAULT_FROZEN_REFUSAL } from "../../src/mcp/frozen-refusal.ts"; import { OutputContractError } from "../../src/mcp/output-contract.ts"; import { OWNER_SCOPE_REFUSALS } from "../../src/mcp/owner-scope-refusal.ts"; @@ -180,6 +183,7 @@ describe("TOOL_ERROR_CODES", () => { VAULT_FROZEN_REFUSAL, WRITE_BINDING_REFUSED_CODE, ...Object.values(WRITE_REFUSAL_CODES), + PENDING_STAGE_CONFLICT_CODE, REACH_REFUSAL, ...OWNER_SCOPE_REFUSALS, ...TYPE_ONLY_MEMBERS, From 9fe54cfe91e127744d60a997533ee57d3679547c Mon Sep 17 00:00:00 2001 From: Sergey Eroshenkov Date: Sun, 11 Oct 2026 00:59:28 +0200 Subject: [PATCH 78/84] fix(permissions): resolve the no-subject principal through the caller's configPath The document consult's no-subject fallback resolved resolveAgentName() without the caller's configPath, so on an explicit-configPath run the consult answered for a different principal than the owner gate (which honors opts.configPath): a document rule naming the config agent could fail to refuse - a fail-open - and the ledger row named the wrong actor. The fallback now resolves through ResolveWriteDispositionOptions .configPath, threaded from the write-batch update and append consults and the create-note disposition; callers that carry no config path keep the ambient fallback. The signal and ingest surfaces always hand a resolved subject, so their rows were never wrong. --- src/core/brain/notes/create-note.ts | 4 +- src/core/brain/write-batch.ts | 14 +++- src/core/brain/write-disposition.ts | 13 +++- tests/core/brain/pending-lanes.test.ts | 104 +++++++++++++++++++++++++ 4 files changed, 130 insertions(+), 5 deletions(-) diff --git a/src/core/brain/notes/create-note.ts b/src/core/brain/notes/create-note.ts index 5500cde4..b85ebfb4 100644 --- a/src/core/brain/notes/create-note.ts +++ b/src/core/brain/notes/create-note.ts @@ -809,7 +809,9 @@ export function createNote(vault: string, input: CreateNoteInput): CreateNoteRes // (write-side-trust, Task 7); resolveAgentName stays the stdio and // CLI fallback. input.subject ?? { agent: resolveAgentName(input.configPath), via: "config" }, - { target: relPath }, + // The no-subject fallback (unused while a subject always arrives, and + // honest if that ever changes) resolves through this caller's config. + { target: relPath, configPath: input.configPath }, ); if (disposition.verdict === "stage") { if (existsSync(abs)) { diff --git a/src/core/brain/write-batch.ts b/src/core/brain/write-batch.ts index 5e4965fa..37021b23 100644 --- a/src/core/brain/write-batch.ts +++ b/src/core/brain/write-batch.ts @@ -801,7 +801,12 @@ function projectUpdateNote( // denies cannot rewrite it either - mutation is not a door around the // rule that refused the create. An ask and an allow change nothing // here: no row, no stage, the update proceeds exactly as before. - refuseDocumentDeny(vault, REVIEW_LANE.notes, opts.subject, { target: target.relPath }); + refuseDocumentDeny(vault, REVIEW_LANE.notes, opts.subject, { + target: target.relPath, + // The no-subject fallback answers for the principal THIS config + // declares - the same one the owner gate below resolves against. + ...(opts.configPath !== undefined ? { configPath: opts.configPath } : {}), + }); // The owner gate rides the update seam AHEAD of the existing-note read // (write-side-trust, Task 13): a caller-named `owner:` the gate refuses // is refused with the same error whether the target exists or not, so @@ -927,7 +932,12 @@ function projectAppendNote( // The same deny-only consult the update runs (write-side trust): an // append mutates an admitted note, so the document's deny refuses it, // while an ask and an allow leave the append exactly as it was. - refuseDocumentDeny(vault, REVIEW_LANE.notes, opts.subject, { target: target.relPath }); + refuseDocumentDeny(vault, REVIEW_LANE.notes, opts.subject, { + target: target.relPath, + // The no-subject fallback answers for the principal THIS config + // declares, like the update's consult above. + ...(opts.configPath !== undefined ? { configPath: opts.configPath } : {}), + }); const state = readExistingNote(target.abs, target.relPath, index, opts.readable); const appended = op.content.trim(); const body = state.body.length > 0 ? `${state.body}${APPEND_SEPARATOR}${appended}` : appended; diff --git a/src/core/brain/write-disposition.ts b/src/core/brain/write-disposition.ts index bf5a49ad..bd11687a 100644 --- a/src/core/brain/write-disposition.ts +++ b/src/core/brain/write-disposition.ts @@ -179,6 +179,15 @@ export interface ResolveWriteDispositionOptions { * decision-ledger row records it. */ readonly target?: string; + /** + * Path of the config file the caller resolved its identity against, when + * it has one. The no-subject fallback resolves the ambient agent through + * it, so a document consult on an explicit-configPath run answers the + * SAME principal the owner gate does - a document rule naming that + * agent refuses, and the ledger row names it - instead of answering for + * whatever the ambient discovery finds. + */ + readonly configPath?: string; /** Injected clock for the ledger row's `ts`. Defaults to now. */ readonly now?: Date; } @@ -295,7 +304,7 @@ export function resolveWriteDisposition( }); } const effectiveSubject: WriteSubject = subject ?? { - agent: resolveAgentName(), + agent: resolveAgentName(opts.configPath), via: "config", }; const action = documentActionFor(lane); @@ -358,7 +367,7 @@ export function refuseDocumentDeny( const { document } = loadPermissionsDocument(vault); if (document === null) return; const effectiveSubject: WriteSubject = subject ?? { - agent: resolveAgentName(), + agent: resolveAgentName(opts.configPath), via: "config", }; const action = documentActionFor(lane); diff --git a/tests/core/brain/pending-lanes.test.ts b/tests/core/brain/pending-lanes.test.ts index 6c507a18..16fc58ca 100644 --- a/tests/core/brain/pending-lanes.test.ts +++ b/tests/core/brain/pending-lanes.test.ts @@ -29,6 +29,7 @@ import { freezeVault } from "../../../src/core/brain/freeze.ts"; import { requireNextStep } from "../../../src/core/brain/next-step.ts"; import { WRITE_REFUSAL_NEXT_COMMAND } from "../../../src/core/brain/write-refusal-exit.ts"; import { createNote } from "../../../src/core/brain/notes/create-note.ts"; +import { applyWriteBatch } from "../../../src/core/brain/write-batch.ts"; import { InvalidPendingIdError, PendingApplyConflictError, @@ -42,6 +43,7 @@ import { decodePendingTargetPath, encodePendingTargetPath, listPendingLane, + refuseDocumentDeny, resolveWriteDisposition, rejectPendingLane, stageForReview, @@ -679,6 +681,108 @@ describe("resolveWriteDisposition under a permissions document", () => { }); }); +/** + * The no-subject fallback (write-side trust, Task 12): a caller that + * carries no transport identity - the stdio and CLI surfaces - must be + * consulted as the principal ITS config declares, the same one the owner + * gate answers for, so a document rule naming that agent refuses a run + * launched with an explicit configPath instead of fail-opening against + * whatever the ambient discovery finds. + */ +describe("the no-subject fallback resolves the caller's configPath", () => { + const DOC_PATH = () => join(vault, "Brain", "_permissions.yaml"); + + function writeDoc(text: string): void { + writeFileSync(DOC_PATH(), text, "utf8"); + } + + function writeAltConfig(): string { + const altConfig = join(tmp, "alt-config.yaml"); + writeFileSync(altConfig, `vault: ${vault}\nagent_name: config-agent\n`); + return altConfig; + } + + test("refuseDocumentDeny consults the document as the configPath agent", () => { + setEnv("VAULT_AGENT_NAME", undefined); + const altConfig = writeAltConfig(); + writeDoc( + [ + "version: 1", + "default_action: allow", + "entries:", + " - id: deny-config-agent", + " agent: config-agent", + " action: write", + " verdict: deny", + ].join("\n") + "\n", + ); + let refused: WriteRefusedError | undefined; + try { + refuseDocumentDeny(vault, "notes", undefined, { + target: "Notes/X.md", + configPath: altConfig, + }); + } catch (err) { + refused = err instanceof WriteRefusedError ? err : undefined; + } + expect(refused).toBeInstanceOf(WriteRefusedError); + expect(refused?.agent).toBe("config-agent"); + expect(refused?.rule).toBe("entry:deny-config-agent"); + const rows = queryDecisionLedger(vault, { verdict: "deny" }); + expect(rows).toHaveLength(1); + expect(rows[0]!.actor).toBe("config-agent"); + }); + + test("resolveWriteDisposition's fallback consults through the configPath too", () => { + setEnv("VAULT_AGENT_NAME", undefined); + const altConfig = writeAltConfig(); + writeDoc("version: 1\ndefault_action: deny\n"); + let refused: WriteRefusedError | undefined; + try { + resolveWriteDisposition(vault, "notes", undefined, { + target: "Notes/Y.md", + configPath: altConfig, + }); + } catch (err) { + refused = err instanceof WriteRefusedError ? err : undefined; + } + expect(refused).toBeInstanceOf(WriteRefusedError); + expect(refused?.agent).toBe("config-agent"); + }); + + test("a write-batch update without a subject refuses naming the configPath agent", () => { + setEnv("VAULT_AGENT_NAME", undefined); + const altConfig = writeAltConfig(); + writeDoc( + [ + "version: 1", + "default_action: allow", + "entries:", + " - id: deny-config-agent", + " agent: config-agent", + " action: write", + " verdict: deny", + ].join("\n") + "\n", + ); + // The deny-only consult rides ahead of the existing-note read, so the + // refusal needs no target on disk. + let refused: WriteRefusedError | undefined; + try { + applyWriteBatch(vault, [{ kind: "update_note", path: "Notes/Any.md", body: "rewritten" }], { + configPath: altConfig, + }); + } catch (err) { + refused = err instanceof WriteRefusedError ? err : undefined; + } + expect(refused).toBeInstanceOf(WriteRefusedError); + expect(refused?.agent).toBe("config-agent"); + const rows = queryDecisionLedger(vault, { verdict: "deny" }); + expect(rows).toHaveLength(1); + expect(rows[0]!.actor).toBe("config-agent"); + expect(rows[0]!.target).toBe("Notes/Any.md"); + }); +}); + // ----- consumer refusals (write-side trust, Task 12) ------------------------- describe("createNote under a permissions document", () => { From 3a56652291a4bea504ecf5a9f2599d61a2cef12b Mon Sep 17 00:00:00 2001 From: Sergey Eroshenkov Date: Sun, 11 Oct 2026 01:00:11 +0200 Subject: [PATCH 79/84] fix(mcp): fail closed when token enforcement is requested and the store is unreadable With mcp_tokens_required configured and a token store that could not be read, the per-request probe answered no tokens and enforcement dropped: a credential-less request was waved through exactly when the operator had asked for the opposite. The probe's catch now treats the map as non-empty whenever enforcement was requested (the config key or a key-less exposed bind), so the request is refused with the generic 401 and the read failure stays named on stderr; the shared key keeps answering, so the operator's master credential is not locked out. --- src/mcp/http.ts | 17 +++++++++++++---- tests/mcp/http-token-auth.test.ts | 24 ++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/src/mcp/http.ts b/src/mcp/http.ts index 244520a7..2b59d207 100644 --- a/src/mcp/http.ts +++ b/src/mcp/http.ts @@ -471,10 +471,14 @@ interface HttpAuth { * holds whatever the map holds: revoking the last token must not * re-open anonymous access an exposed bind never promised. * - * A token store that cannot be READ never widens this gate: the probe - * answers "no tokens" and the presented-credential check falls through - * to the shared key, with the failure named on stderr - a corrupt store - * must not lock the operator's master credential out of its own server. + * A token store that cannot be READ never widens this gate for a + * PRESENTED credential: the match falls through to the shared key with + * the failure named on stderr - a corrupt store must not lock the + * operator's master credential out of its own server. The enforcement + * probe fails the other way: when enforcement was requested (the config + * key or a key-less exposed bind), an unreadable map is treated as + * non-empty, so a credential-less request is refused rather than waved + * through - the requirement must not depend on the store's readability. * * The store probes sit behind the flags that need them: a loopback bind * with no requirement and no presented credential reads nothing. @@ -495,6 +499,11 @@ function authenticateHttpRequest( mapNonEmpty = hasAnyAgentToken(mcp.vault); } catch (err) { warnTokenStoreUnreadable(stderr, err); + // Enforcement was REQUESTED (the config key, or the exposed bind), + // so an unreadable map must not read as an empty one: fail closed + // and refuse what the requirement would have refused. The shared + // key still answers - see the presented-credential catch below. + mapNonEmpty = true; } } const enforced = networkBare || (configTokensRequired && mapNonEmpty); diff --git a/tests/mcp/http-token-auth.test.ts b/tests/mcp/http-token-auth.test.ts index 67b9b8cb..589d68cd 100644 --- a/tests/mcp/http-token-auth.test.ts +++ b/tests/mcp/http-token-auth.test.ts @@ -260,6 +260,30 @@ describe("HTTP token authentication", () => { expect(res.status).toBe(200); }); + test("mcp_tokens_required with a corrupt map still refuses credential-less calls", async () => { + // Enforcement was REQUESTED by config; an unreadable store is not "no + // tokens, come on in" - the probe fails closed, so the anonymous + // request is refused with the same generic 401, and the loss is named + // on stderr. + mintAgentToken(vault, "mcp_token_edge", "edge-agent"); + corruptTokenStore(); + const warnings: string[] = []; + const original = process.stderr.write.bind(process.stderr); + process.stderr.write = ((chunk: string | Uint8Array): boolean => { + warnings.push(String(chunk)); + return true; + }) as typeof process.stderr.write; + try { + await start({ tokensRequired: true }); + const missing = await post(rpc("ping", 1)); + expect(missing.status).toBe(401); + expect(await missing.text()).toBe("Unauthorized\n"); + } finally { + process.stderr.write = original; + } + expect(warnings.join("")).toContain("the MCP token store could not be read"); + }); + test("a non-loopback bind accepts a token map without an api key", async () => { const { tokenMaterial } = mintAgentToken(vault, "mcp_token_edge", "edge-agent"); handle = await startHttp({ vault }, { host: "0.0.0.0", port: 0 }); From 144c769929bc7586075ebb51733f2e0feab628f5 Mon Sep 17 00:00:00 2001 From: Sergey Eroshenkov Date: Sun, 11 Oct 2026 01:02:04 +0200 Subject: [PATCH 80/84] docs: align the escape and finding docblocks with what the code does escapeBodyProse overclaimed byte-faithfulness: a question that OPENS with an already-indented heading-shaped line reads back dedented, because the section reader's own trim strips the leading whitespace - the writer's escape with it - before the dedent runs. The claim now scopes to the lines the trim leaves alone and names the exception. The permissions-agent-denied doctor finding names its exit through the WRITE_REFUSAL_NEXT_COMMAND constant, the same shape as the two refusal findings below it, instead of repeating the literal. --- src/core/brain/decisions/open-store.ts | 9 +++++++-- src/core/brain/diagnostics.ts | 6 ++++-- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/core/brain/decisions/open-store.ts b/src/core/brain/decisions/open-store.ts index 0c89a20d..a2fc395e 100644 --- a/src/core/brain/decisions/open-store.ts +++ b/src/core/brain/decisions/open-store.ts @@ -328,8 +328,13 @@ function sectionText(body: string, heading: string): string { * reader dedents exactly the lines the writer could have indented - a * line the writer left alone never has `#` as its first non-space * character, because that shape is what the writer escapes - so the - * round trip is byte-faithful for every input the writer accepts, - * including a question line that was already indented. + * round trip is byte-faithful for every line the section reader's own + * trim leaves alone, an already-indented heading-shaped line included. + * The exception is the section's opening line: the reader's trim strips + * a section's leading whitespace - the writer's escape with it - before + * the dedent runs, so a question OPENING with an indented heading-shaped + * line reads back dedented. (It re-escapes on the next render, so the + * shape is stable; the first round trip just is not byte-faithful there.) */ function escapeBodyProse(text: string): string { return text diff --git a/src/core/brain/diagnostics.ts b/src/core/brain/diagnostics.ts index 3092d8b5..a7580cca 100644 --- a/src/core/brain/diagnostics.ts +++ b/src/core/brain/diagnostics.ts @@ -563,10 +563,12 @@ export const DIAGNOSTIC_SIGNALS: ReadonlyMap = new Map // as the unreadable finding above - its dry-run decision table makes // the denial visible before the first refusal does - and // `autoRepairable` stays false because editing a policy file is the - // operator's act, never a fixer's. + // operator's act, never a fixer's. Built from the same constant as + // the two refusal findings below, keeping the string at one + // definition. code: "permissions-agent-denied", issueClass: "permissions document denies the locally configured agent's write", - nextCommand: "o2b brain permissions show", + nextCommand: WRITE_REFUSAL_NEXT_COMMAND, autoRepairable: false, }, { From a05933552a3cb2f0871d9c0f48b97f960a010e39 Mon Sep 17 00:00:00 2001 From: Sergey Eroshenkov Date: Sun, 11 Oct 2026 01:02:04 +0200 Subject: [PATCH 81/84] fix(secrets): report the original read-back refusal when the restore also fails The read-back catch called writeRawKeyfileBytes bare, so a restore that itself failed replaced the original error: the caller saw the restore's fault instead of the read-back failure that named what actually went wrong first. The restore now runs inside its own guard - its failure is demoted to a named stderr warning beside the original refusal, which propagates unchanged. --- src/core/brain/secrets/envelope.ts | 15 ++++++++- tests/core/brain/secrets/envelope.test.ts | 41 ++++++++++++++++++++++- 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/src/core/brain/secrets/envelope.ts b/src/core/brain/secrets/envelope.ts index 171ef886..2ce69917 100644 --- a/src/core/brain/secrets/envelope.ts +++ b/src/core/brain/secrets/envelope.ts @@ -593,7 +593,20 @@ export function wrapKeyfile( ); } } catch (err) { - writeRawKeyfileBytes(keyPath, dek); + // The rename replaced the raw keyfile, so this restore is the only + // copy of the DEK left to write - but a restore that fails must not + // replace the refusal the caller needs: the ORIGINAL error names the + // read-back fault that actually happened first, so the restore runs + // inside its own guard and its failure is demoted to a named warning + // beside the original error, never instead of it. + try { + writeRawKeyfileBytes(keyPath, dek); + } catch (restoreErr) { + process.stderr.write( + `warning: could not restore the raw keyfile after a failed read-back check: ` + + `${keyPath}: ${restoreErr instanceof Error ? restoreErr.message : String(restoreErr)}\n`, + ); + } throw err; } // The envelope now IS the keyfile: the same owner-only treatment the raw diff --git a/tests/core/brain/secrets/envelope.test.ts b/tests/core/brain/secrets/envelope.test.ts index 358a902e..0d51c204 100644 --- a/tests/core/brain/secrets/envelope.test.ts +++ b/tests/core/brain/secrets/envelope.test.ts @@ -6,7 +6,15 @@ */ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { readdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { + mkdirSync, + readdirSync, + mkdtempSync, + readFileSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -249,6 +257,37 @@ describe("the keyfile envelope", () => { } }); + test("a restore that itself fails reports the ORIGINAL read-back refusal, not the restore's", () => { + const dek = loadOrCreateKey(keyPath); + // The write lands bytes that are not the envelope (the read-back will + // refuse), and the restore's own temp path is occupied by a directory, + // so the restore cannot write the raw bytes back either. The thrown + // refusal must still be the read-back one - the fault that actually + // happened first - with the restore's own failure demoted to stderr. + mkdirSync(`${keyPath}.raw-tmp`); + const warnings: string[] = []; + const original = process.stderr.write.bind(process.stderr); + process.stderr.write = ((chunk: string | Uint8Array): boolean => { + warnings.push(String(chunk)); + return true; + }) as typeof process.stderr.write; + let refused: unknown; + try { + wrapKeyfile(keyPath, PASSPHRASE, dek, { + write: (target) => writeFileSync(target, '{"version": 1, "kdf": {"n": 1'), + }); + } catch (err) { + refused = err; + } finally { + process.stderr.write = original; + } + expect(refused).toBeInstanceOf(SecretEnvelopeError); + expect((refused as Error).message).not.toContain("raw-tmp"); + // The restore failed, so the mangled envelope is what sits at the path. + expect(readFileSync(keyPath).equals(dek)).toBe(false); + expect(warnings.join("")).toContain("could not restore the raw keyfile"); + }); + test("a wrapped store unlocks from the environment once and the variable is consumed", () => { const dek = loadOrCreateKey(keyPath); wrapKeyfile(keyPath, PASSPHRASE, dek); From 4c711539835aeb94b7532f34df749817836eb0fc Mon Sep 17 00:00:00 2001 From: Sergey Eroshenkov Date: Sun, 11 Oct 2026 01:02:26 +0200 Subject: [PATCH 82/84] docs: fold the delta-review repairs into the 1.79.0 changelog --- CHANGELOG.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ba6a1be..ad771136 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,7 +29,7 @@ Open Second Brain 1.79.0 gives an operator who lets agents write into a vault th ### Fixed -- **The passphrase-wrapped store is reachable again.** Every key-bearing verb (`set`, `rm`, `run`, `export`, `import`) ingests the passphrase through `--passphrase-from-env` or stdin exactly as `unlock` does; the MCP server unlocks once from `OPEN_SECOND_BRAIN_SECRETS_PASSPHRASE` at its first key use and drops the variable; `o2b brain secret unwrap` restores the raw keyfile; and the wrap path verifies the envelope against the key and writes it durably before replacing the only copy. +- **The passphrase-wrapped store is reachable again.** Every key-bearing verb (`set`, `rm`, `run`, `export`, `import`) ingests the passphrase through `--passphrase-from-env` or stdin exactly as `unlock` does; the MCP server unlocks once from `OPEN_SECOND_BRAIN_SECRETS_PASSPHRASE` at its first key use and drops the variable; `o2b brain secret unwrap` restores the raw keyfile; and the wrap path verifies the envelope against the key and writes it durably before replacing the only copy, and a failed post-write restore reports the original read-back refusal beside a named warning instead of the restore's own error. - **Credential bundles authenticate their metadata.** Each entry's value is sealed against its name, env mapping, allow patterns, the bundle version and the KDF parameters, so an edited or swapped bundle refuses on import before any write; the bundle schema moves to 2 and older bundles refuse naming the re-export remedy. - **Approval digests bind the content that lands.** The import plan seals each row's source and rendered-body hashes, so a file edited after the dry run fails the apply; `brain upgrade --apply` accepts `--approval-digest`, required when non-interactive and verified against the freshly computed plan. - **The ingest manifest carries the extraction contract per entry.** Ingesting one source no longer cancels the owed reprocess of the others; a manifest without per-entry contracts degrades toward reprocessing, never toward skipping. @@ -37,11 +37,11 @@ Open Second Brain 1.79.0 gives an operator who lets agents write into a vault th - **A refused capture no longer crash-loops the Telegram daemon.** Contract refusals are recorded per update, the offset advances, and the run continues with the messages behind the refusal. - **Session-summary divergence is a signal again.** Records dedupe by content hash on read, `divergent` fires only when distinct hashes coexist at one instant, and the revision count is reported separately. - **Secret references resolve the names the store holds.** Hyphenated and mixed-case names resolve through the store leg while the env fallback keeps its case rules, and redaction covers env-resolved values without blanking error text on one- or two-character values. -- **The HTTP transport survives a corrupt token store.** A store read error falls through to the shared-key compare with a named warning; a presented credential matching nothing is refused outright rather than read as anonymous; and revoking the last token on a keyless network bind no longer re-opens anonymous access. -- **Request identity reaches every write decision.** A token caller's writes are judged as the token's agent: document deny entries refuse them, cross-owner claims are checked against the credential identity, a caller-supplied agent that differs from the token refuses by name, and the decision ledger names the true actor. The signal lane consults the permissions document like every other writer, denied agents cannot update or append to published notes, and the review door appends one resolution row per apply and reject. -- **Staging the same target twice refuses by name** instead of silently replacing the earlier entry's bytes, and a failed decision-ledger append surfaces its audit reason instead of vanishing. -- **Open decisions stay resolvable.** Same-titled questions resolve into distinct decision pages, a resolved or discarded question can be parked again with the same wording, adversarial headings in question text round-trip byte-faithfully, callers below the record's reach cannot resolve or discard what they cannot list, and the CLI help says non-Latin titles hash to an unnamed id. -- **Bootstrap tells the truth about tokens.** The minted token authenticates hand-configured HTTP clients while the registered harness keeps its config-derived identity (stated in the output and the docs); a failed apply no longer loses the minted material; placeholder agent names refuse at mint; rotation keeps a ten-minute grace window for the previous material; and `o2b bootstrap --remove` tears a provisioning down through the receipt. +- **The HTTP transport survives a corrupt token store.** A store read error falls through to the shared-key compare with a named warning, while token enforcement that was requested by config stays on (an unreadable map refuses credential-less requests rather than reading as empty); a presented credential matching nothing is refused outright rather than read as anonymous; and revoking the last token on a keyless network bind no longer re-opens anonymous access. +- **Request identity reaches every write decision.** A token caller's writes are judged as the token's agent: document deny entries refuse them, cross-owner claims are checked against the credential identity, a caller-supplied agent that differs from the token refuses by name, and the decision ledger names the true actor. A run launched with an explicit config path is consulted as the agent that config declares even when no transport identity is present, so a document rule naming that agent refuses it and the ledger row names it. The signal lane consults the permissions document like every other writer, denied agents cannot update or append to published notes, and the review door appends one resolution row per apply and reject. +- **Staging the same target twice refuses by name** instead of silently replacing the earlier entry's bytes - and the retried `brain_create_note`, `brain_write_batch` create op or `brain_ingest_source` answers the `pending-stage-conflict` code carrying the queue entry that holds the target, not a generic internal error - and a failed decision-ledger append surfaces its audit reason instead of vanishing. +- **Open decisions stay resolvable.** Same-titled questions resolve into distinct decision pages even when the title's slug fills the 64-character cap - the per-record fallback candidates keep their distinguishing id inside the cap, so resolving one never spins forever against a foreign page at that slug - a resolved or discarded question can be parked again with the same wording, adversarial headings in question text round-trip byte-faithfully, callers below the record's reach cannot resolve or discard what they cannot list, and the CLI help says non-Latin titles hash to an unnamed id. +- **Bootstrap tells the truth about tokens.** The minted token authenticates hand-configured HTTP clients while the registered harness keeps its config-derived identity (stated in the output and the docs); a failure anywhere in the registration half - the payload build refusing, the apply throwing - no longer loses the minted material; placeholder agent names refuse at mint; rotation keeps a ten-minute grace window for the previous material; and `o2b bootstrap --remove` tears a provisioning down through the receipt. - **The doctor sees a document that denies the locally configured agent**, and sessions import reports the ambient-withheld count when consent suppresses capture. - **Expired transient facts cannot consolidate.** The dream topic and promotion planning readers drop signals past their ambient time-to-live before clustering, matching the recall path. From 5d598dc99b6fa774b01742250851a52a4ba41b6e Mon Sep 17 00:00:00 2001 From: Sergey Eroshenkov Date: Sun, 11 Oct 2026 01:08:48 +0200 Subject: [PATCH 83/84] fix(bootstrap): keep the orphaned-token receipt's pointers on the write itself Hoisting the rescue print into a helper moved the retry and revoke literals into a variable argument, out of the process.stdout.write call text the forward-pointer rail reads, so the sanctioned sites measured zero and the rail went blind to pointers that still reach stdout. The shared retry and revoke tail is spelled on the write itself again; the per-branch failure clauses name no invocation. --- src/cli/bootstrap/run.ts | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/src/cli/bootstrap/run.ts b/src/cli/bootstrap/run.ts index b14051b5..539f9e4c 100644 --- a/src/cli/bootstrap/run.ts +++ b/src/cli/bootstrap/run.ts @@ -388,7 +388,9 @@ function runCheck(input: CheckInput): number { * (the name now exists, so nothing is minted), so it prints here, exactly * once, on stdout, beside the failure notice and the retry and revoke * paths - returning or rethrowing without it would orphan a live - * credential behind a failed run. + * credential behind a failed run. The retry and revoke tail is spelled + * here, on the write itself, so the forward-pointer rail keeps counting + * the two invocation literals this receipt carries. */ function printOrphanedTokenMaterial(input: { readonly target: string; @@ -405,7 +407,9 @@ function printOrphanedTokenMaterial(input: { ` ${input.material}\n` + ` ${SHOWN_ONCE_NOTICE}\n` + ` ${HTTP_BOUNDARY_NOTICE}\n` + - ` ${input.failure}\n`, + ` ${input.failure}\n` + + ` Re-run o2b bootstrap --target ${input.target} to apply the registration alone, ` + + `or revoke with: o2b mcp token revoke --name ${input.name}\n`, ); } @@ -526,9 +530,8 @@ function runProvision(input: ProvisionInput): number { // live credential behind a failed registration - so it prints // here, exactly once, beside the failure and the retry path. rescueMaterial( - `The registration half failed (the error above); the token is live in the store. ` + - `Fix the cause and re-run o2b bootstrap --target ${target} to apply the ` + - `registration alone, or revoke with: o2b mcp token revoke --name ${name}`, + "The registration half failed (the error above); the token is live in the store. " + + "Fix the cause, then take the retry or revoke path below.", ); return e.kind === "user-modified-block" ? BOOTSTRAP_EXIT.userModifiedBlock @@ -539,9 +542,8 @@ function runProvision(input: ProvisionInput): number { // apply above, so the material prints here too - before the usage // refusal or the rethrow. rescueMaterial( - `The run failed before its registration could report; the token is live in the store. ` + - `Re-run o2b bootstrap --target ${target} to apply the registration alone, ` + - `or revoke with: o2b mcp token revoke --name ${name}`, + "The run failed before its registration could report; the token is live in the store. " + + "The retry or revoke path below finishes or undoes it.", ); if (e instanceof PayloadError) return usageRefusal(e.message); throw e; From 363d6cf75d1ab0a39a646a06340558d8dcd74600 Mon Sep 17 00:00:00 2001 From: Sergey Eroshenkov Date: Sun, 11 Oct 2026 01:11:28 +0200 Subject: [PATCH 84/84] chore(openclaw): rebuild the bundle --- openclaw/index.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/openclaw/index.js b/openclaw/index.js index f4b3482a..5f0345e9 100644 --- a/openclaw/index.js +++ b/openclaw/index.js @@ -3871,7 +3871,12 @@ function wrapKeyfile(keyPath, passphrase, dek, seams = {}) { throw new SecretEnvelopeError(ENVELOPE_REFUSAL_CODES.malformed, keyPath, "the written envelope does not unwrap to the key it wraps"); } } catch (err) { - writeRawKeyfileBytes(keyPath, dek); + try { + writeRawKeyfileBytes(keyPath, dek); + } catch (restoreErr) { + process.stderr.write(`warning: could not restore the raw keyfile after a failed read-back check: ` + `${keyPath}: ${restoreErr instanceof Error ? restoreErr.message : String(restoreErr)} +`); + } throw err; } restrictToOwner(keyPath, "file", process.platform, true);