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..64c2add 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) { @@ -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; @@ -44,8 +80,22 @@ 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 (validated above; must be mode 0600). + let passphrase; + 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..de25551 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: 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/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..4e352b0 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,66 @@ export async function readPassphrase({ confirm = false } = {}) { } } +/** + * Read a passphrase from a file. Used for non-interactive automation + * (CI, headless servers, scripted agent setup). + * + * 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(). + */ +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") { + 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"); + 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..cab69be 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 @@ -61,6 +65,102 @@ 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`) + +`--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 +umask 077 # ensure new files default to 0600 +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 ~/.zerion-pass + +# Clean up immediately +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`. + +#### 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. +- Trailing newline (`\n` or `\r\n`) is stripped; leading/trailing spaces inside the passphrase are preserved. +- Empty file is rejected. +- 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: + +| 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 +248,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)