Skip to content

feat(cli): make stash env mint real deployment credentials (rc.2 B3)#682

Merged
coderdan merged 6 commits into
mainfrom
feat/stash-env-mint-credentials
Jul 17, 2026
Merged

feat(cli): make stash env mint real deployment credentials (rc.2 B3)#682
coderdan merged 6 commits into
mainfrom
feat/stash-env-mint-credentials

Conversation

@coderdan

@coderdan coderdan commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

What

stash env was a gated stub (STASH_EXPERIMENTAL_ENV_CMD) whose TODO said the CTS mint endpoint "isn't wired up yet." That comment was stale — both endpoints exist and are what the dashboard itself uses. The command now mints working deployment credentials from the local device-code session and prints them as env vars:

stash env --name my-app-prod           # print CS_* vars to stdout
stash env --name my-app-prod --write   # .env.production.local, mode 0600
stash env --name edge-dev --json       # single JSON object, no prompts

Output: CS_WORKSPACE_CRN, CS_CLIENT_ID, CS_CLIENT_KEY, CS_CLIENT_ACCESS_KEY.

How

All plain fetch, no new native code — DeviceSessionStrategy.fromProfile().getToken() already exposes the bearer token, workspace id, CTS base URL (issuer), and ZeroKMS URL (services.zerokms):

  1. GET {cts}/api/workspaces → the workspace's server-authoritative region → CRN (works for self-hosted CTS; no issuer-hostname parsing)
  2. POST {zerokms}/create-client → a fresh, dedicated client id/key for the deployment (not the dev machine's secretkey.json) — key material transcoded base64→hex, the format every released SDK accepts
  3. POST {cts}/api/access-keys → the access key, member role always: role is omitted so the server owns the default, and the CLI deliberately has no --role — admin keys belong in the dashboard where they're visible

Deliberate ordering/safety properties:

  • ZeroKMS client is created before the access key: a partial failure leaves an inert client record, never an unaccounted-for live credential (and the error names the leftover).
  • The key name resolves before any profile/network access, so the non-interactive missing-name failure is credential-free — that's the seam the new e2e exercises. Minting live keys from a test run is structurally impossible.
  • 403 → actionable "needs workspace admin" message; duplicate name → suggests --name; --write refuses to overwrite non-interactively and writes 0600.
  • The bearer token never reaches stdout (asserted in tests).

Why (rc.2 B3, #663, CIP-2997)

Closes #663. The wasm-inline surface has no ~/.cipherstash fallback, and for the headline case it never can: supabase functions serve runs in a container that doesn't mount the host home dir (verified against a live edge-runtime container — no binds), and Workers have no fs. stash env gives every such runtime — plus production, per CIP-2997's original intent — one credential path: mint a key, feed it via --env-file or the platform secret store.

Verification

  • 10 new unit tests (mocked auth + fetch): wire shapes, call ordering, hex transcode, CRN assembly, 403/duplicate/not-logged-in paths, --write mode bits, --json envelopes, token-never-on-stdout
  • 2 new pty-less e2e tests (missing-name, both output modes) — full e2e suite 60/60 against the built CLI
  • pnpm --filter stash test 523+10 green; code:check exit 0
  • skills/stash-cli (stub section → real docs) and skills/stash-supabase (edge-functions credential path) updated in the same PR; stash manifest --json resolves every flag the skill names
  • Changeset: stash minor

Not exercised here: a live mint against a real workspace (needs my session; will do as a manual smoke before merge and paste the redacted output).

https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w

Summary by CodeRabbit

  • New Features

    • Made stash env production-ready to mint deployment credentials and output CS_WORKSPACE_CRN, CS_CLIENT_ID, CS_CLIENT_KEY, and CS_CLIENT_ACCESS_KEY.
    • Added --write to save credentials to .env.production.local with owner-only permissions (0600) and safe overwrite behavior, plus --json NDJSON output.
    • Updated help text and moved env under a new Deployment section; --name is required for non-interactive runs.
  • Documentation

    • Updated command reference and deployment/local-development guidance for edge/serverless runtimes and env-file usage.
  • Tests

    • Expanded unit and non-interactive e2e coverage for validation and error payloads (including missing/invalid --name).

The command was a gated stub (STASH_EXPERIMENTAL_ENV_CMD) waiting on "the
CTS mint endpoint" — which turned out to already exist. It now mints
working credentials from the device-code session using the same APIs the
dashboard uses, all plain fetch against the session's own service URLs:

1. GET  {cts}/api/workspaces    → server-authoritative region → CS_WORKSPACE_CRN
2. POST {zerokms}/create-client → CS_CLIENT_ID / CS_CLIENT_KEY (hex-transcoded)
3. POST {cts}/api/access-keys   → CS_CLIENT_ACCESS_KEY (member role, always —
   the CLI deliberately cannot mint admin keys; role is omitted so the
   server owns the default)

Design points:
- The ZeroKMS client is created before the access key, so a partial
  failure leaves an inert client record, never an unaccounted-for live
  credential.
- The key name resolves before any profile/network access, so the
  non-interactive missing-name failure is credential-free — that is the
  seam the new pty-less e2e exercises (minting live keys from a dev
  machine's real session in tests is deliberately impossible).
- --write emits .env.production.local with mode 0600 and refuses to
  overwrite non-interactively; --json emits one minted object or the
  shared { status: 'error' } envelope.

Closes #663 (the wasm/edge credential gap, CIP-2997): edge runtimes that
cannot read ~/.cipherstash (containerised supabase functions serve,
Workers) now have a supported path — stash env + --env-file/secret store.

Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w
@coderdan
coderdan requested a review from a team as a code owner July 17, 2026 04:56
@changeset-bot

changeset-bot Bot commented Jul 17, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: f1efc2d

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 11 packages
Name Type
stash Minor
@cipherstash/basic-example Patch
@cipherstash/e2e Patch
@cipherstash/stack Minor
@cipherstash/stack-drizzle Minor
@cipherstash/stack-supabase Minor
@cipherstash/prisma-next Minor
@cipherstash/wizard Minor
@cipherstash/bench Patch
@cipherstash/test-kit Patch
@cipherstash/prisma-next-example Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@coderabbitai

coderabbitai Bot commented Jul 17, 2026

Copy link
Copy Markdown

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 1376c414-38e8-4a79-8b9a-9921ff22be01

📥 Commits

Reviewing files that changed from the base of the PR and between c33bf61 and f1efc2d.

📒 Files selected for processing (10)
  • .changeset/stash-env-mint-credentials.md
  • packages/cli/src/bin/main.ts
  • packages/cli/src/cli/registry.ts
  • packages/cli/src/commands/env/__tests__/env.test.ts
  • packages/cli/src/commands/env/index.ts
  • packages/cli/src/messages.ts
  • packages/cli/tests/e2e/env-non-interactive.e2e.test.ts
  • skills/stash-cli/SKILL.md
  • skills/stash-encryption/SKILL.md
  • skills/stash-supabase/SKILL.md

📝 Walkthrough

Walkthrough

stash env now mints deployment credentials through a device session, creates ZeroKMS and member access-key credentials, and outputs them to stdout, JSON, or a protected file. The experimental gate is removed, with updated CLI wiring, validation, tests, documentation, and edge-runtime guidance.

Changes

Deployment credential flow

Layer / File(s) Summary
CLI contract and dispatch
packages/cli/src/cli/registry.ts, packages/cli/src/bin/main.ts, packages/cli/src/commands/env/index.ts, packages/cli/src/messages.ts
The env command moves to Deployment, adds --name and --json, and dispatches expanded argument-validation and output options.
Credential minting and output
packages/cli/src/commands/env/index.ts
The command resolves names and write targets, authenticates with a device session, creates ZeroKMS and member access-key credentials, validates responses, and supports dotenv, JSON, and protected-file output.
Credential flow validation
packages/cli/src/commands/env/__tests__/env.test.ts, packages/cli/tests/e2e/env-non-interactive.e2e.test.ts
Tests cover argument failures, successful minting, request ordering, error mappings, response validation, JSON output, secret handling, permissions, and overwrite protection.
Deployment documentation and release notes
skills/stash-cli/SKILL.md, skills/stash-supabase/SKILL.md, skills/stash-encryption/SKILL.md, .changeset/stash-env-mint-credentials.md
Documentation describes credential generation, authentication, output modes, role constraints, secret handling, and edge-runtime usage.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant stash env
  participant Device session
  participant CipherStash services
  User->>stash env: Run with --name and output options
  stash env->>Device session: Obtain authenticated token
  Device session-->>stash env: Return bearer token
  stash env->>CipherStash services: Fetch workspace and create ZeroKMS client
  CipherStash services-->>stash env: Return client credentials
  stash env->>CipherStash services: Create member access key
  CipherStash services-->>stash env: Return access-key credentials
  stash env-->>User: Print or write CS_* variables
Loading

Possibly related PRs

Suggested reviewers: auxesis, calvinbrewer, tobyhede

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main change: stash env now mints real deployment credentials instead of using the experimental stub.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/stash-env-mint-credentials

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

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

Actionable comments posted: 5

🧹 Nitpick comments (1)
packages/cli/src/commands/env/__tests__/env.test.ts (1)

143-145: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Drop the as never cast from the process.exit mock.
Type the mock callback to match process.exit directly; if the exit-code type needs widening, update ExitSignal instead of erasing the callback type.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/cli/src/commands/env/__tests__/env.test.ts` around lines 143 - 145,
Update the process.exit mock setup in the env tests to use a callback typed
directly as process.exit, removing the as never cast. If the callback’s
exit-code type is incompatible, widen the ExitSignal constructor or definition
rather than suppressing the type mismatch.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/cli/src/commands/env/index.ts`:
- Around line 89-98: Move the existing `.env.production.local` write-destination
and interactive-overwrite check from the post-`mintCredentials` path to before
the credential-minting flow in the command handler. Preserve the current refusal
behavior for non-interactive existing files and declined overwrites, and ensure
these preflight failures return without invoking any API or credential-mutation
calls.
- Around line 322-335: The apiFetch helper currently allows credential requests
to hang indefinitely. Add a bounded AbortSignal timeout to the fetch call in
apiFetch, and convert timeout or abort failures into a clear MintError while
preserving normal responses and other request behavior.
- Around line 109-110: Update the file-writing flow around writeFileSync to
explicitly enforce permissions with chmodSync or fchmodSync after writing,
including when an existing 0644 file is overwritten. Add or update tests to
cover the confirmed-overwrite case and verify the resulting mode is 0600.
- Around line 47-56: Update the env command’s Clack UI calls—`p.intro`, the
spinner started after `resolveKeyName`, and the corresponding `p.outro`—so they
are only used when `isInteractive()` is true or are directed to stderr. Preserve
clean dotenv output on stdout for piped, non-JSON execution.

In `@skills/stash-cli/SKILL.md`:
- Line 502: Specify the fenced code block language in the relevant section of
SKILL.md by changing the unlabeled fence to use the dotenv language identifier,
while preserving the existing environment-variable example content.

---

Nitpick comments:
In `@packages/cli/src/commands/env/__tests__/env.test.ts`:
- Around line 143-145: Update the process.exit mock setup in the env tests to
use a callback typed directly as process.exit, removing the as never cast. If
the callback’s exit-code type is incompatible, widen the ExitSignal constructor
or definition rather than suppressing the type mismatch.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 4a27040b-f47e-4867-a274-17c2c10f1dc6

📥 Commits

Reviewing files that changed from the base of the PR and between 8b2551a and c888cc3.

📒 Files selected for processing (9)
  • .changeset/stash-env-mint-credentials.md
  • packages/cli/src/bin/main.ts
  • packages/cli/src/cli/registry.ts
  • packages/cli/src/commands/env/__tests__/env.test.ts
  • packages/cli/src/commands/env/index.ts
  • packages/cli/src/messages.ts
  • packages/cli/tests/e2e/env-non-interactive.e2e.test.ts
  • skills/stash-cli/SKILL.md
  • skills/stash-supabase/SKILL.md

Comment thread packages/cli/src/commands/env/index.ts Outdated
Comment thread packages/cli/src/commands/env/index.ts Outdated
Comment thread packages/cli/src/commands/env/index.ts Outdated
Comment thread packages/cli/src/commands/env/index.ts Outdated
Comment thread skills/stash-cli/SKILL.md Outdated
@coderdan

Copy link
Copy Markdown
Contributor Author

Live smoke ✅ (as promised in the PR body)

Ran against my real workspace with a disposable key name:

node packages/cli/dist/bin/stash.js env --name stash-env-smoke-20260717 --json

First-shot success (exit 0), minted (secrets redacted):

{
  "status": "minted",
  "keyName": "stash-env-smoke-20260717",
  "workspaceCrn": "crn:us-west-2.aws:5QSZTRROIQGIKBOH",
  "clientId": "c8157833-8b28-4a1c-82d0-418a16abb576",
  "clientKey": "<hex, 336 chars — REDACTED>",
  "accessKey": "CSAK…<69 chars — REDACTED>"
}

Then proved the credentials on the surface B3 is about — @cipherstash/stack/wasm-inline with explicit config only (no env vars, no ~/.cipherstash), types.TextSearch schema:

ENCRYPT:      ok (envelope keys: bf,c,hm,i,op,v)
DECRYPT:      ok (plaintext matches)
ENCRYPTQUERY: ok (term keys: bf,hm,i,op,v)

Notes:

  • The v3 envelope carries all three scalar index terms; the query term is correctly ciphertext-free (no c).
  • The hex-transcoded CS_CLIENT_KEY (ZeroKMS returns more than the schema-doc's 32 bytes — 168 bytes here) is accepted as-is by the wasm client, so the transcode is right.
  • CRN region came from GET /api/workspaces (us-west-2), not issuer parsing.

Cleanup: the access key + ZeroKMS client stash-env-smoke-20260717 now exist in the workspace — revoke/remove in the dashboard whenever, or keep as evidence until merge. The minted secrets were written only to a mode-0600 scratch file, now deleted.

https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w

The Configuration section listed the four CS_* variables but left how to
obtain them to the dashboard. Both the stash-cli and stash-supabase skills
now document `stash env`; this closes the loop in the skill the other two
reference for configuration.

Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR turns stash env from an experimental stub into a real “deployment credentials minting” command for the stash CLI, and updates the shipped Skills docs to describe the new workflow (including edge/WASM runtimes).

Changes:

  • Implement stash env to mint CS_WORKSPACE_CRN, CS_CLIENT_ID, CS_CLIENT_KEY (hex), and CS_CLIENT_ACCESS_KEY from the local device-session token (CTS + ZeroKMS calls).
  • Add unit + e2e coverage for non-interactive behavior, JSON output envelopes, ordering guarantees, and safe --write semantics (0600, no overwrite non-interactively).
  • Update skills/* docs, CLI registry metadata, help output, and add a stash changeset for the new command behavior.

Reviewed changes

Copilot reviewed 9 out of 10 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
skills/stash-supabase/SKILL.md Documents stash env as the recommended credential path for Supabase Edge Functions (local + deploy).
skills/stash-encryption/SKILL.md Adds guidance for minting CS_* via stash env and notes edge/WASM constraints.
skills/stash-cli/SKILL.md Replaces “stub” docs with real env command docs, examples, and behavioral notes.
packages/cli/tests/e2e/env-non-interactive.e2e.test.ts Adds non-TTY e2e coverage for missing --name and JSON error envelope.
packages/cli/src/messages.ts Introduces a stable, test-asserted missing-name message leader for stash env.
packages/cli/src/commands/env/index.ts Implements the actual minting flow (workspaces → create-client → access-keys), output modes, and safety behaviors.
packages/cli/src/commands/env/tests/env.test.ts Adds unit tests for happy path, ordering, error mapping, JSON mode, and --write file permissions/overwrite refusal.
packages/cli/src/cli/registry.ts Promotes env into “Deployment”, adds long help, examples, and flags (--name, --write, --json).
packages/cli/src/bin/main.ts Updates help text and wires --name/--json/--write into envCommand.
.changeset/stash-env-mint-credentials.md Adds a minor changeset describing the new stash env behavior and constraints.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread packages/cli/src/commands/env/index.ts
Comment thread skills/stash-supabase/SKILL.md Outdated
Comment thread packages/cli/src/commands/env/index.ts
coderdan added 2 commits July 17, 2026 15:15
…verified

Ran the flow for real (agent starts `auth login --json --region us-west-2`
backgrounded, human approves the relayed URL): authorization_required →
authorized → device_bound, exit 0, session verified via getToken. The event
table in stash-cli was already accurate; what was missing was the part that
bites an agent — the command blocks polling for up to ~15 minutes, so it
must run as a background task with a generous timeout, and success is
confirmed from the event stream (never by inspecting ~/.cipherstash).
stash-encryption's Local Development section now points agents at the flow.

Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w
An 8-angle adversarial review of this branch confirmed 10 defects (plus one
skill nit); this commit fixes all of them:

1. --write's overwrite decision (refusal AND interactive confirm) now runs
   BEFORE minting — a declined overwrite previously discarded a live,
   shown-exactly-once access key, contradicting the file's own contract.
2. Stdout is now actually pipe-clean: every clack call routes chrome to
   stderr via { output: process.stderr }, so `stash env > prod.env` and
   pipes into dotenv consumers capture only the block. (clack defaults
   everything to stdout — the old comment's claim was wrong.)
3. This also fixes prompts rendering into redirected stdout (isInteractive
   is stdin-only): prompts on stderr stay visible on the terminal.
4. `--write <path>` is now a feature, not a parse trap: parseArgs put the
   path in values.write leaving flags.write undefined, silently printing
   secrets to stdout. main.ts passes values.write ?? flags.write and the
   command accepts a target path.
5. `--name` followed by another flag gets its own name_requires_value
   diagnostic instead of a false missing_name.
6. `stash env <positional>` is rejected with did-you-mean --name guidance
   instead of silently vanishing into the subcommand slot.
7. --json + --write now compose: the file is written and the JSON
   confirmation ({ status: 'written' }) is deliberately secret-free.
8. 0600 is enforced on overwrite too (writeFileSync's mode only applies on
   creation) via an explicit chmod.
9. All three API responses are zod-validated; non-base64 client_key is
   refused before Node's lenient decoder can transcode garbage; a service
   shape change can no longer print undefined into a credentials file.
10. The member role is pinned in the request (wire value verified lowercase
    in cts-common) AND asserted on the response — a non-member key is
    refused with revocation guidance, so the documented 'cannot mint admin
    keys' invariant is enforced, not assumed.
11. All exits go through CliExit (never deep process.exit), so run()
    records env outcomes for telemetry; cancel paths use CliExit(0). The
    three divergent dead-code exit trailers are gone.
12. skills/stash-cli: the bare --env-file mention is attributed to
    `supabase functions serve`, per the manifest-resolution rule.

Tests: 21 env unit tests (preflight-before-mint asserted via zero fetch
calls; chrome-on-stderr; role pinning; validation refusals; json+write
secret-free event; chmod-on-overwrite) and 4 e2e cases (all credential-free
pre-mint failures, including asserting error chrome lands on stderr, not
stdout). Full suite: 534 unit / 62 e2e green, code:check exit 0.

Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w
@coderdan

Copy link
Copy Markdown
Contributor Author

Branch review → all findings fixed (5e0bb31e)

Ran an 8-angle adversarial review over this branch (line-by-line, removed-behavior, cross-file tracing, reuse/simplification/efficiency/altitude/conventions), verified every deduped candidate with independent verifier passes — 11 confirmed findings, all fixed:

# Finding Fix
1 --write overwrite check ran after minting → a refusal/decline discarded a live, shown-once access key Overwrite decision (refusal and interactive confirm) moved before the mint; tests assert zero fetch calls on the refusal paths
2 Stdout wasn't pipe-clean — clack chrome defaults to stdout, corrupting stash env > prod.env All chrome routed to stderr via { output: process.stderr }; e2e asserts error text lands on stderr only
3 Prompts rendered into redirected stdout (stdin-only isInteractive) Solved by the same stderr routing — prompts stay visible when stdout is redirected
4 --write <path> parse trap: path swallowed into values, secrets printed to stdout, exit 0 Now a feature — --write [path] targets that file (values.write ?? flags.write)
5 --name --json → false missing_name Dedicated name_requires_value diagnostic
6 stash env my-app positional silently vanished into the subcommand slot Rejected with did-you-mean --name my-app guidance
7 --json --write silently dropped --write, secrets into captured stdout They compose: file written, JSON confirmation { status: \"written\" } is deliberately secret-free
8 mode: 0600 not applied when overwriting an existing 0644 file Explicit chmodSync after every write
9 Bare as-casts on all three API responses → crn:undefined / CS_CLIENT_ACCESS_KEY=undefined with exit 0 zod-validated (loose list, strict on our workspace); non-base64 client_key refused before Node's lenient decoder can emit corrupt hex
10 'Cannot mint admin keys' enforced only by the server default role: 'member' pinned in the request (wire value verified lowercase in cts-common) and asserted on the response — a non-member key is refused with revocation guidance
11 Deep process.exit → env failures invisible to telemetry; 3 divergent dead-code exit idioms All exits via CliExit (verified unwindable to run()); cancels are CliExit(0)

Plus the skill nit: the bare --env-file mention in stash-cli is now attributed to supabase functions serve, per the AGENTS.md manifest-resolution rule.

Coverage grew 10 → 21 unit tests + 2 → 4 e2e cases; full suite 534 unit / 62 e2e green, code:check clean, manifest resolves --name <name> / --write [path] / --json.

https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w

…doc nits)

- apiFetch now carries a 30s AbortSignal.timeout; timeouts surface as
  request_timeout and connection failures as network_error (naming the
  host, and the undici cause rather than the opaque 'fetch failed').
  A stalled CTS/ZeroKMS endpoint no longer hangs the CLI. (CodeRabbit)
- The credential name rejects control characters (invalid_name) — a
  newline could break out of the dotenv comment line and inject a line
  into a written credentials file. Enforced pre-mint for both the flag
  and the interactive prompt. (Copilot)
- skills/stash-cli: dotenv language on the output fence (MD040);
  skills/stash-supabase: keep the example command in one code span.

Unit tests: 24 env tests (invalid_name pre-profile, request_timeout,
network_error). Suite 537/62 green, code:check clean.

Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w

@freshtonic freshtonic left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Overview

Turns stash env from a gated stub into a working credential-minter: it reuses the device-code session token to call the same CTS + ZeroKMS endpoints the dashboard uses, and emits CS_WORKSPACE_CRN / CS_CLIENT_ID / CS_CLIENT_KEY / CS_CLIENT_ACCESS_KEY to stdout, a 0600 file (--write), or NDJSON (--json). Closes the rc.2 B3 blocker (#663) and gives WASM/edge runtimes that can't read ~/.cipherstash a supported credential path.

This is security-sensitive code (it mints live credentials), and it's written with the care that warrants. Approving.

Why this is solid

  • Pre-mint refusal ordering is load-bearing and correct. Every refusable condition — stray positional, missing/valueless --name, control-char name, and the --write overwrite decision — resolves before mintCredentials runs, so a refusal can never discard a shown-exactly-once access key. The e2e suite pins that the missing-name path never touches the profile or network, which structurally prevents a CI run from minting real keys.
  • Mint ordering minimizes blast radius. ZeroKMS client is created before the access key, so the only partial-failure leftover is an inert client (and the error names it). Correct call sequence is asserted in tests.
  • Least privilege, enforced both ways. role: 'member' is pinned in the request and asserted on the response; there's deliberately no --role, and an over-privileged response is refused without printing the secret. Good defense against a server/default drift.
  • Every cross-boundary response is schema-validated (zod) before any field reaches the env block — the comment correctly notes an as-cast would print undefined into a credentials file after a live key was minted. The create-client key material is validated as padded base64 before Node's lenient decoder can transcode garbage into a plausible-but-wrong hex key.
  • Secret hygiene is thorough. Bearer token never reaches stdout (asserted); stdout is pipe-clean (all chrome → stderr, spinner suppressed in --json); --json --write confirmation is deliberately secret-free; writeEnvFile re-chmods 0600 after write because writeFileSync's mode only applies on create. Control-char rejection on the name closes the dotenv-comment injection vector.
  • CliExit instead of process.exit so run() records telemetry; cancel paths exit 0, failures exit 1, single-emit. Timeouts (AbortSignal.timeout) and network/timeout error mapping name the host.
  • Tests (10 unit + 4 e2e) are excellent and cover the security-relevant invariants directly. Skills (stash-cli, stash-encryption, stash-supabase) and the help/registry surface are updated in-PR; stash minor changeset present.

Notes — all minor, none blocking

  1. CRN assembly is the one thing only a live mint validates. crn:${region}:${workspaceId} is new, and if that string shape doesn't match what the SDK's CS_WORKSPACE_CRN parser expects, the minted creds fail only at deploy time — past all the unit mocks. Good that the PR flags a redacted live smoke test before merge; please make sure that smoke test actually uses the emitted CRN to init a client, not just eyeballs it.

  2. Role assertion is skipped when the response omits role. if (accessKeyBody.role && …) — an absent role field bypasses the member check. Acceptable because the request pins member and the server owns the default, but since the docs promise "verified on the response," consider treating missing role as an explicit member assumption (comment or ?? 'member') so the guarantee doesn't depend on the field always being present.

  3. The "if the name is already taken, rerun with --name" hint is appended to every non-403 access-key error, not just duplicates (there's no duplicate-specific status check). Harmless, but a 500 would carry a slightly misleading suggestion. Cosmetic.

  4. Defense-in-depth (optional): the emitted dotenv values (region-derived CRN, access key) aren't newline-validated the way the name is. A malformed server-provided region could in theory break the block. Server-trust makes this very low risk, but a one-line assertion that no emitted value contains a newline would fully close the injection surface.

Verdict

Approve. Careful, least-privilege, well-tested implementation of a sensitive command; the failure/ordering design is exactly right. The notes are polish — item 1 (confirm the CRN round-trips through a real client in the pre-merge smoke) is the only one worth acting on before shipping.

- Note 2: treat an ABSENT access-key role as member (server default) rather
  than skipping the check — `(role ?? 'member')` — so the documented
  'verified on the response' guarantee doesn't depend on the field always
  being present.
- Note 3: only append the 'name already taken, use --name' hint on 400/409
  (where duplicates land), not every non-403 — a 500 no longer carries a
  misleading suggestion.
- Note 4 (defense-in-depth): reject any server-provided emitted value
  (CRN/region, client id, access key) that contains a control character,
  closing the dotenv line-injection surface the name guard already covered.
  clientKey is hex by construction.

Tests: absent-role → success; 500 omits the --name hint (keeps the
leftover-client note); a newline in the server region → refused, no
injected line on stdout. 27 env unit tests, suite 540/62 green.

Note 1 (CRN round-trip) needs no code change — the pre-merge live smoke
already inits a wasm-inline client with the emitted workspaceCrn (see PR
comment); replying there.

Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w
@coderdan

Copy link
Copy Markdown
Contributor Author

Thanks James — addressed all four in f1efc2da.

2. Absent role skips the member check → fixed. Now const returnedRole = (accessKeyBody.role ?? 'member').toLowerCase() — an absent field is treated as the server default (member) explicitly, so the 'verified on the response' guarantee no longer depends on the field being present. Test: absent-role response → succeeds.

3. Duplicate-name hint on every non-403 → fixed. The 'name already taken, use --name' hint is now gated on status === 400 || status === 409 (where CTS lands a duplicate). A 500 no longer carries it — but still keeps the leftover-client note. Test added.

4. Emitted values not newline-validated → fixed. After assembling the creds, any server-provided value (CRN/region, client id, access key) containing a C0/DEL control char is refused with unexpected_response before it can reach the block. clientKey is hex by construction so it's inherently safe. Test: a \n in the server-returned region → refused, no injected line reaches stdout.

1. CRN round-trip — no code change needed, and I want to confirm it's genuinely covered, not eyeballed: the live smoke fed the emitted workspaceCrn straight into @cipherstash/stack/wasm-inline's Encryption({ config: { workspaceCrn, accessKey, clientId, clientKey } }) with no env vars and no profile. AccessKeyStrategy derives the region from the CRN, so the client couldn't have constructed — let alone completed a real ZeroKMS encrypt/decrypt/encryptQuery round-trip — if the crn:<region>:<workspaceId> shape were wrong. So the CRN assembly is validated by a real client init against live CTS, exactly as you asked.

Also: CodeRabbit's one nitpick (as never on the process.exit mock) is moot — the CliExit rewrite removed that mock entirely; the suite now catches CliExit instead of stubbing process.exit.

Suite: 27 env unit tests, 540 unit / 62 e2e green, code:check clean.

@coderdan
coderdan merged commit f188c7a into main Jul 17, 2026
9 of 10 checks passed
@coderdan
coderdan deleted the feat/stash-env-mint-credentials branch July 17, 2026 07:01
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.

wasm-inline has no auto/dev-profile auth strategy — credential-free local dev impossible on WASM

3 participants