Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <path>` (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.
Expand Down Expand Up @@ -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 <bot> --wallet <wallet>` | Create scoped token | `zerion agent create-token --name dca-bot --wallet trading-bot` |
| `zerion agent create-token --name <bot> --wallet <wallet>` | Create scoped token (interactive passphrase) | `zerion agent create-token --name dca-bot --wallet trading-bot` |
| `zerion agent create-token … --passphrase-file <path>` | Non-interactive: passphrase read from a `chmod 600` file (CI / headless) | `zerion agent create-token --name dca-bot --wallet trading-bot --policy <id> --passphrase-file /run/zerion/pass` |
| `zerion agent list-tokens` | List active agent tokens | `zerion agent list-tokens` |
| `zerion agent use-token --wallet <wallet>` | Switch active token by wallet | `zerion agent use-token --wallet trading-bot` |
| `zerion agent revoke-token --name <bot>` | Revoke a token | `zerion agent revoke-token --name dca-bot` |
Expand Down
56 changes: 53 additions & 3 deletions cli/commands/agent/create-token.js
Original file line number Diff line number Diff line change
@@ -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) {
Expand All @@ -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 <value> — 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 <path> instead. Write the passphrase to a file with mode 0600 (chmod 600 <path>) 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 <bot> --wallet <wallet> --policy <id> --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 <bot> --wallet <wallet> --policy <id> --passphrase-file ~/.zerion-pass',
});
process.exit(1);
}

// Resolve policy — from flag or interactive picker
let policyIds;

Expand All @@ -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 <path> (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);
Expand Down
1 change: 1 addition & 0 deletions cli/router.js
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ function printUsage() {
},
agent_tokens: {
"agent create-token --name <bot> --wallet <wallet>": "Create scoped API token for unattended trading",
"agent create-token --name <bot> --wallet <wallet> --passphrase-file <path>": "Non-interactive: <path> 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 <wallet>": "Switch active agent token by wallet",
"agent revoke-token --name <bot>": "Revoke an agent token",
Expand Down
74 changes: 74 additions & 0 deletions cli/tests/unit/cli/utils/common/prompt.test.mjs
Original file line number Diff line number Diff line change
@@ -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);
});
});
61 changes: 61 additions & 0 deletions cli/utils/common/prompt.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -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).
Expand Down
105 changes: 103 additions & 2 deletions skills/zerion-agent-management/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <path>` | **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

Expand All @@ -44,7 +46,9 @@ zerion agent show-policy <id> # Full policy details
zerion agent use-token --wallet <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

Expand All @@ -61,6 +65,102 @@ zerion agent create-token --name <bot> --wallet <wallet> --policy <id1>,<id2>

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 <path>` 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 <id> \
--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/<your-user>/...` 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 <value>`) 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
Expand Down Expand Up @@ -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 <bot>` first |
| `passphrase_file_error` | `--passphrase-file` path missing, wrong perms, or empty | `chmod 600 <path>`; ensure file exists and is non-empty |
Loading
Loading