Skip to content

opencode: persist custody telemetry to a bounded file, and say so when serving - #35

Open
iceteaSA wants to merge 4 commits into
cortexkit:masterfrom
legion-works:feat/custody-log
Open

opencode: persist custody telemetry to a bounded file, and say so when serving#35
iceteaSA wants to merge 4 commits into
cortexkit:masterfrom
legion-works:feat/custody-log

Conversation

@iceteaSA

@iceteaSA iceteaSA commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator

The custody plugin's telemetry was going nowhere. It logs structured JSON via console.log/console.error; under the OpenCode TUI that is the pty, which nothing persists — opencode.log had 0 such lines, the daemon journal had 0. And the plugin was silent on the happy path (1 debug / 0 info / 2 warn / 2 error call sites), so "no news" was indistinguishable from "not running". The only witness that it was serving at all was the vault's audit chain, which only works when one operator owns both ends.

What this adds

A bounded JSONL file sink, on by default, alongside the console sink.

  • $CLAUSTRUM_CUSTODY_LOG if set, else ${XDG_STATE_HOME:-~/.local/state}/cortexkit/opencode-plugin/custody.jsonl. Dir 0700, file 0600.
  • Rotates to <path>.1 past 5 MiB; one generation kept.
  • CLAUSTRUM_CUSTODY_LOG=off|0|false|no disables it.
  • Fail-open for telemetry: if the path cannot be created or written, one console warn and serving continues on the console sink. The inverse of the credential path, deliberately — a logging failure must never refuse a request.

Two happy-path lines, both bounded.

  • At the config hook, one info per provider with the cell decision in the plugin's existing vocabulary (serving / refusal states).
  • On the first successful serve per provider per process, one info with {provider, label, credentialId, recordVersion, state:"served"}. Never per request.

The property that matters: the file cannot carry a secret

Entries written to the file pass through an explicit allowlist (FILE_FIELDS: level, provider, label, credentialId, recordVersion, state, httpStatus, cooldownUntil, errorClass, errorCode). errorMessage is not in it — Bun's JSON.parse quotes adjacent tokens into its error message, which is how a hand-edited handle file leaks a bearer handle (fixed once already in #28). No free-text field reaches disk.

Pinned by a canary that drives a fault path whose error text contains a fake 47-char ckh_ handle and a fake key, then asserts neither appears in the file. Mutation-proved independently of the implementer: adding errorMessage to the written record (exactly one site) turns it red —

(fail) custody logger > file sink excludes free-text error messages
 6 pass · 1 fail

— byte-identical restore, 7 pass.

Verified on the installed bundle

Exercised the built bundle's config hook in-process against a scratch XDG_STATE_HOME on the live box: wrote {"level":"info","provider":"minimax-coding-plan","state":"serving","ts":…,"pid":…} (the one provider under custody here), file 600 / dir 700, secret-shape scan clean. Bundle shape unchanged: single {id, server} default export, Node builtins only.

Hermetic 152/152, gate green, exit census updated (plugin 33/41, serve 13/15).


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.


Summary by cubic

Custody telemetry previously went to the OpenCode pty and was lost; the plugin was also silent on the happy path. This persists custody logs to a bounded JSONL file (on by default), logs configuration decisions plus the first successful serve per provider, and routes the console to warn/error only so happy-path lines no longer surface in the TUI.

File sink

  • Writes to $CLAUSTRUM_CUSTODY_LOG if set, else ~/.local/state/cortexkit/opencode-plugin/custody.jsonl; dir 0700, file 0600.
  • Rotates to <path>.1 past 5 MiB, keeping one generation; CLAUSTRUM_CUSTODY_LOG=off|0|false|no disables it.
  • Fails open: one console warn on write failure, then info/debug are dropped and faults still reach the console.

Log lines and secret safety

  • Logs one info per provider at config time with the decision (serving, orphan, split, unmanaged, refusing, other_owner); the first successful serve per provider per process logs {provider, label, credentialId, recordVersion, state:"served"} — never per request.
  • Written entries pass a field allowlist; errorMessage is excluded so error text from malformed handle files cannot leak a bearer handle to disk.
  • Field shapes are validated too, so secret-bearing strings or objects routed into allowed fields are written as invalid_shape; a canary drives a malformed handle file through the real config hook to pin this.

Written for commit ee4807e. Summary will update on new commits.

Review in cubic

…n serving

Custody logs previously went to the OpenCode pty and were not persisted. Add an on-by-default JSONL file sink with the configured or XDG state path, private permissions, one-generation 5 MiB rotation, and fail-open telemetry degradation. Configuration decisions and the first successful serve per provider are logged with bounded structured fields, while the secret-absence canary proves parser error text cannot enter the file.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 issues found across 6 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/opencode/src/log.ts">

<violation number="1" location="packages/opencode/src/log.ts:97">
P2: When the file is near 5 MiB, this append can push it over the limit and leave it oversized until another event occurs. Rotate based on existing size plus the next line's byte length before appending.</violation>
</file>

<file name="packages/opencode/src/plugin.ts">

<violation number="1" location="packages/opencode/src/plugin.ts:396">
P2: A split provider (custody handle present with a real local credential) is logged with both `state: "split"` and `state: "unmanaged"`. The split branch never continues, so control falls through to the `unmanaged` line after setting the throwing fetch. These are contradictory states and this also breaks the PR's stated "one info line per provider" contract. Only log `unmanaged` for providers that are not split, e.g. move `log.info({ provider, state: "unmanaged" })` into an `else` of the `owner === OUR_PLUGIN_ID` branch (or `continue` at the end of that branch).</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread packages/opencode/src/log.ts
Comment thread packages/opencode/src/log.ts Outdated
initialized = true;
}
rotateIfNeeded();
appendFileSync(path, `${JSON.stringify(fileEntry(entry))}\n`, { mode: 0o600 });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When the file is near 5 MiB, this append can push it over the limit and leave it oversized until another event occurs. Rotate based on existing size plus the next line's byte length before appending.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/opencode/src/log.ts, line 97:

<comment>When the file is near 5 MiB, this append can push it over the limit and leave it oversized until another event occurs. Rotate based on existing size plus the next line's byte length before appending.</comment>

<file context>
@@ -32,12 +41,78 @@ function defaultSink(entry: CustodyLogEntry): void {
+        initialized = true;
+      }
+      rotateIfNeeded();
+      appendFileSync(path, `${JSON.stringify(fileEntry(entry))}\n`, { mode: 0o600 });
+      chmodSync(path, 0o600);
+    } catch {
</file context>

Comment thread packages/opencode/src/log.ts Outdated
fetch: async () => { throw error; },
};
}
log.info({ provider, state: "unmanaged" });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: A split provider (custody handle present with a real local credential) is logged with both state: "split" and state: "unmanaged". The split branch never continues, so control falls through to the unmanaged line after setting the throwing fetch. These are contradictory states and this also breaks the PR's stated "one info line per provider" contract. Only log unmanaged for providers that are not split, e.g. move log.info({ provider, state: "unmanaged" }) into an else of the owner === OUR_PLUGIN_ID branch (or continue at the end of that branch).

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/opencode/src/plugin.ts, line 396:

<comment>A split provider (custody handle present with a real local credential) is logged with both `state: "split"` and `state: "unmanaged"`. The split branch never continues, so control falls through to the `unmanaged` line after setting the throwing fetch. These are contradictory states and this also breaks the PR's stated "one info line per provider" contract. Only log `unmanaged` for providers that are not split, e.g. move `log.info({ provider, state: "unmanaged" })` into an `else` of the `owner === OUR_PLUGIN_ID` branch (or `continue` at the end of that branch).</comment>

<file context>
@@ -369,36 +371,42 @@ export function createOpencodeClaustrumPlugin(dependencies: ConfigHookDependenci
                 fetch: async () => { throw error; },
               };
             }
+            log.info({ provider, state: "unmanaged" });
             continue;
           }
</file context>

Comment thread packages/opencode/src/tests/log.test.ts Outdated
The file sink landed alongside a console sink that still carried every level,
and the new info lines were the plugin's first happy-path output ever — so
they surfaced straight into the OpenCode TUI (three "serving" lines per boot).
The console was quiet before by accident, not design.

Console now carries warn/error only; info/debug are file-only. If the file is
unavailable the one-shot warning says so and those levels are dropped rather
than redirected to the screen. Pinned by inverting the test that had
documented the old routing; mutation (info back to console.log) is RED.
@iceteaSA

iceteaSA commented Sep 5, 2026

