Skip to content

feat(cli): add --passphrase-file flag for agent create-token#67

Merged
graysonhyc merged 4 commits into
mainfrom
feat/agent-token-passphrase-file
May 12, 2026
Merged

feat(cli): add --passphrase-file flag for agent create-token#67
graysonhyc merged 4 commits into
mainfrom
feat/agent-token-passphrase-file

Conversation

@graysonhyc
Copy link
Copy Markdown
Collaborator

Summary

  • Adds --passphrase-file <path> to zerion agent create-token so the wallet passphrase can be read from a chmod 600 file instead of an interactive TTY prompt. Unblocks CI, headless servers, Docker / k8s / Vault, and scripted agent provisioning.
  • Argv-passed passphrases are explicitly rejected (--passphrase <value>unsupported_flag error) because they leak to ps aux, shell history, and CI logs. SSH-style file at parity is the security/UX balance.
  • Scope strictly limited to agent create-token. wallet create, wallet import, and other passphrase-gated commands still require an interactive TTY. Skill docs (zerion-agent-management, zerion-sign, zerion-trading) and the README updated to point CI users at the new flag.

File format (documented in zerion-agent-management/SKILL.md)

The file <path> points at is a plain UTF-8 text file whose entire contents are the wallet passphrase (same string a human would type at the TTY prompt). No JSON, no key=value, no headers, no quotes. One optional trailing \n / \r\n is stripped; leading/trailing spaces inside the passphrase are preserved.

Canonical usage:

umask 077
printf '%s' "\$ZERION_PASSPHRASE" > ~/.zerion-pass
chmod 600 ~/.zerion-pass
zerion agent create-token --name bot --wallet my-agent --policy <id> --passphrase-file ~/.zerion-pass
shred -u ~/.zerion-pass 2>/dev/null || rm -f ~/.zerion-pass

Security model

