totemlayer 0.1.0 → 0.1.2

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.
Files changed (3) hide show
  1. package/README.md +17 -1
  2. package/bin/totem.mjs +1056 -156
  3. package/package.json +1 -1
package/bin/totem.mjs CHANGED
@@ -4,9 +4,7 @@ import { createHash, randomUUID } from "node:crypto";
4
4
  import { createReadStream } from "node:fs";
5
5
  import { stripVTControlCharacters } from "node:util";
6
6
 
7
- const RFQ_EXAMPLE =
8
- "Request a quote for 50,000 Industrial Sensors (SKU-1001) from SupplyCo using Acme Procurement Agent.";
9
- const CLI_VERSION = "0.1.0";
7
+ const CLI_VERSION = "0.1.2";
10
8
  const DEFAULT_BASE_URL = "https://totemlayer.com";
11
9
  const API_KEY_PATTERN = /^at_(?:live|test)_[A-Za-z0-9_-]{20,}$/;
12
10
  const ACTION_PATTERN = /^[a-z0-9][a-z0-9._:-]*$/;
@@ -16,73 +14,361 @@ const TERMINAL_UNSAFE_PATTERN =
16
14
  /[\u0000-\u001f\u007f-\u009f\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/g;
17
15
  const MAX_JSON_INPUT_BYTES = 128 * 1_024;
18
16
  const MAX_REQUEST_FILE_BYTES = 16 * 1_024 * 1_024;
19
- const PRESENTATION_REJECTION_CODES = new Set([
20
- "CHALLENGE_NOT_FOUND",
21
- "CHALLENGE_NOT_PRESENTED",
22
- "CHALLENGE_ALREADY_PRESENTED",
17
+ const ISO_CURRENCY_CODES = new Set(Intl.supportedValuesOf("currency"));
18
+ const BRAND_ART = [
19
+ " ╭╮",
20
+ " ╭───╯╰────╮ ╺┳╸┏━┓╺┳╸┏━╸┏┳┓",
21
+ " ╰────╮╭────╯ ┃ ┃ ┃ ┃ ┣╸ ┃┃┃",
22
+ " ╰╯ ╹ ┗━┛ ╹ ┗━╸╹ ╹",
23
+ ];
24
+ const HANDSHAKE_REJECTION_CODES = new Set([
25
+ "AGENT_AUTHORITY_REVOKED",
26
+ "BEACON_VERIFICATION_FAILED",
23
27
  "CHALLENGE_ALREADY_CONSUMED",
28
+ "CHALLENGE_ALREADY_PRESENTED",
24
29
  "CHALLENGE_EXPIRED",
30
+ "CHALLENGE_NOT_FOUND",
31
+ "CHALLENGE_NOT_PENDING",
32
+ "CHALLENGE_NOT_PRESENTED",
33
+ "CONTACT_RELATIONSHIP_CHANGED",
34
+ "CONTACT_RELATIONSHIP_REQUIRED",
35
+ "CREDENTIAL_NOT_PRESENTABLE",
36
+ "ENVIRONMENT_MISMATCH",
37
+ "ENVIRONMENT_UNCLASSIFIED",
38
+ "IDEMPOTENCY_KEY_REUSED",
39
+ "INVALID_PRESENTATION_SIGNATURE",
25
40
  "PRESENTATION_EXPIRED",
41
+ "PRESENTATION_CLAIMS_MISMATCH",
26
42
  "PRESENTATION_MISMATCH",
27
43
  "PRESENTER_AGENT_INACTIVE",
28
- "AGENT_AUTHORITY_REVOKED",
29
- "INVALID_PRESENTATION_SIGNATURE",
30
- "PRESENTATION_CLAIMS_MISMATCH",
44
+ "TRANSACTION_NOT_AUTHORIZED",
45
+ "VERIFIER_AGENT_INACTIVE",
46
+ ]);
47
+ const ACTION_CHECK_SUITE_STATUSES = new Set(["QUEUED", "RUNNING", "COMPLETED", "STALE"]);
48
+ const ACTION_CHECK_SUITE_CONCLUSIONS = new Set([
49
+ "ALLOW",
50
+ "ALLOW_WITH_WARNINGS",
51
+ "REQUIRES_APPROVAL",
52
+ "BLOCK",
53
+ "ERROR",
54
+ ]);
55
+ const ACTION_EXECUTION_OUTCOMES = new Set(["SUCCEEDED", "FAILED", "UNKNOWN", "COMPENSATED"]);
56
+ const ACTION_CHECK_POLICY_CODES = new Set([
57
+ "ACTION_APPROVAL_REQUIRED",
58
+ "ACTION_APPROVAL_STALE",
59
+ "ACTION_AUTHORITY_CHANGED",
60
+ "ACTION_CHECKS_NOT_ALLOWED",
61
+ "ACTION_CONFIRMATION_MISMATCH",
62
+ "ACTION_DIGEST_MISMATCH",
63
+ "ACTION_EXECUTION_ATTEMPT_CONFLICT",
64
+ "ACTION_EXPIRED",
65
+ "ACTION_FINAL_CHECKS_FAILED",
66
+ "ACTION_GRANT_BINDING_MISMATCH",
67
+ "ACTION_GRANT_EXPIRED",
68
+ "ACTION_GRANT_INVALID",
69
+ "ACTION_GRANT_REPLAYED",
70
+ "ACTION_POLICY_CHANGED",
71
+ "ACTION_RECEIPT_STATE_CHANGED",
72
+ "ACTION_SUITE_EXPIRED",
73
+ "ACTION_SUITE_INCOMPLETE",
74
+ "ACTION_SUITE_STALE",
75
+ "ACTION_SUITE_STATE_CHANGED",
76
+ "DEMO_FIXTURE_INACTIVE",
77
+ "DEMO_FIXTURE_NOT_OWNED",
78
+ "DESTRUCTIVE_TARGET_UNCONSTRAINED",
79
+ "EXECUTOR_ADAPTER_MISMATCH",
80
+ "EXECUTOR_ADAPTER_NOT_PUBLIC",
81
+ "FINANCIAL_CONTEXT_MISSING",
31
82
  ]);
32
83
 
84
+ function supportsColor(stream = process.stdout) {
85
+ return Boolean(stream.isTTY && !process.env.NO_COLOR && process.env.TERM !== "dumb");
86
+ }
87
+
88
+ function styled(value, code, stream = process.stdout) {
89
+ return supportsColor(stream) ? `\u001b[${code}m${value}\u001b[0m` : value;
90
+ }
91
+
92
+ function interactiveWidth(stream = process.stdout) {
93
+ const columns = Number(stream.columns);
94
+ if (!stream.isTTY || !Number.isInteger(columns) || columns < 48) return undefined;
95
+ return Math.min(columns, 120);
96
+ }
97
+
98
+ function wrappedTerminalLines(value, prefixLength, stream = process.stdout) {
99
+ const width = interactiveWidth(stream);
100
+ if (!width) return [value];
101
+ const available = Math.max(24, width - prefixLength - 1);
102
+ const words = value.split(" ");
103
+ const lines = [];
104
+ let line = "";
105
+ for (const word of words) {
106
+ if (!line) {
107
+ line = word;
108
+ continue;
109
+ }
110
+ if (line.length + word.length + 1 <= available) {
111
+ line += ` ${word}`;
112
+ continue;
113
+ }
114
+ lines.push(line);
115
+ line = word;
116
+ }
117
+ if (line) lines.push(line);
118
+ return lines.length > 0 ? lines : [""];
119
+ }
120
+
121
+ function writeHumanLine(
122
+ label,
123
+ value,
124
+ { stream = process.stdout, labelCode = "2", valueCode, suffix = ":" } = {},
125
+ ) {
126
+ const safeLabel = terminalText(label, "Result");
127
+ const safeValue = terminalText(value, "unknown");
128
+ const prefix = `${safeLabel}${suffix} `;
129
+ const continuation = " ".repeat(prefix.length);
130
+ for (const [index, line] of wrappedTerminalLines(safeValue, prefix.length, stream).entries()) {
131
+ const renderedValue = valueCode ? styled(line, valueCode, stream) : line;
132
+ stream.write(
133
+ index === 0
134
+ ? `${styled(`${safeLabel}${suffix}`, labelCode, stream)} ${renderedValue}\n`
135
+ : `${continuation}${renderedValue}\n`,
136
+ );
137
+ }
138
+ }
139
+
140
+ function wrappedHelpLines(line, width) {
141
+ if (!width || line.length <= width) return [line];
142
+
143
+ const optionMatch = line.match(
144
+ /^(\s{2}(?:--\S+(?:\s+<[^>]+>)?|-[hv],\s+--\S+|decision\.json|Install once|Confirm installation|Run one-off)\s+)(\S.*)$/,
145
+ );
146
+ const firstPrefix = optionMatch?.[1] ?? line.match(/^\s*/)?.[0] ?? "";
147
+ let continuationPrefix = optionMatch ? " ".repeat(firstPrefix.length) : firstPrefix;
148
+ const fallbackPrefix = firstPrefix ? " " : "";
149
+ const words = (optionMatch?.[2] ?? line.slice(firstPrefix.length)).split(" ");
150
+ const output = [];
151
+ let prefix = firstPrefix;
152
+ let current = prefix;
153
+
154
+ for (const word of words) {
155
+ if (!word) continue;
156
+ const separator = current.length > prefix.length ? " " : "";
157
+ if (`${current}${separator}${word}`.length <= width) {
158
+ current += `${separator}${word}`;
159
+ continue;
160
+ }
161
+ if (current.length > prefix.length) output.push(current);
162
+ if (continuationPrefix.length + word.length > width) continuationPrefix = fallbackPrefix;
163
+ prefix = continuationPrefix;
164
+ current = `${prefix}${word}`;
165
+ }
166
+ if (current.length > prefix.length) output.push(current);
167
+ return output.length > 0 ? output : [line];
168
+ }
169
+
170
+ function writeHelpBody(body) {
171
+ const headings = new Set([
172
+ "Start here",
173
+ "Authorization handshake",
174
+ "Action Checks · local or self-hosted",
175
+ "Procurement demonstration",
176
+ "Shared options:",
177
+ "Challenge options:",
178
+ "Verification binding:",
179
+ "Presentation input:",
180
+ "Action Checks input (local/self-hosted only):",
181
+ "Authentication:",
182
+ ]);
183
+ const width = interactiveWidth();
184
+ const output = body
185
+ .split("\n")
186
+ .flatMap((line) => wrappedHelpLines(line, width))
187
+ .map((line) => (headings.has(line) ? styled(line, "1;38;2;58;153;200") : line))
188
+ .join("\n");
189
+ process.stdout.write(output);
190
+ }
191
+
192
+ function writeBrand() {
193
+ const art = BRAND_ART.map((line, index) =>
194
+ styled(line, index < 2 ? "38;2;58;153;200" : "38;2;21;37;131"),
195
+ ).join("\n");
196
+ process.stdout.write(`${art}\n`);
197
+ const title = `TotemLayer CLI ${CLI_VERSION} · cross-company agent authorization`;
198
+ const titleLines = wrappedTerminalLines(title, 0);
199
+ for (const [index, line] of titleLines.entries()) {
200
+ const versionPrefix = `TotemLayer CLI ${CLI_VERSION}`;
201
+ process.stdout.write(
202
+ `${index === 0 && line.startsWith(versionPrefix) ? styled(versionPrefix, "1") + line.slice(versionPrefix.length) : line}\n`,
203
+ );
204
+ }
205
+ process.stdout.write("\n");
206
+ }
207
+
208
+ function writeHumanResult(header, fields, nextStep, tone = "success", secrets = []) {
209
+ const toneCode = tone === "danger" ? "38;2;255;114;114" : "38;2;58;153;200";
210
+ process.stdout.write(`${styled(redactCliSecrets(header, secrets), `1;${toneCode}`)}\n`);
211
+ const dividerLength = Math.min(interactiveWidth() ?? 40, 56);
212
+ process.stdout.write(`${styled("─".repeat(dividerLength), "2")}\n`);
213
+ for (const [label, value] of fields) {
214
+ if (value === undefined || value === null || value === "") continue;
215
+ writeHumanLine(label, redactCliSecrets(value, secrets), {
216
+ valueCode: ["Status", "Decision", "Run", "Agent authority"].includes(label)
217
+ ? `1;${toneCode}`
218
+ : undefined,
219
+ });
220
+ }
221
+ if (nextStep) {
222
+ process.stdout.write("\n");
223
+ writeHumanLine("Next →", nextStep, { labelCode: `1;${toneCode}`, suffix: "" });
224
+ }
225
+ }
226
+
227
+ function writeHumanArtifact(payload, secrets) {
228
+ process.stdout.write(`\n${styled("Artifact JSON · save securely", "1")}\n`);
229
+ writeJson(redactJsonSecrets(payload, secrets));
230
+ }
231
+
232
+ function compactDigest(value) {
233
+ const digest = terminalText(value, "not returned");
234
+ return digest.length > 28 ? `${digest.slice(0, 16)}…${digest.slice(-8)}` : digest;
235
+ }
236
+
33
237
  function help() {
34
- process.stdout.write(`TotemLayer CLI ${CLI_VERSION}
238
+ writeBrand();
239
+ writeHelpBody(`Start here
240
+ Install once npm install --global totemlayer
241
+ Confirm installation totem --version
242
+ Run one-off npx --yes totemlayer --help
243
+
244
+ Authorization handshake
245
+ totem challenge \\
246
+ --presenter-agent <agent-id> \\
247
+ --action <action> \\
248
+ (--request-digest <sha256> | \\
249
+ --request-file <path>) \\
250
+ [--amount <number> --currency <ISO-4217>] \\
251
+ --json
252
+ totem present \\
253
+ --credential-id <credential-id> \\
254
+ [--challenge-file <path>] \\
255
+ --json
256
+ totem verify \\
257
+ --challenge-file <receiver-challenge.json> \\
258
+ (--request-digest <sha256> | \\
259
+ --request-file <path>) \\
260
+ [--presentation-file <path>] \\
261
+ --json
35
262
 
36
- Install:
37
- npm install --global totemlayer
38
- npx --yes totemlayer --help
263
+ Action Checks · local or self-hosted
264
+ totem checks run --file <action.json>
265
+ totem checks view <suite-id>
266
+ totem checks approve <suite-id> \\
267
+ [--file <decision.json>]
268
+ totem checks reject <suite-id> \\
269
+ [--file <decision.json>]
270
+ totem checks grant <suite-id> \\
271
+ --file <action.json>
272
+ totem checks execute <suite-id> \\
273
+ --file <execution.json>
39
274
 
40
- Usage:
41
- totem challenge --presenter-agent <agent-id> --action <action> \\
42
- (--request-digest <sha256> | --request-file <path>) [--amount <number> --currency <ISO-4217>] --json
43
- totem present --credential-id <credential-id> [--challenge-file <path>] --json
44
- totem verify --challenge-file <receiver-challenge.json> \\
45
- (--request-digest <sha256> | --request-file <path>) [--presentation-file <path>] --json
46
- totem rfq "${RFQ_EXAMPLE}" # sandbox/self-hosted demo only
275
+ Procurement demonstration
276
+ totem rfq \\
277
+ "Request a quote for 50,000 Industrial" \\
278
+ "Sensors (SKU-1001) from SupplyCo using" \\
279
+ "Acme Procurement Agent."
280
+ Sandbox/self-hosted demo only.
47
281
 
48
282
  Shared options:
49
- --base-url <url> Totem URL (default: TOTEM_URL or ${DEFAULT_BASE_URL})
50
- --json Print the complete machine-readable JSON response
283
+ --base-url <url> Totem URL; default:
284
+ TOTEM_URL or
285
+ ${DEFAULT_BASE_URL}
286
+ --json Print complete machine-readable
287
+ JSON
51
288
  -h, --help Show this help
52
289
  -v, --version Print the CLI version
53
290
 
54
291
  Challenge options:
55
- --presenter-agent <id> Exact agent expected to present authority
56
- --action <action> Namespaced action, for example inventory.read
57
- --request-digest <sha256> SHA-256 of the request being authorized
58
- --request-file <path> Hash this file locally; file contents are never uploaded
59
- --amount <number> Optional transaction amount; requires --currency
60
- --currency <code> Three-letter ISO currency; requires --amount
61
- --idempotency-key <key> Reuse for an exact retry (a random key is used by default)
292
+ --presenter-agent <id> Exact expected presenter agent
293
+ --action <action> Namespaced action; for example,
294
+ inventory.read
295
+ --request-digest <sha256> SHA-256 of the exact request
296
+ --request-file <path> Hash this file locally; contents
297
+ are never uploaded
298
+ --amount <number> Optional amount; requires
299
+ --currency
300
+ --currency <code> ISO currency; requires --amount
301
+ --idempotency-key <key> Reuse for an exact retry; a
302
+ random key is used by default
62
303
 
63
304
  Verification binding:
64
- --challenge-file <path> Receiver's original challenge; never trust a returned copy
65
- --request-digest <sha256> Require approval evidence for this exact request digest
66
- --request-file <path> Hash the exact local request before accepting an approval
305
+ --challenge-file <path> Receiver's original challenge;
306
+ never trust a returned copy
307
+ --request-digest <sha256> Require evidence for this exact
308
+ request digest
309
+ --request-file <path> Hash the exact local request
310
+ before accepting approval
67
311
 
68
312
  Presentation input:
69
- present reads challenge JSON from stdin unless --challenge-file is supplied.
70
- verify reads presentation JSON from stdin unless --presentation-file is supplied and
71
- compares it with the receiver-owned --challenge-file before consuming the proof.
72
- Raw keys, nonces, credentials, and presentation tokens are never accepted as arguments.
313
+ present reads challenge JSON from stdin unless
314
+ you supply --challenge-file.
315
+ verify reads presentation JSON from stdin unless
316
+ you supply --presentation-file. It compares that proof
317
+ with the receiver-owned --challenge-file before use.
318
+ Raw keys, nonces, credentials, and presentation tokens are
319
+ never accepted as arguments.
320
+
321
+ Action Checks input (local/self-hosted only):
322
+ --file <path> run/grant: exact action JSON
323
+ execute: JSON containing
324
+ "action" and "grant"
325
+ decision.json Optional decision body; example:
326
+ {"confirmation":"...","reason":"..."}
327
+ Never put confirmation, grant,
328
+ session cookie, or action
329
+ payload on the command line
330
+ Action Checks always emit structured JSON.
331
+ Blocked, rejected, expired, stale, or replayed actions
332
+ exit 2. Invalid input, network, authentication, and
333
+ operational failures exit 1.
73
334
 
74
335
  Authentication:
75
336
  challenge and verify use TOTEM_API_KEY.
76
- present uses TOTEM_AGENT_API_KEY bound to the presenting agent.
77
- Keys are sent only in the Authorization header and are never printed.
337
+ present uses TOTEM_AGENT_API_KEY bound to the presenter.
338
+ checks run/view/grant/execute use TOTEM_API_KEY with an
339
+ Action Checks scope.
340
+ checks approve/reject never use an API key. They require
341
+ an authenticated human browser session, supplied only
342
+ through TOTEM_SESSION_COOKIE as a Cookie header value.
343
+ Clear it after the command. Without it, use /checks.
344
+ Keys are sent only in the Authorization header and are
345
+ never printed.
78
346
 
79
- Production supports challenge, present, and verify. The RFQ command requires a
80
- sandbox or self-hosted Totem deployment with the procurement demo enabled.
347
+ Production supports challenge, present, and verify.
348
+ Action Checks and RFQ require a sandbox or self-hosted Totem
349
+ deployment with their local demonstrations enabled.
350
+
351
+ Human-readable commands show the decision, evidence, and
352
+ safest next step. Successful human challenge and present
353
+ commands also print their complete short-lived artifact.
354
+ Save it securely: the next participant needs its nonce or
355
+ presentation token. Add --json when another program needs
356
+ the complete machine-readable response.
81
357
  `);
82
358
  }
83
359
 
84
360
  function fail(message) {
85
- process.stderr.write(`Totem: ${terminalText(message, "The Totem command failed.")}\n`);
361
+ process.stderr.write(
362
+ `${styled("Totem: command failed", "1;38;2;255;114;114", process.stderr)}\n`,
363
+ );
364
+ writeHumanLine("Reason", terminalText(message, "The Totem command failed."), {
365
+ stream: process.stderr,
366
+ labelCode: "1;38;2;255;114;114",
367
+ });
368
+ writeHumanLine("Next", "Fix the reason above, or run `totem --help` for a complete example.", {
369
+ stream: process.stderr,
370
+ labelCode: "1;38;2;58;153;200",
371
+ });
86
372
  process.exitCode = 1;
87
373
  }
88
374
 
@@ -104,8 +390,23 @@ function failUnknownOutcome(message, idempotencyKey, json, machineStdout = false
104
390
  fail(`${message} The outcome is unknown; retry with --idempotency-key "${idempotencyKey}".`);
105
391
  }
106
392
 
393
+ function failHandshakeUnknownOutcome(operation, message, json) {
394
+ const guidance =
395
+ operation === "presentation"
396
+ ? "Do not retry this challenge blindly; ask the receiver to reconcile it or create a fresh challenge."
397
+ : "Do not act on the request; reconcile the original challenge before retrying or create a fresh handshake.";
398
+ const safeMessage = `${message} The ${operation} outcome is unknown. ${guidance}`;
399
+ if (json) {
400
+ writeJson({ error: { message: safeMessage, outcome: "unknown", operation } });
401
+ process.exitCode = 1;
402
+ return;
403
+ }
404
+ fail(safeMessage);
405
+ }
406
+
107
407
  function parseArguments(values) {
108
408
  const options = { json: false, positionals: [] };
409
+ let parsingOptions = true;
109
410
  const valuedOptions = new Set([
110
411
  "--base-url",
111
412
  "--idempotency-key",
@@ -118,10 +419,18 @@ function parseArguments(values) {
118
419
  "--challenge-file",
119
420
  "--credential-id",
120
421
  "--presentation-file",
422
+ "--file",
121
423
  ]);
122
424
  for (let index = 0; index < values.length; index += 1) {
123
425
  const value = values[index];
124
- if (value === "--") continue;
426
+ if (parsingOptions && value === "--") {
427
+ parsingOptions = false;
428
+ continue;
429
+ }
430
+ if (!parsingOptions) {
431
+ options.positionals.push(value);
432
+ continue;
433
+ }
125
434
  if (value === "--json") {
126
435
  options.json = true;
127
436
  continue;
@@ -137,7 +446,9 @@ function parseArguments(values) {
137
446
  if (valuedOptions.has(value)) {
138
447
  const next = values[index + 1];
139
448
  if (!next || next.startsWith("--")) throw new Error(`${value} requires a value.`);
140
- options[value.slice(2).replaceAll("-", "_")] = next;
449
+ const key = value.slice(2).replaceAll("-", "_");
450
+ if (options[key] !== undefined) throw new Error(`${value} may be supplied only once.`);
451
+ options[key] = next;
141
452
  index += 1;
142
453
  continue;
143
454
  }
@@ -149,8 +460,13 @@ function parseArguments(values) {
149
460
 
150
461
  function safeBaseUrl(value) {
151
462
  const url = new URL(value);
152
- if (!["http:", "https:"].includes(url.protocol) || url.username || url.password) {
153
- throw new Error("The Totem URL must be an HTTP(S) origin without credentials.");
463
+ if (
464
+ !["http:", "https:"].includes(url.protocol) ||
465
+ url.username ||
466
+ url.password ||
467
+ (url.pathname !== "/" && url.pathname !== "")
468
+ ) {
469
+ throw new Error("The Totem URL must be an HTTP(S) origin without credentials or a path.");
154
470
  }
155
471
  const local = ["localhost", "127.0.0.1", "::1", "[::1]"].includes(url.hostname);
156
472
  if (url.protocol !== "https:" && !local) {
@@ -162,6 +478,29 @@ function safeBaseUrl(value) {
162
478
  return url;
163
479
  }
164
480
 
481
+ function validIdempotencyKey(value, flag = "--idempotency-key") {
482
+ if (
483
+ typeof value !== "string" ||
484
+ value !== value.trim() ||
485
+ value.length < 8 ||
486
+ value.length > 128 ||
487
+ !SAFE_IDENTIFIER_PATTERN.test(value) ||
488
+ /^at_(?:live|test)_/i.test(value) ||
489
+ value.split(".").length === 3
490
+ ) {
491
+ throw new Error(`${flag} must contain 8 to 128 safe identifier characters.`);
492
+ }
493
+ return value;
494
+ }
495
+
496
+ function requireLocalActionChecks(baseUrl) {
497
+ if (baseUrl.origin === DEFAULT_BASE_URL) {
498
+ throw new Error(
499
+ "Action Checks commands are available only on a local or self-hosted Totem deployment. Set TOTEM_URL to that deployment; use the /checks browser page for human decisions.",
500
+ );
501
+ }
502
+ }
503
+
165
504
  function aliasedEnvironment(primaryName, legacyName) {
166
505
  const primary = process.env[primaryName]?.trim();
167
506
  const legacy = process.env[legacyName]?.trim();
@@ -181,13 +520,115 @@ function apiKey(environmentName, legacyEnvironmentName) {
181
520
  return value;
182
521
  }
183
522
 
184
- function errorFrom(payload, fallback) {
185
- if (!payload || typeof payload !== "object") return fallback;
523
+ function actionCheckSessionCookie() {
524
+ const raw = process.env.TOTEM_SESSION_COOKIE;
525
+ if (!raw?.trim()) {
526
+ throw new Error(
527
+ "Action check decisions require an authenticated human browser session. Open the local /checks page, or set TOTEM_SESSION_COOKIE from your own current browser session. API keys cannot approve or reject.",
528
+ );
529
+ }
530
+ const value = raw.trim();
531
+ if (value.length > 16 * 1_024 || /[\u0000-\u001f\u007f]/.test(value)) {
532
+ throw new Error("TOTEM_SESSION_COOKIE is not a valid browser Cookie header.");
533
+ }
534
+ const pairs = value
535
+ .split(";")
536
+ .map((pair) => pair.trim())
537
+ .filter(Boolean);
538
+ let hasSessionCookie = false;
539
+ for (const pair of pairs) {
540
+ const separator = pair.indexOf("=");
541
+ const name = separator > 0 ? pair.slice(0, separator) : "";
542
+ const cookieValue = separator > 0 ? pair.slice(separator + 1) : "";
543
+ if (
544
+ !/^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/.test(name) ||
545
+ !cookieValue ||
546
+ /[\u0000-\u0020\u007f;]/.test(cookieValue)
547
+ ) {
548
+ throw new Error("TOTEM_SESSION_COOKIE is not a valid browser Cookie header.");
549
+ }
550
+ if (/^(?:__Secure-)?(?:next-auth|authjs)\.session-token(?:\.\d+)?$/.test(name)) {
551
+ hasSessionCookie = true;
552
+ }
553
+ }
554
+ if (!hasSessionCookie) {
555
+ throw new Error(
556
+ "TOTEM_SESSION_COOKIE does not contain a Totem browser session. Open the local /checks page to approve or reject instead; API keys cannot make human decisions.",
557
+ );
558
+ }
559
+ return value;
560
+ }
561
+
562
+ function errorFrom(payload, fallback, secrets = []) {
563
+ if (!payload || typeof payload !== "object") return redactCliSecrets(fallback, secrets);
186
564
  const error = payload.error;
187
- if (!error || typeof error !== "object") return fallback;
565
+ if (!error || typeof error !== "object") return redactCliSecrets(fallback, secrets);
188
566
  const code = typeof error.code === "string" ? error.code : "REQUEST_FAILED";
189
567
  const message = typeof error.message === "string" ? error.message : fallback;
190
- return `${code}: ${message}`;
568
+ return redactCliSecrets(`${code}: ${message}`, secrets);
569
+ }
570
+
571
+ function redactCliSecrets(value, secrets) {
572
+ let redacted = terminalText(value, "The Action Checks request failed.");
573
+ for (const secret of secrets) {
574
+ if (typeof secret === "string" && secret.length >= 8)
575
+ redacted = redacted.replaceAll(secret, "[REDACTED]");
576
+ }
577
+ return redacted;
578
+ }
579
+
580
+ function redactJsonSecrets(value, secrets) {
581
+ if (typeof value === "string") {
582
+ let redacted = value;
583
+ for (const secret of secrets) {
584
+ if (typeof secret === "string" && secret.length >= 8) {
585
+ redacted = redacted.replaceAll(secret, "[REDACTED]");
586
+ }
587
+ }
588
+ return redacted;
589
+ }
590
+ if (Array.isArray(value)) {
591
+ return value.map((item) => redactJsonSecrets(item, secrets));
592
+ }
593
+ if (record(value)) {
594
+ return Object.fromEntries(
595
+ Object.entries(value).map(([key, item]) => [
596
+ redactJsonSecrets(key, secrets),
597
+ redactJsonSecrets(item, secrets),
598
+ ]),
599
+ );
600
+ }
601
+ return value;
602
+ }
603
+
604
+ function writeActionCheckFailure(payload, fallback, status, secrets = []) {
605
+ const error = record(record(payload)?.error);
606
+ const code = redactCliSecrets(
607
+ typeof error?.code === "string" ? error.code : "ACTION_CHECK_REQUEST_FAILED",
608
+ secrets,
609
+ );
610
+ const message = redactCliSecrets(
611
+ typeof error?.message === "string" ? error.message : fallback,
612
+ secrets,
613
+ );
614
+ const safePayload = { error: { code, message } };
615
+ writeJson(safePayload);
616
+ const policyRejection =
617
+ status < 500 &&
618
+ (ACTION_CHECK_POLICY_CODES.has(code) ||
619
+ (status === 409 && code !== "ACTION_APPROVAL_NOT_REQUIRED"));
620
+ process.exitCode = policyRejection ? 2 : 1;
621
+ }
622
+
623
+ function writeActionCheckOperationalError(message, outcome) {
624
+ writeJson({
625
+ error: {
626
+ code: "ACTION_CHECK_OPERATIONAL_ERROR",
627
+ message: terminalText(message, "The Action Checks command failed."),
628
+ ...(outcome ? { outcome } : {}),
629
+ },
630
+ });
631
+ process.exitCode = 1;
191
632
  }
192
633
 
193
634
  function number(value) {
@@ -203,10 +644,33 @@ function terminalText(value, fallback = "unknown") {
203
644
  return sanitized.slice(0, 2_000) || fallback;
204
645
  }
205
646
 
647
+ function organizationProofText(organization) {
648
+ if (organization?.verified !== true) return organization?.domain;
649
+ if (organization.accountType === "PERSONAL") return "Owner email · verified";
650
+ return `${terminalText(organization.domain, "verified domain")} · verified`;
651
+ }
652
+
206
653
  function record(value) {
207
654
  return value && typeof value === "object" && !Array.isArray(value) ? value : undefined;
208
655
  }
209
656
 
657
+ function validActionCheckSuite(suite, expectedId) {
658
+ if (
659
+ !suite ||
660
+ typeof suite.id !== "string" ||
661
+ suite.id.length > 200 ||
662
+ !SAFE_IDENTIFIER_PATTERN.test(suite.id) ||
663
+ (expectedId && suite.id !== expectedId) ||
664
+ !ACTION_CHECK_SUITE_STATUSES.has(suite.status)
665
+ ) {
666
+ return false;
667
+ }
668
+ if (suite.conclusion === null || suite.conclusion === undefined) {
669
+ return suite.status !== "COMPLETED";
670
+ }
671
+ return ACTION_CHECK_SUITE_CONCLUSIONS.has(suite.conclusion);
672
+ }
673
+
210
674
  function requiredOption(options, key, flag, pattern = SAFE_IDENTIFIER_PATTERN) {
211
675
  const value = options[key]?.trim();
212
676
  if (!value || value.length > 200 || (pattern && !pattern.test(value))) {
@@ -237,6 +701,7 @@ function validateCommandOptions(command, options) {
237
701
  "request_digest",
238
702
  "request_file",
239
703
  ]),
704
+ checks: new Set([...shared, "file"]),
240
705
  }[command];
241
706
  if (!allowed) throw new Error(`Unknown command ${command || "(missing)"}.`);
242
707
  for (const [key, value] of Object.entries(options)) {
@@ -350,6 +815,7 @@ function receiverChallenge(input) {
350
815
  challenge.nonce.length < 32 ||
351
816
  challenge.nonce.length > 256 ||
352
817
  !/^[A-Za-z0-9_-]+$/.test(challenge.nonce) ||
818
+ !["SANDBOX", "PRODUCTION"].includes(challenge.environment) ||
353
819
  typeof challenge.requestedAction !== "string" ||
354
820
  !ACTION_PATTERN.test(challenge.requestedAction) ||
355
821
  typeof challenge.requestDigest !== "string" ||
@@ -383,6 +849,7 @@ function receiverChallenge(input) {
383
849
  issuerOrganizationId: challenge.issuerOrganizationId,
384
850
  verifierAgentId: challenge.verifierAgentId,
385
851
  presenterAgentId: challenge.presenterAgentId,
852
+ environment: challenge.environment,
386
853
  requestedAction: challenge.requestedAction,
387
854
  requestDigest: challenge.requestDigest.toLowerCase(),
388
855
  ...(hasAmount
@@ -432,6 +899,7 @@ function boundPresentationClaims(token, challenge, credentialId, expiresAt) {
432
899
  claims.credentialId !== credentialId ||
433
900
  claims.agentId !== challenge.presenterAgentId ||
434
901
  claims.organizationId !== challenge.issuerOrganizationId ||
902
+ claims.environment !== challenge.environment ||
435
903
  claims.requestedAction !== challenge.requestedAction ||
436
904
  claims.requestDigest !== challenge.requestDigest ||
437
905
  claims.transactionAmount !== expectedAmount ||
@@ -451,18 +919,28 @@ function boundPresentationClaims(token, challenge, credentialId, expiresAt) {
451
919
  return claims;
452
920
  }
453
921
 
454
- async function requestJson({ baseUrl, path, key, body, idempotencyKey, timeout = 30_000 }) {
455
- const headers = {
456
- authorization: `Bearer ${key}`,
457
- "content-type": "application/json",
458
- };
922
+ async function requestJson({
923
+ baseUrl,
924
+ path,
925
+ key,
926
+ cookie,
927
+ method = "POST",
928
+ body,
929
+ idempotencyKey,
930
+ timeout = 30_000,
931
+ }) {
932
+ const headers = { accept: "application/json" };
933
+ if (key) headers.authorization = `Bearer ${key}`;
934
+ if (cookie) headers.cookie = cookie;
935
+ if (body !== undefined) headers["content-type"] = "application/json";
459
936
  if (idempotencyKey) headers["idempotency-key"] = idempotencyKey;
460
937
  let response;
461
938
  try {
462
939
  response = await fetch(new URL(path, baseUrl), {
463
- method: "POST",
940
+ method,
464
941
  headers,
465
- body: JSON.stringify(body),
942
+ ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
943
+ redirect: "manual",
466
944
  signal: AbortSignal.timeout(timeout),
467
945
  });
468
946
  } catch (error) {
@@ -493,25 +971,21 @@ async function runChallenge(options, baseUrl) {
493
971
  let transactionAmount;
494
972
  let currency;
495
973
  if (hasAmount) {
496
- transactionAmount = Number(options.amount);
497
- if (!Number.isFinite(transactionAmount) || transactionAmount < 0 || transactionAmount > 1e12) {
974
+ const rawAmount = options.amount.trim();
975
+ if (!/^(?:0|[1-9]\d*)(?:\.\d+)?$/.test(rawAmount)) {
976
+ throw new Error("--amount must be a decimal number from 0 through 1000000000000.");
977
+ }
978
+ transactionAmount = Number(rawAmount);
979
+ if (!Number.isFinite(transactionAmount) || transactionAmount > 1e12) {
498
980
  throw new Error("--amount must be a finite number from 0 through 1000000000000.");
499
981
  }
500
982
  currency = options.currency.trim().toUpperCase();
501
- if (!/^[A-Z]{3}$/.test(currency)) {
502
- throw new Error("--currency must be a three-letter ISO currency code.");
983
+ if (!ISO_CURRENCY_CODES.has(currency)) {
984
+ throw new Error("--currency must be a recognized three-letter ISO 4217 currency code.");
503
985
  }
504
986
  }
505
987
 
506
- const idempotencyKey = options.idempotency_key ?? `cli:${randomUUID()}`;
507
- if (
508
- typeof idempotencyKey !== "string" ||
509
- idempotencyKey.length < 8 ||
510
- idempotencyKey.length > 128 ||
511
- !SAFE_IDENTIFIER_PATTERN.test(idempotencyKey)
512
- ) {
513
- throw new Error("--idempotency-key must contain 8 to 128 safe identifier characters.");
514
- }
988
+ const idempotencyKey = validIdempotencyKey(options.idempotency_key ?? `cli:${randomUUID()}`);
515
989
  if (!options.json) process.stderr.write(`Totem retry key: ${idempotencyKey}\n`);
516
990
 
517
991
  let result;
@@ -533,15 +1007,39 @@ async function runChallenge(options, baseUrl) {
533
1007
  failUnknownOutcome(message, idempotencyKey, options.json, true);
534
1008
  return;
535
1009
  }
536
- const challenge = record(result.payload?.challenge);
537
1010
  if (!result.response.ok) {
1011
+ const apiError = record(result.payload?.error);
1012
+ if (typeof apiError?.code === "string" && HANDSHAKE_REJECTION_CODES.has(apiError.code)) {
1013
+ if (options.json) writeJson(redactJsonSecrets(result.payload, [key]));
1014
+ else {
1015
+ writeHumanResult(
1016
+ "Totem challenge · REJECTED",
1017
+ [
1018
+ ["Status", "Challenge was not created"],
1019
+ ["Presenter", presenterAgentId],
1020
+ ["Action", requestedAction],
1021
+ ["Reason", apiError.code],
1022
+ ],
1023
+ "Resolve the policy state before creating a new challenge; this request is not authorized.",
1024
+ "danger",
1025
+ [key],
1026
+ );
1027
+ }
1028
+ process.exitCode = 2;
1029
+ return;
1030
+ }
538
1031
  failCommand(
539
- errorFrom(result.payload, `Challenge creation returned HTTP ${result.response.status}.`),
1032
+ errorFrom(result.payload, `Challenge creation returned HTTP ${result.response.status}.`, [
1033
+ key,
1034
+ ]),
540
1035
  options.json,
541
1036
  );
542
1037
  return;
543
1038
  }
544
- if (!challenge || typeof challenge.id !== "string" || typeof challenge.nonce !== "string") {
1039
+ let challenge;
1040
+ try {
1041
+ challenge = receiverChallenge(result.payload);
1042
+ } catch {
545
1043
  failUnknownOutcome(
546
1044
  "Totem returned an invalid challenge response.",
547
1045
  idempotencyKey,
@@ -550,14 +1048,51 @@ async function runChallenge(options, baseUrl) {
550
1048
  );
551
1049
  return;
552
1050
  }
553
- if (options.json) writeJson(result.payload);
1051
+ const responseHasTransaction = challenge.transactionAmount !== undefined;
1052
+ const replayed = result.payload?.replayed;
1053
+ if (
1054
+ typeof replayed !== "boolean" ||
1055
+ result.response.status !== (replayed ? 200 : 201) ||
1056
+ challenge.presenterAgentId !== presenterAgentId ||
1057
+ challenge.requestedAction !== requestedAction ||
1058
+ challenge.requestDigest !== requestDigest ||
1059
+ Date.parse(challenge.expiresAt) <= Date.now() ||
1060
+ responseHasTransaction !== (transactionAmount !== undefined) ||
1061
+ (responseHasTransaction &&
1062
+ (challenge.transactionAmount !== transactionAmount || challenge.currency !== currency))
1063
+ ) {
1064
+ failUnknownOutcome(
1065
+ "Totem returned a challenge that does not match the exact request.",
1066
+ idempotencyKey,
1067
+ options.json,
1068
+ true,
1069
+ );
1070
+ return;
1071
+ }
1072
+ if (options.json) writeJson(redactJsonSecrets(result.payload, [key]));
554
1073
  else {
555
- process.stdout.write(`Totem challenge · ${terminalText(challenge.id)}\n`);
556
- process.stdout.write(`Action: ${terminalText(challenge.requestedAction ?? requestedAction)}\n`);
557
- process.stdout.write(`Expires: ${terminalText(challenge.expiresAt)}\n`);
558
- process.stdout.write(
559
- "Use --json to emit the nonce-bearing challenge for the presenting agent.\n",
1074
+ writeHumanResult(
1075
+ `Totem challenge · ${terminalText(challenge.id)}`,
1076
+ [
1077
+ [
1078
+ "Status",
1079
+ result.payload?.replayed ? "READY · existing idempotent result" : "READY · created",
1080
+ ],
1081
+ ["Presenter", challenge.presenterAgentId ?? presenterAgentId],
1082
+ ["Action", challenge.requestedAction ?? requestedAction],
1083
+ ["Request", compactDigest(challenge.requestDigest ?? requestDigest)],
1084
+ [
1085
+ "Transaction",
1086
+ transactionAmount !== undefined ? `${number(transactionAmount)} ${currency}` : undefined,
1087
+ ],
1088
+ ["Expires", challenge.expiresAt],
1089
+ ["Protection", "Recipient-bound · request-bound · single-use"],
1090
+ ],
1091
+ "Save the complete JSON artifact below as the receiver-owned challenge, then send a copy to the presenting agent.",
1092
+ "success",
1093
+ [key],
560
1094
  );
1095
+ writeHumanArtifact(result.payload, [key]);
561
1096
  }
562
1097
  }
563
1098
 
@@ -572,6 +1107,7 @@ async function runPresent(options, baseUrl) {
572
1107
  const nonce = challenge.nonce;
573
1108
  if (
574
1109
  typeof challengeId !== "string" ||
1110
+ challengeId.length > 200 ||
575
1111
  !SAFE_IDENTIFIER_PATTERN.test(challengeId) ||
576
1112
  typeof nonce !== "string" ||
577
1113
  nonce.length < 32 ||
@@ -581,46 +1117,89 @@ async function runPresent(options, baseUrl) {
581
1117
  throw new Error("Challenge JSON is missing a valid challenge id or nonce.");
582
1118
  }
583
1119
 
584
- const result = await requestJson({
585
- baseUrl,
586
- path: "/api/v1/presentations",
587
- key,
588
- body: { credentialId, challengeId, nonce },
589
- });
1120
+ let result;
1121
+ try {
1122
+ result = await requestJson({
1123
+ baseUrl,
1124
+ path: "/api/v1/presentations",
1125
+ key,
1126
+ body: { credentialId, challengeId, nonce },
1127
+ });
1128
+ } catch (error) {
1129
+ failHandshakeUnknownOutcome(
1130
+ "presentation",
1131
+ error instanceof Error ? error.message : "Could not create the presentation.",
1132
+ options.json,
1133
+ );
1134
+ return;
1135
+ }
590
1136
  const presentation = record(result.payload?.presentation);
591
1137
  if (!result.response.ok) {
592
1138
  const apiError = record(result.payload?.error);
593
- if (typeof apiError?.code === "string" && PRESENTATION_REJECTION_CODES.has(apiError.code)) {
594
- if (options.json) writeJson(result.payload);
1139
+ if (typeof apiError?.code === "string" && HANDSHAKE_REJECTION_CODES.has(apiError.code)) {
1140
+ if (options.json) writeJson(redactJsonSecrets(result.payload, [key]));
595
1141
  else {
596
- process.stdout.write("Totem presentation · REJECTED\n");
597
- process.stdout.write(`Reason: ${apiError.code}\n`);
1142
+ writeHumanResult(
1143
+ "Totem presentation · REJECTED",
1144
+ [
1145
+ ["Status", "Proof was not created"],
1146
+ ["Challenge", challengeId],
1147
+ ["Reason", apiError.code],
1148
+ ],
1149
+ "Return to the receiver for a fresh challenge before trying again.",
1150
+ "danger",
1151
+ [key],
1152
+ );
598
1153
  }
599
1154
  process.exitCode = 2;
600
1155
  return;
601
1156
  }
602
1157
  failCommand(
603
- errorFrom(result.payload, `Presentation creation returned HTTP ${result.response.status}.`),
1158
+ errorFrom(result.payload, `Presentation creation returned HTTP ${result.response.status}.`, [
1159
+ key,
1160
+ ]),
604
1161
  options.json,
605
1162
  );
606
1163
  return;
607
1164
  }
608
1165
  if (
609
1166
  !presentation ||
610
- typeof presentation.challengeId !== "string" ||
611
- typeof presentation.credentialId !== "string" ||
1167
+ result.response.status !== 201 ||
1168
+ presentation.challengeId !== challengeId ||
1169
+ presentation.credentialId !== credentialId ||
612
1170
  typeof presentation.token !== "string" ||
613
- typeof presentation.expiresAt !== "string"
1171
+ presentation.token.length < 20 ||
1172
+ presentation.token.length > 100_000 ||
1173
+ presentation.token.split(".").length !== 3 ||
1174
+ !presentation.token.split(".").every((part) => /^[A-Za-z0-9_-]+$/.test(part)) ||
1175
+ !validIsoTimestamp(presentation.expiresAt) ||
1176
+ Date.parse(presentation.expiresAt) <= Date.now()
614
1177
  ) {
615
- failCommand("Totem returned an invalid presentation response.", options.json);
1178
+ failHandshakeUnknownOutcome(
1179
+ "presentation",
1180
+ "Totem returned an invalid or mismatched presentation response.",
1181
+ options.json,
1182
+ );
616
1183
  return;
617
1184
  }
618
- if (options.json) writeJson(result.payload);
1185
+ if (options.json) writeJson(redactJsonSecrets(result.payload, [key]));
619
1186
  else {
620
- process.stdout.write(`Totem presentation · ${terminalText(presentation.challengeId)}\n`);
621
- process.stdout.write(`Credential: ${terminalText(presentation.credentialId)}\n`);
622
- process.stdout.write(`Expires: ${terminalText(presentation.expiresAt)}\n`);
623
- process.stdout.write("Use --json to emit the token-bearing presentation for the verifier.\n");
1187
+ writeHumanResult(
1188
+ `Totem presentation · ${terminalText(presentation.challengeId)}`,
1189
+ [
1190
+ ["Status", "READY · signed proof created"],
1191
+ ["Challenge", presentation.challengeId],
1192
+ ["Credential", presentation.credentialId],
1193
+ ["Presenter", challenge.presenterAgentId],
1194
+ ["Action", challenge.requestedAction],
1195
+ ["Expires", presentation.expiresAt],
1196
+ ["Protection", "Challenge-bound · recipient-bound · one-minute proof"],
1197
+ ],
1198
+ "Save the complete JSON artifact below and return it to the verifier before the proof expires.",
1199
+ "success",
1200
+ [key],
1201
+ );
1202
+ writeHumanArtifact(result.payload, [key]);
624
1203
  }
625
1204
  }
626
1205
 
@@ -662,31 +1241,54 @@ async function runVerify(options, baseUrl) {
662
1241
  }
663
1242
  const claims = boundPresentationClaims(token, challenge, credentialId, presentation.expiresAt);
664
1243
 
665
- const result = await requestJson({
666
- baseUrl,
667
- path: "/api/v1/presentations/verify",
668
- key,
669
- body: { challengeId: challenge.id, presentation: token },
670
- });
1244
+ let result;
1245
+ try {
1246
+ result = await requestJson({
1247
+ baseUrl,
1248
+ path: "/api/v1/presentations/verify",
1249
+ key,
1250
+ body: { challengeId: challenge.id, presentation: token },
1251
+ });
1252
+ } catch (error) {
1253
+ failHandshakeUnknownOutcome(
1254
+ "verification",
1255
+ error instanceof Error ? error.message : "Could not verify the presentation.",
1256
+ options.json,
1257
+ );
1258
+ return;
1259
+ }
671
1260
  const payload = record(result.payload);
672
1261
  if (!payload || typeof payload.verified !== "boolean" || typeof payload.result !== "string") {
673
1262
  const apiError = record(payload?.error);
674
- if (typeof apiError?.code === "string" && PRESENTATION_REJECTION_CODES.has(apiError.code)) {
675
- if (options.json) writeJson(payload);
1263
+ if (typeof apiError?.code === "string" && HANDSHAKE_REJECTION_CODES.has(apiError.code)) {
1264
+ if (options.json) writeJson(redactJsonSecrets(payload, [key, token]));
676
1265
  else {
677
- process.stdout.write("Totem verification · REJECTED\n");
678
- process.stdout.write(`Reason: ${apiError.code}\n`);
1266
+ writeHumanResult(
1267
+ "Totem verification · REJECTED",
1268
+ [
1269
+ ["Decision", "DO NOT ACT"],
1270
+ ["Challenge", challenge.id],
1271
+ ["Action", challenge.requestedAction],
1272
+ ["Reason", apiError.code],
1273
+ ],
1274
+ "Create a fresh challenge and verify the exact request again before taking action.",
1275
+ "danger",
1276
+ [key, token],
1277
+ );
679
1278
  }
680
1279
  process.exitCode = 2;
681
1280
  return;
682
1281
  }
683
- failCommand(
684
- errorFrom(
685
- result.payload,
686
- `Presentation verification returned HTTP ${result.response.status}.`,
687
- ),
688
- options.json,
1282
+ const message = errorFrom(
1283
+ result.payload,
1284
+ `Presentation verification returned HTTP ${result.response.status}.`,
1285
+ [key, token],
689
1286
  );
1287
+ if (result.response.status >= 500 || result.response.ok) {
1288
+ failHandshakeUnknownOutcome("verification", message, options.json);
1289
+ } else {
1290
+ failCommand(message, options.json);
1291
+ }
690
1292
  return;
691
1293
  }
692
1294
 
@@ -738,25 +1340,295 @@ async function runVerify(options, baseUrl) {
738
1340
  }
739
1341
  }
740
1342
 
741
- if (options.json) writeJson(payload);
1343
+ if (options.json) writeJson(redactJsonSecrets(payload, [key, token]));
742
1344
  else {
743
- process.stdout.write(`Totem verification · ${terminalText(payload.result)}\n`);
744
- if (payload.agent?.name || payload.agent?.id) {
745
- process.stdout.write(`Agent: ${terminalText(payload.agent.name ?? payload.agent.id)}\n`);
1345
+ const approvedResult = payload.verified && payload.result === "APPROVED" && result.response.ok;
1346
+ writeHumanResult(
1347
+ `Totem verification · ${terminalText(payload.result)}`,
1348
+ [
1349
+ ["Decision", approvedResult ? "AUTHORIZED FOR THIS EXACT REQUEST" : "DO NOT ACT"],
1350
+ ["Agent", payload.agent?.name ?? payload.agent?.id],
1351
+ ["Agent ID", payload.agent?.id],
1352
+ ["Organization", payload.organization?.name ?? payload.organization?.id],
1353
+ ["Organization proof", organizationProofText(payload.organization)],
1354
+ ["Action", payload.authorization?.requestedAction],
1355
+ [
1356
+ "Authority",
1357
+ payload.authorization?.allowed ? "Active credential permits this action" : undefined,
1358
+ ],
1359
+ [
1360
+ "Human review",
1361
+ typeof payload.authorization?.humanApprovalRequired === "boolean"
1362
+ ? payload.authorization.humanApprovalRequired
1363
+ ? "Required"
1364
+ : "Not required by credential"
1365
+ : undefined,
1366
+ ],
1367
+ ["Credential", payload.evidence?.credentialId],
1368
+ ["Request", compactDigest(payload.evidence?.requestDigest ?? expectedRequestDigest)],
1369
+ ["Verified", payload.evidence?.verifiedAt],
1370
+ ["Reason", payload.reasonCode],
1371
+ ["Receipt", payload.requestId],
1372
+ ],
1373
+ approvedResult
1374
+ ? "Continue only with the unchanged request bytes shown by this approval."
1375
+ : "Do not process the request; resolve the reason and create a fresh proof.",
1376
+ approvedResult ? "success" : "danger",
1377
+ [key, token],
1378
+ );
1379
+ }
1380
+ if (payload.verified && payload.result === "APPROVED" && result.response.ok) return;
1381
+ process.exitCode = payload.result === "REJECTED" ? 2 : 1;
1382
+ }
1383
+
1384
+ function checksFile(options, command) {
1385
+ const path = options.file?.trim();
1386
+ if (!path || path.length > 4_096 || path.includes("\0")) {
1387
+ throw new Error(`${command} requires --file with a readable JSON file.`);
1388
+ }
1389
+ return path;
1390
+ }
1391
+
1392
+ function checksSuiteId(options, subcommand) {
1393
+ if (options.positionals.length !== 3) {
1394
+ throw new Error(`checks ${subcommand} requires exactly one suite ID.`);
1395
+ }
1396
+ const suiteId = options.positionals[2];
1397
+ if (
1398
+ typeof suiteId !== "string" ||
1399
+ suiteId.length > 200 ||
1400
+ !SAFE_IDENTIFIER_PATTERN.test(suiteId)
1401
+ ) {
1402
+ throw new Error("The Action Checks suite ID is invalid.");
1403
+ }
1404
+ return suiteId;
1405
+ }
1406
+
1407
+ async function decisionFile(options) {
1408
+ if (!options.file) return { confirmation: null, reason: null };
1409
+ const input = await readJson(checksFile(options, "The decision command"), "decision JSON");
1410
+ if (Object.keys(input).some((key) => !["confirmation", "reason"].includes(key))) {
1411
+ throw new Error("Decision JSON may contain only confirmation and reason.");
1412
+ }
1413
+ const confirmation = input.confirmation;
1414
+ const reason = input.reason;
1415
+ if (
1416
+ confirmation !== undefined &&
1417
+ confirmation !== null &&
1418
+ (typeof confirmation !== "string" || confirmation.length > 500)
1419
+ ) {
1420
+ throw new Error("Decision confirmation must be a string of at most 500 characters.");
1421
+ }
1422
+ if (
1423
+ reason !== undefined &&
1424
+ reason !== null &&
1425
+ (typeof reason !== "string" || reason.length > 1_000)
1426
+ ) {
1427
+ throw new Error("Decision reason must be a string of at most 1000 characters.");
1428
+ }
1429
+ return {
1430
+ confirmation:
1431
+ typeof confirmation === "string" && confirmation.trim() ? confirmation.trim() : null,
1432
+ reason: typeof reason === "string" && reason.trim() ? reason.trim() : null,
1433
+ };
1434
+ }
1435
+
1436
+ async function actionCheckRequest({
1437
+ baseUrl,
1438
+ path,
1439
+ method = "POST",
1440
+ key,
1441
+ cookie,
1442
+ body,
1443
+ secrets = [],
1444
+ }) {
1445
+ let result;
1446
+ try {
1447
+ result = await requestJson({ baseUrl, path, method, key, cookie, body });
1448
+ } catch (error) {
1449
+ writeActionCheckOperationalError(
1450
+ error instanceof Error ? error.message : "Could not reach Totem.",
1451
+ method === "GET" ? undefined : "unknown",
1452
+ );
1453
+ return undefined;
1454
+ }
1455
+ if (!result.response.ok) {
1456
+ writeActionCheckFailure(
1457
+ result.payload,
1458
+ `The Action Checks endpoint returned HTTP ${result.response.status}.`,
1459
+ result.response.status,
1460
+ secrets,
1461
+ );
1462
+ return undefined;
1463
+ }
1464
+ const payload = record(result.payload);
1465
+ if (!payload) {
1466
+ writeActionCheckOperationalError("Totem returned an invalid Action Checks response.");
1467
+ return undefined;
1468
+ }
1469
+ return payload;
1470
+ }
1471
+
1472
+ async function runChecks(options, baseUrl) {
1473
+ requireLocalActionChecks(baseUrl);
1474
+ const subcommand = options.positionals[1];
1475
+ if (!subcommand)
1476
+ throw new Error(
1477
+ "Provide an Action Checks command: run, view, approve, reject, grant, or execute.",
1478
+ );
1479
+
1480
+ if (subcommand === "run") {
1481
+ if (options.positionals.length !== 2)
1482
+ throw new Error("checks run does not accept positional values.");
1483
+ const action = await readJson(checksFile(options, "checks run"), "action JSON");
1484
+ const key = apiKey("TOTEM_API_KEY", "AGENTTRUST_API_KEY");
1485
+ const payload = await actionCheckRequest({
1486
+ baseUrl,
1487
+ path: "/api/action-checks",
1488
+ key,
1489
+ body: { action },
1490
+ secrets: [key],
1491
+ });
1492
+ if (!payload) return;
1493
+ const suite = record(payload.suite);
1494
+ if (!validActionCheckSuite(suite)) {
1495
+ writeActionCheckOperationalError("Totem returned an invalid action check suite.");
1496
+ return;
746
1497
  }
747
- if (payload.organization?.name || payload.organization?.id) {
748
- process.stdout.write(
749
- `Organization: ${terminalText(payload.organization.name ?? payload.organization.id)}\n`,
750
- );
1498
+ writeJson(redactJsonSecrets({ suite }, [key]));
1499
+ if (suite.status === "STALE" || ["BLOCK", "REQUIRES_APPROVAL"].includes(suite.conclusion)) {
1500
+ process.exitCode = 2;
1501
+ } else if (suite.status !== "COMPLETED" || suite.conclusion === "ERROR") {
1502
+ process.exitCode = 1;
751
1503
  }
752
- if (payload.authorization?.requestedAction) {
753
- process.stdout.write(`Action: ${terminalText(payload.authorization.requestedAction)}\n`);
1504
+ return;
1505
+ }
1506
+
1507
+ if (subcommand === "view") {
1508
+ if (options.file !== undefined) throw new Error("--file is not valid for checks view.");
1509
+ const suiteId = checksSuiteId(options, subcommand);
1510
+ const key = apiKey("TOTEM_API_KEY", "AGENTTRUST_API_KEY");
1511
+ const payload = await actionCheckRequest({
1512
+ baseUrl,
1513
+ path: `/api/action-checks/${encodeURIComponent(suiteId)}`,
1514
+ method: "GET",
1515
+ key,
1516
+ secrets: [key],
1517
+ });
1518
+ if (!payload) return;
1519
+ const suite = record(payload.suite);
1520
+ if (!validActionCheckSuite(suite, suiteId)) {
1521
+ writeActionCheckOperationalError("Totem returned an invalid action check suite.");
1522
+ return;
754
1523
  }
755
- if (payload.reasonCode) process.stdout.write(`Reason: ${terminalText(payload.reasonCode)}\n`);
756
- if (payload.requestId) process.stdout.write(`Receipt: ${terminalText(payload.requestId)}\n`);
1524
+ writeJson(redactJsonSecrets({ suite }, [key]));
1525
+ return;
757
1526
  }
758
- if (payload.verified && payload.result === "APPROVED" && result.response.ok) return;
759
- process.exitCode = payload.result === "REJECTED" ? 2 : 1;
1527
+
1528
+ if (subcommand === "approve" || subcommand === "reject") {
1529
+ const suiteId = checksSuiteId(options, subcommand);
1530
+ const cookie = actionCheckSessionCookie();
1531
+ const decision = subcommand === "approve" ? "APPROVE" : "REJECT";
1532
+ const input = await decisionFile(options);
1533
+ const payload = await actionCheckRequest({
1534
+ baseUrl,
1535
+ path: `/api/action-checks/${encodeURIComponent(suiteId)}/decision`,
1536
+ cookie,
1537
+ body: { decision, confirmation: input.confirmation, reason: input.reason },
1538
+ secrets: [cookie],
1539
+ });
1540
+ if (!payload) return;
1541
+ const suite = record(payload.suite);
1542
+ if (!validActionCheckSuite(suite, suiteId)) {
1543
+ writeActionCheckOperationalError("Totem returned an invalid action check decision response.");
1544
+ return;
1545
+ }
1546
+ writeJson(redactJsonSecrets({ suite }, [cookie]));
1547
+ if (decision === "REJECT") process.exitCode = 2;
1548
+ return;
1549
+ }
1550
+
1551
+ if (subcommand === "grant") {
1552
+ const suiteId = checksSuiteId(options, subcommand);
1553
+ const action = await readJson(checksFile(options, "checks grant"), "action JSON");
1554
+ const key = apiKey("TOTEM_API_KEY", "AGENTTRUST_API_KEY");
1555
+ const payload = await actionCheckRequest({
1556
+ baseUrl,
1557
+ path: `/api/action-checks/${encodeURIComponent(suiteId)}/grant`,
1558
+ key,
1559
+ body: { action },
1560
+ secrets: [key],
1561
+ });
1562
+ if (!payload) return;
1563
+ const grant = record(payload.grant);
1564
+ const suite = record(grant?.suite);
1565
+ if (
1566
+ !grant ||
1567
+ typeof grant.token !== "string" ||
1568
+ grant.token.length < 32 ||
1569
+ grant.token.length > 512 ||
1570
+ !validIsoTimestamp(grant.expiresAt) ||
1571
+ !validActionCheckSuite(suite, suiteId)
1572
+ ) {
1573
+ writeActionCheckOperationalError("Totem returned an invalid execution grant response.");
1574
+ return;
1575
+ }
1576
+ writeJson(
1577
+ redactJsonSecrets({ grant: { token: grant.token, expiresAt: grant.expiresAt, suite } }, [
1578
+ key,
1579
+ ]),
1580
+ );
1581
+ return;
1582
+ }
1583
+
1584
+ if (subcommand === "execute") {
1585
+ const suiteId = checksSuiteId(options, subcommand);
1586
+ const input = await readJson(checksFile(options, "checks execute"), "execution JSON");
1587
+ const action = record(input.action);
1588
+ const grant = input.grant;
1589
+ if (!action || typeof grant !== "string" || grant.length < 32 || grant.length > 512) {
1590
+ throw new Error("Execution JSON must contain the exact action object and opaque grant.");
1591
+ }
1592
+ const key = apiKey("TOTEM_API_KEY", "AGENTTRUST_API_KEY");
1593
+ const payload = await actionCheckRequest({
1594
+ baseUrl,
1595
+ path: `/api/action-checks/${encodeURIComponent(suiteId)}/execute`,
1596
+ key,
1597
+ body: { action, grant },
1598
+ secrets: [key, grant],
1599
+ });
1600
+ if (!payload) return;
1601
+ const suite = record(payload.suite);
1602
+ const receipt = record(payload.receipt);
1603
+ if (
1604
+ !validActionCheckSuite(suite, suiteId) ||
1605
+ !receipt ||
1606
+ typeof receipt.id !== "string" ||
1607
+ receipt.id.length > 200 ||
1608
+ !SAFE_IDENTIFIER_PATTERN.test(receipt.id) ||
1609
+ !ACTION_EXECUTION_OUTCOMES.has(receipt.outcome) ||
1610
+ (typeof receipt.suiteId === "string" && receipt.suiteId !== suiteId)
1611
+ ) {
1612
+ writeActionCheckOperationalError("Totem returned an invalid execution receipt response.");
1613
+ return;
1614
+ }
1615
+ writeJson(
1616
+ redactJsonSecrets(
1617
+ {
1618
+ suite,
1619
+ receipt,
1620
+ ...(typeof payload.receiptId === "string" ? { receiptId: payload.receiptId } : {}),
1621
+ },
1622
+ [key, grant],
1623
+ ),
1624
+ );
1625
+ if (["FAILED", "UNKNOWN", "COMPENSATED"].includes(receipt.outcome)) process.exitCode = 1;
1626
+ return;
1627
+ }
1628
+
1629
+ throw new Error(
1630
+ "Unknown Action Checks command. Use run, view, approve, reject, grant, or execute.",
1631
+ );
760
1632
  }
761
1633
 
762
1634
  async function runRfq(options, baseUrl) {
@@ -768,8 +1640,11 @@ async function runRfq(options, baseUrl) {
768
1640
  }
769
1641
  const requestParts = options.positionals.slice(1);
770
1642
  if (requestParts.length === 0) throw new Error('Provide an RFQ request after the "rfq" command.');
771
- const idempotencyKey = options.idempotency_key ?? `cli:${randomUUID()}`;
772
1643
  const prompt = requestParts.join(" ").trim();
1644
+ if (prompt.length < 4 || prompt.length > 2_000) {
1645
+ throw new Error("The RFQ request must contain 4 to 2000 characters.");
1646
+ }
1647
+ const idempotencyKey = validIdempotencyKey(options.idempotency_key ?? `cli:${randomUUID()}`);
773
1648
  process.stderr.write(`Totem retry key: ${idempotencyKey}\n`);
774
1649
 
775
1650
  let result;
@@ -789,6 +1664,7 @@ async function runRfq(options, baseUrl) {
789
1664
  : "Could not reach Totem.",
790
1665
  idempotencyKey,
791
1666
  options.json,
1667
+ options.json,
792
1668
  );
793
1669
  return;
794
1670
  }
@@ -796,47 +1672,70 @@ async function runRfq(options, baseUrl) {
796
1672
  const message = errorFrom(
797
1673
  result.payload,
798
1674
  `The RFQ endpoint returned HTTP ${result.response.status}.`,
1675
+ [key],
799
1676
  );
800
- if (result.response.status >= 500) failUnknownOutcome(message, idempotencyKey, options.json);
801
- else fail(message);
1677
+ if (result.response.status >= 500) {
1678
+ failUnknownOutcome(message, idempotencyKey, options.json, options.json);
1679
+ } else {
1680
+ failCommand(message, options.json);
1681
+ }
802
1682
  return;
803
1683
  }
804
1684
  const run = record(result.payload?.run);
805
1685
  if (!run) {
806
- failUnknownOutcome("Totem returned an invalid success response.", idempotencyKey, options.json);
1686
+ failUnknownOutcome(
1687
+ "Totem returned an invalid success response.",
1688
+ idempotencyKey,
1689
+ options.json,
1690
+ options.json,
1691
+ );
807
1692
  return;
808
1693
  }
809
1694
  if (options.json) {
810
- writeJson(result.payload);
1695
+ writeJson(redactJsonSecrets(result.payload, [key]));
1696
+ if (run.status !== "SUCCEEDED") process.exitCode = 1;
811
1697
  return;
812
1698
  }
813
1699
 
814
1700
  const verification = record(run.verification);
815
1701
  const supplier = record(run.supplierRequest);
816
- process.stdout.write(`Totem RFQ · ${terminalText(run.id, "unknown run")}\n`);
817
- process.stdout.write(`Run: ${terminalText(run.status, "UNKNOWN")}\n`);
818
- if (run.summary) process.stdout.write(`${terminalText(run.summary)}\n`);
819
- if (verification)
820
- process.stdout.write(`Agent authority: ${terminalText(verification.result, "UNKNOWN")}\n`);
821
- if (supplier) {
822
- process.stdout.write(`Delivery: ${terminalText(supplier.status, "UNKNOWN")}\n`);
823
- process.stdout.write(
824
- `Supplier response: ${terminalText(supplier.businessStatus, "UNKNOWN")}\n`,
825
- );
826
- process.stdout.write(
827
- `Available now: ${number(supplier.availableQuantity)} ${terminalText(supplier.unit, "units")}\n`,
828
- );
829
- process.stdout.write(
830
- `Backorder: ${number(supplier.backorderQuantity)} ${terminalText(supplier.unit, "units")}\n`,
831
- );
832
- if (supplier.supplierReference)
833
- process.stdout.write(`Reference: ${terminalText(supplier.supplierReference)}\n`);
834
- }
1702
+ const succeeded = run.status === "SUCCEEDED";
1703
+ writeHumanResult(
1704
+ `Totem RFQ · ${terminalText(run.id, "unknown run")}`,
1705
+ [
1706
+ ["Run", run.status ?? "UNKNOWN"],
1707
+ ["Request", prompt],
1708
+ ["Summary", run.summary],
1709
+ ["Agent authority", verification?.result ?? "UNKNOWN"],
1710
+ ["Delivery", supplier?.status ?? "UNKNOWN"],
1711
+ ["Supplier response", supplier?.businessStatus ?? "UNKNOWN"],
1712
+ [
1713
+ "Available now",
1714
+ supplier
1715
+ ? `${number(supplier.availableQuantity)} ${terminalText(supplier.unit, "units")}`
1716
+ : undefined,
1717
+ ],
1718
+ [
1719
+ "Backorder",
1720
+ supplier
1721
+ ? `${number(supplier.backorderQuantity)} ${terminalText(supplier.unit, "units")}`
1722
+ : undefined,
1723
+ ],
1724
+ ["Reference", supplier?.supplierReference],
1725
+ ["Mode", "Non-binding procurement demonstration"],
1726
+ ],
1727
+ succeeded
1728
+ ? "Review the supplier response and retain the run ID as the audit reference."
1729
+ : "Open the Totem operator run for the bounded trace before retrying.",
1730
+ succeeded ? "success" : "danger",
1731
+ [key],
1732
+ );
835
1733
  if (run.status !== "SUCCEEDED") process.exitCode = 1;
836
1734
  }
837
1735
 
838
1736
  async function main() {
839
1737
  let options;
1738
+ const jsonRequested = process.argv.slice(2).includes("--json");
840
1739
  try {
841
1740
  options = parseArguments(process.argv.slice(2));
842
1741
  const command = options.positionals[0];
@@ -855,12 +1754,13 @@ async function main() {
855
1754
  if (command === "challenge") await runChallenge(options, baseUrl);
856
1755
  else if (command === "present") await runPresent(options, baseUrl);
857
1756
  else if (command === "verify") await runVerify(options, baseUrl);
1757
+ else if (command === "checks") await runChecks(options, baseUrl);
858
1758
  else await runRfq(options, baseUrl);
859
1759
  } catch (error) {
860
1760
  const message = error instanceof Error ? error.message : "The Totem command failed.";
861
- const command = options?.positionals?.[0];
862
- if (command && command !== "rfq") failCommand(message, options?.json);
863
- else fail(message);
1761
+ const command = options?.positionals?.[0] ?? process.argv[2];
1762
+ if (command === "checks") writeActionCheckOperationalError(message);
1763
+ else failCommand(message, options?.json ?? jsonRequested);
864
1764
  }
865
1765
  }
866
1766