Skip to content

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

Open
iceteaSA wants to merge 8 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 8 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, with existing dirs and files tightened to those modes on first write.
  • 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.
  • Each entry gains process-generated ts and pid metadata.

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.
  • Each allowlisted field has a rule matched to producer output (errorClass PascalCase or lower-snake, errorCode upper- or lower-snake, state pinned by a source scan of producers); non-string values must be numbers or booleans, all-hex bodies are rejected, and anything else is written as invalid_shape. A canary drives a malformed handle file through the real config hook to pin this.
  • Short lowercase underscore tokens that match the code shape (e.g. sk_fake_secret) are written as-is because they are indistinguishable from real error codes.

Written for commit c117b21. 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.

…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.
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.
… 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.
…d from producers

ERROR_CODE and ERROR_CLASS admitted hyphen/underscore/long-token shapes that real keys and handles satisfy, and the canary forced rejection with a trailing space instead of a real value. Rules now match what the producers emit (PascalCase error names; upper- or lower-snake codes, max 32, one case class); the canary uses realistic key/handle shapes with no dodge. STATES is the exact producer set, pinned by a source scan. Integration arm parses records, asserts keys within FILE_FIELDS, and cleans its temp tree.
…ield-rule population pinned by source scan

ERROR_CLASS admitted only Error.name values, so the freshness and ownership paths (credential_warm, transient, auth_required, other_owner) were written as invalid_shape — the rule matched half the producers. Both errorClass and errorCode now accept a PascalCase name or a lower-snake token (max 24; a 32-char hex key no longer fits), and a source scan pins every literal producer plus the wire ErrorClass set against the rules so the population cannot drift again.
…al pinned

A short lowercase underscore token (sk_fake_secret) has the same shape as not_found and no rule admitting codes can reject it. The canary now claims realistic credential shapes, and a separate test pins the residual as written-verbatim so a future rule that rejects it also visibly rejects real codes.
…is process-generated only

A length bound is not a shape bound: capping the snake arms at 24 moved the hex-token window, and admitting the snake wire classes in errorClass reopened it on a second field. Both rules now reject an all-hex (or all-digit) body outright — no real class or code is hex — which removes the class instead of narrowing it. The pre-filter spread in fileEntry is documented as process-generated values only.
@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.

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