@verax-ai/body 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -54,8 +54,13 @@ your issuer. The tools the body offers are `memory.get`, `memory.put`,
54
54
  decides which of them a token's scopes may call, and a call can be held
55
55
  until an operator on this machine approves it.
56
56
 
57
- Other commands: `approve`, `operator`, `reconcile`, `witness`, `halt`,
58
- `unlock`, `desktop`. `verax --help` lists them.
57
+ Other commands: `install`, `uninstall`, `approve`, `operator`, `reconcile`,
58
+ `witness`, `halt`, `unlock`, `desktop`. `verax install` and an elevated
59
+ `verax approve` need an administrator-owned copy of this program (Windows,
60
+ Administrator PowerShell: `npm install -g --prefix "$env:ProgramFiles\verax-cli" @verax-ai/body`;
61
+ Linux and macOS: `sudo npm install -g --prefix /opt/verax-cli @verax-ai/body`). That
62
+ install is the boundary against an agent that has a shell as your user;
63
+ `verax init --local` is not that boundary. `verax --help` lists them.
59
64
 
60
65
  ## License
61
66
 
@@ -1,3 +1,4 @@
1
+ import { type ToolExec, type WindowsMachineRoots } from "./install.ts";
1
2
  export declare function resolveApproveRef(pending: {
2
3
  ref: string;
3
4
  status?: string;
@@ -9,4 +10,24 @@ export declare function resolveApproveRef(pending: {
9
10
  reason: "unknown-ref" | "ambiguous-ref";
10
11
  candidates: string[];
11
12
  };
12
- export declare function runApprove(argv: string[], writeErr?: (s: string) => void, writeOut?: (s: string) => void): Promise<number>;
13
+ export type ApproveIo = {
14
+ isTTY: boolean;
15
+ ask: (prompt: string) => Promise<string>;
16
+ };
17
+ export type ApproveHooks = {
18
+ elevated?: () => boolean;
19
+ codeProbe?: (dir: string) => boolean;
20
+ execPathProbe?: (file: string) => boolean;
21
+ execArgv?: readonly string[];
22
+ /**
23
+ * True when the invoking user can change that state directory.
24
+ * Absent means the real ACL or mode check. Ignored when the process is not elevated.
25
+ */
26
+ stateProbe?: (dir: string) => boolean;
27
+ platform?: NodeJS.Platform;
28
+ env?: NodeJS.ProcessEnv;
29
+ exec?: ToolExec;
30
+ /** Machine ProgramData and Program Files. When omitted, approve reads HKLM. */
31
+ windowsMachineRoots?: WindowsMachineRoots;
32
+ };
33
+ export declare function runApprove(argv: string[], writeErr?: (s: string) => void, writeOut?: (s: string) => void, io?: ApproveIo, hooks?: ApproveHooks): Promise<number>;
@@ -1,28 +1,46 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
  import { userInfo } from "node:os";
3
3
  import { join } from "node:path";
4
- import { approvePending, approvalsLogFor, createApprovalBudgetGuard, enqueueApprovalCommand, FileLedger, loadApprovalsFromDir, loadPolicy, } from "@verax-ai/proxy";
4
+ import { createInterface } from "node:readline";
5
+ import { approvePending, approvalsLogFor, createApprovalBudgetGuard, enqueueApprovalCommand, FileLedger, loadApprovalsFromDir, loadPolicy, TERMINAL_CONTROL_CLASS, } from "@verax-ai/proxy";
6
+ import { EX_CONFIG } from "./config.js";
7
+ import { cliCodeCheckPassedAlready, defaultElevated, directoryAccess, elevatedCommandCodeRefusal, elevatedStateDirRefusal, readWindowsMachineRoots, stateDirFor, SystemToolError, unreadableSentence, windowsProgramDataRefusal } from "./install.js";
5
8
  import { loadOrCreateSigners } from "./keys.js";
6
- function policyForApprove(stateDir, policyHash) {
7
- const fromEnv = process.env.VERAX_POLICY_FILE?.trim();
8
- if (fromEnv) {
9
- try {
10
- return loadPolicy(readFileSync(fromEnv, "utf8"));
11
- }
12
- catch {
13
- // Fall through to the snapshot the body wrote for this hash.
14
- }
15
- }
9
+ function policyFromSnapshot(stateDir, policyHash) {
16
10
  const snap = join(stateDir, "policies", `${policyHash}.json`);
17
11
  if (!existsSync(snap))
18
12
  return null;
19
13
  try {
20
- return loadPolicy(JSON.parse(readFileSync(snap, "utf8")));
14
+ const loaded = loadPolicy(JSON.parse(readFileSync(snap, "utf8")));
15
+ if (loaded.hash !== policyHash)
16
+ return null;
17
+ return loaded;
21
18
  }
22
19
  catch {
23
20
  return null;
24
21
  }
25
22
  }
23
+ function policyForApprove(stateDir, policyHash, env, elevated) {
24
+ // An elevated approve inherits the user's environment. The snapshot the body
25
+ // wrote is the policy that held the call. The environment is not.
26
+ // When not elevated, the environment file is that policy only when its
27
+ // canonical hash is the defer's policyHash. Any other file falls through
28
+ // to the snapshot.
29
+ if (!elevated) {
30
+ const fromEnv = env.VERAX_POLICY_FILE?.trim();
31
+ if (fromEnv) {
32
+ try {
33
+ const loaded = loadPolicy(readFileSync(fromEnv, "utf8"));
34
+ if (loaded.hash === policyHash)
35
+ return loaded;
36
+ }
37
+ catch {
38
+ // Fall through to the snapshot the body wrote for this hash.
39
+ }
40
+ }
41
+ }
42
+ return policyFromSnapshot(stateDir, policyHash);
43
+ }
26
44
  function operatorName() {
27
45
  try {
28
46
  const name = userInfo().username;
@@ -46,15 +64,146 @@ export function resolveApproveRef(pending, given) {
46
64
  return { ok: false, reason: "ambiguous-ref", candidates: unique };
47
65
  return { ok: false, reason: "unknown-ref", candidates: [] };
48
66
  }
49
- export async function runApprove(argv, writeErr = (s) => process.stderr.write(s), writeOut = (s) => process.stdout.write(s)) {
50
- const rest = argv.slice(1);
51
- const stateDir = rest[0];
52
- const given = rest[1];
53
- if (!stateDir || !given || rest.length !== 2) {
67
+ const NEEDS_TERMINAL = "verax approve needs a terminal: it shows what is waiting and asks you to type the amount back\n";
68
+ function integerMinor(value) {
69
+ return typeof value === "number" && Number.isInteger(value) ? value : null;
70
+ }
71
+ /** An integer minor-unit amount, or null when the stored amount cannot be read as one. */
72
+ function minorUnits(row) {
73
+ if (typeof row.amount === "number")
74
+ return integerMinor(row.amount);
75
+ return integerMinor(row.args.amountMinor);
76
+ }
77
+ const HELD_CONTROL = new RegExp(`[${TERMINAL_CONTROL_CLASS}]`, "gu");
78
+ /**
79
+ * One argument per line, name and value both JSON-quoted, so neither can look like another field.
80
+ * JSON leaves U+0085, U+FEFF, U+2028 and U+2029 as they are, so every terminal-control code point
81
+ * is written as a \u escape as well.
82
+ */
83
+ function escapeHeld(text) {
84
+ return text.replace(HELD_CONTROL, (ch) => `\\u${ch.codePointAt(0).toString(16).padStart(4, "0")}`);
85
+ }
86
+ function quotedField(name, value) {
87
+ const text = typeof value === "string" ? value : value === undefined || value === null ? "" : JSON.stringify(value);
88
+ const quotedName = escapeHeld(JSON.stringify(name));
89
+ const quoted = escapeHeld(JSON.stringify(text));
90
+ return `${quotedName}: ${quoted}\n`;
91
+ }
92
+ function spendPrompt(row, amount) {
93
+ const payee = typeof row.payee === "string" ? row.payee : String(row.args.payee ?? "");
94
+ const currency = typeof row.currency === "string" ? row.currency : String(row.args.currency ?? "");
95
+ const reference = typeof row.args.reference === "string" ? row.args.reference : "";
96
+ return [
97
+ quotedField("tool", row.subject),
98
+ quotedField("payee", payee),
99
+ quotedField("amount", String(amount)),
100
+ quotedField("currency", currency),
101
+ quotedField("reference", reference),
102
+ quotedField("requestHash", row.requestHash.slice(0, 12)),
103
+ ].join("");
104
+ }
105
+ function argumentPrompt(row) {
106
+ const lines = [quotedField("tool", row.subject)];
107
+ for (const key of Object.keys(row.args).sort())
108
+ lines.push(quotedField(key, row.args[key]));
109
+ return lines.join("");
110
+ }
111
+ function readTypedAmount() {
112
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
113
+ return new Promise((resolve) => {
114
+ rl.once("line", (line) => {
115
+ rl.close();
116
+ resolve(line);
117
+ });
118
+ });
119
+ }
120
+ function defaultApproveIo() {
121
+ return {
122
+ isTTY: Boolean(process.stdin.isTTY),
123
+ ask: () => readTypedAmount(),
124
+ };
125
+ }
126
+ export async function runApprove(argv, writeErr = (s) => process.stderr.write(s), writeOut = (s) => process.stdout.write(s), io, hooks) {
127
+ const platform = hooks?.platform ?? process.platform;
128
+ const env = hooks?.env ?? process.env;
129
+ let isElevated = false;
130
+ try {
131
+ isElevated = hooks?.elevated ? hooks.elevated() : defaultElevated(platform);
132
+ }
133
+ catch (err) {
134
+ if (err instanceof SystemToolError) {
135
+ writeErr(`${err.message}\n`);
136
+ return EX_CONFIG;
137
+ }
138
+ throw err;
139
+ }
140
+ if (isElevated && !cliCodeCheckPassedAlready()) {
141
+ const refusal = elevatedCommandCodeRefusal(platform, env, hooks?.codeProbe, {
142
+ execPathProbe: hooks?.execPathProbe,
143
+ execArgv: hooks?.execArgv,
144
+ });
145
+ if (refusal) {
146
+ writeErr(refusal.endsWith("\n") ? refusal : `${refusal}\n`);
147
+ return EX_CONFIG;
148
+ }
149
+ }
150
+ const terminal = io ?? defaultApproveIo();
151
+ const rest = argv.slice(1).filter((a) => a !== "--from-script");
152
+ const fromScript = argv.includes("--from-script");
153
+ let stateDir = rest[0];
154
+ let given = rest[1];
155
+ if (rest.length === 1 && stateDir) {
156
+ let installed;
157
+ try {
158
+ let machine = hooks?.windowsMachineRoots;
159
+ if (platform === "win32" && !machine)
160
+ machine = readWindowsMachineRoots(hooks?.exec);
161
+ if (platform === "win32") {
162
+ const rootRefusal = windowsProgramDataRefusal(env, hooks?.exec, machine);
163
+ if (rootRefusal) {
164
+ writeErr(`${rootRefusal}\n`);
165
+ return 78;
166
+ }
167
+ }
168
+ installed = stateDirFor(platform, env, undefined, machine);
169
+ }
170
+ catch (err) {
171
+ writeErr(`${err instanceof SystemToolError ? err.message : "refusing install root"}\n`);
172
+ return 78;
173
+ }
174
+ const access = directoryAccess(installed);
175
+ if (access === "unreadable") {
176
+ writeErr(`${unreadableSentence(installed, "approve")}\n`);
177
+ return 77;
178
+ }
179
+ if (access === "ok") {
180
+ given = stateDir;
181
+ stateDir = installed;
182
+ }
183
+ }
184
+ if (!stateDir || !given || (rest.length !== 2 && !(rest.length === 1 && stateDir && given))) {
54
185
  writeErr("verax approve <stateDir> <ref>\n");
55
186
  return 78;
56
187
  }
57
- const resolved = resolveApproveRef(loadApprovalsFromDir(stateDir), given);
188
+ if (isElevated) {
189
+ const owned = elevatedStateDirRefusal(stateDir, platform, env, hooks?.stateProbe);
190
+ if (owned) {
191
+ writeErr(`${owned}\n`);
192
+ return 78;
193
+ }
194
+ }
195
+ if (directoryAccess(stateDir) === "unreadable") {
196
+ writeErr(`${unreadableSentence(stateDir, "approve")}\n`);
197
+ return 77;
198
+ }
199
+ // A non-interactive shell can read the state directory. Without a person
200
+ // at a terminal, or an explicit --from-script, nothing is approved.
201
+ if (!fromScript && !terminal.isTTY) {
202
+ writeErr(NEEDS_TERMINAL);
203
+ return 78;
204
+ }
205
+ const rows = loadApprovalsFromDir(stateDir);
206
+ const resolved = resolveApproveRef(rows, given);
58
207
  if (!resolved.ok) {
59
208
  if (resolved.reason === "ambiguous-ref") {
60
209
  writeErr(`ambiguous-ref\n${resolved.candidates.join("\n")}\n`);
@@ -64,6 +213,39 @@ export async function runApprove(argv, writeErr = (s) => process.stderr.write(s)
64
213
  return 78;
65
214
  }
66
215
  const ref = resolved.ref;
216
+ const via = fromScript ? "cli-script" : "cli";
217
+ // The amount is checked before the ledger is opened. A running body holds
218
+ // the lock, and queueing first would approve without the person ever typing it.
219
+ if (!fromScript) {
220
+ const waiting = rows.find((row) => row.ref === ref);
221
+ if (!waiting) {
222
+ writeErr("approve-unknown-ref\n");
223
+ return 78;
224
+ }
225
+ if (waiting.subject === "spend") {
226
+ const expected = minorUnits(waiting);
227
+ if (expected === null) {
228
+ writeErr("approve-amount-unreadable\n");
229
+ return 1;
230
+ }
231
+ writeOut(spendPrompt(waiting, expected));
232
+ writeOut("Type the amount in minor units:\n");
233
+ const typed = (await terminal.ask("Type the amount in minor units:\n")).trim();
234
+ if (typed !== String(expected)) {
235
+ writeErr("approve-amount-mismatch\n");
236
+ return 1;
237
+ }
238
+ }
239
+ else {
240
+ writeOut(argumentPrompt(waiting));
241
+ writeOut("Type yes:\n");
242
+ const typed = (await terminal.ask("Type yes:\n")).trim();
243
+ if (typed !== "yes") {
244
+ writeErr("approve-confirm-mismatch\n");
245
+ return 1;
246
+ }
247
+ }
248
+ }
67
249
  let ledger;
68
250
  try {
69
251
  ledger = new FileLedger(stateDir);
@@ -71,7 +253,7 @@ export async function runApprove(argv, writeErr = (s) => process.stderr.write(s)
71
253
  catch (err) {
72
254
  const msg = err instanceof Error ? err.message : "";
73
255
  if (msg.startsWith("ledger-locked")) {
74
- enqueueApprovalCommand(stateDir, { ref, approverId: operatorName(), atMs: Date.now() });
256
+ enqueueApprovalCommand(stateDir, { ref, approverId: operatorName(), atMs: Date.now(), via });
75
257
  writeOut("approve-queued\n");
76
258
  return 0;
77
259
  }
@@ -87,7 +269,12 @@ export async function runApprove(argv, writeErr = (s) => process.stderr.write(s)
87
269
  writeErr("approve-unknown-ref\n");
88
270
  return 78;
89
271
  }
90
- const policy = policyForApprove(stateDir, defer.claims.policyHash);
272
+ const policy = policyForApprove(stateDir, defer.claims.policyHash, env, isElevated);
273
+ const waitingSubject = rows.find((row) => row.ref === ref)?.subject;
274
+ if (!policy && waitingSubject === "spend") {
275
+ writeErr("approve-policy-missing\n");
276
+ return 1;
277
+ }
91
278
  const result = await approvePending({
92
279
  ledger,
93
280
  recordSigner: signers.recordSigner,
@@ -95,11 +282,11 @@ export async function runApprove(argv, writeErr = (s) => process.stderr.write(s)
95
282
  nonce: () => crypto.randomUUID(),
96
283
  ref,
97
284
  approverId: operatorName(),
98
- via: "cli",
285
+ via,
99
286
  policyHash: defer.claims.policyHash,
100
287
  approvals,
101
288
  ...(policy
102
- ? { budgetGuard: createApprovalBudgetGuard({ policy, approvals, now: () => Date.now() }) }
289
+ ? { budgetGuard: createApprovalBudgetGuard({ policy, approvals, now: () => Date.now(), ledger }) }
103
290
  : {}),
104
291
  });
105
292
  if (!result.ok) {
package/dist/auth.d.ts CHANGED
@@ -10,5 +10,13 @@ export declare class JwksFileError extends Error {
10
10
  readonly code = 78;
11
11
  constructor(message?: string);
12
12
  }
13
- export declare function createVerifier(jwksUrl: string, issuer: string, audience: string, jwksFile?: string | null): (token: string) => Promise<VerifiedBearer>;
13
+ export declare function createVerifier(jwksUrl: string, issuer: string, audience: string, jwksFile?: string | null,
14
+ /**
15
+ * Keys fetched once, before this process started accepting tokens.
16
+ * `createLocalJWKSet` does not refetch. A `kid` that was not in this set
17
+ * fails closed. This is not `VERAX_JWKS_FILE`: that mode refuses operator scopes.
18
+ */
19
+ jwksPin?: {
20
+ keys: Record<string, unknown>[];
21
+ } | null): (token: string) => Promise<VerifiedBearer>;
14
22
  export declare function readBearer(header: string | undefined): string | null;
package/dist/auth.js CHANGED
@@ -35,8 +35,18 @@ export class JwksFileError extends Error {
35
35
  this.name = "JwksFileError";
36
36
  }
37
37
  }
38
- export function createVerifier(jwksUrl, issuer, audience, jwksFile) {
39
- const jwks = jwksFile ? localJwks(jwksFile) : createRemoteJWKSet(new URL(jwksUrl));
38
+ export function createVerifier(jwksUrl, issuer, audience, jwksFile,
39
+ /**
40
+ * Keys fetched once, before this process started accepting tokens.
41
+ * `createLocalJWKSet` does not refetch. A `kid` that was not in this set
42
+ * fails closed. This is not `VERAX_JWKS_FILE`: that mode refuses operator scopes.
43
+ */
44
+ jwksPin) {
45
+ const jwks = jwksFile
46
+ ? localJwks(jwksFile)
47
+ : jwksPin
48
+ ? createLocalJWKSet(jwksPin)
49
+ : createRemoteJWKSet(new URL(jwksUrl));
40
50
  return async (token) => {
41
51
  const { payload } = await jwtVerify(token, jwks, {
42
52
  issuer,
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Append this process's stdout and stderr to `file`.
3
+ * POSIX: the file is mode 0600 (umask cannot widen it).
4
+ * Windows: CreateFile sets no explicit DACL, so the new file inherits the
5
+ * parent directory ACL. The state directory's service ACL is that parent.
6
+ */
7
+ export declare function attachBodyLog(file: string): {
8
+ ok: true;
9
+ } | {
10
+ ok: false;
11
+ reason: string;
12
+ };
13
+ /** Last `n` lines, or null when the file cannot be read. A trailing newline is not an extra line. */
14
+ export declare function readLogTail(file: string, n: number): string | null;
@@ -0,0 +1,61 @@
1
+ import { chmodSync, openSync, readFileSync, writeSync } from "node:fs";
2
+ /**
3
+ * Append this process's stdout and stderr to `file`.
4
+ * POSIX: the file is mode 0600 (umask cannot widen it).
5
+ * Windows: CreateFile sets no explicit DACL, so the new file inherits the
6
+ * parent directory ACL. The state directory's service ACL is that parent.
7
+ */
8
+ export function attachBodyLog(file) {
9
+ let fd;
10
+ try {
11
+ fd = openSync(file, "a", 0o600);
12
+ }
13
+ catch (err) {
14
+ return { ok: false, reason: `cannot open log file ${file}: ${err.message}` };
15
+ }
16
+ if (process.platform !== "win32") {
17
+ try {
18
+ chmodSync(file, 0o600);
19
+ }
20
+ catch {
21
+ // The platform does not honour the mode bit.
22
+ }
23
+ }
24
+ const append = (chunk, encoding) => {
25
+ try {
26
+ const buf = typeof chunk === "string" ? Buffer.from(chunk, encoding ?? "utf8") : Buffer.from(chunk);
27
+ writeSync(fd, buf);
28
+ }
29
+ catch {
30
+ // A full disk must not hide the line already going to the console.
31
+ }
32
+ };
33
+ tee(process.stdout, append);
34
+ tee(process.stderr, append);
35
+ return { ok: true };
36
+ }
37
+ function tee(stream, append) {
38
+ const original = stream.write.bind(stream);
39
+ stream.write = ((chunk, encoding, cb) => {
40
+ if (typeof encoding === "function") {
41
+ append(chunk);
42
+ return original(chunk, encoding);
43
+ }
44
+ append(chunk, encoding);
45
+ return original(chunk, encoding, cb);
46
+ });
47
+ }
48
+ /** Last `n` lines, or null when the file cannot be read. A trailing newline is not an extra line. */
49
+ export function readLogTail(file, n) {
50
+ try {
51
+ const text = readFileSync(file, "utf8");
52
+ const lines = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
53
+ if (lines.length > 0 && lines[lines.length - 1] === "")
54
+ lines.pop();
55
+ const tail = lines.slice(-n).join("\n");
56
+ return tail === "" ? "" : `${tail}\n`;
57
+ }
58
+ catch {
59
+ return null;
60
+ }
61
+ }
package/dist/cli.d.ts CHANGED
@@ -1,2 +1,24 @@
1
1
  #!/usr/bin/env node
2
- export {};
2
+ export type CliHooks = {
3
+ elevated?: () => boolean;
4
+ codeProbe?: (dir: string) => boolean;
5
+ /** True when that Node path can be changed by the invoking user. See `NodeTrustProbe`. */
6
+ execPathProbe?: (file: string) => boolean;
7
+ /** Defaults to `process.execArgv` for the elevated preload check. */
8
+ execArgv?: readonly string[];
9
+ platform?: NodeJS.Platform;
10
+ env?: NodeJS.ProcessEnv;
11
+ stdout?: {
12
+ write(s: string): unknown;
13
+ };
14
+ stderr?: {
15
+ write(s: string): unknown;
16
+ };
17
+ };
18
+ /**
19
+ * One gate for every command except `--help` and `--version`. An elevated
20
+ * process refuses code the invoking user can change before the command runs.
21
+ * Install and approve keep their own check for direct callers; the flag stops
22
+ * a second check in this process.
23
+ */
24
+ export declare function runCli(argv: string[], hooks?: CliHooks): Promise<number>;