totemlayer 0.1.1 → 0.1.3

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/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.1";
7
+ const CLI_VERSION = "0.1.3";
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,25 +14,45 @@ 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;
17
+ const ISO_CURRENCY_CODES = new Set(Intl.supportedValuesOf("currency"));
19
18
  const BRAND_ART = [
20
19
  " ╭╮",
21
20
  " ╭───╯╰────╮ ╺┳╸┏━┓╺┳╸┏━╸┏┳┓",
22
21
  " ╰────╮╭────╯ ┃ ┃ ┃ ┃ ┣╸ ┃┃┃",
23
22
  " ╰╯ ╹ ┗━┛ ╹ ┗━╸╹ ╹",
24
23
  ];
25
- const PRESENTATION_REJECTION_CODES = new Set([
26
- "CHALLENGE_NOT_FOUND",
27
- "CHALLENGE_NOT_PRESENTED",
28
- "CHALLENGE_ALREADY_PRESENTED",
24
+ const HANDSHAKE_REJECTION_CODES = new Set([
25
+ "AGENT_AUTHORITY_REVOKED",
26
+ "BEACON_VERIFICATION_FAILED",
29
27
  "CHALLENGE_ALREADY_CONSUMED",
28
+ "CHALLENGE_ALREADY_PRESENTED",
30
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",
31
40
  "PRESENTATION_EXPIRED",
41
+ "PRESENTATION_CLAIMS_MISMATCH",
32
42
  "PRESENTATION_MISMATCH",
33
43
  "PRESENTER_AGENT_INACTIVE",
34
- "AGENT_AUTHORITY_REVOKED",
35
- "INVALID_PRESENTATION_SIGNATURE",
36
- "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",
37
54
  ]);
55
+ const ACTION_EXECUTION_OUTCOMES = new Set(["SUCCEEDED", "FAILED", "UNKNOWN", "COMPENSATED"]);
38
56
  const ACTION_CHECK_POLICY_CODES = new Set([
39
57
  "ACTION_APPROVAL_REQUIRED",
40
58
  "ACTION_APPROVAL_STALE",
@@ -71,25 +89,144 @@ function styled(value, code, stream = process.stdout) {
71
89
  return supportsColor(stream) ? `\u001b[${code}m${value}\u001b[0m` : value;
72
90
  }
73
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
+
74
192
  function writeBrand() {
75
193
  const art = BRAND_ART.map((line, index) =>
76
194
  styled(line, index < 2 ? "38;2;58;153;200" : "38;2;21;37;131"),
77
195
  ).join("\n");
78
196
  process.stdout.write(`${art}\n`);
79
- process.stdout.write(
80
- `${styled(`TotemLayer CLI ${CLI_VERSION}`, "1")} · cross-company agent authorization\n\n`,
81
- );
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");
82
206
  }
83
207
 
84
- function writeHumanResult(header, fields, nextStep, tone = "success") {
208
+ function writeHumanResult(header, fields, nextStep, tone = "success", secrets = []) {
85
209
  const toneCode = tone === "danger" ? "38;2;255;114;114" : "38;2;58;153;200";
86
- process.stdout.write(`${styled(header, `1;${toneCode}`)}\n`);
87
- process.stdout.write(`${styled("────────────────────────────────────────", "2")}\n`);
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`);
88
213
  for (const [label, value] of fields) {
89
214
  if (value === undefined || value === null || value === "") continue;
90
- process.stdout.write(`${styled(`${label}:`, "2")} ${terminalText(value)}\n`);
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: "" });
91
224
  }
92
- if (nextStep) process.stdout.write(`\n${styled("Next →", "1")} ${nextStep}\n`);
225
+ }
226
+
227
+ function writeHumanArtifact(payload, secrets) {
228
+ process.stdout.write(`\n${styled("Artifact JSON · save securely", "1")}\n`);
229
+ writeJson(redactJsonSecrets(payload, secrets));
93
230
  }
94
231
 
95
232
  function compactDigest(value) {
@@ -99,76 +236,131 @@ function compactDigest(value) {
99
236
 
100
237
  function help() {
101
238
  writeBrand();
102
- process.stdout.write(`Start here
239
+ writeHelpBody(`Start here
103
240
  Install once npm install --global totemlayer
104
241
  Confirm installation totem --version
105
242
  Run one-off npx --yes totemlayer --help
106
243
 
244
+ Agent messaging and local runtime
245
+ totem network --help
246
+ totem network run --provider ollama --model qwen3.5:9b
247
+
248
+ Connect an AI app through MCP
249
+ totem mcp
250
+
107
251
  Authorization handshake
108
- totem challenge --presenter-agent <agent-id> --action <action> \\
109
- (--request-digest <sha256> | --request-file <path>) [--amount <number> --currency <ISO-4217>] --json
110
- totem present --credential-id <credential-id> [--challenge-file <path>] --json
111
- totem verify --challenge-file <receiver-challenge.json> \\
112
- (--request-digest <sha256> | --request-file <path>) [--presentation-file <path>] --json
252
+ totem challenge \\
253
+ --presenter-agent <agent-id> \\
254
+ --action <action> \\
255
+ (--request-digest <sha256> | \\
256
+ --request-file <path>) \\
257
+ [--amount <number> --currency <ISO-4217>] \\
258
+ --json
259
+ totem present \\
260
+ --credential-id <credential-id> \\
261
+ [--challenge-file <path>] \\
262
+ --json
263
+ totem verify \\
264
+ --challenge-file <receiver-challenge.json> \\
265
+ (--request-digest <sha256> | \\
266
+ --request-file <path>) \\
267
+ [--presentation-file <path>] \\
268
+ --json
113
269
 
114
270
  Action Checks · local or self-hosted
115
271
  totem checks run --file <action.json>
116
272
  totem checks view <suite-id>
117
- totem checks approve <suite-id> [--file <decision.json>]
118
- totem checks reject <suite-id> [--file <decision.json>]
119
- totem checks grant <suite-id> --file <action.json>
120
- totem checks execute <suite-id> --file <execution.json>
273
+ totem checks approve <suite-id> \\
274
+ [--file <decision.json>]
275
+ totem checks reject <suite-id> \\
276
+ [--file <decision.json>]
277
+ totem checks grant <suite-id> \\
278
+ --file <action.json>
279
+ totem checks execute <suite-id> \\
280
+ --file <execution.json>
121
281
 
122
282
  Procurement demonstration
123
- totem rfq "${RFQ_EXAMPLE}" # sandbox/self-hosted demo only
283
+ totem rfq \\
284
+ "Request a quote for 50,000 Industrial" \\
285
+ "Sensors (SKU-1001) from SupplyCo using" \\
286
+ "Acme Procurement Agent."
287
+ Sandbox/self-hosted demo only.
124
288
 
125
289
  Shared options:
126
- --base-url <url> Totem URL (default: TOTEM_URL or ${DEFAULT_BASE_URL})
127
- --json Print the complete machine-readable JSON response
290
+ --base-url <url> Totem URL; default:
291
+ TOTEM_URL or
292
+ ${DEFAULT_BASE_URL}
293
+ --json Print complete machine-readable
294
+ JSON
128
295
  -h, --help Show this help
129
296
  -v, --version Print the CLI version
130
297
 
131
298
  Challenge options:
132
- --presenter-agent <id> Exact agent expected to present authority
133
- --action <action> Namespaced action, for example inventory.read
134
- --request-digest <sha256> SHA-256 of the request being authorized
135
- --request-file <path> Hash this file locally; file contents are never uploaded
136
- --amount <number> Optional transaction amount; requires --currency
137
- --currency <code> Three-letter ISO currency; requires --amount
138
- --idempotency-key <key> Reuse for an exact retry (a random key is used by default)
299
+ --presenter-agent <id> Exact expected presenter agent
300
+ --action <action> Namespaced action; for example,
301
+ inventory.read
302
+ --request-digest <sha256> SHA-256 of the exact request
303
+ --request-file <path> Hash this file locally; contents
304
+ are never uploaded
305
+ --amount <number> Optional amount; requires
306
+ --currency
307
+ --currency <code> ISO currency; requires --amount
308
+ --idempotency-key <key> Reuse for an exact retry; a
309
+ random key is used by default
139
310
 
140
311
  Verification binding:
141
- --challenge-file <path> Receiver's original challenge; never trust a returned copy
142
- --request-digest <sha256> Require approval evidence for this exact request digest
143
- --request-file <path> Hash the exact local request before accepting an approval
312
+ --challenge-file <path> Receiver's original challenge;
313
+ never trust a returned copy
314
+ --request-digest <sha256> Require evidence for this exact
315
+ request digest
316
+ --request-file <path> Hash the exact local request
317
+ before accepting approval
144
318
 
145
319
  Presentation input:
146
- present reads challenge JSON from stdin unless --challenge-file is supplied.
147
- verify reads presentation JSON from stdin unless --presentation-file is supplied and
148
- compares it with the receiver-owned --challenge-file before consuming the proof.
149
- Raw keys, nonces, credentials, and presentation tokens are never accepted as arguments.
320
+ present reads challenge JSON from stdin unless
321
+ you supply --challenge-file.
322
+ verify reads presentation JSON from stdin unless
323
+ you supply --presentation-file. It compares that proof
324
+ with the receiver-owned --challenge-file before use.
325
+ Raw keys, nonces, credentials, and presentation tokens are
326
+ never accepted as arguments.
150
327
 
151
328
  Action Checks input (local/self-hosted only):
152
- --file <path> run/grant: exact action JSON; execute: {"action":...,"grant":"..."}
153
- decision.json Optional {"confirmation":"...","reason":"..."}; never put the
154
- confirmation, grant, session cookie, or action payload on the command line
155
- Action Checks always emit structured JSON. A blocked, rejected, expired, stale, or replayed
156
- action exits 2; invalid input, network, authentication, and operational failures exit 1.
329
+ --file <path> run/grant: exact action JSON
330
+ execute: JSON containing
331
+ "action" and "grant"
332
+ decision.json Optional decision body; example:
333
+ {"confirmation":"...","reason":"..."}
334
+ Never put confirmation, grant,
335
+ session cookie, or action
336
+ payload on the command line
337
+ Action Checks always emit structured JSON.
338
+ Blocked, rejected, expired, stale, or replayed actions
339
+ exit 2. Invalid input, network, authentication, and
340
+ operational failures exit 1.
157
341
 
158
342
  Authentication:
159
343
  challenge and verify use TOTEM_API_KEY.
160
- present uses TOTEM_AGENT_API_KEY bound to the presenting agent.
161
- checks run/view/grant/execute use TOTEM_API_KEY with an Action Checks scope.
162
- checks approve/reject never use an API key. They require an authenticated human browser
163
- session supplied only through TOTEM_SESSION_COOKIE as a Cookie header value. Clear it after
164
- the command. Without it, use the /checks browser page.
165
- Keys are sent only in the Authorization header and are never printed.
166
-
167
- Production supports challenge, present, and verify. Action Checks and RFQ commands require a
168
- sandbox or self-hosted Totem deployment with their respective local demonstrations enabled.
169
-
170
- Human-readable commands show the decision, relevant evidence, and the safest next step.
171
- Add --json whenever another program needs the complete machine-readable response.
344
+ present uses TOTEM_AGENT_API_KEY bound to the presenter.
345
+ checks run/view/grant/execute use TOTEM_API_KEY with an
346
+ Action Checks scope.
347
+ checks approve/reject never use an API key. They require
348
+ an authenticated human browser session, supplied only
349
+ through TOTEM_SESSION_COOKIE as a Cookie header value.
350
+ Clear it after the command. Without it, use /checks.
351
+ Keys are sent only in the Authorization header and are
352
+ never printed.
353
+
354
+ Production supports challenge, present, and verify.
355
+ Action Checks and RFQ require a sandbox or self-hosted Totem
356
+ deployment with their local demonstrations enabled.
357
+
358
+ Human-readable commands show the decision, evidence, and
359
+ safest next step. Successful human challenge and present
360
+ commands also print their complete short-lived artifact.
361
+ Save it securely: the next participant needs its nonce or
362
+ presentation token. Add --json when another program needs
363
+ the complete machine-readable response.
172
364
  `);
173
365
  }
174
366
 
@@ -176,10 +368,14 @@ function fail(message) {
176
368
  process.stderr.write(
177
369
  `${styled("Totem: command failed", "1;38;2;255;114;114", process.stderr)}\n`,
178
370
  );
179
- process.stderr.write(`Reason: ${terminalText(message, "The Totem command failed.")}\n`);
180
- process.stderr.write(
181
- "Next: Fix the reason above, or run `totem --help` for a complete example.\n",
182
- );
371
+ writeHumanLine("Reason", terminalText(message, "The Totem command failed."), {
372
+ stream: process.stderr,
373
+ labelCode: "1;38;2;255;114;114",
374
+ });
375
+ writeHumanLine("Next", "Fix the reason above, or run `totem --help` for a complete example.", {
376
+ stream: process.stderr,
377
+ labelCode: "1;38;2;58;153;200",
378
+ });
183
379
  process.exitCode = 1;
184
380
  }
185
381
 
@@ -201,8 +397,23 @@ function failUnknownOutcome(message, idempotencyKey, json, machineStdout = false
201
397
  fail(`${message} The outcome is unknown; retry with --idempotency-key "${idempotencyKey}".`);
202
398
  }
203
399
 
400
+ function failHandshakeUnknownOutcome(operation, message, json) {
401
+ const guidance =
402
+ operation === "presentation"
403
+ ? "Do not retry this challenge blindly; ask the receiver to reconcile it or create a fresh challenge."
404
+ : "Do not act on the request; reconcile the original challenge before retrying or create a fresh handshake.";
405
+ const safeMessage = `${message} The ${operation} outcome is unknown. ${guidance}`;
406
+ if (json) {
407
+ writeJson({ error: { message: safeMessage, outcome: "unknown", operation } });
408
+ process.exitCode = 1;
409
+ return;
410
+ }
411
+ fail(safeMessage);
412
+ }
413
+
204
414
  function parseArguments(values) {
205
415
  const options = { json: false, positionals: [] };
416
+ let parsingOptions = true;
206
417
  const valuedOptions = new Set([
207
418
  "--base-url",
208
419
  "--idempotency-key",
@@ -219,7 +430,14 @@ function parseArguments(values) {
219
430
  ]);
220
431
  for (let index = 0; index < values.length; index += 1) {
221
432
  const value = values[index];
222
- if (value === "--") continue;
433
+ if (parsingOptions && value === "--") {
434
+ parsingOptions = false;
435
+ continue;
436
+ }
437
+ if (!parsingOptions) {
438
+ options.positionals.push(value);
439
+ continue;
440
+ }
223
441
  if (value === "--json") {
224
442
  options.json = true;
225
443
  continue;
@@ -235,7 +453,9 @@ function parseArguments(values) {
235
453
  if (valuedOptions.has(value)) {
236
454
  const next = values[index + 1];
237
455
  if (!next || next.startsWith("--")) throw new Error(`${value} requires a value.`);
238
- options[value.slice(2).replaceAll("-", "_")] = next;
456
+ const key = value.slice(2).replaceAll("-", "_");
457
+ if (options[key] !== undefined) throw new Error(`${value} may be supplied only once.`);
458
+ options[key] = next;
239
459
  index += 1;
240
460
  continue;
241
461
  }
@@ -247,8 +467,13 @@ function parseArguments(values) {
247
467
 
248
468
  function safeBaseUrl(value) {
249
469
  const url = new URL(value);
250
- if (!["http:", "https:"].includes(url.protocol) || url.username || url.password) {
251
- throw new Error("The Totem URL must be an HTTP(S) origin without credentials.");
470
+ if (
471
+ !["http:", "https:"].includes(url.protocol) ||
472
+ url.username ||
473
+ url.password ||
474
+ (url.pathname !== "/" && url.pathname !== "")
475
+ ) {
476
+ throw new Error("The Totem URL must be an HTTP(S) origin without credentials or a path.");
252
477
  }
253
478
  const local = ["localhost", "127.0.0.1", "::1", "[::1]"].includes(url.hostname);
254
479
  if (url.protocol !== "https:" && !local) {
@@ -260,6 +485,21 @@ function safeBaseUrl(value) {
260
485
  return url;
261
486
  }
262
487
 
488
+ function validIdempotencyKey(value, flag = "--idempotency-key") {
489
+ if (
490
+ typeof value !== "string" ||
491
+ value !== value.trim() ||
492
+ value.length < 8 ||
493
+ value.length > 128 ||
494
+ !SAFE_IDENTIFIER_PATTERN.test(value) ||
495
+ /^at_(?:live|test)_/i.test(value) ||
496
+ value.split(".").length === 3
497
+ ) {
498
+ throw new Error(`${flag} must contain 8 to 128 safe identifier characters.`);
499
+ }
500
+ return value;
501
+ }
502
+
263
503
  function requireLocalActionChecks(baseUrl) {
264
504
  if (baseUrl.origin === DEFAULT_BASE_URL) {
265
505
  throw new Error(
@@ -326,13 +566,13 @@ function actionCheckSessionCookie() {
326
566
  return value;
327
567
  }
328
568
 
329
- function errorFrom(payload, fallback) {
330
- if (!payload || typeof payload !== "object") return fallback;
569
+ function errorFrom(payload, fallback, secrets = []) {
570
+ if (!payload || typeof payload !== "object") return redactCliSecrets(fallback, secrets);
331
571
  const error = payload.error;
332
- if (!error || typeof error !== "object") return fallback;
572
+ if (!error || typeof error !== "object") return redactCliSecrets(fallback, secrets);
333
573
  const code = typeof error.code === "string" ? error.code : "REQUEST_FAILED";
334
574
  const message = typeof error.message === "string" ? error.message : fallback;
335
- return `${code}: ${message}`;
575
+ return redactCliSecrets(`${code}: ${message}`, secrets);
336
576
  }
337
577
 
338
578
  function redactCliSecrets(value, secrets) {
@@ -344,6 +584,30 @@ function redactCliSecrets(value, secrets) {
344
584
  return redacted;
345
585
  }
346
586
 
587
+ function redactJsonSecrets(value, secrets) {
588
+ if (typeof value === "string") {
589
+ let redacted = value;
590
+ for (const secret of secrets) {
591
+ if (typeof secret === "string" && secret.length >= 8) {
592
+ redacted = redacted.replaceAll(secret, "[REDACTED]");
593
+ }
594
+ }
595
+ return redacted;
596
+ }
597
+ if (Array.isArray(value)) {
598
+ return value.map((item) => redactJsonSecrets(item, secrets));
599
+ }
600
+ if (record(value)) {
601
+ return Object.fromEntries(
602
+ Object.entries(value).map(([key, item]) => [
603
+ redactJsonSecrets(key, secrets),
604
+ redactJsonSecrets(item, secrets),
605
+ ]),
606
+ );
607
+ }
608
+ return value;
609
+ }
610
+
347
611
  function writeActionCheckFailure(payload, fallback, status, secrets = []) {
348
612
  const error = record(record(payload)?.error);
349
613
  const code = redactCliSecrets(
@@ -387,10 +651,33 @@ function terminalText(value, fallback = "unknown") {
387
651
  return sanitized.slice(0, 2_000) || fallback;
388
652
  }
389
653
 
654
+ function organizationProofText(organization) {
655
+ if (organization?.verified !== true) return organization?.domain;
656
+ if (organization.accountType === "PERSONAL") return "Owner email · verified";
657
+ return `${terminalText(organization.domain, "verified domain")} · verified`;
658
+ }
659
+
390
660
  function record(value) {
391
661
  return value && typeof value === "object" && !Array.isArray(value) ? value : undefined;
392
662
  }
393
663
 
664
+ function validActionCheckSuite(suite, expectedId) {
665
+ if (
666
+ !suite ||
667
+ typeof suite.id !== "string" ||
668
+ suite.id.length > 200 ||
669
+ !SAFE_IDENTIFIER_PATTERN.test(suite.id) ||
670
+ (expectedId && suite.id !== expectedId) ||
671
+ !ACTION_CHECK_SUITE_STATUSES.has(suite.status)
672
+ ) {
673
+ return false;
674
+ }
675
+ if (suite.conclusion === null || suite.conclusion === undefined) {
676
+ return suite.status !== "COMPLETED";
677
+ }
678
+ return ACTION_CHECK_SUITE_CONCLUSIONS.has(suite.conclusion);
679
+ }
680
+
394
681
  function requiredOption(options, key, flag, pattern = SAFE_IDENTIFIER_PATTERN) {
395
682
  const value = options[key]?.trim();
396
683
  if (!value || value.length > 200 || (pattern && !pattern.test(value))) {
@@ -535,6 +822,7 @@ function receiverChallenge(input) {
535
822
  challenge.nonce.length < 32 ||
536
823
  challenge.nonce.length > 256 ||
537
824
  !/^[A-Za-z0-9_-]+$/.test(challenge.nonce) ||
825
+ !["SANDBOX", "PRODUCTION"].includes(challenge.environment) ||
538
826
  typeof challenge.requestedAction !== "string" ||
539
827
  !ACTION_PATTERN.test(challenge.requestedAction) ||
540
828
  typeof challenge.requestDigest !== "string" ||
@@ -568,6 +856,7 @@ function receiverChallenge(input) {
568
856
  issuerOrganizationId: challenge.issuerOrganizationId,
569
857
  verifierAgentId: challenge.verifierAgentId,
570
858
  presenterAgentId: challenge.presenterAgentId,
859
+ environment: challenge.environment,
571
860
  requestedAction: challenge.requestedAction,
572
861
  requestDigest: challenge.requestDigest.toLowerCase(),
573
862
  ...(hasAmount
@@ -617,6 +906,7 @@ function boundPresentationClaims(token, challenge, credentialId, expiresAt) {
617
906
  claims.credentialId !== credentialId ||
618
907
  claims.agentId !== challenge.presenterAgentId ||
619
908
  claims.organizationId !== challenge.issuerOrganizationId ||
909
+ claims.environment !== challenge.environment ||
620
910
  claims.requestedAction !== challenge.requestedAction ||
621
911
  claims.requestDigest !== challenge.requestDigest ||
622
912
  claims.transactionAmount !== expectedAmount ||
@@ -688,25 +978,21 @@ async function runChallenge(options, baseUrl) {
688
978
  let transactionAmount;
689
979
  let currency;
690
980
  if (hasAmount) {
691
- transactionAmount = Number(options.amount);
692
- if (!Number.isFinite(transactionAmount) || transactionAmount < 0 || transactionAmount > 1e12) {
981
+ const rawAmount = options.amount.trim();
982
+ if (!/^(?:0|[1-9]\d*)(?:\.\d+)?$/.test(rawAmount)) {
983
+ throw new Error("--amount must be a decimal number from 0 through 1000000000000.");
984
+ }
985
+ transactionAmount = Number(rawAmount);
986
+ if (!Number.isFinite(transactionAmount) || transactionAmount > 1e12) {
693
987
  throw new Error("--amount must be a finite number from 0 through 1000000000000.");
694
988
  }
695
989
  currency = options.currency.trim().toUpperCase();
696
- if (!/^[A-Z]{3}$/.test(currency)) {
697
- throw new Error("--currency must be a three-letter ISO currency code.");
990
+ if (!ISO_CURRENCY_CODES.has(currency)) {
991
+ throw new Error("--currency must be a recognized three-letter ISO 4217 currency code.");
698
992
  }
699
993
  }
700
994
 
701
- const idempotencyKey = options.idempotency_key ?? `cli:${randomUUID()}`;
702
- if (
703
- typeof idempotencyKey !== "string" ||
704
- idempotencyKey.length < 8 ||
705
- idempotencyKey.length > 128 ||
706
- !SAFE_IDENTIFIER_PATTERN.test(idempotencyKey)
707
- ) {
708
- throw new Error("--idempotency-key must contain 8 to 128 safe identifier characters.");
709
- }
995
+ const idempotencyKey = validIdempotencyKey(options.idempotency_key ?? `cli:${randomUUID()}`);
710
996
  if (!options.json) process.stderr.write(`Totem retry key: ${idempotencyKey}\n`);
711
997
 
712
998
  let result;
@@ -728,15 +1014,39 @@ async function runChallenge(options, baseUrl) {
728
1014
  failUnknownOutcome(message, idempotencyKey, options.json, true);
729
1015
  return;
730
1016
  }
731
- const challenge = record(result.payload?.challenge);
732
1017
  if (!result.response.ok) {
1018
+ const apiError = record(result.payload?.error);
1019
+ if (typeof apiError?.code === "string" && HANDSHAKE_REJECTION_CODES.has(apiError.code)) {
1020
+ if (options.json) writeJson(redactJsonSecrets(result.payload, [key]));
1021
+ else {
1022
+ writeHumanResult(
1023
+ "Totem challenge · REJECTED",
1024
+ [
1025
+ ["Status", "Challenge was not created"],
1026
+ ["Presenter", presenterAgentId],
1027
+ ["Action", requestedAction],
1028
+ ["Reason", apiError.code],
1029
+ ],
1030
+ "Resolve the policy state before creating a new challenge; this request is not authorized.",
1031
+ "danger",
1032
+ [key],
1033
+ );
1034
+ }
1035
+ process.exitCode = 2;
1036
+ return;
1037
+ }
733
1038
  failCommand(
734
- errorFrom(result.payload, `Challenge creation returned HTTP ${result.response.status}.`),
1039
+ errorFrom(result.payload, `Challenge creation returned HTTP ${result.response.status}.`, [
1040
+ key,
1041
+ ]),
735
1042
  options.json,
736
1043
  );
737
1044
  return;
738
1045
  }
739
- if (!challenge || typeof challenge.id !== "string" || typeof challenge.nonce !== "string") {
1046
+ let challenge;
1047
+ try {
1048
+ challenge = receiverChallenge(result.payload);
1049
+ } catch {
740
1050
  failUnknownOutcome(
741
1051
  "Totem returned an invalid challenge response.",
742
1052
  idempotencyKey,
@@ -745,7 +1055,28 @@ async function runChallenge(options, baseUrl) {
745
1055
  );
746
1056
  return;
747
1057
  }
748
- if (options.json) writeJson(result.payload);
1058
+ const responseHasTransaction = challenge.transactionAmount !== undefined;
1059
+ const replayed = result.payload?.replayed;
1060
+ if (
1061
+ typeof replayed !== "boolean" ||
1062
+ result.response.status !== (replayed ? 200 : 201) ||
1063
+ challenge.presenterAgentId !== presenterAgentId ||
1064
+ challenge.requestedAction !== requestedAction ||
1065
+ challenge.requestDigest !== requestDigest ||
1066
+ Date.parse(challenge.expiresAt) <= Date.now() ||
1067
+ responseHasTransaction !== (transactionAmount !== undefined) ||
1068
+ (responseHasTransaction &&
1069
+ (challenge.transactionAmount !== transactionAmount || challenge.currency !== currency))
1070
+ ) {
1071
+ failUnknownOutcome(
1072
+ "Totem returned a challenge that does not match the exact request.",
1073
+ idempotencyKey,
1074
+ options.json,
1075
+ true,
1076
+ );
1077
+ return;
1078
+ }
1079
+ if (options.json) writeJson(redactJsonSecrets(result.payload, [key]));
749
1080
  else {
750
1081
  writeHumanResult(
751
1082
  `Totem challenge · ${terminalText(challenge.id)}`,
@@ -764,8 +1095,11 @@ async function runChallenge(options, baseUrl) {
764
1095
  ["Expires", challenge.expiresAt],
765
1096
  ["Protection", "Recipient-bound · request-bound · single-use"],
766
1097
  ],
767
- "Run again with --json to save the nonce-bearing challenge for the presenting agent.",
1098
+ "Save the complete JSON artifact below as the receiver-owned challenge, then send a copy to the presenting agent.",
1099
+ "success",
1100
+ [key],
768
1101
  );
1102
+ writeHumanArtifact(result.payload, [key]);
769
1103
  }
770
1104
  }
771
1105
 
@@ -780,6 +1114,7 @@ async function runPresent(options, baseUrl) {
780
1114
  const nonce = challenge.nonce;
781
1115
  if (
782
1116
  typeof challengeId !== "string" ||
1117
+ challengeId.length > 200 ||
783
1118
  !SAFE_IDENTIFIER_PATTERN.test(challengeId) ||
784
1119
  typeof nonce !== "string" ||
785
1120
  nonce.length < 32 ||
@@ -789,17 +1124,27 @@ async function runPresent(options, baseUrl) {
789
1124
  throw new Error("Challenge JSON is missing a valid challenge id or nonce.");
790
1125
  }
791
1126
 
792
- const result = await requestJson({
793
- baseUrl,
794
- path: "/api/v1/presentations",
795
- key,
796
- body: { credentialId, challengeId, nonce },
797
- });
1127
+ let result;
1128
+ try {
1129
+ result = await requestJson({
1130
+ baseUrl,
1131
+ path: "/api/v1/presentations",
1132
+ key,
1133
+ body: { credentialId, challengeId, nonce },
1134
+ });
1135
+ } catch (error) {
1136
+ failHandshakeUnknownOutcome(
1137
+ "presentation",
1138
+ error instanceof Error ? error.message : "Could not create the presentation.",
1139
+ options.json,
1140
+ );
1141
+ return;
1142
+ }
798
1143
  const presentation = record(result.payload?.presentation);
799
1144
  if (!result.response.ok) {
800
1145
  const apiError = record(result.payload?.error);
801
- if (typeof apiError?.code === "string" && PRESENTATION_REJECTION_CODES.has(apiError.code)) {
802
- if (options.json) writeJson(result.payload);
1146
+ if (typeof apiError?.code === "string" && HANDSHAKE_REJECTION_CODES.has(apiError.code)) {
1147
+ if (options.json) writeJson(redactJsonSecrets(result.payload, [key]));
803
1148
  else {
804
1149
  writeHumanResult(
805
1150
  "Totem presentation · REJECTED",
@@ -810,28 +1155,41 @@ async function runPresent(options, baseUrl) {
810
1155
  ],
811
1156
  "Return to the receiver for a fresh challenge before trying again.",
812
1157
  "danger",
1158
+ [key],
813
1159
  );
814
1160
  }
815
1161
  process.exitCode = 2;
816
1162
  return;
817
1163
  }
818
1164
  failCommand(
819
- errorFrom(result.payload, `Presentation creation returned HTTP ${result.response.status}.`),
1165
+ errorFrom(result.payload, `Presentation creation returned HTTP ${result.response.status}.`, [
1166
+ key,
1167
+ ]),
820
1168
  options.json,
821
1169
  );
822
1170
  return;
823
1171
  }
824
1172
  if (
825
1173
  !presentation ||
826
- typeof presentation.challengeId !== "string" ||
827
- typeof presentation.credentialId !== "string" ||
1174
+ result.response.status !== 201 ||
1175
+ presentation.challengeId !== challengeId ||
1176
+ presentation.credentialId !== credentialId ||
828
1177
  typeof presentation.token !== "string" ||
829
- typeof presentation.expiresAt !== "string"
1178
+ presentation.token.length < 20 ||
1179
+ presentation.token.length > 100_000 ||
1180
+ presentation.token.split(".").length !== 3 ||
1181
+ !presentation.token.split(".").every((part) => /^[A-Za-z0-9_-]+$/.test(part)) ||
1182
+ !validIsoTimestamp(presentation.expiresAt) ||
1183
+ Date.parse(presentation.expiresAt) <= Date.now()
830
1184
  ) {
831
- failCommand("Totem returned an invalid presentation response.", options.json);
1185
+ failHandshakeUnknownOutcome(
1186
+ "presentation",
1187
+ "Totem returned an invalid or mismatched presentation response.",
1188
+ options.json,
1189
+ );
832
1190
  return;
833
1191
  }
834
- if (options.json) writeJson(result.payload);
1192
+ if (options.json) writeJson(redactJsonSecrets(result.payload, [key]));
835
1193
  else {
836
1194
  writeHumanResult(
837
1195
  `Totem presentation · ${terminalText(presentation.challengeId)}`,
@@ -844,8 +1202,11 @@ async function runPresent(options, baseUrl) {
844
1202
  ["Expires", presentation.expiresAt],
845
1203
  ["Protection", "Challenge-bound · recipient-bound · one-minute proof"],
846
1204
  ],
847
- "Run again with --json to save the token-bearing presentation for the verifier.",
1205
+ "Save the complete JSON artifact below and return it to the verifier before the proof expires.",
1206
+ "success",
1207
+ [key],
848
1208
  );
1209
+ writeHumanArtifact(result.payload, [key]);
849
1210
  }
850
1211
  }
851
1212
 
@@ -887,17 +1248,27 @@ async function runVerify(options, baseUrl) {
887
1248
  }
888
1249
  const claims = boundPresentationClaims(token, challenge, credentialId, presentation.expiresAt);
889
1250
 
890
- const result = await requestJson({
891
- baseUrl,
892
- path: "/api/v1/presentations/verify",
893
- key,
894
- body: { challengeId: challenge.id, presentation: token },
895
- });
1251
+ let result;
1252
+ try {
1253
+ result = await requestJson({
1254
+ baseUrl,
1255
+ path: "/api/v1/presentations/verify",
1256
+ key,
1257
+ body: { challengeId: challenge.id, presentation: token },
1258
+ });
1259
+ } catch (error) {
1260
+ failHandshakeUnknownOutcome(
1261
+ "verification",
1262
+ error instanceof Error ? error.message : "Could not verify the presentation.",
1263
+ options.json,
1264
+ );
1265
+ return;
1266
+ }
896
1267
  const payload = record(result.payload);
897
1268
  if (!payload || typeof payload.verified !== "boolean" || typeof payload.result !== "string") {
898
1269
  const apiError = record(payload?.error);
899
- if (typeof apiError?.code === "string" && PRESENTATION_REJECTION_CODES.has(apiError.code)) {
900
- if (options.json) writeJson(payload);
1270
+ if (typeof apiError?.code === "string" && HANDSHAKE_REJECTION_CODES.has(apiError.code)) {
1271
+ if (options.json) writeJson(redactJsonSecrets(payload, [key, token]));
901
1272
  else {
902
1273
  writeHumanResult(
903
1274
  "Totem verification · REJECTED",
@@ -909,18 +1280,22 @@ async function runVerify(options, baseUrl) {
909
1280
  ],
910
1281
  "Create a fresh challenge and verify the exact request again before taking action.",
911
1282
  "danger",
1283
+ [key, token],
912
1284
  );
913
1285
  }
914
1286
  process.exitCode = 2;
915
1287
  return;
916
1288
  }
917
- failCommand(
918
- errorFrom(
919
- result.payload,
920
- `Presentation verification returned HTTP ${result.response.status}.`,
921
- ),
922
- options.json,
1289
+ const message = errorFrom(
1290
+ result.payload,
1291
+ `Presentation verification returned HTTP ${result.response.status}.`,
1292
+ [key, token],
923
1293
  );
1294
+ if (result.response.status >= 500 || result.response.ok) {
1295
+ failHandshakeUnknownOutcome("verification", message, options.json);
1296
+ } else {
1297
+ failCommand(message, options.json);
1298
+ }
924
1299
  return;
925
1300
  }
926
1301
 
@@ -972,7 +1347,7 @@ async function runVerify(options, baseUrl) {
972
1347
  }
973
1348
  }
974
1349
 
975
- if (options.json) writeJson(payload);
1350
+ if (options.json) writeJson(redactJsonSecrets(payload, [key, token]));
976
1351
  else {
977
1352
  const approvedResult = payload.verified && payload.result === "APPROVED" && result.response.ok;
978
1353
  writeHumanResult(
@@ -982,12 +1357,7 @@ async function runVerify(options, baseUrl) {
982
1357
  ["Agent", payload.agent?.name ?? payload.agent?.id],
983
1358
  ["Agent ID", payload.agent?.id],
984
1359
  ["Organization", payload.organization?.name ?? payload.organization?.id],
985
- [
986
- "Organization proof",
987
- payload.organization?.verified
988
- ? `${terminalText(payload.organization.domain, "verified domain")} · verified`
989
- : payload.organization?.domain,
990
- ],
1360
+ ["Organization proof", organizationProofText(payload.organization)],
991
1361
  ["Action", payload.authorization?.requestedAction],
992
1362
  [
993
1363
  "Authority",
@@ -1011,6 +1381,7 @@ async function runVerify(options, baseUrl) {
1011
1381
  ? "Continue only with the unchanged request bytes shown by this approval."
1012
1382
  : "Do not process the request; resolve the reason and create a fresh proof.",
1013
1383
  approvedResult ? "success" : "danger",
1384
+ [key, token],
1014
1385
  );
1015
1386
  }
1016
1387
  if (payload.verified && payload.result === "APPROVED" && result.response.ok) return;
@@ -1127,14 +1498,14 @@ async function runChecks(options, baseUrl) {
1127
1498
  });
1128
1499
  if (!payload) return;
1129
1500
  const suite = record(payload.suite);
1130
- if (!suite || typeof suite.id !== "string" || typeof suite.conclusion !== "string") {
1501
+ if (!validActionCheckSuite(suite)) {
1131
1502
  writeActionCheckOperationalError("Totem returned an invalid action check suite.");
1132
1503
  return;
1133
1504
  }
1134
- writeJson({ suite });
1505
+ writeJson(redactJsonSecrets({ suite }, [key]));
1135
1506
  if (suite.status === "STALE" || ["BLOCK", "REQUIRES_APPROVAL"].includes(suite.conclusion)) {
1136
1507
  process.exitCode = 2;
1137
- } else if (suite.conclusion === "ERROR") {
1508
+ } else if (suite.status !== "COMPLETED" || suite.conclusion === "ERROR") {
1138
1509
  process.exitCode = 1;
1139
1510
  }
1140
1511
  return;
@@ -1153,11 +1524,11 @@ async function runChecks(options, baseUrl) {
1153
1524
  });
1154
1525
  if (!payload) return;
1155
1526
  const suite = record(payload.suite);
1156
- if (!suite || typeof suite.id !== "string") {
1527
+ if (!validActionCheckSuite(suite, suiteId)) {
1157
1528
  writeActionCheckOperationalError("Totem returned an invalid action check suite.");
1158
1529
  return;
1159
1530
  }
1160
- writeJson({ suite });
1531
+ writeJson(redactJsonSecrets({ suite }, [key]));
1161
1532
  return;
1162
1533
  }
1163
1534
 
@@ -1175,11 +1546,11 @@ async function runChecks(options, baseUrl) {
1175
1546
  });
1176
1547
  if (!payload) return;
1177
1548
  const suite = record(payload.suite);
1178
- if (!suite || typeof suite.id !== "string") {
1549
+ if (!validActionCheckSuite(suite, suiteId)) {
1179
1550
  writeActionCheckOperationalError("Totem returned an invalid action check decision response.");
1180
1551
  return;
1181
1552
  }
1182
- writeJson({ suite });
1553
+ writeJson(redactJsonSecrets({ suite }, [cookie]));
1183
1554
  if (decision === "REJECT") process.exitCode = 2;
1184
1555
  return;
1185
1556
  }
@@ -1203,13 +1574,17 @@ async function runChecks(options, baseUrl) {
1203
1574
  typeof grant.token !== "string" ||
1204
1575
  grant.token.length < 32 ||
1205
1576
  grant.token.length > 512 ||
1206
- typeof grant.expiresAt !== "string" ||
1207
- !suite
1577
+ !validIsoTimestamp(grant.expiresAt) ||
1578
+ !validActionCheckSuite(suite, suiteId)
1208
1579
  ) {
1209
1580
  writeActionCheckOperationalError("Totem returned an invalid execution grant response.");
1210
1581
  return;
1211
1582
  }
1212
- writeJson({ grant: { token: grant.token, expiresAt: grant.expiresAt, suite } });
1583
+ writeJson(
1584
+ redactJsonSecrets({ grant: { token: grant.token, expiresAt: grant.expiresAt, suite } }, [
1585
+ key,
1586
+ ]),
1587
+ );
1213
1588
  return;
1214
1589
  }
1215
1590
 
@@ -1233,19 +1608,27 @@ async function runChecks(options, baseUrl) {
1233
1608
  const suite = record(payload.suite);
1234
1609
  const receipt = record(payload.receipt);
1235
1610
  if (
1236
- !suite ||
1611
+ !validActionCheckSuite(suite, suiteId) ||
1237
1612
  !receipt ||
1238
1613
  typeof receipt.id !== "string" ||
1239
- typeof receipt.outcome !== "string"
1614
+ receipt.id.length > 200 ||
1615
+ !SAFE_IDENTIFIER_PATTERN.test(receipt.id) ||
1616
+ !ACTION_EXECUTION_OUTCOMES.has(receipt.outcome) ||
1617
+ (typeof receipt.suiteId === "string" && receipt.suiteId !== suiteId)
1240
1618
  ) {
1241
1619
  writeActionCheckOperationalError("Totem returned an invalid execution receipt response.");
1242
1620
  return;
1243
1621
  }
1244
- writeJson({
1245
- suite,
1246
- receipt,
1247
- ...(typeof payload.receiptId === "string" ? { receiptId: payload.receiptId } : {}),
1248
- });
1622
+ writeJson(
1623
+ redactJsonSecrets(
1624
+ {
1625
+ suite,
1626
+ receipt,
1627
+ ...(typeof payload.receiptId === "string" ? { receiptId: payload.receiptId } : {}),
1628
+ },
1629
+ [key, grant],
1630
+ ),
1631
+ );
1249
1632
  if (["FAILED", "UNKNOWN", "COMPENSATED"].includes(receipt.outcome)) process.exitCode = 1;
1250
1633
  return;
1251
1634
  }
@@ -1264,8 +1647,11 @@ async function runRfq(options, baseUrl) {
1264
1647
  }
1265
1648
  const requestParts = options.positionals.slice(1);
1266
1649
  if (requestParts.length === 0) throw new Error('Provide an RFQ request after the "rfq" command.');
1267
- const idempotencyKey = options.idempotency_key ?? `cli:${randomUUID()}`;
1268
1650
  const prompt = requestParts.join(" ").trim();
1651
+ if (prompt.length < 4 || prompt.length > 2_000) {
1652
+ throw new Error("The RFQ request must contain 4 to 2000 characters.");
1653
+ }
1654
+ const idempotencyKey = validIdempotencyKey(options.idempotency_key ?? `cli:${randomUUID()}`);
1269
1655
  process.stderr.write(`Totem retry key: ${idempotencyKey}\n`);
1270
1656
 
1271
1657
  let result;
@@ -1285,6 +1671,7 @@ async function runRfq(options, baseUrl) {
1285
1671
  : "Could not reach Totem.",
1286
1672
  idempotencyKey,
1287
1673
  options.json,
1674
+ options.json,
1288
1675
  );
1289
1676
  return;
1290
1677
  }
@@ -1292,18 +1679,28 @@ async function runRfq(options, baseUrl) {
1292
1679
  const message = errorFrom(
1293
1680
  result.payload,
1294
1681
  `The RFQ endpoint returned HTTP ${result.response.status}.`,
1682
+ [key],
1295
1683
  );
1296
- if (result.response.status >= 500) failUnknownOutcome(message, idempotencyKey, options.json);
1297
- else fail(message);
1684
+ if (result.response.status >= 500) {
1685
+ failUnknownOutcome(message, idempotencyKey, options.json, options.json);
1686
+ } else {
1687
+ failCommand(message, options.json);
1688
+ }
1298
1689
  return;
1299
1690
  }
1300
1691
  const run = record(result.payload?.run);
1301
1692
  if (!run) {
1302
- failUnknownOutcome("Totem returned an invalid success response.", idempotencyKey, options.json);
1693
+ failUnknownOutcome(
1694
+ "Totem returned an invalid success response.",
1695
+ idempotencyKey,
1696
+ options.json,
1697
+ options.json,
1698
+ );
1303
1699
  return;
1304
1700
  }
1305
1701
  if (options.json) {
1306
- writeJson(result.payload);
1702
+ writeJson(redactJsonSecrets(result.payload, [key]));
1703
+ if (run.status !== "SUCCEEDED") process.exitCode = 1;
1307
1704
  return;
1308
1705
  }
1309
1706
 
@@ -1338,12 +1735,43 @@ async function runRfq(options, baseUrl) {
1338
1735
  ? "Review the supplier response and retain the run ID as the audit reference."
1339
1736
  : "Open the Totem operator run for the bounded trace before retrying.",
1340
1737
  succeeded ? "success" : "danger",
1738
+ [key],
1341
1739
  );
1342
1740
  if (run.status !== "SUCCEEDED") process.exitCode = 1;
1343
1741
  }
1344
1742
 
1345
1743
  async function main() {
1744
+ if (process.argv[2] === "mcp") {
1745
+ if (process.argv[3] === "install" || process.argv[3] === "--help" || process.argv[3] === "-h") {
1746
+ const { installTotemMcp } = await import("./mcp-install.mjs");
1747
+ try {
1748
+ process.stdout.write(
1749
+ await installTotemMcp(process.argv[3] === "install" ? process.argv.slice(4) : ["--help"]),
1750
+ );
1751
+ } catch (error) {
1752
+ process.stderr.write(
1753
+ `${error instanceof Error ? error.message : "Could not install the Totem MCP server."}\n`,
1754
+ );
1755
+ process.exitCode = 1;
1756
+ }
1757
+ return;
1758
+ }
1759
+ if (process.argv.length > 3) {
1760
+ process.stderr.write("Unknown Totem MCP option. Run `totem mcp install --help`.\n");
1761
+ process.exitCode = 1;
1762
+ return;
1763
+ }
1764
+ const { mcpMain } = await import("./local-mcp.mjs");
1765
+ await mcpMain();
1766
+ return;
1767
+ }
1768
+ if (process.argv[2] === "network") {
1769
+ const { networkMain } = await import("./network.mjs");
1770
+ await networkMain(process.argv.slice(3));
1771
+ return;
1772
+ }
1346
1773
  let options;
1774
+ const jsonRequested = process.argv.slice(2).includes("--json");
1347
1775
  try {
1348
1776
  options = parseArguments(process.argv.slice(2));
1349
1777
  const command = options.positionals[0];
@@ -1368,8 +1796,7 @@ async function main() {
1368
1796
  const message = error instanceof Error ? error.message : "The Totem command failed.";
1369
1797
  const command = options?.positionals?.[0] ?? process.argv[2];
1370
1798
  if (command === "checks") writeActionCheckOperationalError(message);
1371
- else if (command && command !== "rfq") failCommand(message, options?.json);
1372
- else fail(message);
1799
+ else failCommand(message, options?.json ?? jsonRequested);
1373
1800
  }
1374
1801
  }
1375
1802