Copy link
Copy Markdown
Collaborator Author

Pushed 55d8a7c — a defect in 60257a5 found on the live box: the file sink landed alongside a console sink that still carried every level, and the new info lines were the plugin's first happy-path output ever, so they surfaced straight into the OpenCode TUI (three "state":"serving" lines per boot). The console had been quiet before by accident, not design.

Now: console carries warn/error only; info/debug are file-only. If the file is unavailable, the one-shot warning says those levels are dropped rather than redirecting them to the screen.

Pinned by inverting the test that had documented the old routing (info → stdout). Mutation — routing info back to console.log — is RED on that test; restore is byte-identical and green. Hermetic 152/152.

Verified on the built bundle: exercising the config hook with stdout and stderr captured separately gives 0 stdout lines, 0 stderr lines, 3 file lines (serving × 3 providers).

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 2 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/opencode/src/tests/log.test.ts">

<violation number="1" location="packages/opencode/src/tests/log.test.ts:40">
P3: This test calls `createLogger()` with no sink, so it constructs a real `createFileLogSink()` that writes every logged record - including the `state:"serving"` line - to the process's actual default path (`$XDG_STATE_HOME` or `~/.local/state/cortexkit/opencode-plugin/custody.jsonl`). The test is not hermetic: it pollutes the developer's real custody log and silently depends on that path being writable. In an environment where the default path is unwritable (e.g. a read-only HOME), the file sink's fail-open path emits a console.error warning on the first write, changing `errorLines` to length 3 and making `expect(errorLines).toHaveLength(2)` plus `errorLines[0]`/`errorLines[1]` assertions fail. Since this test only exercises console routing, route the records explicitly instead of the default logger so the real filesystem is not touched.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

test("the console sink carries only faults: info and debug never reach stdout or stderr", () => {
// The console is the OpenCode TUI's screen. Happy-path telemetry surfacing
// there is the defect this pins (2026-09-05: three "serving" lines per boot in the TUI).
const real = createLogger();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: This test calls createLogger() with no sink, so it constructs a real createFileLogSink() that writes every logged record - including the state:"serving" line - to the process's actual default path ($XDG_STATE_HOME or ~/.local/state/cortexkit/opencode-plugin/custody.jsonl). The test is not hermetic: it pollutes the developer's real custody log and silently depends on that path being writable. In an environment where the default path is unwritable (e.g. a read-only HOME), the file sink's fail-open path emits a console.error warning on the first write, changing errorLines to length 3 and making expect(errorLines).toHaveLength(2) plus errorLines[0]/errorLines[1] assertions fail. Since this test only exercises console routing, route the records explicitly instead of the default logger so the real filesystem is not touched.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/opencode/src/tests/log.test.ts, line 40:

<comment>This test calls `createLogger()` with no sink, so it constructs a real `createFileLogSink()` that writes every logged record - including the `state:"serving"` line - to the process's actual default path (`$XDG_STATE_HOME` or `~/.local/state/cortexkit/opencode-plugin/custody.jsonl`). The test is not hermetic: it pollutes the developer's real custody log and silently depends on that path being writable. In an environment where the default path is unwritable (e.g. a read-only HOME), the file sink's fail-open path emits a console.error warning on the first write, changing `errorLines` to length 3 and making `expect(errorLines).toHaveLength(2)` plus `errorLines[0]`/`errorLines[1]` assertions fail. Since this test only exercises console routing, route the records explicitly instead of the default logger so the real filesystem is not touched.</comment>

<file context>
@@ -34,20 +34,23 @@ describe("custody logger", () => {
+  test("the console sink carries only faults: info and debug never reach stdout or stderr", () => {
+    // The console is the OpenCode TUI's screen. Happy-path telemetry surfacing
+    // there is the defect this pins (2026-09-05: three "serving" lines per boot in the TUI).
+    const real = createLogger();
+    real.debug({ provider: "deepseek", state: "available" });
+    real.info({ provider: "deepseek", state: "serving" });
</file context>

@ckcred-alfonso

ckcred-alfonso Bot commented Sep 5, 2026

Copy link
Copy Markdown

Gated green at 55d8a7c in a worktree beside the repo: GATE PASSED, every arm. I had reviewed 60257a5; the head moved while my post was queued, so I re-read and re-gated rather than posting a verdict about a commit that is no longer there. Both findings below survive the move — I checked each at the new head rather than assuming.

The diagnosis is right and worth stating plainly, because it is the part that makes the rest necessary: telemetry going to a pty that nothing persists is telemetry that does not exist, and a plugin silent on the happy path cannot be distinguished from a plugin that is not running.

I reviewed the security claim rather than the feature, since that is my half.

One thing 55d8a7c changed that is worth naming

Routing info/debug to the file only is right — the console is the TUI's screen and happy-path telemetry there is noise. But it narrows what fail-open means, and the PR's opening argument is the reason to say so out loud:

before  file unavailable -> everything still goes to the console
after   file unavailable -> faults reach the console, info/debug are dropped

So in the degraded case the plugin is silent on the happy path again — the exact state this PR exists to end, now reachable by a permissions error on one directory. That is a defensible trade against TUI noise, and your new warn line says precisely what is lost, which is the part that makes it honest rather than quiet. Worth keeping in view: the one-line warn is now the only evidence that a plugin which looks idle is actually serving.

The property holds — and not for the reason stated

fileEntry builds safe only from FILE_FIELDS, so errorMessage cannot reach disk. I checked the callers too, because the sink is only half the question:

freshness.ts:90  errorClass: error instanceof Error ? error.name : "FreshnessTickError"
plugin.ts:192    errorClass: error.name
plugin.ts:193    errorCode: (error as NodeJS.ErrnoException).code
serve.ts:193     errorClass: error instanceof Error ? error.name : "UpstreamFetchError"

Those write error-derived values into allowlisted fields. They are safe: .name is a class name and .code is an errno string, and the only two .name assignments in packages/ are fixed literals (ClaustrumCredentialError, SecretJsonParseError). So nothing leaks today.

But the canary does not prove that. It hand-builds its entry:

createLogger(createFileLogSink({ path })).error({ provider: "openai", errorMessage: `${handle} ${key}` })

That proves the sink drops errorMessage. The PR body says it "drives a fault path", and it does not — no JSON.parse throws in that test. The leak vector you cite is Bun quoting adjacent tokens into a SyntaxError message, and a message lands wherever the caller decides to put it. A fifth call site writing errorClass: String(error) would put that text on disk and every test would still pass, including the canary.

So the protection is a convention held at four call sites, not a mechanism. That is worth knowing before it is described as a mechanism to a downstream tenant. Cheapest pin I can suggest: assert in the canary that a real thrown SyntaxError from a malformed handle file, driven through the code path that catches it, leaves no ckh_ in the file. That fails if a caller ever routes a message into an allowlisted field, which is the case the current test cannot see.

The ts/pid finding is true, and its severity is not what it looks like

return { ...safe, ts: new Date().toISOString(), pid: process.pid };

safe is filtered; these two are added after it. Both are locally generated — an ISO clock and process.pid — so neither can carry credential- or attacker-derived content, and I would not hold the PR for a leak that is not there.

The shape is the finding. A filter followed by a spread reads as "allowlist, plus whatever we felt like", and the next field added that way will be added the same way by someone who sees this line as the pattern. Put ts and pid in FILE_FIELDS and let nothing be added post-filter; then the allowlist is the only door and the code says what it does.

Verified, not blocking

  • Directory and rotation modes. mkdirSync(mode: 0o700) does not tighten a directory that already exists, and rotation carries an existing 0644 onto .1. No secret reaches this file, but credential IDs do — that is inventory disclosure, not credential disclosure, so it is worth a chmodSync on both paths rather than a block.
  • Rotation checks before the append, so a near-limit write leaves the file slightly over until the next event. Correct as designed; the limit is a bound on unbounded growth, not a hard cap.
  • The disable assertion. The bot is right that it does not verify disabling; worth making it fail if the file appears.

What I would merge

The first item is the one I would want changed here, and it is a test rather than a behaviour change — the current canary is the thing a future reader will trust and it covers less than it appears to. ts/pid into the allowlist is two lines and removes a pattern that invites the real defect.

Everything else can travel. The feature itself is right: a bounded file, on by default, fail-open, with the happy path finally saying something.

… the only door

The secret-absence canary proved the sink drops errorMessage, not that a real SyntaxError on the handle-file path cannot reach an allowlisted field (a caller writing errorClass: String(error) would leak Bun's token-quoting message and every test would stay green). The canary now feeds a malformed handle file through the plugin's own config hook and asserts no handle or key reaches disk; the sink validates field shapes so a caller routing an error message into an allowlisted field cannot leak; mutation on the sink is RED. ts/pid join FILE_FIELDS so nothing is appended after the filter; existing dirs/files/rotations are chmod'd to 0700/0600; the off-switch test asserts the file never appears.
…rings; every file field has a named rule

An object routed into an allowlisted field serialised whole, message and all, past the string-shape checks. Non-strings are now number|boolean only; level and ts get closed rules; the identifier validator is imported from handles.ts rather than copied a third time.
@iceteaSA

iceteaSA commented Sep 5, 2026

Copy link
Copy Markdown
Collaborator Author

Both landed, two commits: 4d26672 (your four items) and ee4807e (a gap I found in my own read of the first).

The canary as you specified it cannot go RED on this branch — and that is a finding, not a dodge. The implementer drove a real malformed handle file ({"providers":[{"handle":ckh_A…A} — Bun's message is Unexpected identifier "ckh_AAAA…", handle quoted verbatim, confirmed) through the plugin's own config hook, then mutated the caller to errorClass: String(error). It stayed green: handles.ts already routes the parse through parseSecretJson and rethrows a fixed-message HandleFileValidationError, so the token-quoting message never reaches plugin.ts on that path (that is the #28 fix doing its job). The real-path canary stays in the suite as the integration arm with a positive control (a line is written) and a comment naming the sanitising site.

So the protection is now a mechanism at the sink, which is what you asked for. fileEntry validates every allowlisted field by a named rule before writing: errorClass must look like a class name, errorCode like an errno, provider/label via identifierIsValid (exported from handles.ts, not a third copy), credentialId/state/level/ts their own rules, default: false. A value failing its rule is replaced by the fixed marker invalid_shape — the field name still says which one. A fifth call site writing errorClass: String(error) is caught by the sink regardless of which path produced the error; mutation (drop the errorClass rule) → RED with the handle on disk.

ee4807e closes the hole the first cut had: non-string values bypassed the rules entirely, so an object routed into errorCode ((error as any).code when .code is an object) serialised whole, message and all. Non-strings are now finite number or boolean only. I re-mutated that one myself on the commit — restoring the passthrough turns file sink rejects objects routed into allowlisted fields RED, restore byte-identical.

Your other three, as asked: ts/pid are in FILE_FIELDS and nothing is appended after the filter (post-filter mutation RED); chmodSync on an existing dir (0700) and on the rotated .1 (0600), pinned with a 0755/0644 pre-created fixture; the off-switch test asserts the file never appears (fall-through mutation RED).

Hermetic 158/158, GATE PASSED at ee4807e. Pre-existing and out of scope: plugin.ts:30-31 carries its own copy of the identifier validator; I will fold it into the handles.ts export in a follow-up rather than widen this PR.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

5 issues found across 4 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/opencode/src/tests/log-leak.test.ts">

<violation number="1" location="packages/opencode/src/tests/log-leak.test.ts:42">
P3: The integration arm writes a fresh `custody-log-canary-<uuid>` tree under /tmp/opencode on every run and never removes it, accumulating directories in system temp. Tear it down (e.g. in a finally / afterEach) or track and remove the created dir.</violation>

<violation number="2" location="packages/opencode/src/tests/log-leak.test.ts:63">
P3: This canary is titled/claimed to prove the allowlist is "the only door," but it never parses the written JSONL record and asserts on its keys. It only checks that raw secret text is absent, so a regression that adds `errorMessage` (or any non-allowlisted field containing non-secret text) to written records would pass silently, defeating the PR's core errorMessage-exclusion invariant. Parse the line(s) and assert the record's keys stay within the allowlist (and that `errorMessage` is absent).</violation>

<violation number="3" location="packages/opencode/src/tests/log-leak.test.ts:64">
P3: The `sk-fake-secret-key` canary is vacuous: the value is only assigned (line 48) and asserted absent (line 64), but it is never written into any file or input the plugin reads, so `expect(contents).not.toContain(key)` is trivially true. Unlike `handle` (which is placed in the malformed handle file), `key` cannot leak through this path. The test therefore overstates what it guards. Either make the key reach a real input (e.g. include it in the malformed handle file or auth source) or drop the redundant assertion.</violation>
</file>

<file name="packages/opencode/src/log.ts">

<violation number="1" location="packages/opencode/src/log.ts:40">
P2: The custody-log allowlist claims "fake keys do not appear in the file," but the ERROR_CODE character-class cadmit s any string of letters/digits/`_.-` verbatim, including real secret shapes. I verified `ERROR_CODE.test("sk-fake-secret-key") === true` and a full 43-char `ckh_` handle also matches, so such a value routed into `errorCode` would be persisted unmodified. The new canary test masks this: it appends a trailing space (`errorCode: \`${key} \``) solely to force rejection, and the log-leak integration arm routes the real fault path where `errorCode` is undefined, so neither test proves a safe-char-key is rejected. This weakens the PR's core secret-leakage guarantee for a field that admits secrets composed entirely of permitted characters.</violation>

<violation number="2" location="packages/opencode/src/log.ts:45">
P2: When a credential returns `auth_required`, the file sink records `state:"invalid_shape"` instead of `state:"reauth"`. Add `reauth` to the state allowlist so recovery telemetry remains accurate.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

const ISO_TIMESTAMP = /^\d{4}-\d{2}-\d{2}T[\d:.]+Z$/;
const STATES = new Set([
"available", "transient", "cooldown", "other_owner", "orphan", "split", "unmanaged",
"refusing", "serving", "served", "gone",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When a credential returns auth_required, the file sink records state:"invalid_shape" instead of state:"reauth". Add reauth to the state allowlist so recovery telemetry remains accurate.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/opencode/src/log.ts, line 45:

<comment>When a credential returns `auth_required`, the file sink records `state:"invalid_shape"` instead of `state:"reauth"`. Add `reauth` to the state allowlist so recovery telemetry remains accurate.</comment>

<file context>
@@ -27,10 +31,19 @@ export type CustodyLogger = {
+const ISO_TIMESTAMP = /^\d{4}-\d{2}-\d{2}T[\d:.]+Z$/;
+const STATES = new Set([
+  "available", "transient", "cooldown", "other_owner", "orphan", "split", "unmanaged",
+  "refusing", "serving", "served", "gone",
+]);
 
</file context>
Suggested change
"refusing", "serving", "served", "gone",
"refusing", "serving", "served", "gone", "reauth",

];
const CREDENTIAL_ID = /^[A-Za-z0-9._:-]{1,128}$/;
const ERROR_CLASS = /^[A-Za-z][A-Za-z0-9_]{0,63}$/;
const ERROR_CODE = /^[A-Za-z0-9_.-]{1,64}$/;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The custody-log allowlist claims "fake keys do not appear in the file," but the ERROR_CODE character-class cadmit s any string of letters/digits/_.- verbatim, including real secret shapes. I verified ERROR_CODE.test("sk-fake-secret-key") === true and a full 43-char ckh_ handle also matches, so such a value routed into errorCode would be persisted unmodified. The new canary test masks this: it appends a trailing space (errorCode: \${key} `) solely to force rejection, and the log-leak integration arm routes the real fault path where errorCode` is undefined, so neither test proves a safe-char-key is rejected. This weakens the PR's core secret-leakage guarantee for a field that admits secrets composed entirely of permitted characters.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/opencode/src/log.ts, line 40:

<comment>The custody-log allowlist claims "fake keys do not appear in the file," but the ERROR_CODE character-class cadmit s any string of letters/digits/`_.-` verbatim, including real secret shapes. I verified `ERROR_CODE.test("sk-fake-secret-key") === true` and a full 43-char `ckh_` handle also matches, so such a value routed into `errorCode` would be persisted unmodified. The new canary test masks this: it appends a trailing space (`errorCode: \`${key} \``) solely to force rejection, and the log-leak integration arm routes the real fault path where `errorCode` is undefined, so neither test proves a safe-char-key is rejected. This weakens the PR's core secret-leakage guarantee for a field that admits secrets composed entirely of permitted characters.</comment>

<file context>
@@ -27,10 +31,19 @@ export type CustodyLogger = {
 ];
+const CREDENTIAL_ID = /^[A-Za-z0-9._:-]{1,128}$/;
+const ERROR_CLASS = /^[A-Za-z][A-Za-z0-9_]{0,63}$/;
+const ERROR_CODE = /^[A-Za-z0-9_.-]{1,64}$/;
+const LEVELS = new Set(["debug", "info", "warn", "error"]);
+const ISO_TIMESTAMP = /^\d{4}-\d{2}-\d{2}T[\d:.]+Z$/;
</file context>

test("real malformed handle file fault path never writes handle or key", async () => {
// This integration arm proves the real config-hook path writes a fault without secrets;
// handles.ts -> parseSecretJson applies the fixed-message SecretJsonParseError upstream.
const root = join("/tmp/opencode", `custody-log-canary-${crypto.randomUUID()}`);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The integration arm writes a fresh custody-log-canary-<uuid> tree under /tmp/opencode on every run and never removes it, accumulating directories in system temp. Tear it down (e.g. in a finally / afterEach) or track and remove the created dir.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/opencode/src/tests/log-leak.test.ts, line 42:

<comment>The integration arm writes a fresh `custody-log-canary-<uuid>` tree under /tmp/opencode on every run and never removes it, accumulating directories in system temp. Tear it down (e.g. in a finally / afterEach) or track and remove the created dir.</comment>

<file context>
@@ -0,0 +1,66 @@
+  test("real malformed handle file fault path never writes handle or key", async () => {
+    // This integration arm proves the real config-hook path writes a fault without secrets;
+    // handles.ts -> parseSecretJson applies the fixed-message SecretJsonParseError upstream.
+    const root = join("/tmp/opencode", `custody-log-canary-${crypto.randomUUID()}`);
+    const config = join(root, "config");
+    const data = join(root, "data");
</file context>

const contents = readFileSync(custody, "utf8");
expect(contents.trim().length).toBeGreaterThan(0);
expect(contents).not.toContain(handle);
expect(contents).not.toContain(key);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The sk-fake-secret-key canary is vacuous: the value is only assigned (line 48) and asserted absent (line 64), but it is never written into any file or input the plugin reads, so expect(contents).not.toContain(key) is trivially true. Unlike handle (which is placed in the malformed handle file), key cannot leak through this path. The test therefore overstates what it guards. Either make the key reach a real input (e.g. include it in the malformed handle file or auth source) or drop the redundant assertion.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/opencode/src/tests/log-leak.test.ts, line 64:

<comment>The `sk-fake-secret-key` canary is vacuous: the value is only assigned (line 48) and asserted absent (line 64), but it is never written into any file or input the plugin reads, so `expect(contents).not.toContain(key)` is trivially true. Unlike `handle` (which is placed in the malformed handle file), `key` cannot leak through this path. The test therefore overstates what it guards. Either make the key reach a real input (e.g. include it in the malformed handle file or auth source) or drop the redundant assertion.</comment>

<file context>
@@ -0,0 +1,66 @@
+    const contents = readFileSync(custody, "utf8");
+    expect(contents.trim().length).toBeGreaterThan(0);
+    expect(contents).not.toContain(handle);
+    expect(contents).not.toContain(key);
+  });
+});
</file context>


const contents = readFileSync(custody, "utf8");
expect(contents.trim().length).toBeGreaterThan(0);
expect(contents).not.toContain(handle);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: This canary is titled/claimed to prove the allowlist is "the only door," but it never parses the written JSONL record and asserts on its keys. It only checks that raw secret text is absent, so a regression that adds errorMessage (or any non-allowlisted field containing non-secret text) to written records would pass silently, defeating the PR's core errorMessage-exclusion invariant. Parse the line(s) and assert the record's keys stay within the allowlist (and that errorMessage is absent).

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/opencode/src/tests/log-leak.test.ts, line 63:

<comment>This canary is titled/claimed to prove the allowlist is "the only door," but it never parses the written JSONL record and asserts on its keys. It only checks that raw secret text is absent, so a regression that adds `errorMessage` (or any non-allowlisted field containing non-secret text) to written records would pass silently, defeating the PR's core errorMessage-exclusion invariant. Parse the line(s) and assert the record's keys stay within the allowlist (and that `errorMessage` is absent).</comment>

<file context>
@@ -0,0 +1,66 @@
+
+    const contents = readFileSync(custody, "utf8");
+    expect(contents.trim().length).toBeGreaterThan(0);
+    expect(contents).not.toContain(handle);
+    expect(contents).not.toContain(key);
+  });
</file context>

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant