diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index f824009e2..1422bb685 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 387e08764..ff7cbc265 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/CHANGELOG.md b/CHANGELOG.md index d29d85b23..ad7711363 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,46 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.79.0] - 2026-10-10 + +Open Second Brain 1.79.0 gives an operator who lets agents write into a vault the answers the write paths never recorded: who made a given write, what rule allowed or refused it, and how to stop the next one. Agents authenticate as themselves through named per-agent MCP tokens, one operator-edited permissions document resolves allow/ask/deny at a single chokepoint, a queryable decision ledger records which rule decided, every gated write stages into a review queue that recall cannot see until an operator applies it, an owner-write gate keeps callers from publishing under a foreign owner, open questions get a durable artifact of their own, and the ambient extraction lane answers to operator consent and a time-to-live. The default posture is byte-identity: no tokens, no document, no gate keys and no flags means every existing write path behaves exactly as before. + +### Added + +- **Named per-agent MCP tokens, hash at rest.** `o2b mcp token mint|rotate|revoke|list` manages one token per agent in `.open-second-brain/secrets/mcp-tokens.json` beside the custody store: the store keeps only the sha-256 hash of the material plus a non-secret prefix, verification compares hashes in constant time, the plaintext material is printed exactly once with a shown-once notice and never persisted, writes serialize under the secrets lock, names use the `mcp_token_` spelling the `$secret:` grammar can address, and every mint, rotation and revocation appends a no-values custody audit record. +- **Token authentication and request-scoped identity on the HTTP transport.** A request whose credential matches a stored token authenticates as that token's agent for that one request - identity threads as a parameter through the dispatch path, never as server state, so concurrent callers with different tokens never observe each other's identity. The token map is consulted before the shared key, the shared `--api-key` stays valid as the operator master credential with the process's configured identity, 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 new `mcp_tokens_required` device key (default `false`, env twin `OPEN_SECOND_BRAIN_MCP_TOKENS_REQUIRED`) makes the endpoint refuse credential-less requests while a non-empty token map exists; the non-loopback bind rule accepts a key or a non-empty token map, and stdio identity stays config-derived. A token mints identity only, never reach. +- **One bootstrap command.** `o2b bootstrap --target ` runs the harness adapter's existing idempotent apply, optionally mints and prints the harness token once (`--token`, never on argv and never in a harness config), and writes a receipt at `/.open-second-brain/bootstrap.lock.json` recording the owned entries, the token name and prefix, and the applied time. A second identical run is a byte-identical no-op, `--rotate` re-mints under the same name and reprints once with the new material authenticating on the next request, `--check` verifies drift, and unsupported targets are refused naming the available list. The config-writing adapters (codex, grok, opencode) apply directly, `generic` prints the payload plus manual steps, and the plugin runtimes stay verify-only. +- **One permissions document.** `Brain/_permissions.yaml` - an operator-edited vault file that rides vault sync like the freeze marker - resolves `allow`/`ask`/`deny` per agent, operator-defined role and target-scoped entry for the `write`, `ingest` and `owner_write` actions. The loader is strict: `default_action` is required, unknown keys warn, a `version` other than 1 hard-refuses naming the file, and a present-but-unreadable document fails closed with a field-named error instead of falling back to permissive. Resolution order is target entry, then agent override, then role, then default, with deny over ask over allow at equal specificity; when a document exists it is the only disposition source, so one write has exactly one deciding rule. With the file absent every check behaves exactly as today. +- **A queryable decision ledger.** Every ask/deny verdict plus the owner-write gate's warn rows lands as one JSONL row in the month/device shards under `Brain/logs/decisions/`, carrying `actor`, `via`, `action`, `target`, `verdict`, the deciding `source` (entry, agent, role, default or gate key), the reason token and optional `tool` and `correlation_id`; allow rows are written only when the document asks for them (`ledger.record_allows: true`). The append never throws - a failed write returns an audit reason for the caller to surface instead of blocking the verdict it records. `o2b brain permissions show` prints the effective document plus a dry-run decision table over the declared agents, `ledger` reads the filtered rows, and `o2b brain doctor` reports an unreadable document as the `permissions-unreadable` finding with the show verb as the exit. +- **Staged review for every writer lane.** Beyond the existing signals queue, note creates stage at `Brain/pending/notes/.md` (the id carrying a reversible percent-encoding of the publish target, round-trip safe over CJK, spaces and dots, over-long targets refused by name) and ingest summary pages stage at `Brain/pending/ingest/`, while registration completes and cleanup removes the staged page. The signal gate now resolves inside `writeSignal`, so every ungated caller - MCP and CLI feedback, inline scan, session import, lifecycle and checkpoint - inherits it without per-tool wiring; batch create ops stage per operation and report receipt status `staged` with a `pending_id`, and staged receipts on the writer tools carry the next command `o2b brain pending apply `. `o2b brain pending list` shows every lane sorted with `--lane` filtering and named-unreadable entries, apply is an exclusive create with a typed conflict on an occupied target, and `--dry-run` runs every check and writes nothing. Toggles are per lane: `write_approval.notes` and `write_approval.ingest` each fall back to the `write_approval.enabled` master (default off) when absent. +- **Staged documents leave recall.** `Brain/pending/` is excluded from search-index admission (reason `review-pending`), so `brain_search` and recall-inject cannot surface a document no operator has admitted, while gate-off vaults without a pending directory are unaffected. +- **The owner-write gate.** `integrity.owner_scope_writes` (`off` | `warn` | `fail`, default `off`, unreadable config fails closed to `fail`) refuses a caller-named owner that differs from the caller's resolved identity: on the preference lane at the explicit-owner arm, and on the note lane where `owner` joins the refused frontmatter keys for creates and batch updates under `fail`. The refusal carries the `owner-write-refused` token (`owner_write_refused` on the write-batch and note-creation error vocabularies) naming both the named and the resolved owner and the fix - name your own identity or omit the owner. `warn` allows and appends exactly one decision-ledger row carrying the gate key as its source, and a document's `owner_write` verdict composes most-restrictive-wins with the gate in both directions. Gate off, behavior is byte-identical. +- **Open decisions.** `o2b brain decision open --title --question --option [--option ...] [--context ]` parks a question with enumerated options at `Brain/decisions/open-.md` - a frozen key table, JSON-quoted free text, a `## Question`/`## Options`/`## Context` body, a directory lock around transitions, and a typed duplicate refusal naming the existing id. `list_open` partitions by status with unreadable records named, `show_open` reads one back, `resolve --choice ` mints the real `type: decision` page through the ordinary record path and stamps the open record `resolved` with a `[[decision-]]` pointer plus exactly one `open-resolved` receipt, and `discard --reason ` closes without deciding. Terminal records stay in place, the morning brief renders at most five open records plus an unreadable block and never mutates them, and the `decision-open`, `decision-resolved` and `decision-discarded` log kinds keep the lifecycle in the Brain log timeline. +- **Consent and a TTL for ambient capture.** `guardrails.ambient_writeback: false` suppresses the ambient extraction lane with a counted, logged `ambient-withheld` event per capture and no signal written (absent keeps today's behavior, since the lane already writes on capture); `guardrails.ambient_ttl_days` stamps `expiration_date` at creation on ambient-extracted signals, which the expiration filter then drops at read. A TTL-stamped signal still stages when the review gate is on, and the expiration survives apply verbatim. + +### Changed + +- **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, 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. +- **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, 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. + ## [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. @@ -8366,6 +8406,7 @@ plugin config (vault field)`, and exits with a clear - Sandbox vault and plugin manifest fixtures for tests. - GitHub release workflow for tag-based and manually dispatched releases. +[1.79.0]: https://github.com/itechmeat/open-second-brain/compare/v1.78.0...v1.79.0 [1.78.0]: https://github.com/itechmeat/open-second-brain/compare/v1.77.0...v1.78.0 [1.77.0]: https://github.com/itechmeat/open-second-brain/compare/v1.76.0...v1.77.0 [1.76.0]: https://github.com/itechmeat/open-second-brain/compare/v1.75.0...v1.76.0 diff --git a/README.md b/README.md index 8ee8d8662..7a9fae9de 100644 --- a/README.md +++ b/README.md @@ -114,10 +114,16 @@ The full router with readiness criteria is [`install.md`](install.md); native Wi - **Semantic search:** an embedding provider plus `sqlite-vec`; the `embeddings-setup` skill walks through it: [`skills/embeddings-setup/SKILL.md`](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`](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](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](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, 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). ## 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](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](CHANGELOG.md). ## Documentation 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 000000000..ed990370c --- /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 000000000..1c9e9165a --- /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 000000000..3255cff1a --- /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 000000000..6ce734090 --- /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: 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 +``` + +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 000000000..8fc018790 --- /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. diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 71e472be6..a564df96d 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -17,7 +17,7 @@ o2b doctor Run vault + adapter checks o2b index Rebuild the Markdown page index o2b export-config Write a redacted config snapshot o2b secrets list|status Inspect $secret:NAME references without printing values; `--vault ` joins that vault's custody store (names and availability only, never a decrypt; without the flag both commands are byte-identical to the store-less report) -o2b mcp Run the MCP tool server (stdio by default; --transport http binds loopback, and a key - --api-key or, kept out of the process list, OPEN_SECOND_BRAIN_MCP_API_KEY - is required only for a non-loopback --host); --scope full|writer|catalog, --tool-profile full|writer|catalog|recall|minimal (an unknown profile exits 2 rather than serving the full surface), --host-target , --harness , --probe, --allow-tool, --disable-tool, --max-tools +o2b mcp Run the MCP tool server (stdio by default; --transport http binds loopback, and a credential - a key, --api-key or, kept out of the process list, OPEN_SECOND_BRAIN_MCP_API_KEY, or a non-empty per-agent token map from `o2b mcp token mint` - is required only for a non-loopback --host); a token-matched request authenticates as that token's agent for the request, the shared key keeps the process identity, and `mcp_tokens_required` / OPEN_SECOND_BRAIN_MCP_TOKENS_REQUIRED (default false) refuses credential-less requests while a non-empty token map exists; --scope full|writer|catalog, --tool-profile full|writer|catalog|recall|minimal (an unknown profile exits 2 rather than serving the full surface), --host-target , --harness , --probe, --allow-tool, --disable-tool, --max-tools o2b state status|migrate|rollback Inventory the state this vault holds, move it to another directory, or put it back (see "State surfaces" below) o2b tool-call Invoke an MCP tool handler from the CLI @@ -26,6 +26,8 @@ o2b help --json Print the command/flag manifest as JSON o2b completions --shell zsh Print completions for bash|zsh|fish|elvish|nushell|powershell o2b uninstall Print uninstall plan; --apply-local cleans config; --remove-cli removes symlinks o2b update Update Open Second Brain across all detected runtimes; --target / --dry-run / --force / --json +o2b bootstrap One-command harness provisioning for --target (codex, grok, opencode run the adapter's idempotent apply; generic prints the payload plus manual steps; claude-code and zcode are plugin verify-only). --token mints mcp_token_ and prints the material exactly once - never on argv, never in a harness config; a second identical run is a byte-identical no-op; --rotate re-mints under the same name (effective on the next request, no restart); --check verifies drift; receipt at /.open-second-brain/bootstrap.lock.json +o2b mcp token Per-agent MCP token management over the vault's hash-at-rest store: mint (derives mcp_token_ unless --name), rotate, revoke, list; mint and rotate print the material exactly once with a shown-once notice, list shows metadata only ``` ### `o2b version` (since v1.56.0) @@ -429,6 +431,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) @@ -673,7 +676,7 @@ Wikilinks to frontmatter `aliases:` resolve at index materialization (schema v7) ### Write-path integrity and store safety (since v1.32.0) ```text -o2b brain pending list | apply | reject --reason - review the opt-in write-approval queue (`write_approval.enabled`); staged extracted signals live in Brain/pending/, apply moves the unchanged document to the inbox, reject moves it to Brain/retired/ with the reason +o2b brain pending list [--lane signals|notes|ingest|all] | apply [--dry-run] | reject --reason [--dry-run] - review the write-approval queues (`write_approval.enabled` plus the per-lane keys); signals stage flat in Brain/pending/ (sig- ids), note creates under Brain/pending/notes/ (note- ids carrying the encoded publish target), ingest summary pages under Brain/pending/ingest/ (ing- ids); list shows every lane sorted and names unreadable entries with a reason, apply moves the unchanged document to its decoded publish target (--dry-run previews and writes nothing), reject renders it into Brain/retired/ with the reason; everything staged under Brain/pending/ stays out of the search index (admission reason `review-pending`), so recall cannot surface an unreviewed document o2b brain signal retire --reason [--superseded-by ] - move an inbox signal to Brain/retired/ with retire frontmatter (_status, retired_at, retired_reason, optional superseded_by, old-id alias); retired signals leave dream intake but stay queryable o2b brain entity prune [--confirm] [--json] - list entity nodes whose labels fail the structural quality gate (dry-run default); --confirm removes nodes and their edges behind the snapshot gate and reports the recovery point o2b brain forget-source --confirm now snapshots Brain/ before any deletion and reports the snapshot run id; dry runs take no snapshot @@ -687,7 +690,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 efb0b616d..ebab44155 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -292,8 +292,24 @@ through unchanged: - the frozen-vault refusal `vault_frozen`, the write-binding refusal `write-binding-refused`, the reach refusal `caller-supplied-reach`, and the owner-scope refusals `foreign-owner` and `unresolved-identity`; +- the permissions-document write refusals (since 1.79.0): `write-refused` + (a document rule denied the write) and + `force-confirmed-requires-allow` (`force_confirmed` skipped the dream + trial window while the caller's document `write` verdict is not + `allow`); both resolve `next_command: "o2b brain permissions show"`, + the same re-derive loop the `permissions-unreadable` doctor finding + 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`, ...), note creation, note lifecycle, note revert, + `invalid_target`, ..., and since 1.79.0 the frontmatter + `owner_write_refused` guard), note creation (which carries the same + `owner_write_refused` guard), note lifecycle, note revert, stub scaffolding, note title resolution, note templates, pinned context, exact state, host memory writes, the count guard (`count_guard`), and the shared `config_invalid` and @@ -307,7 +323,8 @@ The canonical list is `TOOL_ERROR_CODES` in `src/mcp/tool-error-codes.ts`. **Casing follows the vocabulary.** New tokens are lower snake_case. A code that was already on the wire keeps its spelling: search codes stay -UPPER_SNAKE, the write-binding, reach and owner-scope refusals stay +UPPER_SNAKE, the write-binding, reach, owner-scope and permissions-document +write refusals stay kebab-case, and the `brain_expire` refusals keep their class names. Nothing was renamed, so a client that already matched `vault_frozen` or `budget_exceeded` keeps working. @@ -714,8 +731,9 @@ Optional flags: The stdio server logs its banner to `stderr` and only writes JSON-RPC frames to `stdout`, so it is safe to use as a subprocess in any MCP client. HTTP refuses -to start when `--host` is not loopback and no key was given (`--api-key` or -`OPEN_SECOND_BRAIN_MCP_API_KEY`); with a key +to start when `--host` is not loopback and neither a key (`--api-key` or +`OPEN_SECOND_BRAIN_MCP_API_KEY`) nor a non-empty agent-token map is present; +with a key configured it checks that key on every request using a generic constant-time comparison, and returns the same `401 Unauthorized` body for a missing or wrong key. JSON responses are the default; clients that send @@ -724,6 +742,46 @@ JSON-RPC response — one event, then the connection closes, which is why a progress token sent over HTTP is refused by name rather than honoured (see "Progress notifications" above). +Since 1.79.0 the HTTP transport also answers per-agent credentials. `o2b mcp +token mint` (or `o2b bootstrap --token`) mints a named token per agent into +the vault's hash-at-rest store (`.open-second-brain/secrets/mcp-tokens.json`; +only a sha-256 hash and a non-secret prefix are stored, the material is shown +exactly once). A request whose `Authorization: Bearer` or `X-API-Key` +credential matches a stored token authenticates as that token's agent for +that one request - identity threads as a request parameter, never as server +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 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 +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. + +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 @@ -1521,7 +1579,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/docs/observability.md b/docs/observability.md index 12adceb4e..247272b22 100644 --- a/docs/observability.md +++ b/docs/observability.md @@ -9,6 +9,7 @@ Open Second Brain records what it did - learning events, recall decisions, serve | Brain log | `Brain/log/[.].md` + JSONL sidecar | `appendLogEvent()` in `src/core/brain/log.ts` | Markdown line + JSONL row per event | | Continuity store | `Brain/log/continuity/[.].jsonl` | `appendContinuityRecord()` in `src/core/brain/continuity/store.ts` | one JSON record per line | | Idempotency ledger | `Brain/logs/idempotency/[.].jsonl` | `rememberKey()` in `src/core/brain/idempotency-ledger.ts` | one `key -> content hash` record per line | +| Decision ledger | `Brain/logs/decisions/[.].jsonl` | `appendDecisionLedger()` in `src/core/brain/permissions/ledger.ts`; read back by `o2b brain permissions ledger` | one JSON decision row per line (see "The decision ledger" below) | | Preference mutation audit | `Brain/log/pref-audit//device[.].jsonl` (legacy flat `Brain/log/pref-audit/[.].jsonl` still read) | `appendPrefAudit()` in `src/core/brain/pref-audit.ts` | one JSON record per line | | Session lineage ledger | `Brain/.state/session-lineage[.].jsonl` (+ `session-lineage-gaps[.].jsonl`) | `recordLineageObservation()` in `src/core/brain/lineage/ledger.ts` | one JSON record per line, sequence-numbered and hash-chained per file | | Session lifecycle audit | `Brain/log/session-lifecycle/[.].jsonl` | `captureSessionLifecycleEvent()` in `src/core/brain/session-lifecycle.ts` | JSONL audit rows | @@ -51,6 +52,10 @@ The `prompt_prefix` metric surface measures STRUCTURAL prompt-prefix stability ( | `note` | a narrative milestone is recorded (`brain_note`) | | `note-write` | a vault note is created, updated, appended to, or reverted | | `session-lifecycle` | a captured lifecycle event also produced Brain writes | +| `decision-open` | a question was parked with enumerated options at `Brain/decisions/open-.md`, ahead of the decision it may become. Payload carries the `open` wikilink, the `title`, the option `count` and the `agent`. Distinct from `decision-record` because nothing has been decided yet | +| `decision-resolved` | an open decision was closed by choosing one of its options, minting a real `type: decision` page. Payload carries the `open` wikilink, the minted `decision` wikilink, the `choice` and the `agent`. The minted page itself carries the usual `decision-record` event; this event records the transition | +| `decision-discarded` | an open decision was closed WITHOUT a decision. Payload carries the `open` wikilink, the `reason` and the `agent`. Separated from `decision-resolved` so "never decided" is machine-filterable against "decided as X" | +| `ambient-withheld` | an ambient extraction capture was suppressed by operator consent (`guardrails.ambient_writeback: false`). One event per suppressed capture, carrying the count of signals the capture would have written - never their content. Keys absent log nothing, because the lane ran as today | ### Note writes @@ -103,6 +108,25 @@ Once the log is the attribution record for every write, a line someone deleted o Rows written before the chain shipped carry no `h`. They are counted as **legacy** and are clean while they precede the chain — the first chained line after them anchors the shard with `prev: null`. The same shape *after* a chained line is not history but a line whose links were stripped, and it is reported. The head of a shard is not exempt either: the Brain log never compacts, so a first chained line naming a predecessor means the head of the file was cut off. Sync-conflict copies are excluded from verification — they are the doctor's separate `sync-conflict-log` finding, and their chain never held by construction. +## The decision ledger + +The Brain log, the pref-audit, the idempotency ledger and the continuity store each record what happened; none of them answers "which rule allowed, asked or denied this write". The decision ledger (`Brain/logs/decisions/`, write-side-trust wave) is the queryable record of that answer: one JSONL row per gate disposition that was not a plain publish, written by the gate that produced the verdict. `appendDecisionLedger()` (`src/core/brain/permissions/ledger.ts`) appends to the month/device shards on the shared shard grammar - the per-device rule and the merged `(timestamp, shard id, line)` read order are exactly the ones "The per-device shard rule" above states. + +The row shape is closed: `ts`, `actor` (the resolved principal), `via` (`token`, `config` or `operator` - where the identity came from), `action` (`write`, `ingest` or `owner_write`), `target`, `verdict` (the disposition - `allow`, `ask`, `deny`, a gate mode such as `warn`, or a refusal token), `source` (the deciding rule: an entry id, an agent or role name, `default`, or a gate key such as `integrity.owner_scope_writes`), `reason` (the named token), and the optional `tool` and `correlation_id` that join a row to the other trails the way `event-trace.ts` joins log events. + +When rows land is deliberately quiet: + +| Trigger | Rows | +|---|---| +| No permissions document, every gate key off | none - default-silent like every ledger | +| A permissions document resolves `ask` or `deny` for a staged-lane write | one row naming the entry, role or default that decided | +| A permissions document resolves `allow` | one row ONLY when the document sets `ledger.record_allows: true` | +| `integrity.owner_scope_writes: warn` allows a caller-named foreign owner on the preference lane | one row carrying the gate key as `source` | + +The append contract is absolute: a failed append NEVER throws and never blocks the verdict it records. The ledger rides behind gates that must refuse or stage a write even when accountability cannot be written, so every failure - an unwritable directory, a lock another writer holds - comes back as `{ logged: false, audit_reason }` that the caller surfaces; the receipt carries the reason rather than claiming a record that does not exist. + +`o2b brain permissions ledger --actor --action --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/openclaw.plugin.json b/openclaw.plugin.json index e7e4a9a0d..9f597411e 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/openclaw/index.js b/openclaw/index.js index 7d7c26ca6..5f0345e99 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,36 @@ 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); + } 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); @@ -3306,6 +3890,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 +3943,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 +3973,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 +3994,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 +4017,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 +4031,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 +4039,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,20 +4085,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 join12(secretsDir(vault), "mcp-tokens.json"); } function isValidSecretName(name) { return NAME_RE.test(name); @@ -3603,7 +4235,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 { @@ -3637,13 +4269,29 @@ 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 (existsSync8(tokenStorePath(vault))) + targets.push([tokenStorePath(vault), "file"]); return targets; } function toMetadata(name, stored) { @@ -3657,19 +4305,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}`); } @@ -3686,7 +4334,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); } @@ -3703,7 +4351,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, @@ -3901,7 +4549,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) { @@ -3909,6 +4557,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) @@ -3921,12 +4576,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); } }); } @@ -3943,15 +4598,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; } @@ -3977,21 +4655,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({ @@ -4094,11 +4772,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; } @@ -4111,7 +4789,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); @@ -4122,7 +4800,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 { @@ -4134,7 +4812,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 ?? []) { @@ -4276,7 +4954,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; @@ -4355,7 +5033,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, @@ -4363,11 +5041,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", @@ -4381,20 +5059,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}` }; @@ -4424,7 +5102,7 @@ function loadJsonManifest(path, name) { } let data; try { - data = JSON.parse(readFileSync8(path, "utf8")); + data = JSON.parse(readFileSync10(path, "utf8")); } catch (exc) { return { result: { @@ -4550,7 +5228,7 @@ function checkHermesManifest(path) { } let text; try { - text = readFileSync8(path, "utf8"); + text = readFileSync10(path, "utf8"); } catch (exc) { return { name: "hermes_manifest", @@ -4599,7 +5277,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) @@ -4630,7 +5308,7 @@ function checkOpenclawInstallability(repoRoot) { }); continue; } - const entryPath = join12(repoRoot, entry); + const entryPath = join14(repoRoot, entry); const problem = manifestFileProblem(entryPath); if (problem === null) { results.push({ @@ -4666,10 +5344,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({ @@ -4684,10 +5362,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" @@ -4704,7 +5382,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); @@ -4713,8 +5391,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); @@ -4722,7 +5400,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; @@ -4761,8 +5439,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({ @@ -4831,7 +5509,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, { @@ -4946,7 +5624,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; @@ -5119,7 +5797,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) { diff --git a/package.json b/package.json index 896ebf6ab..8e11607b6 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 dbe55011c..60f1b0f6e 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 1594e9267..6a94a45af 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/codex/README.md b/plugins/codex/README.md index e22ea6987..f827df4fa 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, 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). ## 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 diff --git a/plugins/hermes/plugin.yaml b/plugins/hermes/plugin.yaml index dbe55011c..60f1b0f6e 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 9881f8866..556cbeb27 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/src/cli/bootstrap/index.ts b/src/cli/bootstrap/index.ts new file mode 100644 index 000000000..f27bc7f49 --- /dev/null +++ b/src/cli/bootstrap/index.ts @@ -0,0 +1,24 @@ +/** + * `src/cli/bootstrap/` barrel (write-side-trust, Task 14). + * + * The two operator surfaces this lane owns: `o2b bootstrap` (one + * idempotent provisioning command per harness) and `o2b mcp token` + * (the named-token management dispatcher riding the `mcp` command). + */ + +export { cmdBootstrap, BOOTSTRAP_EXIT } from "./run.ts"; +export { handleMcpTokenCommand, TOKEN_EXIT, MCP_TOKEN_VERBS } from "./token-cli.ts"; +export { + BOOTSTRAP_TARGETS, + BOOTSTRAP_TARGET_LIST, + resolveBootstrapTarget, + tokenNameForTarget, +} from "./targets.ts"; +export type { BootstrapMode, BootstrapTarget } from "./targets.ts"; +export { + BOOTSTRAP_SCHEMA_VERSION, + bootstrapReceiptPath, + readBootstrapReceipt, + upsertBootstrapReceiptEntry, +} from "./receipt.ts"; +export type { BootstrapReceipt, BootstrapReceiptEntry, BootstrapTokenEntry } from "./receipt.ts"; diff --git a/src/cli/bootstrap/receipt.ts b/src/cli/bootstrap/receipt.ts new file mode 100644 index 000000000..c3613b96f --- /dev/null +++ b/src/cli/bootstrap/receipt.ts @@ -0,0 +1,174 @@ +/** + * The bootstrap receipt (write-side-trust, Task 14). + * + * `/.open-second-brain/bootstrap.lock.json` records what one + * bootstrap actually established: the target, its install model, the + * owned entries the adapter reported, the provisioned token's NAME and + * 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 token a receipt names + * authenticates hand-configured HTTP clients only; the registered stdio + * harness keeps its config-derived identity. + * + * Schema (`schema_version: 1`): + * + * ``` + * { + * "schema_version": 1, + * "entries": { + * "": { ...BootstrapReceiptEntry } + * } + * } + * ``` + * + * The writer upserts one entry per target. No-churn is the CALLER's + * rule: a re-run that would recompute the identical entry writes + * nothing, so an idempotent bootstrap leaves the receipt byte-identical. + */ + +import { existsSync, mkdirSync, readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; + +import { atomicWriteFileSync } from "../../core/fs-atomic.ts"; +import { assertVaultIdentityForWrite } from "../../core/brain/vault-identity.ts"; +import type { BootstrapMode } from "./targets.ts"; + +export const BOOTSTRAP_SCHEMA_VERSION = 1; + +/** The non-secret token identification a receipt carries. */ +export interface BootstrapTokenEntry { + readonly name: string; + readonly prefix: string; +} + +export interface BootstrapReceiptEntry { + readonly target: string; + readonly mode: BootstrapMode; + readonly agent: string; + readonly config_path: string | null; + readonly owned_keys?: ReadonlyArray; + readonly owned_paths?: ReadonlyArray; + readonly owned_block_marker?: string; + readonly token?: BootstrapTokenEntry; + readonly applied_at: string; +} + +export interface BootstrapReceipt { + readonly schema_version: typeof BOOTSTRAP_SCHEMA_VERSION; + readonly entries: Record; +} + +export class BootstrapReceiptError extends Error { + constructor(message: string) { + super(message); + this.name = "BootstrapReceiptError"; + } +} + +const EMPTY_RECEIPT: BootstrapReceipt = { schema_version: BOOTSTRAP_SCHEMA_VERSION, entries: {} }; + +export function bootstrapReceiptPath(vault: string): string { + return join(vault, ".open-second-brain", "bootstrap.lock.json"); +} + +export function readBootstrapReceipt(vault: string): BootstrapReceipt { + const path = bootstrapReceiptPath(vault); + if (!existsSync(path)) return EMPTY_RECEIPT; + let parsed: unknown; + try { + parsed = JSON.parse(readFileSync(path, "utf8")); + } catch (e) { + throw new BootstrapReceiptError( + `bootstrap receipt is corrupted JSON: ${path} (${(e as Error).message})`, + ); + } + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new BootstrapReceiptError(`bootstrap receipt is not an object: ${path}`); + } + const obj = parsed as Record; + if (obj["schema_version"] !== BOOTSTRAP_SCHEMA_VERSION) { + throw new BootstrapReceiptError( + `bootstrap receipt schema_version ${String(obj["schema_version"])} not supported ` + + `(expected ${String(BOOTSTRAP_SCHEMA_VERSION)}): ${path}`, + ); + } + const entries = obj["entries"]; + if (entries === undefined) return EMPTY_RECEIPT; + if (entries === null || typeof entries !== "object" || Array.isArray(entries)) { + throw new BootstrapReceiptError(`bootstrap receipt entries is not an object: ${path}`); + } + return { + schema_version: BOOTSTRAP_SCHEMA_VERSION, + entries: entries as Record, + }; +} + +/** Upsert one target's entry, preserving every other entry verbatim. */ +export function upsertBootstrapReceiptEntry(vault: string, entry: BootstrapReceiptEntry): void { + assertVaultIdentityForWrite(vault); + const current = readBootstrapReceipt(vault); + const next: BootstrapReceipt = { + schema_version: BOOTSTRAP_SCHEMA_VERSION, + entries: { ...current.entries, [entry.target]: entry }, + }; + const path = bootstrapReceiptPath(vault); + const dir = dirname(path); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + 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 + * and non-secret prefix must agree. Exactly one present: the receipt + * and the store disagree, and `--check` must say so. + */ +export function receiptTokenMatches( + entry: BootstrapReceiptEntry | undefined, + record: { name: string; token_prefix: string } | undefined, +): boolean { + if (entry === undefined && record === undefined) return true; + if (entry === undefined || record === undefined) return false; + const token = entry.token; + if (token === undefined) return false; + return token.name === record.name && token.prefix === record.token_prefix; +} + +/** + * Whether two entries agree on everything but `applied_at` - the + * comparison behind the no-churn rule. A byte-identical re-run must not + * touch the receipt, so the writer skips when this answers true. + */ +export function receiptEntryEqualsExcludingTimestamp( + left: BootstrapReceiptEntry, + right: BootstrapReceiptEntry, +): boolean { + const strip = (e: BootstrapReceiptEntry): string => { + const { applied_at: _applied_at, ...rest } = e; + return JSON.stringify(rest); + }; + return strip(left) === strip(right); +} diff --git a/src/cli/bootstrap/run.ts b/src/cli/bootstrap/run.ts new file mode 100644 index 000000000..539f9e4c0 --- /dev/null +++ b/src/cli/bootstrap/run.ts @@ -0,0 +1,726 @@ +/** + * `o2b bootstrap` (write-side-trust, Task 14). + * + * o2b bootstrap --target codex --agent codex --token # provision + mint + * o2b bootstrap --target codex --rotate # re-mint, reprint once + * 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 + * token named `mcp_token_`, and records a receipt at + * `/.open-second-brain/bootstrap.lock.json`. The material is + * printed to stdout EXACTLY ONCE with a shown-once notice - never on + * argv, never in any harness config, never in the receipt. The payload + * 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. 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. + * + * `--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 + * 1 I/O, store, or adapter runtime error + * 2 usage error (unknown target, bad flag combination, no vault) + * 3 --check found drift (including never-provisioned) + * 4 user-modified-block conflict on apply (use --force to override) + * 5 --check found the runtime unreachable + */ + +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, 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 { HTTP_BOUNDARY_NOTICE, SHOWN_ONCE_NOTICE } from "./token-cli.ts"; +import { + receiptEntryEqualsExcludingTimestamp, + receiptTokenMatches, + BootstrapReceiptError, + bootstrapReceiptPath, + readBootstrapReceipt, + removeBootstrapReceiptEntry, + upsertBootstrapReceiptEntry, + type BootstrapReceiptEntry, +} from "./receipt.ts"; +import { + BOOTSTRAP_TARGET_LIST, + resolveBootstrapTarget, + tokenNameForTarget, + type BootstrapMode, + type BootstrapTarget, +} from "./targets.ts"; + +/** + * Every code this verb can return, named once so the docblock above, the + * returns below and the tests all read the same table. + */ +export const BOOTSTRAP_EXIT = Object.freeze({ + ok: 0, + runtimeError: 1, + usage: 2, + drift: 3, + userModifiedBlock: 4, + mcpUnreachable: 5, +} as const); + +class BootstrapUsageError extends Error {} + +interface ParsedBootstrapArgs { + readonly target: string | null; + readonly agent: string | null; + readonly token: boolean; + readonly rotate: boolean; + readonly check: boolean; + readonly force: boolean; + readonly remove: string | null; + readonly vault: string | null; + readonly config: string; +} + +function parseBootstrapArgs(argv: string[]): ParsedBootstrapArgs { + const { flags, positional } = parseFlags(argv, { + target: { type: "string" }, + agent: { type: "string" }, + token: { type: "boolean" }, + rotate: { type: "boolean" }, + check: { type: "boolean" }, + force: { type: "boolean" }, + remove: { type: "string" }, + vault: { type: "string" }, + config: { type: "string" }, + }); + if (positional.length > 0) { + throw new BootstrapUsageError( + `o2b bootstrap does not accept positional arguments: ${positional.join(" ")}`, + ); + } + return { + target: (flags["target"] as string | undefined) ?? null, + agent: (flags["agent"] as string | undefined) ?? null, + token: Boolean(flags["token"]), + 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(), + }; +} + +function usageRefusal(message: string): number { + process.stderr.write(`error: ${message}\n`); + return BOOTSTRAP_EXIT.usage; +} + +/** A minimal sink adapters write their apply output into, so this verb + * composes the final stdout block in one deterministic order. */ +function captureStream(): { stream: NodeJS.WritableStream; text: () => string } { + let buffer = ""; + const sink = { + write(chunk: unknown): boolean { + buffer += + typeof chunk === "string" ? chunk : Buffer.from(chunk as Uint8Array).toString("utf8"); + return true; + }, + }; + return { stream: sink as unknown as NodeJS.WritableStream, text: () => buffer }; +} + +/** The vault this run provisions for, through the one chain the CLI uses. */ +function resolveBootstrapVault(explicit: string | null, configPath: string): string { + return explicit ?? resolveVault(configPath) ?? ""; +} + +/** The payload exactly as `o2b install` builds it: config, then env. */ +function buildBootstrapPayload(vault: string, configPath: string) { + const cfg = discoverConfig(configPath).data; + return buildPayload({ + vault, + agent_name: cfg["agent_name"] ?? process.env["VAULT_AGENT_NAME"] ?? null, + timezone: cfg["timezone"] ?? process.env["VAULT_TIMEZONE"] ?? null, + }); +} + +export async function cmdBootstrap(argv: string[]): Promise { + let args: ParsedBootstrapArgs; + try { + args = parseBootstrapArgs(argv); + } catch (e) { + if (e instanceof BootstrapUsageError) return usageRefusal(e.message); + throw e; + } + + const resolved: BootstrapTarget | null = + args.target === null ? null : resolveBootstrapTarget(args.target); + if (args.target === null && args.remove === null) { + return usageRefusal( + `o2b bootstrap requires --target (or --remove ). Available: ${BOOTSTRAP_TARGET_LIST}`, + ); + } + if (args.target !== null && resolved === null) { + return usageRefusal( + `unknown bootstrap target: ${args.target}. Available: ${BOOTSTRAP_TARGET_LIST}`, + ); + } + 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 name = tokenNameForTarget(target); + const now = new Date().toISOString(); + + // The record bootstrap is about to mint, rotate, or keep. A live name + // 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)}, ` + + `not ${JSON.stringify(agent)}; mint a separate name with \`o2b mcp token mint\`\n`, + ); + 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. + 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) { + process.stderr.write(`error: ${e.message}\n`); + return BOOTSTRAP_EXIT.runtimeError; + } + throw e; + } +} + +interface CheckInput { + readonly target: string; + readonly mode: BootstrapMode; + readonly vault: string; + readonly name: string; + readonly env: ReturnType; +} + +/** + * `--check`: drift from `InstallEnv` alone. Never mints, never writes - + * the adapter verifies against what is on disk, and the token half is + * answered from the store and the receipt. `not-installed` is drift + * here (unlike the install verb's table, where it stays 0): the + * operator NAMED this target, so an absent bootstrap is the finding. + */ +function runCheck(input: CheckInput): number { + const { target, mode, vault, name, env } = input; + const lines: string[] = []; + // 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); + if (adapter === undefined) { + // Unreachable while BOOTSTRAP_TARGETS and the registry agree; a + // refusal beats a crash if a future edit desynchronizes them. + return usageRefusal( + `bootstrap target ${target} has no install adapter. Available: ${BOOTSTRAP_TARGET_LIST}`, + ); + } + 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") 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 { + lines.push(" registration: plugin-managed; verify with `o2b doctor`"); + } + + 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`); + drifted = true; + } else { + lines.push(` token: ${name} active (prefix ${record.token_prefix})`); + } + + const entry = readBootstrapReceipt(vault).entries[target]; + if (entry === undefined) { + lines.push(` receipt: no bootstrap receipt; run o2b bootstrap --target ${target} --token`); + drifted = true; + } else if (!receiptTokenMatches(entry, record ?? undefined)) { + lines.push( + ` receipt: the receipt disagrees with the token store; run o2b bootstrap --target ${target} --token`, + ); + drifted = true; + } else { + lines.push(" receipt: ok"); + } + + process.stdout.write(`bootstrap check: ${target}\n${lines.join("\n")}\n`); + if (unreachable) return BOOTSTRAP_EXIT.mcpUnreachable; + if (drifted) return BOOTSTRAP_EXIT.drift; + 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. 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; + 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` + + ` Re-run o2b bootstrap --target ${input.target} to apply the registration alone, ` + + `or revoke with: o2b mcp token revoke --name ${input.name}\n`, + ); +} + +interface ProvisionInput { + readonly args: ParsedBootstrapArgs; + readonly target: string; + readonly mode: BootstrapMode; + readonly agent: string; + readonly name: string; + readonly vault: string; + readonly existing: { name: string; agent: string; status: string; token_prefix: string } | null; + readonly now: string; +} + +function runProvision(input: ProvisionInput): number { + const { args, target, mode, agent, name, vault, existing, now } = input; + + // ----- token plan ----------------------------------------------------- + let tokenMaterial: string | null = null; + let tokenEvent: "minted" | "rotated" | null = null; + try { + if (args.rotate) { + if (existing === null) { + process.stderr.write( + `error: no token ${name} to rotate; run o2b bootstrap --target ${target} --token to mint it\n`, + ); + return BOOTSTRAP_EXIT.runtimeError; + } + tokenMaterial = rotateAgentToken(vault, name).tokenMaterial; + tokenEvent = "rotated"; + } else if (args.token && existing === null) { + tokenMaterial = mintAgentToken(vault, name, agent).tokenMaterial; + tokenEvent = "minted"; + } 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`, + ); + return BOOTSTRAP_EXIT.runtimeError; + } + } catch (e) { + if (e instanceof McpTokenStoreError) { + process.stderr.write(`error: ${e.message}\n`); + return BOOTSTRAP_EXIT.runtimeError; + } + throw e; + } + + // ----- registration --------------------------------------------------- + let manifest: ManifestEntry | null = null; + let printedPayload = ""; + if (mode !== "verify-only") { + 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: args.config }); + + // Byte-identical no-op: with no token event of its own, a clean + // adapter verify and a consistent receipt mean there is nothing to + // write - not the config, not the install manifest, not the receipt. + if (tokenEvent === null) { + const verdict = adapter.verify(env); + const entry = readBootstrapReceipt(vault).entries[target]; + const record = listAgentTokens(vault).find((t) => t.name === name) ?? undefined; + if (verdict.status === "ok" && receiptTokenMatches(entry, record)) { + process.stdout.write( + `bootstrap: ${target} already provisioned; nothing changed\n` + + ` run with --token to mint ${name}, with --rotate to re-mint it, or with --check to verify\n`, + ); + return BOOTSTRAP_EXIT.ok; + } + } + + // 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) { + 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. + rescueMaterial( + "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 + : 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. " + + "The retry or revoke path below finishes or undoes it.", + ); + if (e instanceof PayloadError) return usageRefusal(e.message); + throw e; + } + manifest = result.manifest; + printedPayload = capture.text(); + } + + // ----- receipt (no-churn: skip when nothing would change) -------------- + const refreshed = + listAgentTokens(vault).find((t) => t.name === name) ?? + (existing !== null && existing.status === "active" ? existing : null); + const receiptPath = bootstrapReceiptPath(vault); + if (mode !== "verify-only" || refreshed !== null) { + const current = readBootstrapReceipt(vault).entries[target]; + const next = composeEntry({ target, mode, agent, name, manifest, current, refreshed, now }); + if (current === undefined || !receiptEntryEqualsExcludingTimestamp(current, next)) { + upsertBootstrapReceiptEntry(vault, next); + } + } + + // ----- output --------------------------------------------------------- + const header = `bootstrap: ${target} (${mode === "print" ? "print-and-paste" : mode === "verify-only" ? "plugin runtime; verify only" : "adapter"})`; + const out: string[] = [header]; + if (mode === "adapter" && manifest !== null) { + const owned = [...(manifest.owned_keys ?? []), ...(manifest.owned_paths ?? [])]; + out.push( + ` registration: ${manifest.config_path ?? "(no config file)"}` + + (owned.length > 0 ? ` (owned: ${owned.join(", ")})` : ""), + ); + } + if (mode === "print") { + if (printedPayload.length > 0) out.push(printedPayload.trimEnd()); + out.push( + "manual steps: copy the payload above into your runtime's MCP configuration; " + + "give the agent the token through its environment, never a config file.", + ); + } + if (mode === "verify-only") { + out.push( + " The plugin registers this server itself; nothing was written to any harness " + + "config. Verify with `o2b doctor`.", + ); + } + if (tokenMaterial !== null) { + out.push( + ` token: ${name} (agent ${JSON.stringify(agent)}) ${tokenEvent} - ` + + "shown exactly once, stored only as a hash", + ); + 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}`); + } + process.stdout.write(out.join("\n") + "\n"); + return BOOTSTRAP_EXIT.ok; +} + +/** The receipt entry this run leaves behind, reused fields preserved. */ +function composeEntry(input: { + readonly target: string; + readonly mode: BootstrapMode; + readonly agent: string; + readonly name: string; + readonly manifest: ManifestEntry | null; + readonly current: BootstrapReceiptEntry | undefined; + readonly refreshed: { name: string; token_prefix: string } | null; + readonly now: string; +}): BootstrapReceiptEntry { + const { target, mode, agent, name, manifest, current, refreshed, now } = input; + const token = + refreshed !== null + ? { name, prefix: refreshed.token_prefix } + : current?.token !== undefined + ? current.token + : undefined; + return { + target, + mode, + agent, + config_path: manifest?.config_path ?? current?.config_path ?? null, + ...(manifest?.owned_keys !== undefined || current?.owned_keys !== undefined + ? { owned_keys: manifest?.owned_keys ?? current?.owned_keys } + : {}), + ...(manifest?.owned_paths !== undefined || current?.owned_paths !== undefined + ? { owned_paths: manifest?.owned_paths ?? current?.owned_paths } + : {}), + ...(manifest?.owned_block_marker !== undefined || current?.owned_block_marker !== undefined + ? { owned_block_marker: manifest?.owned_block_marker ?? current?.owned_block_marker } + : {}), + ...(token !== undefined ? { token } : {}), + 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/targets.ts b/src/cli/bootstrap/targets.ts new file mode 100644 index 000000000..99be7651d --- /dev/null +++ b/src/cli/bootstrap/targets.ts @@ -0,0 +1,56 @@ +/** + * The bootstrap target table (write-side-trust, Task 14). + * + * One command spans the three real install models the design names: + * + * - `adapter` - a registered install adapter whose idempotent apply + * writes the MCP registration. Wave-1 covers the config-writing + * adapters (codex, grok, opencode). + * - `print` - the `generic` print-and-paste target: the payload is + * printed with the manual steps, and no harness config is touched. + * - `verify-only` - plugin runtimes (claude-code, zcode). They are + * deliberately NOT adapter targets (`host-facts.ts`), so bootstrap + * writes nothing but the token and the receipt and points at the + * plugin's own verification. + * + * A target outside this table is refused with the list, exactly as the + * install verb refuses an unregistered runtime. `claude-code` and + * `zcode` are bootstrap names, not install-target ids, which is why the + * table is declared here rather than read off the adapter registry. + */ + +export type BootstrapMode = "adapter" | "print" | "verify-only"; + +export interface BootstrapTarget { + readonly target: string; + readonly mode: BootstrapMode; + readonly label: string; +} + +/** Alphabetical: the order the refusal message lists them in. */ +export const BOOTSTRAP_TARGETS: ReadonlyArray = Object.freeze([ + { target: "claude-code", mode: "verify-only", label: "Claude Code (plugin)" }, + { target: "codex", mode: "adapter", label: "Codex CLI" }, + { target: "generic", mode: "print", label: "Generic (print-and-paste)" }, + { target: "grok", mode: "adapter", label: "Grok CLI" }, + { target: "opencode", mode: "adapter", label: "OpenCode" }, + { target: "zcode", mode: "verify-only", label: "ZCode (plugin)" }, +]); + +/** The `--target` refusal list, spelled once. */ +export const BOOTSTRAP_TARGET_LIST = BOOTSTRAP_TARGETS.map((t) => t.target).join(", "); + +export function resolveBootstrapTarget(value: string): BootstrapTarget | null { + return BOOTSTRAP_TARGETS.find((t) => t.target === value) ?? null; +} + +/** + * The per-agent token name bootstrap provisions for a target. + * + * `mcp_token_` with underscores only - the store's name grammar + * doubles as the `$secret:NAME` body grammar, which admits no dashes - + * so a dashed target id (claude-code) becomes `mcp_token_claude_code`. + */ +export function tokenNameForTarget(target: string): string { + return `mcp_token_${target.replaceAll("-", "_")}`; +} diff --git a/src/cli/bootstrap/token-cli.ts b/src/cli/bootstrap/token-cli.ts new file mode 100644 index 000000000..ea41b5107 --- /dev/null +++ b/src/cli/bootstrap/token-cli.ts @@ -0,0 +1,279 @@ +/** + * `o2b mcp token` (write-side-trust, Task 14). + * + * The management sub-dispatcher riding the `mcp` command: mint, rotate, + * revoke, and list the named per-agent MCP tokens. This is the store's + * operator surface - the transport reads the same store per request, so + * a rotation or revocation here lands on a running server's NEXT + * request with no restart. + * + * o2b mcp token mint --agent codex [--name mcp_token_codex] + * o2b mcp token rotate --name mcp_token_codex + * o2b mcp token revoke --name mcp_token_codex + * o2b mcp token list + * + * The material of a mint or rotation is printed exactly once with a + * shown-once notice; `list` prints metadata only, because the store + * holds only hashes and prefixes. Exit codes follow the same table + * style as the install verbs: 0 ok, 1 runtime (unknown name, refused + * mint), 2 usage (bad verb, missing flag, no vault). + */ + +import { defaultConfigPath, resolveVault } from "../../core/config.ts"; +import { VAULT_NOT_CONFIGURED_REASON } from "../../core/install/env.ts"; +import { + isValidMcpTokenName, + listAgentTokens, + McpTokenStoreError, + mintAgentToken, + revokeAgentToken, + ROTATION_GRACE_MS, + rotateAgentToken, +} from "../../core/brain/secrets/token-store.ts"; +import { CliError, parseFlags } from "../argparse.ts"; + +/** Every code this dispatcher can return, named once. */ +export const TOKEN_EXIT = Object.freeze({ + ok: 0, + runtimeError: 1, + usage: 2, +} as const); + +export const MCP_TOKEN_VERBS: ReadonlyArray = Object.freeze([ + "mint", + "rotate", + "revoke", + "list", +]); + +/** + * 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."; + +/** + * 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; +} + +function storeFailure(e: unknown): number { + if (e instanceof McpTokenStoreError) { + process.stderr.write(`error: ${e.message}\n`); + return TOKEN_EXIT.runtimeError; + } + throw e; +} + +/** One parsed verb's flags, exactly the value shapes `parseFlags` produces. */ +type TokenFlags = Map; + +/** The vault the verb acts on: `--vault`, else the canonical resolver. */ +function vaultFromFlags(flags: TokenFlags): string { + const config = (flags.get("config") as string | undefined) ?? defaultConfigPath(); + return (flags.get("vault") as string | undefined) ?? resolveVault(config) ?? ""; +} + +/** + * The verb's own flag parser: one pass over argv with the verb's whole + * schema (its flags plus the vault-addressing pair), so an unknown flag + * is a named refusal and the vault is read from the SAME parse - a + * second narrow parse would re-see the verb's flags and reject them. + */ +function parseVerbFlags( + argv: string[], + schema: Record, +): TokenFlags { + const { flags, positional } = parseFlags(argv, { + ...schema, + vault: { type: "string" }, + config: { type: "string" }, + }); + if (positional.length > 0) { + throw new CliError(`does not accept positional arguments: ${positional.join(" ")}`); + } + return new Map(Object.entries(flags)); +} + +export async function handleMcpTokenCommand(argv: ReadonlyArray): Promise { + if (argv.length === 0) { + return usage(`o2b mcp token requires a verb: ${MCP_TOKEN_VERBS.join(", ")}`); + } + const verb = argv[0]!; + const rest = argv.slice(1); + switch (verb) { + case "mint": + return tokenMint(rest); + case "rotate": + return tokenRotate(rest); + case "revoke": + return tokenRevoke(rest); + case "list": + return tokenList(rest); + default: + return usage( + `unknown mcp token verb: ${verb}. Expected one of: ${MCP_TOKEN_VERBS.join(", ")}`, + ); + } +} + +function tokenMint(argv: string[]): number { + let flags: TokenFlags; + try { + flags = parseVerbFlags(argv, { + agent: { type: "string" }, + name: { type: "string" }, + vault: { type: "string" }, + config: { type: "string" }, + }); + } catch (e) { + if (e instanceof CliError) return usage(e.message); + throw e; + } + const vault = vaultFromFlags(flags); + if (vault === "") return usage(`o2b mcp token mint: ${VAULT_NOT_CONFIGURED_REASON}`); + const agent = (flags.get("agent") as string | undefined)?.trim() ?? ""; + if (agent === "") return usage("o2b mcp token mint requires --agent "); + const explicitName = (flags.get("name") as string | undefined)?.trim() ?? ""; + const name = explicitName !== "" ? explicitName : deriveTokenName(agent); + if (!isValidMcpTokenName(name)) { + return usage( + `token name must be mcp_token_ (lowercase [a-z0-9_]), got: ${JSON.stringify(name)}`, + ); + } + let tokenMaterial: string; + let record: ReturnType["record"]; + try { + ({ tokenMaterial, record } = mintAgentToken(vault, name, agent)); + } catch (e) { + return storeFailure(e); + } + process.stdout.write( + `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` + + ` ${HTTP_BOUNDARY_NOTICE}\n`, + ); + return TOKEN_EXIT.ok; +} + +function tokenRotate(argv: string[]): number { + let flags: TokenFlags; + try { + flags = parseVerbFlags(argv, { + name: { type: "string" }, + vault: { type: "string" }, + config: { type: "string" }, + }); + } catch (e) { + if (e instanceof CliError) return usage(e.message); + throw e; + } + const vault = vaultFromFlags(flags); + if (vault === "") return usage(`o2b mcp token rotate: ${VAULT_NOT_CONFIGURED_REASON}`); + const name = (flags.get("name") as string | undefined)?.trim() ?? ""; + if (name === "") return usage("o2b mcp token rotate requires --name "); + let tokenMaterial: string; + let record: ReturnType["record"]; + try { + ({ tokenMaterial, record } = rotateAgentToken(vault, name)); + } catch (e) { + return storeFailure(e); + } + process.stdout.write( + `token: ${record.name} rotated for agent ${JSON.stringify(record.agent)} - the previous ` + + `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` + + ` ${HTTP_BOUNDARY_NOTICE}\n`, + ); + return TOKEN_EXIT.ok; +} + +function tokenRevoke(argv: string[]): number { + let flags: TokenFlags; + try { + flags = parseVerbFlags(argv, { + name: { type: "string" }, + vault: { type: "string" }, + config: { type: "string" }, + }); + } catch (e) { + if (e instanceof CliError) return usage(e.message); + throw e; + } + const vault = vaultFromFlags(flags); + if (vault === "") return usage(`o2b mcp token revoke: ${VAULT_NOT_CONFIGURED_REASON}`); + const name = (flags.get("name") as string | undefined)?.trim() ?? ""; + if (name === "") return usage("o2b mcp token revoke requires --name "); + let revoked: boolean; + try { + revoked = revokeAgentToken(vault, name); + } catch (e) { + return storeFailure(e); + } + if (!revoked) { + process.stderr.write(`error: token ${JSON.stringify(name)} not found or already revoked\n`); + return TOKEN_EXIT.runtimeError; + } + process.stdout.write( + `token: ${name} revoked; the material stops authenticating on the next request\n`, + ); + return TOKEN_EXIT.ok; +} + +function tokenList(argv: string[]): number { + let flags: TokenFlags; + try { + flags = parseVerbFlags(argv, { + vault: { type: "string" }, + config: { type: "string" }, + }); + } catch (e) { + if (e instanceof CliError) return usage(e.message); + throw e; + } + const vault = vaultFromFlags(flags); + if (vault === "") return usage(`o2b mcp token list: ${VAULT_NOT_CONFIGURED_REASON}`); + const records = listAgentTokens(vault); + if (records.length === 0) { + process.stdout.write("tokens: no tokens minted\n"); + return TOKEN_EXIT.ok; + } + const width = Math.max(...records.map((r) => r.name.length)); + const lines = records.map((r) => { + const rotated = r.rotated_at !== undefined ? ` rotated ${r.rotated_at}` : ""; + return ( + ` ${r.name.padEnd(width)} ${r.status.padEnd(7)} ${r.agent} ${r.token_prefix} ` + + `created ${r.created_at}${rotated}` + ); + }); + process.stdout.write(`tokens (${records.length})\n${lines.join("\n")}\n`); + return TOKEN_EXIT.ok; +} + +/** `mcp_token_`: the agent name lowercased, non-grammar runs dashed to underscores. */ +function deriveTokenName(agent: string): string { + return `mcp_token_${agent.toLowerCase().replaceAll(/[^a-z0-9_]+/g, "_")}`; +} diff --git a/src/cli/brain.ts b/src/cli/brain.ts index 93aac3a7a..e36268f9f 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 bb00711d6..94804bff2 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 @@ -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 @@ -149,7 +150,7 @@ Brain verbs (observing memory): agenda Synthesize agenda conflicts/focus blocks from provided events today Today dashboard: due obligations, open loops, recent activity, totals apply-markers Apply @osb set frontmatter write-backs (report by default; --apply writes) - pending Review the write-approval queue: list | apply | reject + pending Review the write-approval queues (signals/notes/ingest): list | apply | reject signal Fact signal lifecycle: retire --reason capture Stage one capture from the terminal: body, source, sender, guidance telegram-capture Inbound Telegram capture bot: run (long-poll) | catchup @@ -205,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"], }; @@ -251,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 ", " -- "), @@ -357,7 +360,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 +374,16 @@ 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" + + "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" + @@ -455,6 +467,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", @@ -684,11 +700,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", @@ -1112,15 +1134,20 @@ export const VERB_HELP: Record = { "and the run continues with the next marker. Source files\n" + "come from --path (repeatable) or notes.read_paths.\n", pending: - "usage: o2b brain pending list [--vault ] [--json]\n" + - " o2b brain pending apply [--vault ] [--json]\n" + - " o2b brain pending reject --reason [--vault ] [--json]\n" + - "Review the opt-in write-approval queue (write_approval.enabled). When the\n" + - "toggle is on, extracted signals are staged into Brain/pending/ instead of\n" + - "Brain/inbox/. list shows the staged signals; apply moves one into\n" + - "Brain/inbox/ unchanged (entity anchors and dedup hash preserved); reject\n" + - "moves it to Brain/retired/ with retire-shaped frontmatter. Applying or\n" + - "rejecting a missing id exits 2 (never a silent no-op).\n", + "usage: o2b brain pending list [--lane signals|notes|ingest|all] [--vault ] [--json]\n" + + " o2b brain pending apply [--dry-run] [--vault ] [--json]\n" + + " o2b brain pending reject --reason [--dry-run] [--vault ] [--json]\n" + + "Review the write-approval queues (write_approval.enabled plus the\n" + + "per-lane keys). Signals stage flat into Brain/pending/ (sig- ids), note\n" + + "creates under Brain/pending/notes/ (note- ids carrying the encoded\n" + + "publish target) and ingest summary pages under Brain/pending/ingest/\n" + + "(ing- ids). list shows every lane sorted (--lane filters one; files\n" + + "that cannot be read as queue entries are named with a reason, never\n" + + "silently skipped); apply moves one into its decoded publish target\n" + + "unchanged (--dry-run previews the move and writes nothing); reject\n" + + "renders it into Brain/retired/ with retire-shaped frontmatter (the\n" + + "same --dry-run honesty). Applying or rejecting a missing id exits 2\n" + + "(never a silent no-op).\n", signal: "usage: o2b brain signal retire --reason [--superseded-by ] [--vault ] [--json]\n" + "Retire an extracted fact signal: move Brain/inbox/sig-*.md into\n" + diff --git a/src/cli/brain/helpers.ts b/src/cli/brain/helpers.ts index 2abffa2b7..787a2620c 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/decision.ts b/src/cli/brain/verbs/decision.ts index 6f9ecb252..ed04c0fbc 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