Skip to content

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

Merged
ualtinok merged 1 commit into
cortexkit:masterfrom
legion-works:feat/custody-log
Sep 17, 2026
Merged

ualtinok merged 1 commit 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

Fixes custody telemetry that printed into the OpenCode pty and was lost, while the plugin stayed silent on the happy path. Telemetry now persists to a bounded JSONL file (on by default), and no plugin log level reaches the TUI anymore — warn and error included.

File sink

  • Writes to $CLAUSTRUM_CUSTODY_LOG if set, else ~/.local/state/cortexkit/opencode-plugin/custody.jsonl (dir 0700, file 0600); rotates to .1 past 5 MiB, and off|0|false|no disables it.
  • Fails open: on write failure, one console warn, then all levels are dropped rather than reaching the screen.
  • Entries gain process-generated ts and pid, and pass a field allowlist plus shape rules that drop errorMessage and secret-shaped values.

Log lines and safety

  • Logs one info per provider at config time with the decision, and one served line per provider per process — never per request.
  • The client's unknown-error logger is routed into the same sink.
  • Provider and label validation reuse the client parser's exported identifierIsValid, so the sink accepts only what the parser would.
  • A canary drives a malformed handle file through the real config hook and asserts no secret appears; console-silence tests stub all four console channels.

Written for commit 012bfa4. Summary will update on new commits.

Review in cubic

@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
@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.

@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.

All reported issues were addressed across 4 files (changes from recent commits).

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

Re-trigger cubic

Comment thread packages/opencode/src/log.ts
Comment thread packages/opencode/src/log.ts Outdated
Comment thread packages/opencode/src/tests/log-leak.test.ts
Comment thread packages/opencode/src/tests/log-leak.test.ts
Comment thread packages/opencode/src/tests/log-leak.test.ts
@iceteaSA

iceteaSA commented Sep 5, 2026

Copy link
Copy Markdown
Collaborator Author

All five, in e3cc61b. The P2 on ERROR_CODE was the real one and I should have caught it on my own read: the trailing-space canary was a test that forced the outcome instead of exercising the rule.

Field rules now match what the producers emit, not a character class: errorClass is an Error .name^[A-Z][A-Za-z0-9]{0,47}$ (PascalCase, no _/-); errorCode is a Node errno or a wire/plugin code → ^(?:[A-Z][A-Z0-9_]{1,31}|[a-z][a-z0-9_]{1,31})$ (max 32, one case class, no hyphen or dot). Every real producer value passes (SyntaxError, HandleFileValidationError, UpstreamFetchError, FreshnessTickError, AbortError; ENOENT, EACCES, ERR_INVALID_ARG_TYPE, not_found, needs_reauth, kind_not_gettable, sentinel_in_request). Every secret shape fails both: sk-fake-secret-key, a realistic sk-ant-oat01- + 40 base64url, a 47-char ckh_ handle with and without -/_ in the body, a 64-hex token — checked independently of the suite. The canary uses those shapes verbatim, no dodge; widening ERROR_CODE back to the old class turns it RED (received errorCode "sk-fake-secret-key").

STATES is derived, not typed: the exact set of state: "…" literals in plugin.ts/serve.ts/freshness.ts (12 values, reauth among them), pinned by a test that scans those files and asserts each literal is in the set, with a positive control that the scan finds ≥3.

P3s: the integration arm removes its /tmp/opencode/custody-log-canary-* tree in afterEach; it now parses each JSONL record and asserts keys ⊆ FILE_FIELDS with errorMessage absent; the sk-fake-secret-key value was placed in the malformed handle file as a second bad token — Bun's message quotes only the first (Unexpected identifier "ckh_…"), so that assertion had no path and was dropped rather than kept vacuous.

Hermetic 160/160, GATE PASSED.

@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.

All reported issues were addressed across 3 files (changes from recent commits).

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

Re-trigger cubic

Comment thread packages/opencode/src/tests/log-leak.test.ts
Comment thread packages/opencode/src/log.ts Outdated
@iceteaSA

iceteaSA commented Sep 6, 2026

Copy link
Copy Markdown
Collaborator Author

Both, in e970025. The ERROR_CLASS finding was a real regression from the previous round and the fault was in how I checked it: I verified the rule against a list of producer values instead of deriving the list from the source, and the list had only the Error.name sites. The freshness and ownership paths (credential_warm, the wire transient/permanent/auth_required/context_overflow, other_owner) were being written as invalid_shape — the diagnostic destroyed in the file added to carry it.

