Skip to content

OpenCode custody: static-key slots served from the vault through an in-process auth proxy - #28

Open
iceteaSA wants to merge 1 commit into
cortexkit:masterfrom
legion-works:feat/opencode-custody
Open

OpenCode custody: static-key slots served from the vault through an in-process auth proxy#28
iceteaSA wants to merge 1 commit into
cortexkit:masterfrom
legion-works:feat/opencode-custody

Conversation

@iceteaSA

@iceteaSA iceteaSA commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Implements the plugin design from #17, static-key slice: OpenCode type:"api" providers (deepseek, synthetic, minimax-coding-plan, …) get their keys moved into the vault, the auth.json entry becomes a non-secret tombstone, and a plugin serves the real key per request from a capability handle.

How it works (source-verified on stock OpenCode 1.18.25; the spike in scripts/spikes/ is the executed proof):

  • The plugin registers through the config hook by injecting { apiKey: sentinel, fetch } into cfg.provider[id].options. Config provider options are re-applied after every auth.loader (provider.ts:1643-1650), so the injected fetch also replaces a shipped plugin's — no fork, no per-provider knowledge.
  • The fetch is an in-process auth proxy: it rewrites every header value and URL query value equal to the sentinel with the vault-served material, forwards, and observes the response. 401 → report_auth_failure with the record_version that was actually served, then the next account; 429/402 → cooldown, next; 403/5xx → returned as-is (not a credential verdict).
  • Ownership is a conjunction: tombstone in auth.json (absence of a local credential) AND serve: "opencode-claustrum" in the handle file (who owns the slot). The seven cells are explicit; a real key sitting behind an owned slot gets a refusing fetch, never either copy.
  • Multiple keys per provider: apikey:<provider>:<label>, handle-file order is failover order.
  • Redirects are followed manually and same-origin only; cross-origin redirects are refused so a substituted x-api-key never leaves the configured origin.

CLI (ck auth, admin gate, key never on argv): migrate-opencode [--dry-run|--replace|--restore <provider>] [--provider …]... [--serve-by …] and opencode-account add|remove|list. Idempotent nine-step transaction; the handle-file superseded journal makes a crash between tombstone and revoke converge on rerun. Compare/restore read material through the ordinary consumer credential.get with a handle the CLI minted — no new admin op, nothing secret-returning added to the admin surface.

Packages (bun workspace beside the cargo one; CI gets setup-bun): @cortexkit/claustrum-client — detect / identity / wire / errors extracted from anthropic-auth's soak-proven client, policy-free; @cortexkit/opencode-claustrum — the plugin. packages/opencode/golden/{tombstone,handles}.json are the single cross-language source; Rust pins them with include_str!, TS imports them, anthropic-auth vendors them by SHA (cortexkit/anthropic-auth#182).

Verification: scripts/gate.sh green (workspace floor 501 + 57 bun tests + two crash-seam arms; release binary is seam-free). scripts/accept-opencode-custody.sh ran against the live daemon on a scratch XDG home: migrate → real routed request served through the vault → hand-restored key refused as split custody (sentinel never sent) → --restore round-trips the key and revokes the handle; oauth:anthropic* and legacy apikey:* byte-identical throughout. Two defects only the live arm found (OpenCode treats every export of a plugin module as a plugin; "inject nothing" on the split cell let stock OpenCode serve the local key) are fixed with tests.

Out of scope, seams left: OAuth main slots (xai is proven to take the same fetch; anthropic stays with anthropic-auth by serve), Claude Code / Codex.

Design doc: docs/opencode-custody-design.md. Draft until the maintainer has had a look at the admin/CLI surface.


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

Moves supported OpenCode type:"api" credentials out of auth.json and into the vault. The auth entry becomes a non-secret tombstone, and @cortexkit/opencode-claustrum serves live credentials through an in-process fetch proxy; existing installations need migration, while unsupported provider shapes and split custody fail closed.

CLI and safety

  • Adds admin-gated ck auth migrate-opencode and ck auth opencode-account add|remove|list with dry runs, restore/replace, ordered failover, and crash-safe handle cleanup.
  • Mirrors OpenCode auth-source precedence, including OPENCODE_AUTH_CONTENT, and rejects tombstones as importable credentials.
  • Rejects environment, discovery, and metadata provider shapes by default, with ck auth usable warnings when a tombstoned shape changes.
  • Uses bounded descriptor reads, secure atomic writes, provider and handle validation, redacted errors, version-fenced 401 reporting, cooldowns, and same-origin redirects.

Verification

  • Adds @cortexkit/claustrum-client, Bun builds and tests, Rust CLI and crash-seam coverage, hermetic CI coverage on Windows, and live migration, serving, split-custody, and restore acceptance tests.
  • Anthropic OAuth entries remain owned by anthropic-auth; use ck auth migrate-opencode --restore <provider> to return a credential to auth.json and revoke its handle.

Written for commit 2c39e3d. Summary will update on new commits.

Review in cubic

@socket-security

socket-security Bot commented Sep 2, 2026

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Addednpm/​@​types/​bun@​1.3.141001004890100
Addednpm/​@​opencode-ai/​plugin@​1.18.251001007097100
Addednpm/​@​cortexkit/​subc-client@​0.8.18810010093100
Addednpm/​typescript@​7.0.29910089100100

View full report

@socket-security

socket-security Bot commented Sep 2, 2026

Copy link
Copy Markdown

Warning

Review the following alerts detected in dependencies.

According to your organization's Security Policy, it is recommended to resolve "Warn" alerts. Learn more about Socket for GitHub.

Action Severity Alert  (click "▶" to expand/collapse)
Warn High
Obfuscated code: npm json-schema is 90.0% likely obfuscated

Confidence: 0.90

Location: Package overview

From: packages/opencode/package.jsonnpm/@opencode-ai/plugin@1.18.25npm/json-schema@0.4.0

ℹ Read more on: This package | This alert | What is obfuscated code?

Next steps: Take a moment to review the security alert above. Review the linked package source code to understand the potential risk. Ensure the package is not malicious before proceeding. If you're unsure how to proceed, reach out to your security team or ask the Socket team for help at support@socket.dev.

Suggestion: Packages should not obfuscate their code. Consider not using packages with obfuscated code.

Mark the package as acceptable risk. To ignore this alert only in this pull request, reply with the comment @SocketSecurity ignore npm/json-schema@0.4.0. You can also ignore all packages with @SocketSecurity ignore-all. To ignore an alert for all future pull requests, use Socket's Dashboard to change the triage state of this alert.

View full report

@iceteaSA
iceteaSA force-pushed the feat/opencode-custody branch from f1f7f5b to abe93be Compare September 2, 2026 09:20
@iceteaSA
iceteaSA marked this pull request as ready for review September 2, 2026 09:20
iceteaSA added a commit to iceteaSA/anthropic-auth that referenced this pull request Sep 2, 2026
Bytes unchanged (verified IDENTICAL x2); the previous pin named a commit
off cortexkit/claustrum#28's history after its squash.

Also: biome check in the pre-commit hook errored when every staged path
was ignored, so any golden-only bump commit failed the hook. Pass
--no-errors-on-unmatched.

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

4 issues found across 56 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/plugin.ts">