readPassphraseFromFile enforces, on POSIX:

  • Path resolves to a regular file (symlinks-to-files OK, but the target is what we validate).
  • File mode is 0600 — any group/other bits → refused with passphrase_file_error.
  • File is owned by the current uid — refuses another user's file even if it's mode 0600 (matches SSH IdentityFile behavior; prevents a planted symlink from leaking a different principal's secret into this process).
  • File is non-empty after the trailing-newline strip.

On Windows, perm + ownership checks are skipped (POSIX mode bits and uid not meaningful there) — users are directed at NTFS ACLs in the docs.

Argv passphrase is rejected before any keystore state is touched; flag-shape errors fail fast and never reach OWS.

What's in the diff

File Why
`cli/utils/common/prompt.js` New `readPassphraseFromFile(path)` — perm + uid + format guards
`cli/commands/agent/create-token.js` Branches on `flags["passphrase-file"]`, rejects `--passphrase `, validates flag shape before policy lookup
`cli/router.js` Self-explanatory `--help` line for the new flag
`cli/tests/unit/cli/utils/common/prompt.test.mjs` 8 new unit tests: missing, loose perms (POSIX), 0600 happy, CRLF strip, embedded-space preservation, empty file, zero-byte file, non-existent path
`skills/zerion-agent-management/SKILL.md` Non-interactive section + file-format subsection (rules table + worked examples) + cross-platform deployment table (Docker / k8s / GH Actions / Vault) + threat-model paragraph + `passphrase_file_error` row in the error table
`skills/zerion-sign/SKILL.md`, `skills/zerion-trading/SKILL.md` CI/headless callouts now point at `--passphrase-file`
`README.md` New row in the agent-tokens table + one-line intro mention

Test plan

  • `npm test` → 167/167 unit tests pass (8 new `readPassphraseFromFile` tests included)
  • Manual: `--passphrase` rejected with `unsupported_flag` (verified via `node ./cli/zerion.js agent create-token ... --passphrase x`)
  • Manual: `--passphrase-file` (no value) rejected with `missing_args`
  • Manual: `--passphrase-file=` (empty) rejected with `missing_args`
  • Manual end-to-end on a throwaway wallet — recipe in PR comments / SKILL.md:
    • happy path with `printf '%s' > /tmp/zpass && chmod 600`
    • mode-0644 file refused with `passphrase_file_error` (insecure permissions)
    • non-existent path refused with `passphrase_file_error` (not found)
    • wrong passphrase produces `ows_error` (proves the bytes reach the keystore)
  • CI smoke: GitHub Actions snippet from the skill doc, on a feature branch
  • No-op on Windows perm/uid checks (verified via guard `process.platform !== "win32"`)

Out of scope (future work)

  • `wallet create` / `wallet import` non-interactive paths.
  • OS keychain integration (1Password / Keychain / libsecret) — separate PR, different dependency surface.
  • TOCTOU between `statSync` and `readFileSync` (would need `openSync` + `fstatSync`) — accepted for now; exploit requires write access to the file's parent dir, at which point compromise is complete.

🤖 Generated with Claude Code

graysonhyc and others added 4 commits May 12, 2026 17:46
… creation

Lets `zerion agent create-token` read the wallet passphrase from a
chmod-0600 file instead of a TTY prompt, so CI, headless servers, and
scripted agent setup can issue scoped tokens without an interactive
shell. Argv-passed passphrases are intentionally not supported (they
leak to ps, shell history, CI logs); a file at SSH-private-key parity
is the security/UX balance.

- `readPassphraseFromFile()` enforces mode 0600 on POSIX, strips one
  trailing LF/CRLF (leading/trailing spaces inside the passphrase are
  preserved), rejects empty / non-regular files
- Windows skips the perm check (NTFS ACLs, not POSIX bits, are
  authoritative there — documented in SKILL.md)
- Scope limited to `agent create-token`; `wallet create/import` etc.
  still require TTY
- 8 new unit tests cover missing file, loose perms, 0600 happy path,
  CRLF, embedded spaces, empty file
- Docs updated: zerion-agent-management SKILL.md gains a
  non-interactive section + env table (Docker / k8s / GH Actions /
  Vault) + new `passphrase_file_error` row; zerion-sign and
  zerion-trading point CI users at the new flag; README adds a row to
  the agent-tokens table

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three input-validation gaps in `agent create-token`:

- `--passphrase <value>` silently fell through to the TTY prompt
  instead of refusing. Now rejects with `unsupported_flag` and points
  the user at `--passphrase-file` (argv secrets leak to ps, shell
  history, and CI logs).
- `--passphrase-file` with no path (parsed as boolean true) reached
  `readPassphraseFromFile(true)` and surfaced a confusing
  "Cannot read passphrase file" error. Now fails fast with
  `missing_args` and a usage example.
- `--passphrase-file=` (empty string) fell through to the TTY prompt
  instead of erroring. Now also rejected.

These checks moved above policy resolution so flag-shape errors
report before keystore/policy state is touched — fail-fast on user
input regardless of environment.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…docs

Pre-landing review of the --passphrase-file branch surfaced two issues.

1. cli/utils/common/prompt.js — readPassphraseFromFile only checked
   mode bits, not ownership. A symlink at the user's path resolving
   to another user's 0600 file (shared dev box, multi-tenant CI, even
   /etc/shadow on misconfigured boxes) would pass perm validation and
   leak that user's content into the calling process as a passphrase.
   Added stat.uid === process.getuid() ownership check on POSIX,
   matching SSH IdentityFile behavior. Skipped on Windows where uid
   isn't meaningful (NTFS ACLs already enforced via OS).

2. skills/zerion-agent-management/SKILL.md — "Agent vs manual" table
   classified `agent create-token` as Manual unconditionally and the
   section header read "Manual — humans only", contradicting the new
   non-interactive `--passphrase-file` subsection. Split the table
   row into default (Manual) vs --passphrase-file (Agent-capable),
   renamed the section header, and replaced the Linux-only
   /run/zerion/pass example with a portable ~/.zerion-pass primary
   path plus an explicit Linux-tmpfs / macOS-equivalent block.
   Documented the new owner-uid refusal rule.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reviewers (and users) asked what the file actually contains. The flag
description previously assumed the reader would infer that the file's
entire contents are the passphrase. Made it explicit:

- Added a one-line "what this is" sentence at the top of the section:
  the path points to a plain-text file whose contents are the wallet
  passphrase (the same string a human would type at the TTY prompt).
- Added a "File format" subsection with the encoding contract:
  plain UTF-8, raw bytes only, one optional trailing newline stripped,
  spaces inside the passphrase preserved, empty rejected.
- Added a worked-examples table showing what each common shell
  command produces and how the CLI interprets it, including the
  quote-as-literal footgun.
- Promoted the existing rule list to its own "Rules" subsection so
  format constraints and security constraints are visually separate.
- Expanded the router help string to mention plain-text contents,
  ownership, UTF-8, and the newline strip rule on one line so the
  flag is self-explanatory from `zerion --help`.

No behavior change. Tests: 167/167.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@graysonhyc graysonhyc merged commit 078cc34 into main May 12, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant