From 0a3e43f95d02cddd7a5a55c491dfcf543401bf0a Mon Sep 17 00:00:00 2001 From: Grayson Ho Date: Tue, 12 May 2026 17:46:53 +0100 Subject: [PATCH 1/4] feat(cli): add --passphrase-file flag for non-interactive agent token creation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- README.md | 5 +- cli/commands/agent/create-token.js | 21 +++++- cli/router.js | 1 + .../unit/cli/utils/common/prompt.test.mjs | 74 +++++++++++++++++++ cli/utils/common/prompt.js | 50 +++++++++++++ skills/zerion-agent-management/SKILL.md | 42 +++++++++++ skills/zerion-sign/SKILL.md | 2 +- skills/zerion-trading/SKILL.md | 2 +- 8 files changed, 190 insertions(+), 7 deletions(-) create mode 100644 cli/tests/unit/cli/utils/common/prompt.test.mjs diff --git a/README.md b/README.md index 0781edb..97b87bb 100644 --- a/README.md +++ b/README.md @@ -131,7 +131,7 @@ The agent reaches for the right skill (e.g. `zerion-analyze` for "what's in this Zerion CLI splits into two surfaces, by design. -- **Wallet management and agent token setup are manual.** `wallet create`, `import`, `backup`, and `delete` all prompt for a passphrase. `wallet sync` emits a QR code you scan with the Zerion app. `agent create-token` mints a scoped trading credential bound to a specific wallet, and `agent create-policy` attaches the rules it has to obey — allowed chains, expiry, transfer/approval gates, contract allowlists. The sibling admin commands (`agent list-tokens`, `use-token`, `revoke-token`, `list-policies`, `show-policy`, `delete-policy`) are also gestures you make yourself. No key material moves and no spending credential widens without you in the loop. +- **Wallet management and agent token setup are manual.** `wallet create`, `import`, `backup`, and `delete` all prompt for a passphrase. `wallet sync` emits a QR code you scan with the Zerion app. `agent create-token` mints a scoped trading credential bound to a specific wallet, and `agent create-policy` attaches the rules it has to obey — allowed chains, expiry, transfer/approval gates, contract allowlists. The sibling admin commands (`agent list-tokens`, `use-token`, `revoke-token`, `list-policies`, `show-policy`, `delete-policy`) are also gestures you make yourself. No key material moves and no spending credential widens without you in the loop. For CI and headless servers, `agent create-token` accepts `--passphrase-file ` (file must be mode `0600`) so token issuance can be scripted without an interactive TTY — see the `zerion-agent-management` skill. - **Analysis, signing, trading, and discovery are for agents.** `analyze`, `portfolio`, `positions`, `history`, `pnl`, `sign-message`, `sign-typed-data`, `swap`, `bridge`, `send`, `swap tokens`, `search`, `chains`, `wallet list`, `wallet fund`, and `watch list` emit JSON to stdout, structured errors to stderr, and skip confirmation dialogs. Once an agent token is configured, signing and trading fire immediately — the token authorizes operations on behalf of the wallet without a passphrase prompt. Setup gestures (`init`, `setup skills`, `config set/unset/list`, `watch` add/remove) are one-time configuration steps you run yourself before automation takes over. @@ -264,7 +264,8 @@ Scoped API tokens for unattended trading. Token auto-saves to config; required f | Command | Description | Example | |---------|-------------|---------| -| `zerion agent create-token --name --wallet ` | Create scoped token | `zerion agent create-token --name dca-bot --wallet trading-bot` | +| `zerion agent create-token --name --wallet ` | Create scoped token (interactive passphrase) | `zerion agent create-token --name dca-bot --wallet trading-bot` | +| `zerion agent create-token … --passphrase-file ` | Non-interactive: passphrase read from a `chmod 600` file (CI / headless) | `zerion agent create-token --name dca-bot --wallet trading-bot --policy --passphrase-file /run/zerion/pass` | | `zerion agent list-tokens` | List active agent tokens | `zerion agent list-tokens` | | `zerion agent use-token --wallet ` | Switch active token by wallet | `zerion agent use-token --wallet trading-bot` | | `zerion agent revoke-token --name ` | Revoke a token | `zerion agent revoke-token --name dca-bot` | diff --git a/cli/commands/agent/create-token.js b/cli/commands/agent/create-token.js index 9b843b7..18eee56 100644 --- a/cli/commands/agent/create-token.js +++ b/cli/commands/agent/create-token.js @@ -1,7 +1,7 @@ import * as ows from "../../utils/wallet/keystore.js"; import { print, printError } from "../../utils/common/output.js"; import { getConfigValue, setConfigValue, saveAgentToken } from "../../utils/config.js"; -import { readPassphrase } from "../../utils/common/prompt.js"; +import { readPassphrase, readPassphraseFromFile } from "../../utils/common/prompt.js"; import { pickPolicyInteractive } from "../../utils/wallet/policy-picker.js"; export default async function agentCreateToken(args, flags) { @@ -44,8 +44,23 @@ export default async function agentCreateToken(args, flags) { policyIds = [policyId]; } - // Passphrase to prove wallet ownership — always interactive (after policy is resolved) - const passphrase = await readPassphrase(); + // Passphrase to prove wallet ownership. + // Default: interactive TTY prompt (after policy is resolved). + // Non-interactive: --passphrase-file (must be mode 0600). + let passphrase; + const passphraseFile = flags["passphrase-file"]; + if (passphraseFile) { + try { + passphrase = readPassphraseFromFile(passphraseFile); + } catch (err) { + printError("passphrase_file_error", err.message, { + suggestion: "Ensure the file exists, is mode 0600, and contains the passphrase.", + }); + process.exit(1); + } + } else { + passphrase = await readPassphrase(); + } try { const result = ows.createAgentToken(name, walletName, passphrase, flags.expires, policyIds); diff --git a/cli/router.js b/cli/router.js index 18a143e..e0c1e5d 100644 --- a/cli/router.js +++ b/cli/router.js @@ -58,6 +58,7 @@ function printUsage() { }, agent_tokens: { "agent create-token --name --wallet ": "Create scoped API token for unattended trading", + "agent create-token --name --wallet --passphrase-file ": "Non-interactive: read passphrase from a file (must be mode 0600)", "agent list-tokens": "List active agent tokens", "agent use-token --wallet ": "Switch active agent token by wallet", "agent revoke-token --name ": "Revoke an agent token", diff --git a/cli/tests/unit/cli/utils/common/prompt.test.mjs b/cli/tests/unit/cli/utils/common/prompt.test.mjs new file mode 100644 index 0000000..5a24543 --- /dev/null +++ b/cli/tests/unit/cli/utils/common/prompt.test.mjs @@ -0,0 +1,74 @@ +import assert from "node:assert/strict"; +import { describe, it, before, after } from "node:test"; +import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { readPassphraseFromFile } from "#zerion/utils/common/prompt.js"; + +const isWindows = process.platform === "win32"; + +describe("readPassphraseFromFile", () => { + let dir; + + before(() => { + dir = mkdtempSync(join(tmpdir(), "zerion-pass-")); + }); + + after(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + it("throws when file does not exist", () => { + const missing = join(dir, "nope.txt"); + assert.throws(() => readPassphraseFromFile(missing), /not found/i); + }); + + it("refuses files with group-readable perms (POSIX only)", { skip: isWindows }, () => { + const path = join(dir, "loose.txt"); + writeFileSync(path, "my-pass", { mode: 0o644 }); + chmodSync(path, 0o644); + assert.throws(() => readPassphraseFromFile(path), /insecure permissions/i); + }); + + it("refuses files with world-readable perms (POSIX only)", { skip: isWindows }, () => { + const path = join(dir, "world.txt"); + writeFileSync(path, "my-pass", { mode: 0o604 }); + chmodSync(path, 0o604); + assert.throws(() => readPassphraseFromFile(path), /insecure permissions/i); + }); + + it("reads a 0600 file and strips one trailing LF", () => { + const path = join(dir, "good-lf.txt"); + writeFileSync(path, "my-pass\n", { mode: 0o600 }); + chmodSync(path, 0o600); + assert.equal(readPassphraseFromFile(path), "my-pass"); + }); + + it("reads a 0600 file and strips one trailing CRLF", () => { + const path = join(dir, "good-crlf.txt"); + writeFileSync(path, "my-pass\r\n", { mode: 0o600 }); + chmodSync(path, 0o600); + assert.equal(readPassphraseFromFile(path), "my-pass"); + }); + + it("preserves leading and trailing spaces inside passphrase", () => { + const path = join(dir, "spaces.txt"); + writeFileSync(path, " pass with spaces \n", { mode: 0o600 }); + chmodSync(path, 0o600); + assert.equal(readPassphraseFromFile(path), " pass with spaces "); + }); + + it("rejects empty file (newline only)", () => { + const path = join(dir, "empty.txt"); + writeFileSync(path, "\n", { mode: 0o600 }); + chmodSync(path, 0o600); + assert.throws(() => readPassphraseFromFile(path), /empty/i); + }); + + it("rejects zero-byte file", () => { + const path = join(dir, "zero.txt"); + writeFileSync(path, "", { mode: 0o600 }); + chmodSync(path, 0o600); + assert.throws(() => readPassphraseFromFile(path), /empty/i); + }); +}); diff --git a/cli/utils/common/prompt.js b/cli/utils/common/prompt.js index db586a7..dd97ff1 100644 --- a/cli/utils/common/prompt.js +++ b/cli/utils/common/prompt.js @@ -3,6 +3,7 @@ */ import { createInterface } from "node:readline"; +import { readFileSync, statSync } from "node:fs"; export function readSecret(prompt, { mask = false } = {}) { return new Promise((resolve) => { @@ -82,6 +83,55 @@ export async function readPassphrase({ confirm = false } = {}) { } } +/** + * Read a passphrase from a file. Used for non-interactive automation + * (CI, headless servers, scripted agent setup). + * + * Security: refuse to read the file unless it is mode 0600 (owner-only). + * The passphrase unlocks the keystore — same threat model as an SSH + * private key, same perm requirement. Perm check is skipped on Windows + * (POSIX mode bits not meaningful there). + * + * Strips exactly one trailing newline (\n or \r\n) — passphrases may + * legitimately contain leading/trailing spaces, so don't .trim(). + */ +export function readPassphraseFromFile(path) { + let stat; + try { + stat = statSync(path); + } catch (err) { + if (err.code === "ENOENT") { + throw new Error(`Passphrase file not found: ${path}`); + } + throw new Error(`Cannot read passphrase file: ${err.message}`); + } + + if (!stat.isFile()) { + throw new Error(`Passphrase file is not a regular file: ${path}`); + } + + if (process.platform !== "win32" && (stat.mode & 0o077) !== 0) { + const got = (stat.mode & 0o777).toString(8).padStart(3, "0"); + throw new Error( + `Passphrase file ${path} has insecure permissions (mode ${got}). ` + + `Run: chmod 600 ${path}` + ); + } + + const raw = readFileSync(path, "utf8"); + const passphrase = raw.endsWith("\r\n") + ? raw.slice(0, -2) + : raw.endsWith("\n") + ? raw.slice(0, -1) + : raw; + + if (!passphrase) { + throw new Error(`Passphrase file is empty: ${path}`); + } + + return passphrase; +} + /** * Simple y/n confirmation prompt. Returns true for yes, false for no. * Defaults to yes on empty input (use `defaultYes: false` to invert). diff --git a/skills/zerion-agent-management/SKILL.md b/skills/zerion-agent-management/SKILL.md index 0f7ad33..2d941c1 100644 --- a/skills/zerion-agent-management/SKILL.md +++ b/skills/zerion-agent-management/SKILL.md @@ -61,6 +61,47 @@ zerion agent create-token --name --wallet --policy , The token is auto-saved to `~/.zerion/config.json` under `agentTokens` and (if no token was active before) becomes the default. Trading commands (`zerion-trading`) and signing commands (`zerion-sign`) read it from config. +### Non-interactive token creation (`--passphrase-file`) + +For CI, headless servers, or scripted agent setup, read the passphrase from a file instead of the TTY prompt: + +```bash +# Write the passphrase to a file readable only by you +umask 077 # ensure new files default to 0600 +printf '%s' "$ZERION_PASSPHRASE" > /run/zerion/pass +chmod 600 /run/zerion/pass # required — refused if looser + +# Create the token non-interactively +zerion agent create-token \ + --name bot --wallet my-agent \ + --policy \ + --passphrase-file /run/zerion/pass + +# Clean up immediately +shred -u /run/zerion/pass 2>/dev/null || rm -f /run/zerion/pass +``` + +Rules: + +- File **must be mode `0600`** (owner read/write only). Any group/other bits → CLI refuses with `passphrase_file_error`. +- Trailing newline (`\n` or `\r\n`) is stripped; leading/trailing spaces inside the passphrase are preserved. +- Empty file is rejected. +- Permission check is skipped on Windows (POSIX mode bits not meaningful there) — use NTFS ACLs to restrict access instead. + +Recommended patterns: + +| Environment | Source | Notes | +|---|---|---| +| Local dev | `~/.zerion-pass` (chmod 600) | Delete after use. | +| Docker | Bind-mount a tmpfs file | `--tmpfs /run/zerion:mode=0700` then write passphrase inside. | +| Kubernetes | Mount a `Secret` as a file | Default mount mode is `0644` — set `defaultMode: 0600` in the volume spec. | +| GitHub Actions | Write `${{ secrets.X }}` to a temp file, `chmod 600`, then run | Cleanup is automatic at job end. | +| HashiCorp Vault | `vault agent` template renders to a 0600 file | Renew & rotate centrally. | + +Threat model: same as an SSH private key on disk. Anyone with **active-session read access to the file** can unlock the keystore. Keep the file path off shared filesystems, out of git, out of process listings. Argv-passed passphrases (`--passphrase `) are **not** supported because they leak to `ps aux`, shell history, and CI logs. + +`--passphrase-file` only affects `agent create-token` for now. `wallet create`, `wallet import`, and other passphrase-gated commands still require an interactive TTY. + ### Revoke a token ```bash @@ -148,3 +189,4 @@ zerion agent create-token --name agent-bot \ | `policy_not_found` | Policy ID doesn't exist | `agent list-policies` to find valid IDs | | `policy_no_rules` | `create-policy` with no flags | Add at least one rule (`--chains`, `--expires`, `--deny-*`, `--allowlist`) | | `token_name_exists` | Duplicate `--name` | Choose another name or `agent revoke-token --name ` first | +| `passphrase_file_error` | `--passphrase-file` path missing, wrong perms, or empty | `chmod 600 `; ensure file exists and is non-empty | diff --git a/skills/zerion-sign/SKILL.md b/skills/zerion-sign/SKILL.md index 96ff3d9..2b75be9 100644 --- a/skills/zerion-sign/SKILL.md +++ b/skills/zerion-sign/SKILL.md @@ -75,7 +75,7 @@ If no agent token is configured and stderr is a TTY, the CLI offers: Want to setup an agent token for ""? [Y/n] ``` -…and runs `agent create-token` inline. After that completes, the original `sign-*` command continues with the fresh token. In non-TTY contexts (CI, piped) the command fails fast with `no_agent_token` — see `zerion-agent-management`. +…and runs `agent create-token` inline. After that completes, the original `sign-*` command continues with the fresh token. In non-TTY contexts (CI, piped) the command fails fast with `no_agent_token` — pre-create the token with `agent create-token --passphrase-file <0600-path>` (see `zerion-agent-management`). ## Security diff --git a/skills/zerion-trading/SKILL.md b/skills/zerion-trading/SKILL.md index 3e9a197..ca1c7f8 100644 --- a/skills/zerion-trading/SKILL.md +++ b/skills/zerion-trading/SKILL.md @@ -34,7 +34,7 @@ zerion wallet list # confirm wallet exists, see active pol zerion agent list-tokens # confirm agent token is set ``` -If no agent token, the CLI offers an inline create-token prompt on the next trade attempt (TTY only). In CI / piped contexts, see `zerion-agent-management`. +If no agent token, the CLI offers an inline create-token prompt on the next trade attempt (TTY only). In CI / piped contexts, pre-create the token with `zerion agent create-token --passphrase-file <0600-path>` — see `zerion-agent-management`. ## Swap (same-chain) From 838629e8312f90589db283e974e3dac6caf51605 Mon Sep 17 00:00:00 2001 From: Grayson Ho Date: Tue, 12 May 2026 17:56:54 +0100 Subject: [PATCH 2/4] fix(cli): validate passphrase flag shape before any state lookup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three input-validation gaps in `agent create-token`: - `--passphrase ` 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) --- cli/commands/agent/create-token.js | 39 ++++++++++++++++++++++++++++-- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/cli/commands/agent/create-token.js b/cli/commands/agent/create-token.js index 18eee56..64c2add 100644 --- a/cli/commands/agent/create-token.js +++ b/cli/commands/agent/create-token.js @@ -22,6 +22,42 @@ export default async function agentCreateToken(args, flags) { process.exit(1); } + // Validate passphrase-related flags up front (fail fast before any state lookup). + // Reject --passphrase — argv-passed secrets leak to ps/history/logs. + if (Object.prototype.hasOwnProperty.call(flags, "passphrase")) { + printError( + "unsupported_flag", + "--passphrase is not supported (argv-passed secrets leak to `ps`, shell history, and CI logs).", + { + suggestion: + "Use --passphrase-file instead. Write the passphrase to a file with mode 0600 (chmod 600 ) and pass the path.", + } + ); + process.exit(1); + } + + const passphraseFile = flags["passphrase-file"]; + if (passphraseFile === true) { + printError("missing_args", "--passphrase-file requires a path argument", { + example: + 'zerion agent create-token --name --wallet --policy --passphrase-file ~/.zerion-pass', + }); + process.exit(1); + } + if (passphraseFile != null && typeof passphraseFile !== "string") { + printError("invalid_flag", "--passphrase-file must be a string path", { + suggestion: "Example: --passphrase-file /run/zerion/pass", + }); + process.exit(1); + } + if (typeof passphraseFile === "string" && passphraseFile.trim() === "") { + printError("missing_args", "--passphrase-file path cannot be empty", { + example: + 'zerion agent create-token --name --wallet --policy --passphrase-file ~/.zerion-pass', + }); + process.exit(1); + } + // Resolve policy — from flag or interactive picker let policyIds; @@ -46,9 +82,8 @@ export default async function agentCreateToken(args, flags) { // Passphrase to prove wallet ownership. // Default: interactive TTY prompt (after policy is resolved). - // Non-interactive: --passphrase-file (must be mode 0600). + // Non-interactive: --passphrase-file (validated above; must be mode 0600). let passphrase; - const passphraseFile = flags["passphrase-file"]; if (passphraseFile) { try { passphrase = readPassphraseFromFile(passphraseFile); From 2edb959856fba7384a953bec2d446ea9a699124c Mon Sep 17 00:00:00 2001 From: Grayson Ho Date: Tue, 12 May 2026 18:36:44 +0100 Subject: [PATCH 3/4] review(cli): enforce file-owner uid match and clarify cross-platform docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- cli/utils/common/prompt.js | 31 +++++++++++++++++-------- skills/zerion-agent-management/SKILL.md | 30 ++++++++++++++++++------ 2 files changed, 44 insertions(+), 17 deletions(-) diff --git a/cli/utils/common/prompt.js b/cli/utils/common/prompt.js index dd97ff1..4e352b0 100644 --- a/cli/utils/common/prompt.js +++ b/cli/utils/common/prompt.js @@ -87,10 +87,13 @@ export async function readPassphrase({ confirm = false } = {}) { * Read a passphrase from a file. Used for non-interactive automation * (CI, headless servers, scripted agent setup). * - * Security: refuse to read the file unless it is mode 0600 (owner-only). - * The passphrase unlocks the keystore — same threat model as an SSH - * private key, same perm requirement. Perm check is skipped on Windows - * (POSIX mode bits not meaningful there). + * Security: on POSIX, refuse to read the file unless it is mode 0600 + * AND owned by the current uid. The passphrase unlocks the keystore — + * same threat model as an SSH private key, same perm + ownership + * requirement. Without the uid check, a symlink at the given path + * could resolve to another user's 0600 file on a shared host. Perm + * and ownership checks are skipped on Windows (POSIX mode bits and + * uid are not meaningful there; use NTFS ACLs instead). * * Strips exactly one trailing newline (\n or \r\n) — passphrases may * legitimately contain leading/trailing spaces, so don't .trim(). @@ -110,12 +113,20 @@ export function readPassphraseFromFile(path) { throw new Error(`Passphrase file is not a regular file: ${path}`); } - if (process.platform !== "win32" && (stat.mode & 0o077) !== 0) { - const got = (stat.mode & 0o777).toString(8).padStart(3, "0"); - throw new Error( - `Passphrase file ${path} has insecure permissions (mode ${got}). ` + - `Run: chmod 600 ${path}` - ); + if (process.platform !== "win32") { + if ((stat.mode & 0o077) !== 0) { + const got = (stat.mode & 0o777).toString(8).padStart(3, "0"); + throw new Error( + `Passphrase file ${path} has insecure permissions (mode ${got}). ` + + `Run: chmod 600 ${path}` + ); + } + if (typeof process.getuid === "function" && stat.uid !== process.getuid()) { + throw new Error( + `Passphrase file ${path} is not owned by the current user (uid ${stat.uid}). ` + + `Refusing to read another user's file.` + ); + } } const raw = readFileSync(path, "utf8"); diff --git a/skills/zerion-agent-management/SKILL.md b/skills/zerion-agent-management/SKILL.md index 2d941c1..b23d006 100644 --- a/skills/zerion-agent-management/SKILL.md +++ b/skills/zerion-agent-management/SKILL.md @@ -33,7 +33,9 @@ Requires Node.js ≥ 20. For auth see the `zerion` umbrella skill. To execute tr | Operation | Type | Notes | |-----------|------|-------| | `agent list-tokens`, `agent list-policies`, `agent show-policy`, `agent use-token` | **Agent** | Read-only or config-only. Safe autonomously. | -| `agent create-token`, `agent revoke-token`, `agent create-policy`, `agent delete-policy` | **Manual** | Require passphrase or confirmation. Humans must run these directly. | +| `agent create-token` (default) | **Manual** | Interactive passphrase prompt. Humans run it. | +| `agent create-token --passphrase-file ` | **Agent-capable** | Reads passphrase from a `chmod 600` file. For CI / headless / scripted setup once the file is provisioned by a human or secrets manager. | +| `agent revoke-token`, `agent create-policy`, `agent delete-policy` | **Manual** | Require passphrase or confirmation. Humans must run these directly. | ## Read-only — agents may invoke freely @@ -44,7 +46,9 @@ zerion agent show-policy # Full policy details zerion agent use-token --wallet # Switch the active token (config edit, no passphrase) ``` -## Manual — humans only +## Token + policy management + +The default flow is human-interactive. `agent create-token` also has a non-interactive variant (`--passphrase-file`) for automation once the passphrase file is provisioned. Everything else in this section requires a human. ### Create an agent token @@ -68,25 +72,37 @@ For CI, headless servers, or scripted agent setup, read the passphrase from a fi ```bash # Write the passphrase to a file readable only by you umask 077 # ensure new files default to 0600 -printf '%s' "$ZERION_PASSPHRASE" > /run/zerion/pass -chmod 600 /run/zerion/pass # required — refused if looser +printf '%s' "$ZERION_PASSPHRASE" > ~/.zerion-pass +chmod 600 ~/.zerion-pass # required — refused if looser # Create the token non-interactively zerion agent create-token \ --name bot --wallet my-agent \ --policy \ - --passphrase-file /run/zerion/pass + --passphrase-file ~/.zerion-pass # Clean up immediately -shred -u /run/zerion/pass 2>/dev/null || rm -f /run/zerion/pass +shred -u ~/.zerion-pass 2>/dev/null || rm -f ~/.zerion-pass +``` + +For ephemeral storage (recommended on Linux), use a tmpfs mount instead of `$HOME`: + +```bash +# Linux only — /run is tmpfs (RAM-only, gone on reboot) +sudo install -d -m 0700 -o "$USER" /run/zerion +printf '%s' "$ZERION_PASSPHRASE" > /run/zerion/pass && chmod 600 /run/zerion/pass +zerion agent create-token ... --passphrase-file /run/zerion/pass ``` +On macOS, use `/private/tmp//...` or a per-app dir under `~/Library/Caches/`. macOS does not ship a user-writable `/run`. + Rules: - File **must be mode `0600`** (owner read/write only). Any group/other bits → CLI refuses with `passphrase_file_error`. +- File **must be owned by the current uid** (POSIX). Reading another user's file (e.g. via symlink) is refused. Matches SSH `IdentityFile` behavior. - Trailing newline (`\n` or `\r\n`) is stripped; leading/trailing spaces inside the passphrase are preserved. - Empty file is rejected. -- Permission check is skipped on Windows (POSIX mode bits not meaningful there) — use NTFS ACLs to restrict access instead. +- Permission and ownership checks are skipped on Windows (POSIX mode bits and uid not meaningful there) — use NTFS ACLs to restrict access instead. Recommended patterns: From 57d589061bf4f31c0d2b9d76e95bd8cb56fcf6f1 Mon Sep 17 00:00:00 2001 From: Grayson Ho Date: Tue, 12 May 2026 18:40:20 +0100 Subject: [PATCH 4/4] docs(skill): document --passphrase-file file format explicitly 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) --- cli/router.js | 2 +- skills/zerion-agent-management/SKILL.md | 47 +++++++++++++++++++++++-- 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/cli/router.js b/cli/router.js index e0c1e5d..de25551 100644 --- a/cli/router.js +++ b/cli/router.js @@ -58,7 +58,7 @@ function printUsage() { }, agent_tokens: { "agent create-token --name --wallet ": "Create scoped API token for unattended trading", - "agent create-token --name --wallet --passphrase-file ": "Non-interactive: read passphrase from a file (must be mode 0600)", + "agent create-token --name --wallet --passphrase-file ": "Non-interactive: is a plain-text file whose contents are the wallet passphrase (must be mode 0600, owned by you, UTF-8, one optional trailing newline stripped)", "agent list-tokens": "List active agent tokens", "agent use-token --wallet ": "Switch active agent token by wallet", "agent revoke-token --name ": "Revoke an agent token", diff --git a/skills/zerion-agent-management/SKILL.md b/skills/zerion-agent-management/SKILL.md index b23d006..cab69be 100644 --- a/skills/zerion-agent-management/SKILL.md +++ b/skills/zerion-agent-management/SKILL.md @@ -67,7 +67,9 @@ The token is auto-saved to `~/.zerion/config.json` under `agentTokens` and (if n ### Non-interactive token creation (`--passphrase-file`) -For CI, headless servers, or scripted agent setup, read the passphrase from a file instead of the TTY prompt: +`--passphrase-file ` takes a path to a **plain-text file whose entire contents are the wallet passphrase** (the same string a human would type at the TTY prompt). The CLI reads the file once and uses its contents to unlock the keystore. Nothing else lives in the file — no JSON, no key=value, no header. + +For CI, headless servers, or scripted agent setup, write the passphrase to such a file instead of typing it: ```bash # Write the passphrase to a file readable only by you @@ -96,7 +98,48 @@ zerion agent create-token ... --passphrase-file /run/zerion/pass On macOS, use `/private/tmp//...` or a per-app dir under `~/Library/Caches/`. macOS does not ship a user-writable `/run`. -Rules: +#### File format + +Plain UTF-8 text. The entire file (minus exactly one optional trailing `\n` or `\r\n`) is treated as the passphrase. + +| Rule | Reason | +|---|---| +| Content = raw passphrase bytes only | No JSON, no `key=value`, no comments, no quotes. Anything in the file is part of the passphrase. | +| One trailing `\n` or `\r\n` stripped | So `echo "pass" > file` works as expected. Additional newlines are kept. | +| Leading / trailing spaces inside the passphrase are preserved | Passphrases may legitimately contain them. CLI does **not** `.trim()`. | +| UTF-8 encoded | Read via `readFileSync(path, "utf8")`. Non-UTF-8 bytes become replacement chars. | +| Non-empty | Empty file or newline-only file → rejected. | +| Mode `0600` (POSIX) | Refused otherwise — see Rules below. | +| Regular file, owned by current uid | Symlinks-to-files are followed; the target must still pass perm + ownership checks. | + +Examples (file bytes → passphrase used by CLI): + +| Command | File bytes | Passphrase result | +|---|---|---| +| `printf '%s' 'hunter2' > f` | `hunter2` | `hunter2` ✅ | +| `printf '%s\n' 'hunter2' > f` | `hunter2\n` | `hunter2` ✅ | +| `echo 'hunter2' > f` | `hunter2\n` | `hunter2` ✅ | +| `echo '"hunter2"' > f` | `"hunter2"\n` | `"hunter2"` (quotes included!) ❌ | +| `echo ' spaces ' > f` | ` spaces \n` | ` spaces ` (spaces kept) ✅ | +| `printf '' > f` | (empty) | rejected ❌ | +| `printf '\n' > f` | `\n` | rejected ❌ | + +Canonical form — no trailing newline, no surprises: + +```bash +umask 077 +printf '%s' 'YOUR-PASSPHRASE' > ~/.zerion-pass +chmod 600 ~/.zerion-pass +``` + +Verify before using: + +```bash +wc -c ~/.zerion-pass # byte count == passphrase length +xxd ~/.zerion-pass | head # eyeball raw bytes — no BOM, no quotes, no CRLF +``` + +#### Rules - File **must be mode `0600`** (owner read/write only). Any group/other bits → CLI refuses with `passphrase_file_error`. - File **must be owned by the current uid** (POSIX). Reading another user's file (e.g. via symlink) is refused. Matches SSH `IdentityFile` behavior.