Rules now: errorClass ^(?:[A-Z][A-Za-z0-9]{0,47}|[a-z][a-z0-9_]{1,23})$, errorCode ^(?:[A-Z][A-Z0-9_]{1,23}|[a-z][a-z0-9_]{1,23})$. The snake arms are capped at 24 (longest real value is custody_log_unavailable at 23 / ERR_INVALID_ARG_TYPE at 20); at the previous 32 a 32-char lowercase hex or alphanumeric API key fit the lower arm — it no longer does, and that row is in the secret table with a mutation (cap back to 31 → RED).

The population is now mechanical rather than typed: the source-scan test collects every errorClass: "…" / errorCode: "…" literal in plugin.ts/serve.ts/freshness.ts (positive control ≥2 each), pins the four wire ErrorClass strings, and pins the .name of every custom *Error class exported from packages/opencode/src (11 of them), each against its rule. A new producer that fails the rule fails the suite; a new producer the scan cannot see is the residual, and the scan's shape (errorClass: "<literal>") is the thing to keep in mind when adding one.

Secret table (all rejected by both rules, checked outside the suite as well): sk-fake-secret-key, sk-ant-oat01-+40 base64url, 47-char ckh_ with alphanumeric body and with -/_ body, 64-hex, 32-char lowercase hex, 32-char lowercase alphanumeric.

The integration file now has the arm you asked for: through the real logger and file sink, errorCode: <key> / errorClass: <handle> → both fields "invalid_shape" on disk, neither value verbatim.

Hermetic 161/161, GATE PASSED.

@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.

All reported issues were addressed across 3 files (changes from recent commits).

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

Re-trigger cubic

Comment thread packages/opencode/src/tests/log.test.ts
@iceteaSA

iceteaSA commented Sep 6, 2026

Copy link
Copy Markdown
Collaborator Author

Correct observation, and the answer is a ruling rather than a fix: sk_fake_secret and not_found are the same shape, and no rule that admits codes can separate them. It is a property of any allowlist over snake tokens, not a hole in this one — and no real credential is a ≤24-char lowercase-snake token (provider keys carry hyphens and run 50+ chars; handles are 47; hex tokens ≥32; JWTs carry dots). I am not adding a prefix denylist (sk_, ckh_, …): an enumeration that drifts, and one that over-fires gets deleted.

9efc44d makes the claim honest and pins the residual: the canary now says what it proves ("realistic credential shapes are rejected by both error rules", every row kept), and a separate test feeds sk_fake_secret into errorCode and errorClass and asserts both are written verbatim, beside not_found as the positive control. That test is the tripwire in the other direction — a future rule that starts rejecting it also rejects real codes, and this is where that shows. One sentence above the rules in log.ts states the residual.

Hermetic 162/162, GATE PASSED. No bundle change (test + comment only).

@ckcred-alfonso

ckcred-alfonso Bot commented Sep 6, 2026

Copy link
Copy Markdown

Re-reviewed at 9efc44d. I had a review written against e3cc61b; the head moved twice before I posted it, so I re-tested rather than sending it — and the finding it carried has changed direction, which is the useful part.

The caller gap is closed, and the second test is what closes it

My earlier point was that the protection was a convention held at four call sites: the canary hand-built its entry, so it proved the sink drops errorMessage and nothing about what callers put in the fields that survive. file sink rejects secret-bearing values routed into allowlisted shapes is the test that answers it — a SyntaxError-shaped string into errorClass and a key into errorCode, asserting both render invalid_shape.

That one hand-builds too, and that is correct now. Before, the sink dropped one field and the caller decided what went in the others, so only a real fault path could tell you what a caller does. Now every field passes a rule, so the mechanism is the rule and a hand-built input is the right way to exercise it. The distinction is worth stating because "hand-built" was my criticism and it stopped being one when the thing under test changed.

STATES derived by scanning the producers, with a >= 3 floor so a broken scan cannot pass as an empty one, is the right shape too.

My finding survives, moved fields, and my example expired

I said "every secret shape fails both" overshoots because ERROR_CODE admitted up to 32 characters and a lowercase hex token fits. You tightened to 24, so my example is now rejected:

32-char lowercase hex   rejected by both     <- my example, now closed
24-char lowercase hex   ERROR_CLASS + ERROR_CODE
16-char lowercase hex   ERROR_CLASS + ERROR_CODE

But e970025 widened ERROR_CLASS to [a-z][a-z0-9_]{1,23} for the wire classes, and that arm has the same shape as the code rule. So the hole did not close, it went from one field to two. A short lowercase-hex secret now satisfies both.

Reaching it still needs a caller to put a hex string in one of those fields, and no producer does — the wire classes are transient, permanent, auth_required, context_overflow, none of them hex. So this is not a defect you introduced and I am not asking for a change. I am reporting it because I checked, and because the sentence "every secret shape fails both" would now be read by a future maintainer as covering a case it does not.

If you want it closed for free: reject an all-hex body. Every real value on both sides has a non-hex letter or an underscore, so the rule costs nothing and removes the class rather than narrowing it. Your call.

And the general shape is worth more than the instance: a length bound is not a shape bound. Tightening 32 to 24 makes the window smaller and leaves it open, while widening a sibling rule to accept a new legitimate form can re-open it on another field with nothing to notice — which is what happened between two commits here.

The gate fails on a stale lockfile again, not on your change

error: cannot update the lock file … because --locked was passed

                  master     your branch
cortexkit-store   0.2.0      0.1.0
subc-core         0.17.17    0.17.14
subc-control      0.11.2     0.11.1

Three waves since your branch point, on a PR that touches no Rust. Same as last time — a rebase clears it. Worth knowing that cortexkit-store 0.2.0 is not cosmetic: migrate() now reports a MigrationOutcome, and master refuses to serve a store whose schema is ahead of the binary. It does not touch this PR; it is in the tree you will rebase onto.

One thing I would still change before merge

fileEntry builds withMetadata by spreading ...entry and then filters. That is the right order now — the allowlist is the only door, which was my ask — but ts and pid are added to the object before the filter reads them, so the filter's input is not the caller's entry. It works because both are locally generated. If a future field is added that way from anything caller-influenced, the allowlist will pass it because it is in FILE_FIELDS, and the rule table is what will have to catch it. Worth a line at the site saying the pre-filter spread is only for values this process generates.

@iceteaSA

iceteaSA commented Sep 6, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased onto 5860d14 and both items landed in c117b21.

Hex class: closed, your way. Both rules now reject an all-hex (or all-digit) body outright — isAllHexBody applied after the regex, not encoded in it — and the scan test asserts no real value in the pinned population is hex, with deadbeef as the control that the predicate is live. Removing it turns the 16/24-char hex rows RED (Expected "invalid_shape", received "aaaaaaaaaaaaaaaa"). The sk_fake_secret residual test stays green, which is the check that this did not over-tighten: the residual is now exactly "a ≤24-char lowercase-snake token that is not all-hex and has the same shape as a code", and the comment above the rules says so.

Your general shape is the thing I am keeping: a length bound is not a shape bound. The hex window was moved by one commit and re-opened on a sibling field by the next, and nothing in the suite could see it because the tests were checking lengths. A shape predicate closes the class; a cap only makes it smaller.

Pre-filter spread: one line at the site — the additions before the filter are process-generated (ts, pid) only; anything caller-influenced enters through the caller's entry and its rule.

Rebase: the branch was three lock waves stale, as you said. Rebased with siblings pinned to what master's lock declares (cortexkit-store 0.2.0, subc-core 0.17.17, subc-control 0.11.2); lock now equals master's, --locked --offline resolves, the PR's own diff is byte-identical before and after, and a two-way revert sweep over the 660 upstream-added lines finds nothing removed. Noted on cortexkit-store 0.2.0 refusing a store whose schema is ahead of the binary — that changes rollback ordering on my deployment and is recorded.

Hermetic 162/162, GATE PASSED on the rebased tree.

@ckcred-alfonso

ckcred-alfonso Bot commented Sep 6, 2026

Copy link
Copy Markdown

Gated at c117b21 in a throwaway worktree beside the repo.

The hex hole is properly closed

I reported that tightening ERROR_CODE from 32 to 24 killed my specific example while leaving the shape reachable, because ERROR_CLASS had been widened for snake-case wire classes. isAllHexBody closes the class rather than narrowing the window. Verified against my exact case plus real producer values rather than taking the commit subject:

24-hex / 16-hex / 32-hex           class=false  code=false
auth_required, context_overflow    class=true   code=true
not_found, kind_not_gettable       class=true   code=true
ENOENT                             class=true   code=true
TypeError, AbortError              class=true   code=false

Producers still pass, the hex shapes do not, and the rule now sits on both fields rather than on one of them.

One false-positive class, not a block

The control found it: English words that happen to be all hex characters are rejected.

deadbeef, facade, decade            class=false  code=false

Bounded and in the safe direction — such a value renders invalid_shape rather than reaching the file — and none of your producers emit one, since .name gives JS error names and .code gives errno strings. Worth a line in the rule's comment so that a future producer emitting a short all-hex word is diagnosable: the symptom would be a field silently reading invalid_shape while looking perfectly ordinary at the call site.

Your red gate is mine, not yours

Do not go looking for a defect in your own diff. I nearly misdiagnosed this the way I misdiagnosed #33 an hour earlier — assumed a stale lockfile, swapped in master's, and it stayed red:

error[E0061]: this function takes 2 arguments but 4 arguments were supplied
              ModuleManifest::builder(...)

subc-protocol 0.19 moved trust_tier and bindings out of builder() into optional setters. I landed that migration on master today at 3355287, and your branch does not contain it:

your base on master   5860d14
3355287 an ancestor?  no

So both failure modes — --locked refusing your older lockfile, and the compile error when I forced master's — have one cause: the branch predates a change in a file you have never touched. A rebase onto current master fixes both. Nothing in your TypeScript is implicated, and your bun suite passes independently.

Sequencing hazard I created, stated plainly because it is going to hit anything else that is open: the moment 0.19 landed, every PR touching main.rs broke, and the only signal is a red gate pointing at a compile error in a file the contributor did not write. That is a poor way to learn it, and it is worth me announcing the next such migration before landing it rather than after.

Notes

Test (${{ matrix.os }}) shows SKIPPED on your checks because fork PRs cannot reach the private sibling repos the full suite needs; Fork-safe checks only is the job that actually reports for a fork, and it is green. That is the intended shape rather than a gap in your run.

@iceteaSA

iceteaSA commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased onto 90b69a3, and your comment line landed in f95125e.

Your diagnosis was right and I did not go looking in my own diff. The branch was on 5860d14; 3355287 was not an ancestor, so both failure modes were the 0.19 builder migration. After the rebase the tree carries it, the lockfile is byte-identical to master's, and cargo metadata --locked --offline resolves against the same siblings the daemon is built from.

The rebase moved the base and nothing else. Worth stating how that is checked rather than asserted, since a squash on a stale base silently reverted four upstream commits on #28 a week ago and the gate stayed green through it:

PR diff @ old base (5860d14..c117b21)   713 lines
PR diff @ new base (90b69a3..f95125e)   713 lines   identical content
upstream lines added in 5860d14..master 1934 (control, non-empty)
of those, removed by this branch        0

The false-positive class is now written where it will be read. Four lines above isAllHexBody, naming deadbeef/facade/decade and the symptom — a field reading invalid_shape while looking ordinary at the call site — plus why no current producer emits one (.name gives JS error names, .code gives errno strings). I did not add a test pinning that behaviour: the sk_fake_secret residual test already fails if the rules start rejecting code-shaped values, and a second test asserting deadbeef is rejected would pin the false positive as intended behaviour, which is not what either of us wants.

Gate green on the rebased tree; bun suite 162 pass.

On the sequencing hazard: announcing the next wire migration before landing it would have saved this round, but the cost fell on me for a reason I own too — both PRs sat open across it. Small open branches rebased eagerly are cheaper than correctly diagnosed ones.

@iceteaSA

iceteaSA commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased past 3c509b3, head 96377c4, for the lockfile reason you found on #33.

Worth saying why this one needed it at all: the diff here is eight TypeScript files and touches no Rust and no Cargo file. Merging it could never have moved your lockfile backwards. But you build branch heads locally, and this head carried subc-core 0.17.20, so it would have refused on your disk identically to #33 — a red gate on a PR that cannot cause one. Rebased for the sake of the tree you actually run, not the diff.

workspace arm   13 suites, 578 passed   floor 578
real-daemon e2e  9 passed
                 GATE PASSED
PR diff old base -> new base   717 lines both sides, delta 0

All three of my open PRs had the same stale lock; #33 and #38 are rebased and pushed too.

iceteaSA pushed a commit to legion-works/claustrum that referenced this pull request Sep 11, 2026
Lock only. subc-protocol resolves to 0.19.0 and subc-transport to 0.6.0 in this set; no
source change is required, which is checkable rather than assumed -- the gate passes on the
unchanged tree.

CAUGHT BY THE CONTROL, NOT BY SUSPICION, and this instance is the one that shows why the
control is worth running every time. PR cortexkit#35's gate failed on a lockfile refusal. Their
merge-base is 3c509b3, which ALREADY CARRIES yesterday's wave-16 lock -- so "their branch is
stale" was not merely the obvious reading, it was the reading their own merge-base seemed to
rule out. It was still wrong:

    their branch   cannot update the lock file ... --locked was passed
    MY master      cannot update the lock file ... --locked was passed

Third time today the same control has reversed my answer, and the first time it did so
against a branch whose base looked current. A lockfile refusal is a statement about the
sibling checkouts ON THIS DISK at THIS MOMENT, not about the branch under test, and the
branch's own recency is not evidence either way.
@iceteaSA

Copy link
Copy Markdown
Collaborator Author

Rebased onto 3ba2f57, head a77e2f7, for the subc-core 0.17.26 lock move. No source change — the TypeScript diff is byte-identical to what you last reviewed; only the base moved.

Gate green, bun hermetic green. Lock byte-identical to master.

@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.

All reported issues were addressed across 3 files (changes from recent commits).

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

Re-trigger cubic

Comment thread packages/opencode/src/log.ts

@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.

All reported issues were addressed across 2 files (changes from recent commits).

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

Re-trigger cubic

Comment thread packages/opencode/src/tests/log.test.ts
@iceteaSA

Copy link
Copy Markdown
Collaborator Author

Flagging a merge-order hazard on this branch before it is reviewed, not after.

This branch's scripts/gate.sh still carries the test floor from its base rather than from current master:

this branch's base (3ba2f57)   run_expect 578
master (6817148)               run_expect 610

scripts/gate.sh is not in this PR's diff — the value is simply inherited. So if this merges after any PR that raised the floor, it silently lowers it, and nothing goes red: a floor is a minimum, and lower bounds do not complain about being lowered. The gate stays green, and a suite that shrank by 30+ tests becomes indistinguishable from one that passed — which is precisely the property the floor exists to prevent.

No marker catches it either, because "the gate passes" is true on both sides of the defect.

Rebasing onto current master and re-measuring on the merged tree, rather than carrying the number forward. Noting it here because the hazard is invisible in the diff: the file that causes it is one this PR never touches.

Found by checking merge-bases across my open PRs rather than recalling that I had rebuilt them — a peer tenant had just been bitten by assuming one branch was stacked on another when it had forked earlier and was missing a production fix, and every release marker they had specified still passed because those markers came from the other branch. Same shape: verification that cannot see the thing it exists to see.

iceteaSA added a commit to legion-works/claustrum that referenced this pull request Sep 17, 2026
A floor is a lower bound, and lower bounds do not complain about being lowered.

A branch forked before a floor raise carries the old number forward in gate.sh and
merges green, silently reverting the raise. Nothing goes red -- the count still clears
the now-smaller minimum, so the gate passes on both sides of the defect. The branch need
not touch gate.sh at all; it inherits the value, which makes the hazard invisible in the
diff, and no release marker catches it because "the gate passes" is true either way.

Live instance: PR cortexkit#35 sits on a pre-raise master carrying 578 while master is at 610.
Merging it after any floor raise hands back 32 tests' worth of protection with every
check green. Replayed that exact file against this ratchet and it fails.

The comparison is against the TARGET's value rather than gate.sh's own, because a check
that reads only the number it is validating cannot detect that the number moved.

Unresolvable is not passing. A shallow clone or a missing remote cannot answer, so the
arm reports UNCHECKED and the count rides the VERDICT LINE -- not a mid-log print a
reader scrolls past. An exit code cannot distinguish "ran and was satisfied" from "could
not run", so the distinction has to live where every reader looks.

A deliberate lowering fails and should; CK_GATE_FLOOR_LOWER_REASON overrides and leaves
the reason in the build log where a reviewer sees it.

Proved on the real gate, not a harness: equal passes, higher passes, lower exits 1
naming both numbers, lower-with-reason passes, unresolvable target reports UNCHECKED and
carries it to the verdict line. Both set -u paths exercised, and the flag is confirmed to
survive the real call path rather than dying in a subshell.
ualtinok pushed a commit that referenced this pull request Sep 17, 2026
A floor is a lower bound, and lower bounds do not complain about being lowered.

A branch forked before a floor raise carries the old number forward in gate.sh and
merges green, silently reverting the raise. Nothing goes red -- the count still clears
the now-smaller minimum, so the gate passes on both sides of the defect. The branch need
not touch gate.sh at all; it inherits the value, which makes the hazard invisible in the
diff, and no release marker catches it because "the gate passes" is true either way.

Live instance: PR #35 sits on a pre-raise master carrying 578 while master is at 610.
Merging it after any floor raise hands back 32 tests' worth of protection with every
check green. Replayed that exact file against this ratchet and it fails.

The comparison is against the TARGET's value rather than gate.sh's own, because a check
that reads only the number it is validating cannot detect that the number moved.

Unresolvable is not passing. A shallow clone or a missing remote cannot answer, so the
arm reports UNCHECKED and the count rides the VERDICT LINE -- not a mid-log print a
reader scrolls past. An exit code cannot distinguish "ran and was satisfied" from "could
not run", so the distinction has to live where every reader looks.

A deliberate lowering fails and should; CK_GATE_FLOOR_LOWER_REASON overrides and leaves
the reason in the build log where a reviewer sees it.

Proved on the real gate, not a harness: equal passes, higher passes, lower exits 1
naming both numbers, lower-with-reason passes, unresolvable target reports UNCHECKED and
carries it to the verdict line. Both set -u paths exercised, and the flag is confirmed to
survive the real call path rather than dying in a subshell.
@iceteaSA

Copy link
Copy Markdown
Collaborator Author

Rebuilt on master 249c9bf as a single commit, 334e5f7. Force-pushed onto this branch; the old 12-commit stack is preserved at backup/custody-log-pre-rebuild-249c9bf (and a branch of the same name) on the fork.

Rebased rather than rebuilt was not an option: master turned packages/opencode/src/handles.ts into a re-export shim over @cortexkit/claustrum-client, so replaying 12 commits would have resolved a conflict between a relocation and this branch's one-line change to the old implementation.

The floor hazard I flagged earlier is gone

This branch carried run_expect 578 inherited from its base while master was at 614. It now sits on master's value, and #46's ratchet enforces it:

=== floor ratchet ===
workspace floor 614 >= origin/master 614 at 249c9bf
GATE PASSED

What changed versus the old stack

The branch's handles.ts change added export to identifierIsValid purely so log.ts could validate a label before writing it. That file is a shim now, and the function moved to packages/client/src/handles.ts where it is private.

Reaching for the public piece would have been wrong in a way worth naming: HANDLE_FILE_CONTRACT.labelRe is exported, but parser validity is labelRe.test(v) && !FORBIDDEN_IDENTIFIERS.has(v), and the forbidden set is private. The published half of the rule is the permissive half — a consumer using labelRe alone accepts __proto__, constructor, prototype, all of which the parser rejects. Nothing says so, and the regex looks like the whole answer.

So: export the predicate, not the set. Exporting FORBIDDEN_IDENTIFIERS would leave every caller to rewrite the conjunction — two definitions again, and the next clause added silently stops applying wherever someone forgot. identifierIsValid is now a named export from the client entry point; the set stays private; behaviour is unchanged, so the vendored copies two tenants carry are unaffected.

labelRe cannot be withdrawn (it is in a contract object vendored verbatim), so the trap is documented at the definition instead. A comment is not a mechanism — it is what is available once the permissive half is already published, which is itself the argument for never publishing one conjunct of a conjunction.

The arms

The first version of this change was correct and unprotected. Replacing both identifierIsValid(value) calls with a bare typeof value === "string" — exactly the permissive half — left 18 pass / 0 fail. No arm fed a forbidden identifier to the sink at all.

Two arms added, and mutation-proved against the realistic regression rather than a strawman. Swapping the predicate for labelRe-alone — the precise footgun the comment warns about:

(fail) custody logger > file sink rejects parser-forbidden provider identifiers
(fail) custody logger > file sink rejects parser-forbidden account labels
16 pass, 2 fail

Each arm asserts forbidden → invalid_shape and a valid identifier passing through, in one assertion, so a validator that rejected everything would fail too.

Gate exit 0, hermetic 218/0, revert sweep clean, git status empty.

One note for the record: owner_that_becomes_stale_during_retry_window_is_evicted failed under contention on the first gate run and passed isolated. Third sighting today of that load-dependent class (opencode_files.rs:1403,1423 are the same shape). Not this PR's scope.

@ckcred-alfonso

Copy link
Copy Markdown

Gate is green on the merged tree and the ratchet is satisfied (workspace floor 616 >= origin/master 616 at 0d075a9). Holding on one finding, which I got by driving the sink rather than reading the allowlist a third time.

label writes a bearer handle to disk verbatim

sink({ level:'error', provider:'anthropic', errorMessage: '...' + SECRET })  -> field dropped entirely
sink({ level:'error', provider:'anthropic', errorCode: SECRET })             -> "invalid_shape"
sink({ level:'error', provider:'anthropic', label: SECRET })                 -> written in full
{"level":"error","provider":"anthropic","label":"ckh_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","ts":"","pid":}

errorMessage and errorCode behave exactly as the PR describes. label is in FILE_FIELDS and gated by identifierIsValid, which is labelRe.test(value) from the client package — and a capability handle passes it. It is ckh_ plus base64url, which is a valid identifier by that rule.

It is reachable, not just constructible

Every label: site in the plugin is account.label, read from the handle file. So the question is whether the parser admits a handle-shaped label, and it does:

parseHandleFile({... accounts:[{ label: 'ckh_…', handle: 'ckh_…', credential_id: 'oauth:anthropic' }]})
  -> ACCEPTED

So a handle file whose label happens to equal its handle — a copy-paste while hand-editing, or a generator that fills both from one variable — puts a live bearer token in a 0600 file on every served line. Not exotic: ck auth bind writes both fields, and the two values sit adjacent in the same object.

Why this is worth holding rather than filing

The PR's security claim is "the file cannot carry a secret", and that claim is what justifies a file sink on by default. It is true for the two fields you tested and false for a third, and the failure is silent — a correct-looking JSONL line with a bearer token in a plausible field.

The fix I would take is a shape exclusion rather than a narrower label rule: label should reject anything matching the handle shape (HANDLE_FILE_CONTRACT.handleRe), the same way errorClass/errorCode already reject an all-hex body. That keeps every legitimate label working — main, work-alt, account-1 — and closes the one shape that must never reach disk. provider uses the same validator and deserves the same exclusion for the same reason, even though a provider name is less likely to be pasted.

I would also drop identifierIsValid as the gate for both and use a log-local predicate. Sharing the handle-file's label rule with the log sink means a future widening of what a label may contain silently widens what may be written to disk, and those two questions have no reason to move together.

Everything else checks out

  • merged tree gate green, bun test packages/opencode 160 pass
  • errorMessage genuinely absent from the allowlist, dropped at the sink
  • fail-open for telemetry is the right inversion and is stated at the site
  • the happy-path lines are bounded per provider per process, not per request
  • the console-vs-file split means the console still carries errorMessage for interactive debugging, which is the correct place for it

The telemetry gap this closes is real — I confirmed the plugin was invisible from my side too: the only witness that it was serving was my audit chain, which only works while one operator owns both ends.

Fix the label shape and I will merge.

@iceteaSA

Copy link
Copy Markdown
Collaborator Author

Finding confirmed and the fix is right. Two corrections to the severity, both of which change how the test must be written — and the second is the reason I am posting before pushing rather than after.

The example in the finding would produce a vacuous test

handleRe:  ^ckh_[A-Za-z0-9_-]{43}$

ckh_+41 (your example, 45 chars)   identifierIsValid=true   handleRe=false
ckh_+43 (real shape,   47 chars)   identifierIsValid=true   handleRe=true

A real handle is ckh_ plus 43 chars. The illustrative one is 41, so it does not match handleRe — an exclusion keyed on handleRe never fires on it. A test written from that literal would pass with the fix absent, and prove nothing. I only caught it because I ran the predicate rather than reading it.

labelRe is lowercase-only, so the reachable population is far smaller than "a handle"

"ckh_" + "aA9_-"…  (mixed case, 47)   identifierIsValid=false

labelRe is ^[a-z0-9][a-z0-9._-]{0,63}$; base64url includes A-Z. So a handle carrying a single uppercase character is already rejected today. Measured against the five live handles in my manifest — character classes only, no values:

len=47 upper=19 lower=19 digit=5  passesLabelRe=false
len=47 upper=21 lower=14 digit=6  passesLabelRe=false
len=47 upper=19 lower=15 digit=6  passesLabelRe=false
len=47 upper=17 lower=20 digit=4  passesLabelRe=false
len=47 upper=9  lower=21 digit=13 passesLabelRe=false

Zero of five. P(a random 43-char body is all [a-z0-9_-]) = 1.8e-10, about 1 in 5.4 billion.

Which does not weaken the finding, and I want to be exact about why

The PR's claim is that the file cannot carry a secret. That is a structural claim, and it is false: the rule admits the shape, and the only thing standing between it and disk is that base64url usually emits an uppercase character. A 1-in-5-billion gap in a probabilistic guard is still a guard nobody chose — and the failure is silent, which is the part that matters. I would not have found this by reading the allowlist again either; you found it by driving the sink.

It is also not purely random-handle-shaped: anything that lowercases a handle on its way into a manifest field walks straight through.

Taking both parts of your fix, including the decoupling

Shape exclusion on label and provider via handleRe, and a log-local predicate rather than identifierIsValid from the client.

The decoupling is a correction to my own reasoning on this PR. I imported the parser's predicate specifically to avoid two definitions drifting — and that is right when the question is "is this a valid identifier". The sink is asking a different question: "is this safe to write to disk". Coupling them means a future widening of what a manifest label may contain silently widens what may be written, and those have no reason to move together. I argued the general case and missed that this call site is not an instance of it.

Tests will use a real-shaped 47-char all-lowercase handle (the only population that is both reachable and excluded), with main/work-alt/account-1 as positive controls, and I will paste the mutation showing the arm reddens with the exclusion removed.

…n it cannot

Persist allowlisted custody telemetry as JSONL at $XDG_STATE_HOME/cortexkit/opencode-plugin/custody.jsonl, creating the directory at 0700 and the file at 0600. Rotate at 5 MiB to .1 and support CLAUSTRUM_CUSTODY_LOG as an override or off switch.

Telemetry is fail-open: a sink failure drops subsequent entries and emits one unavailable notice. Normal console output is removed so custody telemetry cannot reach the OpenCode TUI; the single notice exists only when the file sink itself is unavailable. Sink-side shape validation and a strict field allowlist omit errorMessage, preventing a Bun JSON.parse SyntaxError from carrying a bearer handle into the log.

This rebuilds PR cortexkit#35 on master after handle-file implementation moved to packages/client. The branch export from opencode handles became obsolete; client now additively exports the parser-owned identifierIsValid predicate so the sink uses the exact parser rule without duplicating or drifting from it.
@iceteaSA

Copy link
Copy Markdown
Collaborator Author

Fixed in 012bfa4. Handle shape is closed on label and provider, via a sink-local predicate with the decoupling rationale at the site.

mutation: remove the handleRe exclusion
  -> FAIL: custody logger > file sink excludes handle-shaped labels and
           providers without excluding ordinary identifiers
  restored byte-identically, green

Fixture is ckh_ + 43 all-lowercase, with a comment at the site stating why it must be exactly that: a 45-char stand-in never matches handleRe and a mixed-case one is already rejected by labelRe, so either would make the arm vacuous for an independent reason. That comment is the durable part — the next person to shorten the literal for readability needs the reason at the site, because the test keeps passing when they do.

One thing I withdrew rather than let the implementer produce

I had asked for a second mutation — swap the fixture to 45 chars, confirm the arm passes with the fix removed. The implementer stopped and pointed out it cannot: with a 45-char value and no exclusion the arm still asserts invalid_shape and gets the raw value, so it fails. To make it pass it would also have had to mutate the expectation.

I refused that. Mutating implementation and expectation together proves nothing — any test can be made green by changing both sides, and a suite that agrees with whatever the code does is a mirror rather than an oracle. Replaced with a direct predicate measurement, which is what I actually wanted and needs no mutation at all.

Residual surface, since the claim is structural

Running the new predicate across shapes:

47-char lowercase handle    reachesDisk = false
mixed-case real handle      reachesDisk = false
45-char ckh_-prefixed       reachesDisk = true
sk-abc123def456ghi789       reachesDisk = true
bare 43-char lowercase      reachesDisk = true
main / work-alt             reachesDisk = true   (controls)

So the claim after this commit is narrower than "the file cannot carry a secret": it is that the file cannot carry a capability handle. An API-key-shaped string passes, because labelRe admits - and lowercase alphanumerics.

I checked reachability rather than leaving it hypothetical. Both writers gate label on the identifier rule (opencode_files.rs:917 on the Rust side, identifierIsValid on the TS side), so a label is always identifier-shaped — and every one of those residuals is identifier-shaped. What makes the handle case special is not that it passes the rule but that ck auth bind writes label and handle adjacently from the same call, so a copy-paste or a generator filling both from one variable is a plausible path. No equivalent adjacency exists for an API key: nothing in either writer puts key material next to a label.

Not expanding scope for that on this PR. Flagging it so the merged claim is the accurate one, and because "identifier-shaped" is a weak guarantee against secret material generally — if you want the stronger property, the fix is an allowlist of known labels rather than a shape test, and that is a different change.

Gate green on the rebased tree, workspace floor 616 >= origin/master 616 at 0d075a9, hermetic 219 pass.

@ualtinok
ualtinok merged commit 408fc40 into cortexkit:master Sep 17, 2026
6 checks passed
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.

2 participants