<violation number="1" location="packages/opencode/src/plugin.ts:117">
P2: When a malformed auth source contains many sentinel-like strings, the recovery scan processes every hit and creates one refusal provider per hit without a cap. Bound the number of scan hits and use a fail-closed fallback for the capped case so corrupted input cannot exhaust OpenCode during configuration.</violation>
</file>

<file name="scripts/spikes/opencode-config-fetch.sh">

<violation number="1" location="scripts/spikes/opencode-config-fetch.sh:236">
P2: The wire assertion can pass without the sentinel in `Authorization`. Match records by their actual `headers.authorization` value, not by sentinel text anywhere in the serialized request.</violation>
</file>

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

<violation number="1" location="packages/opencode/src/freshness.ts:221">
P2: When `credential.get` returns `context_overflow`, this branch retries the unchanged OAuth `minTtlMs` after a 60-second backoff. Handle this class separately by reducing the request or making the slot unusable instead of retrying the same failing demand.</violation>
</file>

<file name="crates/credentials-module/tests/cli_admin.rs">

<violation number="1" location="crates/credentials-module/tests/cli_admin.rs:20">
P3: After moving tmp_root into this module, unique_temp_dir's doc comment still points at `cli_admin::tmp_root`, which no longer exists (it now lives in `common`). Update the reference to `tmp_root` so the cross-reference stays accurate.</violation>
</file>

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

Re-trigger cubic

Comment thread packages/client/src/detect.ts Outdated
Comment thread packages/opencode/src/freshness.ts
Comment thread packages/opencode/src/plugin.ts Outdated
Comment thread scripts/accept-opencode-custody.sh
Comment thread packages/opencode/src/serve.ts
Comment thread packages/opencode/package.json
Comment thread crates/credentials-module/tests/common/mod.rs Outdated
Comment thread packages/opencode/README.md Outdated
Comment thread packages/opencode/README.md Outdated
Comment thread scripts/accept-opencode-custody.sh Outdated
iceteaSA added a commit to iceteaSA/anthropic-auth that referenced this pull request Sep 2, 2026
Bytes unchanged (verified IDENTICAL x2); the previous pin named a commit
off cortexkit/claustrum#28's history after its squash.

Also: biome check in the pre-commit hook errored when every staged path
was ignored, so any golden-only bump commit failed the hook. Pass
--no-errors-on-unmatched.
@ckcred-alfonso

ckcred-alfonso Bot commented Sep 2, 2026

Copy link
Copy Markdown

Reviewed the admin/CLI surface, which is what you asked for. Verified the two structural claims at source rather than reading them, and found one gap in the handle lifecycle.

The two claims hold

"No new admin op, nothing secret-returning added to the admin surface." crates/credentials-core/src/admin_ops.rs is byte-identical to master across this PR — the diff is empty. The op vocabulary, the MAC transcript shape, and Gate 2 are untouched. That was the claim I most wanted to be true and least wanted to take on trust.

The 163 lines out of admin_client.rs are a transport extraction, not a gate change. The removed items are catalog_has_vault, route_open, control_request, route_request, read_control_response, read_route_response, read_matching, error_reason — plumbing, now shared in route_client.rs. Nothing matching mac|challenge|nonce|hmac|verify was deleted. A −163 in the file that holds the challenge-response is exactly the diff stat that deserves reading, and it reads clean.

Reading material back through the ordinary consumer credential.get with a CLI-minted handle is the right call. It keeps the admin surface non-secret-returning, which is the property that makes the admin plane safe to reason about.

The gap: a minted handle can outlive the operation that minted it

Both directions share a shape — mint, then persist, then use:

// restore
handle = mint_handle(global, &account.credential_id)?;
update_specific_handle(&mut handles, provider, &account.label, &handle)?;
write_and_verify_handles(&args.handle_file, &handles)?;
get_material(global, &handle)?

// migrate
None => mint_handle(global, &id)?,
if old_handle.is_none() {
    update_handle(&mut handles, ...)?;
    write_and_verify_handles(&args.handle_file, &handles)?;

If update_* or write_and_verify_handles returns Err, the ? propagates and the handle is already minted, live, and recorded in no file. Not a leak of material, but a live bearer capability for a credential, held by nobody and tracked by nothing the tool will read again.

The window is narrow — the mint has to succeed and a local file write has to fail — and it is not unrecoverable: the mint is in the audit chain, so ck auth audit finds it and ck auth revoke-all-handles <id> closes it. But nothing tells an operator to look, and a handle file that does not mention the handle is precisely where they would look first.

What makes it worth fixing rather than documenting is that your superseded journal already solves the adjacent case and cannot solve this one. It converges a handle that was replaced — recorded before the revoke, so a crash between the two reconciles on rerun. A handle minted before it reaches the file has no such record; the journal's own precondition is the thing that failed.

The cheapest shape that closes it is the journal's own: write the intent before the effect. Record the id in the handle file (or the superseded list) before calling mint_handle, so a rerun has something to reconcile against, or revoke on the error path before propagating. I would take either; the second is smaller and the first composes with what you already built.

Two smaller notes

ServedCredential carries payload: Vec<u8> and restore lifts it into a String for auth.json. That is the zeroize half of #29 arriving in new code rather than a defect in this PR, and I would rather it landed as its own change than got bolted on here — but it is worth knowing that the material now has a second uncleared home in CLI memory.

Your redacted Debug on ServedCredential is the convention done right, and it is what prompted me to check the rest: VaultRecord and AdminOpBody both derived Debug over secret material. Fixed on master at 0679dea, including one your issue did not name — AdminOpBody::RevokeHandle.handle is the raw ckh_ bearer, not a hash. Worth a rebase before you take this out of draft.

What I have not reviewed

The TypeScript half, the plugin's fetch proxy, and the 40 automated findings. Two of those findings look like real defects to me on their face — the ./server export pointing at the plugin entry rather than serve.js, and the lifecycle.test.ts byte-identical assertion that JSON.stringify makes vacuous by dropping function-valued properties. That second one is the shape I would want closed before merge regardless of my opinion of the rest: a test that cannot fail is worse than an absent one.

@iceteaSA
iceteaSA force-pushed the feat/opencode-custody branch from abe93be to f28e419 Compare September 2, 2026 10:53

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

10 issues found across 58 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/client/src/errors.ts">

<violation number="1" location="packages/client/src/errors.ts:1">
P2: Because `ERROR_CLASS_WIRE_SET` is a mutable exported array, a JavaScript consumer can alter error decoding and action selection at runtime. Freeze the array (or use a private immutable membership set) before using it for wire validation.</violation>
</file>

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

<violation number="1" location="packages/opencode/src/plugin.ts:367">
P1: When native LLM mode is enabled, this guard does not cover real-credential and other refusal paths, which only install a rejecting generic fetch. The native runtime bypasses that hook and can send the existing `options.apiKey`; apply the native-mode fail-closed handling before the tombstone split branch and ensure every refusal path blocks the native credential path.</violation>
</file>

<file name="scripts/spikes/opencode-config-fetch.sh">

<violation number="1" location="scripts/spikes/opencode-config-fetch.sh:9">
P2: When `OPENCODE_AUTH_CONTENT` is inherited, OpenCode ignores this fixture's `auth.json`, potentially consuming a real credential and making the assertions nondeterministic. Unset the auth-content override before launching OpenCode.</violation>

<violation number="2" location="scripts/spikes/opencode-config-fetch.sh:16">
P2: Deleting the directory returned by `mktemp` forfeits its ownership guarantee and creates a `/tmp` pathname race that can redirect the fixture's writes outside the temporary directory. Keep the directory created by `mktemp` and remove this second `rm -rf`.</violation>
</file>

<file name="packages/client/src/wire.ts">

<violation number="1" location="packages/client/src/wire.ts:74">
P2: When the handle is revoked or unknown, the daemon intentionally omits `record_version` from `credential.status`; this decoder rejects that valid response as `invalid_status`. Make `recordVersion` optional and validate it only when present.</violation>
</file>

<file name="scripts/accept-opencode-custody.sh">

<violation number="1" location="scripts/accept-opencode-custody.sh:53">
P2: When a post-migration check fails, cleanup preserves `$ROOT` containing plaintext API-key files for diagnostics. Scrub `$AUTH_FILE`, `$PRIVATE_ENTRY`, and other restored secret copies before retaining logs, or remove the scratch directory on failure.</violation>

<violation number="2" location="scripts/accept-opencode-custody.sh:56">
P2: When SIGINT or SIGTERM arrives between commands, `cleanup` can exit with the preceding zero status and report an interrupted acceptance as successful. Install signal traps that exit with 130/143 and let the EXIT trap perform cleanup.</violation>
</file>

<file name="scripts/gate.sh">

<violation number="1" location="scripts/gate.sh:125">
P2: The new `opencode-test-seam` arms in gate.sh have no counterparts in `.github/workflows/ci.yml`, violating this file's own invariant ("THE SET MUST MATCH CI... Every arm here corresponds to a step in .github/workflows/ci.yml; when a step is added there, add it here."). CI's "Clippy (conformance seams)" step still passes `--features kill9-test-seam,rotate-test-seam,login-test-seam,migration-tools` without `opencode-test-seam`, and no CI step runs the two new `run_expect 1` cli_opencode crash-cut tests — the `#[cfg(feature = "opencode-test-seam")]` tests at `crates/credentials-module/tests/cli_opencode.rs:1142` and `:1430` are compiled out of every CI cargo invocation. The seam-gated code in `opencode_migration.rs`/`opencode_accounts.rs` is therefore never compiled or linted in CI, and the tombstone-reread and handle-write crash-cut tests never run there; a regression in either would pass CI and only fail the local gate — the exact silent-divergence failure mode the gate header describes. Add `opencode-test-seam` to the CI clippy features and add CI steps mirroring the two `run_expect 1` arms (e.g. alongside the existing "Security-conformance suite" step).</violation>
</file>

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

<violation number="1" location="packages/opencode/src/handles.ts:161">
P1: A group-writable, non-sticky handle parent passes this check, so another group member can replace the capability file. Reject group or world-writable parents unless sticky-bit protection applies.</violation>
</file>

<file name="crates/credentials-core/src/oauth.rs">

<violation number="1" location="crates/credentials-core/src/oauth.rs:369">
P3: The second suggested command, `migrate-opencode --restore`, is not a valid invocation — the verb is `ck auth migrate-opencode --restore <provider>` (as written in this PR's operator-runbook change and design doc §5). An operator following the message would run a nonexistent command. Drop the bare form or write the full `ck auth migrate-opencode --restore <provider>`.</violation>
</file>

Requires human review: Auto-approval blocked because this review re-detected 2 unresolved issues already reported by Cubic.

Re-trigger cubic


// The native runtime reads `provider.options.apiKey` directly instead of this fetch
// seam. Its case-sensitive flag parser therefore gets an allowlist, not a best guess.
if (nativeLlmEnabled(process.env.OPENCODE_EXPERIMENTAL_NATIVE_LLM)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1: When native LLM mode is enabled, this guard does not cover real-credential and other refusal paths, which only install a rejecting generic fetch. The native runtime bypasses that hook and can send the existing options.apiKey; apply the native-mode fail-closed handling before the tombstone split branch and ensure every refusal path blocks the native credential path.

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 367:

<comment>When native LLM mode is enabled, this guard does not cover real-credential and other refusal paths, which only install a rejecting generic fetch. The native runtime bypasses that hook and can send the existing `options.apiKey`; apply the native-mode fail-closed handling before the tombstone split branch and ensure every refusal path blocks the native credential path.</comment>

<file context>
@@ -0,0 +1,434 @@
+
+          // The native runtime reads `provider.options.apiKey` directly instead of this fetch
+          // seam. Its case-sensitive flag parser therefore gets an allowlist, not a best guess.
+          if (nativeLlmEnabled(process.env.OPENCODE_EXPERIMENTAL_NATIVE_LLM)) {
+            const observed = process.env.OPENCODE_EXPERIMENTAL_NATIVE_LLM;
+            const refusal = new CustodyNativeRuntimeError(
</file context>

Comment thread packages/opencode/src/freshness.ts Outdated
Comment on lines +161 to +162
if ((parent.mode & 0o002) !== 0 && (parent.mode & 0o1000) === 0) {
invalid("handle file parent is world-writable without sticky bit");

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1: A group-writable, non-sticky handle parent passes this check, so another group member can replace the capability file. Reject group or world-writable parents unless sticky-bit protection applies.

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

<comment>A group-writable, non-sticky handle parent passes this check, so another group member can replace the capability file. Reject group or world-writable parents unless sticky-bit protection applies.</comment>

<file context>
@@ -0,0 +1,196 @@
+    if (expectedUid !== undefined && parent.uid !== undefined && parent.uid !== expectedUid) {
+      invalid("handle file parent is not owned by the current uid");
+    }
+    if ((parent.mode & 0o002) !== 0 && (parent.mode & 0o1000) === 0) {
+      invalid("handle file parent is world-writable without sticky bit");
+    }
</file context>
Suggested change
if ((parent.mode & 0o002) !== 0 && (parent.mode & 0o1000) === 0) {
invalid("handle file parent is world-writable without sticky bit");
if ((parent.mode & 0o022) !== 0 && (parent.mode & 0o1000) === 0) {
invalid("handle file parent is group/world-writable without sticky bit");

@@ -0,0 +1,75 @@
export const ERROR_CLASS_WIRE_SET = [

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: Because ERROR_CLASS_WIRE_SET is a mutable exported array, a JavaScript consumer can alter error decoding and action selection at runtime. Freeze the array (or use a private immutable membership set) before using it for wire validation.

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

<comment>Because `ERROR_CLASS_WIRE_SET` is a mutable exported array, a JavaScript consumer can alter error decoding and action selection at runtime. Freeze the array (or use a private immutable membership set) before using it for wire validation.</comment>

<file context>
@@ -0,0 +1,75 @@
+export const ERROR_CLASS_WIRE_SET = [
+  'transient',
+  'permanent',
</file context>

Comment thread packages/client/src/identity.ts Outdated
Comment thread packages/client/src/tests/client.test.ts Outdated
Comment thread packages/client/README.md Outdated
Comment thread packages/opencode/src/request.ts Outdated
Comment thread packages/client/src/detect.ts
Comment thread crates/credentials-core/src/oauth.rs Outdated
}
ImportError::CustodyTombstone => write!(
f,
"refusing Claustrum tombstone material; run ck auth migrate-opencode or migrate-opencode --restore"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3: The second suggested command, migrate-opencode --restore, is not a valid invocation — the verb is ck auth migrate-opencode --restore <provider> (as written in this PR's operator-runbook change and design doc §5). An operator following the message would run a nonexistent command. Drop the bare form or write the full ck auth migrate-opencode --restore <provider>.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At crates/credentials-core/src/oauth.rs, line 369:

<comment>The second suggested command, `migrate-opencode --restore`, is not a valid invocation — the verb is `ck auth migrate-opencode --restore <provider>` (as written in this PR's operator-runbook change and design doc §5). An operator following the message would run a nonexistent command. Drop the bare form or write the full `ck auth migrate-opencode --restore <provider>`.</comment>

<file context>
@@ -350,6 +364,10 @@ impl std::fmt::Display for ImportError {
             }
+            ImportError::CustodyTombstone => write!(
+                f,
+                "refusing Claustrum tombstone material; run ck auth migrate-opencode or migrate-opencode --restore"
+            ),
         }
</file context>
Suggested change
"refusing Claustrum tombstone material; run ck auth migrate-opencode or migrate-opencode --restore"
"refusing Claustrum tombstone material; run ck auth migrate-opencode or ck auth migrate-opencode --restore <provider>"

iceteaSA added a commit to iceteaSA/anthropic-auth that referenced this pull request Sep 2, 2026
Bytes unchanged (verified IDENTICAL x2); the previous pin named a commit
off cortexkit/claustrum#28's history after its squash.

Also: biome check in the pre-commit hook errored when every staged path
was ignored, so any golden-only bump commit failed the hook. Pass
--no-errors-on-unmatched.
@iceteaSA
iceteaSA force-pushed the feat/opencode-custody branch from f28e419 to 898b131 Compare September 2, 2026 11:50
@iceteaSA

iceteaSA commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks for reading the admin surface at that level — the −163 in admin_client.rs was the diff stat I most wanted someone to open.

Handle lifecycle — fixed, 898b131. You were right that the superseded journal cannot cover it; its precondition is the thing that fails. I took the smaller shape: two helpers in opencode_migration.rs, and no bare mint_handle call remains outside them (grep -n 'mint_handle(' src/bin/cli_support/*.rs shows the definition and the two helpers' internals only).

  • mint_then_persist(global, id, |handle| …) — mints, runs the persist closure (which returns only after write_and_verify_handles succeeded), and revokes the handle if the closure fails. Used at the four migrate/restore sites and opencode-account add.
  • with_scoped_handle(global, id, |handle| …) — for the comparison-only handle add mints to check an existing record's material; revoked on every exit (success, mismatch, read failure). That site was a sixth instance of the same shape, one your grep and my first pass both stopped short of.
  • If the revoke itself fails, the propagated error names the credential id and the two closing commands (ck auth audit, ck auth revoke-all-handles <id>) — never silent. add's previous let _ = revoke_handle(...) is gone.

Tests under the opencode-test-seam feature (compiles nothing into release; strings count 0 after the gate's default rebuild): a seam-injected write failure at each site → non-zero exit, zero live handles for the id in the scratch store, mint_handle then revoke_handle in the chain; a seam-injected revoke failure → stderr carries the id and both remedies. Mutation: removing the revoke reddens the write-failure tests; removing the remedy text reddens the revoke-failure test. A SIGKILL between mint and write is out of scope for these — the crash-cut suites and ck auth audit cover that, and the helper's doc says so.

Rebased onto 0679dea. Thanks for closing RevokeHandle.handle too — the raw bearer one, which #29 had not named.

The two bot findings you flagged (./server export → serve.js; the lifecycle.test.ts byte-identical assertion made vacuous by JSON.stringify dropping the injected fetch) were fixed in f28e419 along with the rest of that review — 23 fixed, 12 nits, 3 declined by design with the reason in the commit body, 2 stale. The lifecycle test now asserts the injected fetch refuses.

Zeroize — agreed it should land as its own change against #29 rather than here; restore lifting the payload into a String for auth.json is a second uncleared home for the material in CLI memory, and I'd rather that be one deliberate PR than a rider on this one.

Branch is one commit on top of master; the review-round history is on legion-works:backup/opencode-custody-presquash{,2,3} if you want the per-round diffs.

@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 existing issues remain and 2 new issues found across 58 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/tombstone.ts">

<violation number="1" location="packages/opencode/src/tombstone.ts:66">
P2: When a legitimate API key begins with `claustrum-tombstone:v1:`, `carriesSentinel` classifies it as custody material and the plugin refuses the provider even without a handle. Reserve and reject this prefix during migration, or use an unambiguous sentinel encoding.</violation>
</file>

<file name="packages/opencode/package.json">

<violation number="1" location="packages/opencode/package.json:23">
P2: OpenCode typecheck fails on a fresh checkout because it resolves `@cortexkit/claustrum-client` types from the client package's `dist/index.d.ts`, but CI runs typecheck before build and `dist` is gitignored and never committed. Build the client before typechecking opencode (e.g. reorder CI to build before typecheck, or have the root typecheck build the client's dist first), otherwise every clean CI run errors on TS2307 for the import in `plugin.ts`.</violation>
</file>

Requires human review: Auto-approval blocked because this review re-detected 11 unresolved issues already reported by Cubic.
Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread packages/client/src/detect.ts Outdated
}

function hasSentinel(value: unknown): boolean {
return typeof value === "string" && value.startsWith(TOMBSTONE_PREFIX);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: When a legitimate API key begins with claustrum-tombstone:v1:, carriesSentinel classifies it as custody material and the plugin refuses the provider even without a handle. Reserve and reject this prefix during migration, or use an unambiguous sentinel encoding.

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

<comment>When a legitimate API key begins with `claustrum-tombstone:v1:`, `carriesSentinel` classifies it as custody material and the plugin refuses the provider even without a handle. Reserve and reject this prefix during migration, or use an unambiguous sentinel encoding.</comment>

<file context>
@@ -0,0 +1,98 @@
+}
+
+function hasSentinel(value: unknown): boolean {
+  return typeof value === "string" && value.startsWith(TOMBSTONE_PREFIX);
+}
+
</file context>

Comment thread packages/opencode/src/freshness.ts Outdated
Comment thread packages/opencode/src/plugin.ts Outdated
Comment thread packages/opencode/src/serve.ts Outdated
Comment thread packages/opencode/src/freshness.ts Outdated
Comment thread docs/opencode-custody-design.md Outdated
Comment thread crates/credentials-module/tests/cli_opencode.rs Outdated
Comment thread packages/opencode/src/tests/serve.test.ts Outdated
Comment thread scripts/gate.sh Outdated
iceteaSA added a commit to iceteaSA/anthropic-auth that referenced this pull request Sep 2, 2026
Bytes unchanged (verified IDENTICAL x2); the previous pin named a commit
off cortexkit/claustrum#28's history after its squash.

Also: biome check in the pre-commit hook errored when every staged path
was ignored, so any golden-only bump commit failed the hook. Pass
--no-errors-on-unmatched.
@iceteaSA
iceteaSA force-pushed the feat/opencode-custody branch from 898b131 to f2e65bb Compare September 2, 2026 13:30

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

5 existing issues remain and 5 new issues found across 58 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/client/src/secret-json.ts">

<violation number="1" location="packages/client/src/secret-json.ts:1">
P3: This new file is dead code: nothing imports `parseSecretJson` or `ConnectionJsonParseError`, and `index.ts` never re-exports them, so package consumers cannot reach it either. The client's connection-file parsing lives in `detect.ts` via `readConnectionFile`, not here. It also duplicates `packages/opencode/src/secret-json.ts`, the copy actually used by `handles.ts`/`plugin.ts`. Remove the file or wire it into the client and re-export it.</violation>
</file>

<file name="packages/client/src/identity.ts">

<violation number="1" location="packages/client/src/identity.ts:7">
P2: When `storagePath` contains `..` after a symlink, `resolve` collapses the path before `realpathSync`, so `storageFingerprint` hashes a different location than the storage path the filesystem opens. Canonicalize the original path with symlink-aware resolution, including an equivalent fallback for missing paths, rather than normalizing dot segments first.</violation>
</file>

<file name="packages/client/package.json">

<violation number="1" location="packages/client/package.json:6">
P2: This package is published as `"type": "module"` with an ESM-only `exports.import` target, but the tsc build (tsconfig.base.json uses `moduleResolution: "Bundler"`, `module: "ESNext"`) emits extensionless relative imports in dist (e.g. `export { ... } from './detect'` in dist/index.js). Node's ESM loader requires explicit `.js` extensions in relative imports, so any Node ESM consumer of the published `@cortexkit/claustrum-client` will fail with ERR_MODULE_NOT_FOUND. The in-repo consumer is bun (opencode bundles via `bun build`), which resolves extensionless imports, so the current flow works — but the published artifact is not Node-compatible. Either add `.js` extensions to the source relative imports or bundle the client before publishing.</violation>
</file>

<file name="packages/opencode/package.json">

<violation number="1" location="packages/opencode/package.json:22">
P2: The plugin bundle that OpenCode actually loads (`dist/opencode-plugin.js`, produced by `bun build` overwriting the tsc output) is never imported by any test; all tests import from `src/`. The gate only proves the bundle compiles, not that it runs, so a bundling/runtime failure (e.g. how `@cortexkit/claustrum-client` or its `@cortexkit/subc-client` dependency gets inlined) ships green. The same source is also emitted two ways — the bundled plugin versus the plain tsc `./server` and `"."` entries — and only the non-bundled path is tested. Add a test that dynamically imports the built `dist/opencode-plugin.js` (as `lifecycle.test.ts` does for `../opencode-plugin`) so the shipped artifact is exercised.</violation>
</file>

<file name="packages/opencode/README.md">

<violation number="1" location="packages/opencode/README.md:62">
P3: The maintenance census is already stale at this commit. Running the documented `grep -cE '^\s*(catch|} catch)|^\s*return[; ]|^\s*continue;|^\s*if \(' src/plugin.ts` returns 68, but the table's latest row records 64. The README states "A changed count without a matching sweep row is a review failure, not harmless churn," so this drift should be recorded as a new sweep row (68/27) rather than left mismatched.</violation>
</file>

Requires human review: Auto-approval blocked because this review re-detected 5 unresolved issues already reported by Cubic.
Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread packages/opencode/src/freshness.ts Outdated
Comment thread packages/client/src/wire.ts Outdated
Comment thread packages/client/src/identity.ts Outdated
import type { BindIdentity } from '@cortexkit/subc-client'

export function storageFingerprint(storagePath: string): string {
const absolutePath = resolve(storagePath)

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 storagePath contains .. after a symlink, resolve collapses the path before realpathSync, so storageFingerprint hashes a different location than the storage path the filesystem opens. Canonicalize the original path with symlink-aware resolution, including an equivalent fallback for missing paths, rather than normalizing dot segments first.

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

<comment>When `storagePath` contains `..` after a symlink, `resolve` collapses the path before `realpathSync`, so `storageFingerprint` hashes a different location than the storage path the filesystem opens. Canonicalize the original path with symlink-aware resolution, including an equivalent fallback for missing paths, rather than normalizing dot segments first.</comment>

<file context>
@@ -0,0 +1,30 @@
+import type { BindIdentity } from '@cortexkit/subc-client'
+
+export function storageFingerprint(storagePath: string): string {
+  const absolutePath = resolve(storagePath)
+  let canonicalPath: string
+  try {
</file context>

Comment thread packages/client/src/detect.ts Outdated
Comment thread scripts/spikes/opencode-config-fetch.sh
Comment thread docs/opencode-custody-design.md
Comment thread packages/opencode/README.md Outdated
| `eb034af` | 47/22 | 26/17 |
| `57ce561` | 58/22 | 26/17 |
| closing-wave worktree | 65/27 | 26/17 |
| custody review triage | 64 | 27 |

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3: The maintenance census is already stale at this commit. Running the documented grep -cE '^\s*(catch|} catch)|^\s*return[; ]|^\s*continue;|^\s*if \(' src/plugin.ts returns 68, but the table's latest row records 64. The README states "A changed count without a matching sweep row is a review failure, not harmless churn," so this drift should be recorded as a new sweep row (68/27) rather than left mismatched.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/opencode/README.md, line 62:

<comment>The maintenance census is already stale at this commit. Running the documented `grep -cE '^\s*(catch|} catch)|^\s*return[; ]|^\s*continue;|^\s*if \(' src/plugin.ts` returns 68, but the table's latest row records 64. The README states "A changed count without a matching sweep row is a review failure, not harmless churn," so this drift should be recorded as a new sweep row (68/27) rather than left mismatched.</comment>

<file context>
@@ -0,0 +1,67 @@
+| `eb034af` | 47/22 | 26/17 |
+| `57ce561` | 58/22 | 26/17 |
+| closing-wave worktree | 65/27 | 26/17 |
+| custody review triage | 64 | 27 |
+
+A changed count without a matching sweep row is a review failure, not harmless churn.
</file context>

Comment thread crates/credentials-module/tests/cli_opencode.rs Outdated
Comment thread packages/opencode/src/tests/lifecycle.test.ts
Comment thread packages/opencode/src/handles.ts
@iceteaSA
iceteaSA force-pushed the feat/opencode-custody branch from f2e65bb to 98b46d4 Compare September 2, 2026 14:58

@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 existing issues remain and 5 new issues found across 59 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="scripts/spikes/opencode-config-fetch.sh">

<violation number="1" location="scripts/spikes/opencode-config-fetch.sh:258">
P2: This spike can pass without the custom fetch owning the inference request: it only proves that the wrapper handled some request and that another request carried the sentinel. Record the request path or an ID and require the wrapper observation to correspond to `/chat/completions` or `/responses` before reporting success.</violation>
</file>

<file name=".github/workflows/ci.yml">

<violation number="1" location=".github/workflows/ci.yml:134">
P2: The Windows branch is not platform-agnostic: `bun test packages/client` runs a test that creates a symbolic link without handling Windows link privileges, so this CI leg can fail before completing. Exclude that test on Windows or make the fixture use a Windows-supported link strategy.</violation>
</file>

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

<violation number="1" location="packages/opencode/src/serve.ts:141">
P2: When a handle file is replaced during an in-flight request, this closure can send material from the old handle after ownership has changed. `verifyOwnership` runs before the asynchronous freshness lookup, and revision changes do not abort the old account; revalidate ownership immediately before every upstream forward or make a revision change invalidate the in-flight attempt.</violation>
</file>

<file name="scripts/gate.sh">

<violation number="1" location="scripts/gate.sh:119">
P2: On Windows, this arm runs the Unix-only OpenCode tests that CI intentionally excludes, so the gate cannot pass on a supported platform. Select the same platform-agnostic test subset on Windows and keep `test:hermetic` for Unix hosts.</violation>
</file>

<file name="crates/credentials-module/tests/cli_opencode.rs">

<violation number="1" location="crates/credentials-module/tests/cli_opencode.rs:44">
P2: The fake-daemon threads only poll the shutdown channel inside the `listener.accept()` select, so a daemon blocked in `read_frame` on an accepted connection cannot be interrupted by `TestDaemon::drop`'s `join()`. If a CLI invocation ever keeps a connection open without sending the expected frame, the test binary hangs instead of failing. Wrap the per-frame reads in a `select!` against the shutdown channel (or add a timeout) so teardown can always terminate the thread.</violation>
</file>

Requires human review: Auto-approval blocked because this review re-detected 3 unresolved issues already reported by Cubic.
Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread packages/client/src/detect.ts Outdated
Comment thread scripts/accept-opencode-custody.sh Outdated
Comment thread packages/client/src/detect.ts Outdated
Comment thread packages/opencode/src/freshness.ts Outdated
Comment thread packages/client/package.json
Comment thread package.json Outdated
Comment thread docs/opencode-custody-design.md Outdated
Comment thread packages/opencode/src/tests/lifecycle.test.ts Outdated
Comment thread packages/opencode/src/tests/freshness.test.ts Outdated
Comment thread packages/opencode/src/tests/config-hook.test.ts
@iceteaSA
iceteaSA force-pushed the feat/opencode-custody branch from 98b46d4 to 86ce2a0 Compare September 2, 2026 16:13

@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 existing issues remain and 6 new issues found across 60 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/handles.ts">

<violation number="1" location="packages/opencode/src/handles.ts:211">
P3: This throw is unreachable because `readHandleSnapshot` returns or throws on every path through the preceding `try`; remove the dead code.</violation>
</file>

<file name="tsconfig.base.json">

<violation number="1" location="tsconfig.base.json:12">
P3: The shared base restricts auto-included global types to "bun" only, which excludes @types/node. The client package is a published, plain-Node-compatible library that imports node: built-ins and relies on Node globals, and opencode uses the NodeJS namespace; tying both to Bun's global types through the shared base bakes a bun-only type environment into a general-purpose library and hides any node-global the bun types don't cover. Since @types/bun is the only @types package in scope, dropping the types array from the shared base keeps bun globals auto-included without blocking node types.</violation>
</file>

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

<violation number="1" location="packages/opencode/src/serve.ts:273">
P2: A legitimate same-origin redirect chain longer than six hops is rejected, even though no origin boundary was crossed. Use a documented standard redirect limit and a distinct max-redirect error so valid chains and diagnostics are not conflated.</violation>
</file>

<file name="scripts/gate.sh">

<violation number="1" location="scripts/gate.sh:306">
P2: The release build is never used by the subsequent `validation_bypass_is_absent` test, which therefore checks the debug test binary instead of the shipped artifact. Export `CRED_CLI_BIN` to the release binary before running that assertion.</violation>
</file>

<file name="packages/opencode/package.json">

<violation number="1" location="packages/opencode/package.json:7">
P3: The "exports" map omits a "./package.json" subpath, so the manifest can no longer be resolved by subpath once exports is present. Add an entry like "./package.json": "./package.json" so tooling that reads the package manifest keeps working.</violation>
</file>

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

<violation number="1" location="packages/client/src/tests/client.test.ts:190">
P2: This discovery test is not isolated and can fail or give false coverage on any machine that matches the project's own runtime state. getDefaultClaustrumConnectionPath() resolves the production-home branch through userInfo().homedir (getpwuid), so setting process.env.HOME = homeDir has no effect there: if the real home contains ~/.local/share/cortexkit/run/subc-connection.json, or if any other `subc-*.connection.json` exists in the shared OS tmpdir (e.g. a live daemon, exactly what this daemon writes), matches.length > 1 makes the glob return undefined and the test fails. The HOME override also means the production-home fallback the test claims to mirror is never actually exercised. Isolate the test by pointing XDG_RUNTIME_DIR and the home lookup at controlled dirs and cleaning the temp-glob scope, or scope the assertion to the controlled inputs rather than the shared tmpdir.</violation>
</file>

Requires human review: Auto-approval blocked because this review re-detected 2 unresolved issues already reported by Cubic.
Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread packages/opencode/src/freshness.ts
Comment thread packages/client/src/detect.ts Outdated
Comment thread packages/opencode/src/plugin.ts
Comment thread packages/opencode/src/log.ts Outdated
options.log?.error({ provider: options.provider, errorClass: refusal.name, errorMessage: refusal.message });
throw refusal;
}
if (hop === 5) {

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 legitimate same-origin redirect chain longer than six hops is rejected, even though no origin boundary was crossed. Use a documented standard redirect limit and a distinct max-redirect error so valid chains and diagnostics are not conflated.

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

<comment>A legitimate same-origin redirect chain longer than six hops is rejected, even though no origin boundary was crossed. Use a documented standard redirect limit and a distinct max-redirect error so valid chains and diagnostics are not conflated.</comment>

<file context>
@@ -0,0 +1,294 @@
+          options.log?.error({ provider: options.provider, errorClass: refusal.name, errorMessage: refusal.message });
+          throw refusal;
+        }
+        if (hop === 5) {
+          await discard(response);
+          const refusal = new CustodyRedirectRefusedError(options.provider, fromOrigin, next.origin);
</file context>

"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3: The "exports" map omits a "./package.json" subpath, so the manifest can no longer be resolved by subpath once exports is present. Add an entry like "./package.json": "./package.json" so tooling that reads the package manifest keeps working.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/opencode/package.json, line 7:

<comment>The "exports" map omits a "./package.json" subpath, so the manifest can no longer be resolved by subpath once exports is present. Add an entry like "./package.json": "./package.json" so tooling that reads the package manifest keeps working.</comment>

<file context>
@@ -0,0 +1,31 @@
+  "type": "module",
+  "main": "./dist/index.js",
+  "types": "./dist/index.d.ts",
+  "exports": {
+    ".": {
+      "types": "./dist/index.d.ts",
</file context>

Comment thread tsconfig.base.json
"skipLibCheck": true,
"resolveJsonModule": true,
"declaration": true,
"types": ["bun"]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3: The shared base restricts auto-included global types to "bun" only, which excludes @types/node. The client package is a published, plain-Node-compatible library that imports node: built-ins and relies on Node globals, and opencode uses the NodeJS namespace; tying both to Bun's global types through the shared base bakes a bun-only type environment into a general-purpose library and hides any node-global the bun types don't cover. Since @types/bun is the only @types package in scope, dropping the types array from the shared base keeps bun globals auto-included without blocking node types.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At tsconfig.base.json, line 12:

<comment>The shared base restricts auto-included global types to "bun" only, which excludes @types/node. The client package is a published, plain-Node-compatible library that imports node: built-ins and relies on Node globals, and opencode uses the NodeJS namespace; tying both to Bun's global types through the shared base bakes a bun-only type environment into a general-purpose library and hides any node-global the bun types don't cover. Since @types/bun is the only @types package in scope, dropping the types array from the shared base keeps bun globals auto-included without blocking node types.</comment>

<file context>
@@ -0,0 +1,14 @@
+    "skipLibCheck": true,
+    "resolveJsonModule": true,
+    "declaration": true,
+    "types": ["bun"]
+  }
+}
</file context>

Comment thread packages/opencode/src/tests/lifecycle.test.ts Outdated
Comment thread crates/credentials-module/tests/cli_opencode.rs Outdated
Comment thread crates/credentials-module/tests/common/mod.rs Outdated
@iceteaSA
iceteaSA force-pushed the feat/opencode-custody branch from 86ce2a0 to 0e9dee7 Compare September 2, 2026 17:27
@ckcred-alfonso

ckcred-alfonso Bot commented Sep 2, 2026

Copy link
Copy Markdown

Verified the fix at 898b131 rather than reading the description. Both helpers are right, and the sixth site you found is the better catch — my review stopped at the four I could see from the failure path, and add's comparison-only handle was the one that never appeared in a diff I was reading.

mint_handle( call sites   definition + the two helpers, nothing else
mint_then_persist         revokes on persist failure; keeps the handle on success,
                          which is correct — that one is meant to live in the file
with_scoped_handle        revokes on BOTH arms, and propagates a revoke failure
                          on the success path rather than swallowing it
let _ = revoke_handle     zero hits

The two-helper split is the part I would have got wrong if I had written it myself: one keeps the handle on success and one never does, and collapsing them into a single "always revoke" helper would have broken the migrate path silently.

One gap, and it is in a guard of mine rather than in your fix

The seam is a cargo feature, and this repo's standing rule for test escape hatches is #[cfg(debug_assertions)] precisely because a feature can be switched on in a release build by a Cargo.toml edit nobody reviews as security-relevant. That rule exists because of CORTEXKIT_TEST_BYPASS_VALIDATION, and it came with a release-binary scan asserting the hatch is absent, positive control included.

Your strings count is the right check. It was run by hand once. The automated version already exists and does not know about you:

seam env vars in the new code    CK_OPENCODE_TEST_FAIL_GET_MATERIAL
                                 CK_OPENCODE_TEST_FAIL_HANDLE_WRITE
                                 CK_OPENCODE_TEST_FAIL_REVOKE
                                 CK_OPENCODE_TEST_FAIL_TOMBSTONE_REREAD
strings the release scan asserts CORTEXKIT_TEST_BYPASS_VALIDATION
absent from the release binary

Four hatches, one asserted. The scan is honest about its own subject and blind to everything added after it was written, which is the failure mode I have been chasing across this repo all week: a guard whose population is hardcoded stops being a guard for whatever arrives next, and reads as covering it.

Adding the four strings to that test closes it and is the smallest correct change. What I would rather have — and would take as a follow-up rather than a condition — is the population derived: scan the source for CK_*_TEST_* env reads and assert each is absent from the release binary, so a fifth hatch arms the guard without anyone remembering to. That has the known limit of any source scan (it sees the literal form and not a name built another way), but it fails in the safe direction: it cannot be worse than a hardcoded list of one, and the positive control still proves the scan can see.

Not blocking on the derived version. Blocking on the four strings, because the alternative is that the next hatch inherits a green check that never looked at it.

Closed since my first pass

The lifecycle.test.ts byte-identical assertion is gone — the orphan case now asserts rejects.toThrow("migrate-opencode"), which is a claim JSON.stringify cannot silently satisfy. That was the one thing I wanted closed regardless of the rest, and it closed without my asking.

(Re-checked at 0e9dee7. The branch has moved four times while this sat in my queue; I re-ran the seam-string comparison at each head rather than let the finding age, and it reads the same at this one.)

@iceteaSA

iceteaSA commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator Author

Live on the deploy box — 2026-09-02 18:45–18:59Z

Ran the operator procedure from this branch (0e9dee7) against the real daemon and the real ~/.local/share/opencode/auth.json on OpenCode 1.18.26. No secrets below; handles and keys were never printed.

Migration (ck-auth migrate-opencode --provider …, one provider at a time, dry run first):

provider credential verdict chain rows
synthetic apikey:synthetic:main v1 identical (record pre-existed from the T9 acceptance run; material matched byte-for-byte) seq 896 mint_handle
deepseek apikey:deepseek:main created seq 897 import, 898 mint_handle
minimax-coding-plan apikey:minimax-coding-plan:main created seq 899 import, 900 mint_handle

After: the three api entries in auth.json are claustrum-tombstone:v1:<provider>; the four oauth entries are byte-unchanged (per-field hashes compared against the pre-migration backup); file mode 600 preserved. Handle file created at mode 600 with serve: "opencode-claustrum", one 47-char handle per provider. Dry run wrote nothing (sha unchanged, no handle file).

Proof — plugin registered from packages/opencode/dist/opencode-plugin.js, one session restarted, then:

opencode run -m synthetic/hf:moonshotai/Kimi-K3     'Reply exactly CUSTODY_MIGRATION_OK.'  → CUSTODY_MIGRATION_OK
opencode run -m deepseek/deepseek-v4-flash          'Reply exactly CUSTODY_MIGRATION_OK.'  → CUSTODY_MIGRATION_OK
opencode run -m minimax-coding-plan/MiniMax-M3      'Reply exactly CUSTODY_MIGRATION_OK.'  → CUSTODY_MIGRATION_OK

with auth.json holding only the sentinels at request time (checked after each call). Vault side after the three requests and a fleet-wide restart of every OpenCode session: chain tip still 900 (successful gets write nothing), 0 auth_events, 0 fetch-anomaly alarms, verify-audit intact, oauth:anthropic / oauth:anthropic:work-alt untouched.

Two things learned that are not in the branch:

  1. migrate-opencode re-serializes auth.json with sorted keys. Any order-sensitive digest of another provider's entry (jq -c) sees a phantom change; a key-sorted digest (jq -cS) does not. Worth a line in the operator doc; a writer that preserves key order would remove the effect entirely.
  2. The doc's proof example uses deepseek/deepseek-chat, which no longer exists in the model list (deepseek-v4-flash works). Doc-only.

Sessions started before the migration keep working on their in-memory key until restarted; Auth.set writes from other plugins read the file fresh and did not clobber the tombstones (observed across the anthropic-auth fleet restart at 18:40Z).

@iceteaSA

iceteaSA commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator Author

Correction to the comment above — two of the three migrations were reversed at 19:10Z.

The migration premise "model traffic is unaffected; only tok's balance/quota readers see the tombstone" was wrong on this box: the insula quota module also reads static API keys directly from auth.json (deepseek.rs:180, synthetic.rs:526opencode_auth::read_provider) and sent the tombstone as its bearer → 401 on its deepseek and synthetic quota lanes from the moment of the write. Model traffic through OpenCode was fine throughout (the three proofs stand). minimax-coding-plan is unaffected (insula's minimax lane is env-only) and stays under custody.

migrate-opencode --restore deepseek / --restore synthetic (the branch's own rollback verb) wrote the real keys back — hash-identical to the pre-migration backup — revoked the two serving handles (seq 901, 902), kept the vault records, mode 600 preserved. First live exercise of --restore; it did exactly what the doc says.

Doc consequence for the operator procedure: the precondition step must enumerate every reader of auth.json on the host, not just OpenCode and tok — anything that calls read_provider on a static key becomes a tombstone consumer. On this host that is insula until it gains an apikey:* vault lane (its issue to file).

…h proxy

OpenCode keeps provider API keys in auth.json and reads them per request.
This moves custody of those keys into the vault: the key is replaced on
disk by a non-secret tombstone (`claustrum-tombstone:v1:<provider>`), a
capability handle lives in a mode-600 handle file, and a plugin on
OpenCode's `config` hook injects `{apiKey: <tombstone>, fetch}` so the
request-time closure substitutes the served credential wherever the SDK
placed the sentinel. The plugin holds zero provider knowledge; ownership
is the conjunction of a tombstone in auth.json and a `serve` claim in the
handle file.

Rust (ck-auth):
- `migrate-opencode` (dry-run, `--provider`, `--restore`, `--replace`,
  `--force-shape`) and `opencode-account add|remove|list`, online against
  the daemon or offline on the lease; every write is temp+fsync+rename at
  mode 0600 in a checked parent; a `superseded` journal on the handle
  file makes a crash between tombstone write and revoke converge without
  rotating handles.
- Shared `route_client.rs` transport and a capability-only
  `credential_client.rs`; no new admin op and no secret-returning surface.
- Rust handle-file validation mirrors the TS parser rule for rule
  (provider/label charset, `ckh_` base64url handles, `superseded`
  entries); provider ids never `__proto__`/`constructor`/`prototype`.
- `opencode-provider-shapes.json`: providers whose key leaves OpenCode's
  fetch seam (env copy, discovery, metadata) are refused at migrate time
  with the shape, the reason, the source citation, and the consequence of
  forcing; `ck auth usable` warns on an existing tombstone whose shape
  moved. Data with provenance (anomalyco/opencode@dc4449df0d, method and
  its edge stated), maintained by delta per OpenCode base update.
- Vault import refuses a tombstone as credential material.
- `opencode-test-seam` feature (two env seams) for crash-cut tests;
  compiles nothing into release, and gate.sh ends on a default build.

TypeScript:
- `@cortexkit/claustrum-client`: detect, wire, identity, reconnect and
  error classes extracted from anthropic-auth; policy-free.
- `@cortexkit/claustrum-opencode`: seven-cell ownership table (split
  custody installs a REFUSING fetch; orphan injects nothing), ordered
  per-account failover with 401 reporting fenced on the served
  record_version, 429/402 cooldowns, manual same-origin-only redirects,
  bounded warm and a 60 s oauth tick for idle-account custody, redacted
  logging with canary tests, and a lifecycle suite driven through the
  exported plugin (`dist/opencode-plugin.js`, v1 `{id, server}` shape).
- Fail-closed on every path that sees a tombstone: unreadable or oversized
  files, unrecognised native-runtime flag values, absent handle file.
  auth.json past the parse cap is scanned for sentinels with each hit
  becoming a refusal directly. `readAuth` mirrors `Auth.all` precedence
  including its error behaviour.
- A containment property test asserts the plugin's refusal set is a
  superset of every provider OpenCode would load with a sentinel in it,
  over auth-source × handle-state rows, against a reference model of the
  host derived from its source (not from the plugin) and proven so by a
  two-sided mutation.

Gates: bash scripts/gate.sh with bun arms (install, typecheck, build,
test) ahead of the cargo arms; workspace floor measured in the profile
the gate runs. Live acceptance in scripts/accept-opencode-custody.sh:
migrate, serve a real model call through the vault, refuse a hand-restored
key as split custody, restore — against the running daemon in a scratch
XDG home.

Review response folded in: provider ids are validated where they are
MATERIALIZED (one function at every cfg.provider write; auth.json keys
were a third, unvalidated site); parse errors on secret-bearing files
(auth.json, the handle file, the daemon connection file) never echo
parser text — Bun's SyntaxError quotes the input token, which put a
handle verbatim into a thrown message; catch sites log a fixed code and
the error name, never the message; the handle file is read once through
an O_NOFOLLOW descriptor, fstat-validated, revision from the same bytes;
query substitution percent-encodes the material and leaves untouched
parameters byte-identical; 303-to-GET drops the RFC 9110 representation
headers; connection-file discovery mirrors the daemon's order; the
acceptance script arms rollback before migrating.

Handle lifecycle (maintainer finding): a minted handle never outlives
the operation that minted it. `mint_then_persist` revokes the handle if
its file write fails; `with_scoped_handle` revokes a comparison-only
handle on every exit; both name the credential id and the closing
commands if the revoke itself fails. No bare mint remains outside the
two helpers. Rebased onto master's redacted-Debug change (0679dea).

Design: docs/opencode-custody-design.md. Follow-ups: cortexkit#29.

The TypeScript suite runs hermetically (no daemon, no HOME) and that run is the gate
in scripts/gate.sh and both CI jobs; migrate-opencode and opencode-account add refuse
keys carrying the reserved tombstone prefix; the auth.json read is single-descriptor
bounded.

A rejection from a stale handle revision cannot poison the replacement slot, a stalled
get expires instead of pinning the slot, descriptor reads are bounded to the cap on the
bytes actually read, percent-encoded sentinels match case-insensitively, the client
ships Node-loadable ESM, and the hermetic suite runs on every CI leg including Windows.

Connection discovery mirrors the daemon tier-for-tier and refuses an ambiguous match;
the serve path renders error names only, with a structured code for callers and a canary
covering the substitution-failure arm; a stalled tick warm expires like a request warm.
@iceteaSA
iceteaSA force-pushed the feat/opencode-custody branch from 0e9dee7 to 2c39e3d Compare September 2, 2026 19:36
@iceteaSA

iceteaSA commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator Author

Closed at 2c39e3d, both halves.

Blocking half: the release scan now asserts all five seam strings absent. Preferred half, delivered: the population is derived — the test scans crates/ for "CK_*_TEST_*" / "CORTEXKIT_TEST_*" string literals, requires ≥5 hits (so an empty scan cannot pass), and asserts each absent from the release binary; positive control kept. Known limit stated at the test: a name built by concatenation is invisible to a literal scan — fails safe (never worse than the hardcoded list).

Your standing rule, adopted: the four seams moved from the opencode-test-seam cargo feature to #[cfg(debug_assertions)]; the feature is gone from Cargo.toml, gate.sh and CI (15 sites, zero remaining references). The seam tests now run in the default debug profile (cli_opencode 54/54; workspace floor re-measured 525 and set to it — thanks for the floor note, it moved twice today).

Red-checks: (a) release build with RUSTFLAGS='-C debug-assertions=on' — the exact way a hatch now reaches a release binary — the scan fails naming all four CK_OPENCODE_TEST_FAIL_* plus the bypass; (b) narrowing the source regex to four names trips the population floor. Default release strings count: 0.

Also since your first pass: three static-key providers were migrated on the deploy box and one request answered through each with only the tombstone in auth.json (comment above); two were reversed because another local reader of auth.json (insula's quota lanes) had not been enumerated — doc consequence noted there, --restore did its job on first live use.

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