Skip to content

fix(cli): cotal mint reuses the existing identity unless --force - #9

Merged
mattwilkinsonn merged 6 commits into
mainfrom
cotal-mint-reuse-identity-v2
Jul 25, 2026
Merged

fix(cli): cotal mint reuses the existing identity unless --force#9
mattwilkinsonn merged 6 commits into
mainfrom
cotal-mint-reuse-identity-v2

Conversation

@mattwilkinsonn

@mattwilkinsonn mattwilkinsonn commented Jul 9, 2026

Copy link
Copy Markdown

Problem

cotal mint <name> called newIdentity() unconditionally on every invocation, so re-minting an agent rotated its mesh id. Because the durable ACL row and the per-agent dm/dlv delivery durables are all keyed by the nkey public key, rotation orphaned them: the re-minted agent came up with a fresh id the broker had no ACL row or consumer for, and went @mention-wake-blind (consumer not found) until re-provisioned. This reproduced live 6× during a fleet relaunch.

Re-minting is the normal way to refresh an agent's channels (mint's read/post ACLs are read from the persona file), so an operation that's supposed to be routine was silently breaking delivery.

Fix (option B — maintainer ruling on design PR #3, cubic P1)

Re-mint reuses the same identity by default:

  • packages/core/src/identity.ts — new identityFromCreds(creds: string): Identity, the id-preserving sibling of idFromCreds. It returns { id, seed } from a creds file, reusing idFromCreds for the id + JWT-subject cross-check (no duplicated parse, no new inline cast) and extracting the seed block.
  • implementations/cli/src/commands/mint.ts — the agent path now computes the out creds path before minting. If a creds file already exists at the canonical path and --force is not passed, it re-signs that same id+seed with the fresh ACLs from the persona file; otherwise it mints a new identity. --force keeps the rotation escape hatch (compromised key / deliberate new identity). The output line now reports (reused — re-mint kept the identity) vs (new).

Review refinements (agent-only + fail-loud + canonical-path-only + no-symlink)

  • Reuse is gated to profile === "agent" — observer/admin creds always rotate (they carry no persona-refresh workflow or durable footprint, and silently extending a privileged admin key's lifetime would be surprising).
  • A present-but-unparseable creds file at the out path fails loud with an actionable error naming --force, rather than crashing on a raw parse exception or silently rotating (which would orphan the durable row the existing id may still own).
  • greptile P1 (security) — cross-identity --out guard. Creds identify an agent by nkey id, not by name (the file bakes no name), so the only name↔creds binding is the canonical creds/<name>.creds path. Reuse is therefore canonical-path-only: cotal mint alice --out .../bob.creds no longer reuses bob's id under alice's ACLs, nor silently clobbers bob's creds (which would orphan bob's id-keyed durables). A custom --out onto an existing creds file fails loud unless --force asks for the overwrite deliberately (which rotates to a fresh identity). Normal cotal mint <name> (canonical path) is unaffected.
  • greptile P1 (security) — symlinked creds path. out === canonicalOut is a string compare (resolve() normalizes the path but does not follow symlinks), so it cannot prove the canonical file belongs to <name>. If creds/<name>.creds were a symlink to another agent's creds, existsSync/readFileSync/writeSecretFile all follow it — mint would read the link target's id and write <name>'s ACLs back through the link, clobbering the pointed-to agent (and --force would rotate a fresh id straight through it). mint now lstats the resolved out path (lstat does not follow the link) and refuses a symlink outright — canonical or custom, with or without --force. A creds file must be a real file at its own path.

Closes the #3 cubic-P1

This directly resolves the re-mint-orphans-the-ACL-row P1 that cubic raised on design PR #3 (durable-delivery-acl-provisioning.md) — it is cubic's option (b) verbatim, per Matt's ruling. One fix retires both: #3's design note that re-mint orphans are "out of scope" no longer applies, because the canonical cotal up + cotal mint recipe is now safe under re-mint.

Scope

Orthogonal to the still-open mint-strategy fork in #3: this changes which id mint uses, not where the ACL write triggers. Pairs with the PR #4 DLV-durable fix (that one makes fresh mints land their durables; this one stops re-mints from needing to).

Verification

  • @cotal-ai/core build + core/cli typecheck clean.
  • Offline round-trip smoke: newIdentity()mintCredsidentityFromCreds returns the same {id, seed}; identityFromCreds(creds).id === idFromCreds(creds); re-mint of a reused identity stays STABLE.
  • Live CLI smoke via mint(): agent re-mint keeps the id; admin + observer re-mint each rotate; a corrupt creds file at the out path errors naming --force (no raw crash, no silent rotate).
  • Cross-identity --out guard (red→green regression): mint <name> --out <another agent's creds> fails loud naming --force and leaves the target byte-identical (no re-sign, no clobber); --force overwrites with a fresh identity (escape hatch intact).
  • Symlinked creds path (red→green regression): a plain mint <name> whose canonical creds is a symlink fails loud (no read/write through the link), leaves the link target byte-identical, and --force still refuses (never writes through the link).
  • Red-green regression (double-mint-same-id must fail on the pre-fix unconditional newIdentity() and pass after) is on this branch.

Closes the #3 cubic-P1 (re-mint orphans ACL row). Refs #3.

Co-Authored-By: seal noreply@sealedsecurity.com

mattwilkinsonn and others added 4 commits July 8, 2026 19:11
mint.ts unconditionally called newIdentity() on every mint, so re-minting an
agent (e.g. to refresh its channels from the persona file) rotated the mesh id.
The durable ACL row and the dm/dlv delivery durables are all keyed by the nkey
public key, so rotation orphaned them → the agent went @mention-wake-blind with
'consumer not found' until re-provisioned (hit live 6x during a fleet relaunch).

Option B (maintainer ruling on design PR #3, cubic P1): re-mint reuses the same
identity. New core helper identityFromCreds(creds): Identity — the id-preserving
sibling of idFromCreds (which it reuses for the id + JWT-subject cross-check),
returning { id, seed } from the creds' seed block. mint computes the out path
before minting; if a creds file exists there and --force is not passed, it
re-signs that SAME id+seed with fresh ACLs; otherwise it mints a new identity.
--force keeps the rotation escape hatch (compromised key / deliberate new id).

Orthogonal to the still-open mint-strategy fork (#3): changes WHICH id mint uses,
not WHERE the ACL write triggers. Pairs with the PR #4 DLV-durable fix.

Refs #3.

Co-Authored-By: seal <noreply@sealedsecurity.com>
…rseable creds

Two review findings on the reuse logic (cubic P1 + greptile P2 on #7):

- Reuse now requires profile === "agent". The durable-ACL-orphan rationale is
  agent-specific (persona-refresh workflow + dm/dlv durables keyed to the id);
  observer/admin creds have neither, and silently preserving a privileged admin
  key across re-mints would extend its lifetime unexpectedly. Observer/admin now
  always rotate, as before.

- A present-but-unparseable creds file (empty, truncated, not a user creds file)
  now fails loud with an actionable error naming --force, instead of letting
  identityFromCreds throw a raw parse exception. Silently rotating there would
  orphan the durable row the existing id may still own, so fresh-mint is not a
  safe fallback — the operator must opt in via --force or remove the stale file.

Verified: agent re-mint keeps id; admin/observer re-mint rotate; corrupt creds
at the out path errors naming --force (no raw crash, no silent rotate).

Refs #3.

Co-Authored-By: seal <noreply@sealedsecurity.com>
Layer 1 — packages/core/smoke/identity.smoke.ts (offline, 4 asserts): identityFromCreds
round-trips {id, seed} unchanged; agrees with idFromCreds (one-id-everywhere); rejects
spliced creds (seed vs foreign JWT subject) and a missing seed block.

Layer 2 — implementations/cli/smoke/mint-reuse.smoke.ts (hermetic, exercises the real
mint() against a tmp .cotal root, 8 checks): re-minting an agent reuses the SAME id;
--force rotates; a persona ACL change refreshes the baked sub.allow WITHOUT rotating the
id; first mint of a new name mints fresh (absent-creds path, no crash); observer + admin
re-mint rotate (reuse is agent-only); an unparseable existing creds file fails loud naming
--force (no silent rotate, no raw crash).

Red-green verified: reverting the reuse block to unconditional newIdentity() fails checks
1, 3, and 6 (two mints → two ids; the exact #3 cubic-P1 durable-orphan bug); the fix turns
them green. Registered both as smoke:identity + smoke:mint-reuse and wired into smoke:ci.

Refs #3.

Co-Authored-By: seal <noreply@sealedsecurity.com>
cubic P2 on #7: the corrupt-creds check only asserted the error message, so a
future mint() refactor that wrote fresh creds *before* surfacing the parse error
would still pass while silently rotating the id — the exact regression the test
guards. Add a filesystem-state assertion: after the failed mint, the corrupt
file must be byte-unchanged (still empty), proving no silent fresh-mint.

Refs #3.

Co-Authored-By: seal <noreply@sealedsecurity.com>
@coderabbitai

coderabbitai Bot commented Jul 9, 2026

Copy link
Copy Markdown

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in: 53 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 046cd7e2-4f76-4246-9d94-c6fb69e7adfe

📥 Commits

Reviewing files that changed from the base of the PR and between 4b0553c and b96e3ea.

📒 Files selected for processing (5)
  • implementations/cli/smoke/mint-reuse.smoke.ts
  • implementations/cli/src/commands/mint.ts
  • package.json
  • packages/core/smoke/identity.smoke.ts
  • packages/core/src/identity.ts
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch cotal-mint-reuse-identity-v2

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

Copy link
Copy Markdown
Author

This stack of pull requests is managed by Graphite. Learn more about stacking.

@sealedsecurity-bot
sealedsecurity-bot marked this pull request as ready for review July 9, 2026 15:26
@greptile-apps

greptile-apps Bot commented Jul 9, 2026

Copy link
Copy Markdown

Greptile Summary

This PR makes cotal mint preserve agent identities during normal re-mints. The main changes are:

  • Agent creds are reused from the canonical creds path unless --force is passed.
  • Custom existing --out targets now fail unless explicitly forced.
  • Symlinked creds paths are rejected before read or write.
  • identityFromCreds extracts the reusable { id, seed } pair from existing creds.
  • Smoke coverage was added for identity reuse, forced rotation, corrupt creds, custom output, and symlink cases.

Confidence Score: 5/5

This looks safe to merge.

  • No blocking issues found in the changed code.

Important Files Changed

Filename Overview
implementations/cli/src/commands/mint.ts Adds guarded agent identity reuse, custom-output refusal, parse-error handling, and symlink rejection for minted creds.
packages/core/src/identity.ts Adds identityFromCreds to recover the existing user identity from a creds file.
implementations/cli/smoke/mint-reuse.smoke.ts Adds end-to-end smoke coverage for mint identity reuse and output-path guard behavior.
packages/core/smoke/identity.smoke.ts Adds smoke coverage for identity extraction, round-tripping, and malformed creds rejection.
package.json Adds the new identity and mint-reuse smoke tests to the CI smoke script.

Reviews (3): Last reviewed commit: "fix(cli): reject a symlinked creds path ..." | Re-trigger Greptile

Comment thread implementations/cli/src/commands/mint.ts Outdated
Closes the greptile P1 on #9: `cotal mint <name> --out <another agent's
creds>` reused that file's nkey id and re-signed it with <name>'s ACLs,
crossing the identity boundary (and clobbering the target). Creds identify
an agent by nkey id, not by name, so the only name<->creds binding is the
canonical `creds/<name>.creds` path. Reuse is now canonical-path-only: a
custom `--out` onto an existing creds file fails loud unless `--force`
asks for the overwrite deliberately (which rotates to a fresh identity).

Regression: mint-reuse smoke gains the cross-identity guard (red->green):
refuse + leave the target byte-identical; --force overwrites fresh.

Co-Authored-By: seal <noreply@sealedsecurity.com>
Comment thread implementations/cli/src/commands/mint.ts
Closes the second greptile P1 on #9: `out === canonicalOut` is a string
compare (resolve() normalizes the path but does not follow symlinks), so it
never proved the canonical file belongs to <name>. If creds/<name>.creds was a
symlink to another agent's creds, existsSync/readFileSync/writeSecretFile all
followed it, so `cotal mint <name>` read the target's id and wrote <name>'s
ACLs back through the link, clobbering the pointed-to agent (--force rotated a
fresh id straight through it).

mint now lstat's the resolved out path (lstat does not follow the link) and
refuses a symlink outright, canonical or custom, with or without --force.

Regression: mint-reuse smoke gains the symlinked-canonical-path case
(read-through refused, link target byte-identical, --force still refused).

Co-Authored-By: seal <noreply@sealedsecurity.com>
@seal-agent

Copy link
Copy Markdown

@coderabbitai review

(Re-trigger for an exact-head verdict: the prior approval sits on superseded commit b3d86f1, which is not an ancestor of current head b96e3ea. The head carries an unreviewed change — symlink-rejection fix in mint.ts + smoke. Requesting a fresh exact-head review.)

@coderabbitai

coderabbitai Bot commented Jul 14, 2026

Copy link
Copy Markdown

@seal-agent Understood — triggering a fresh review against the current head commit (b96e3ea), which includes the symlink-rejection fix in mint.ts and its smoke test.

✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@mattwilkinsonn
mattwilkinsonn merged commit d55c6ad into main Jul 25, 2026
8 checks passed
@mattwilkinsonn
mattwilkinsonn deleted the cotal-mint-reuse-identity-v2 branch July 25, 2026 22:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants