totemlayer 0.1.1 → 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 +4 -2
  2. package/bin/totem.mjs +543 -152
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -16,7 +16,9 @@ totem
16
16
  Running `totem` opens the branded command guide, confirms the installed version,
17
17
  and shows the complete challenge → presentation → verification flow. Interactive
18
18
  commands return a readable result card with the decision, relevant evidence, and
19
- the safest next step. Add `--json` when the output is being saved or consumed by
19
+ the safest next step. A successful human-mode `challenge` or `present` also
20
+ prints the complete short-lived JSON artifact needed by the next participant;
21
+ save it securely. Add `--json` when the output is being saved or consumed by
20
22
  another program; machine-readable output remains undecorated JSON.
21
23
 
22
24
  ```text
@@ -24,7 +26,7 @@ another program; machine-readable output remains undecorated JSON.
24
26
  ╭───╯╰────╮ ╺┳╸┏━┓╺┳╸┏━╸┏┳┓
25
27
  ╰────╮╭────╯ ┃ ┃ ┃ ┃ ┣╸ ┃┃┃
26
28
  ╰╯ ╹ ┗━┛ ╹ ┗━╸╹ ╹
27
- TotemLayer CLI 0.1.1 · cross-company agent authorization
29
+ TotemLayer CLI 0.1.2 · cross-company agent authorization
28
30
  ```
29
31
 
30
32
  For a one-off run without a global install:
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.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,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
+ });
91
220
  }
92
- if (nextStep) process.stdout.write(`\n${styled("Next →", "1")} ${nextStep}\n`);
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));
93
230
  }
94
231
 
95
232
  function compactDigest(value) {
@@ -99,76 +236,124 @@ 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
 
107
244
  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
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
113
262
 
114
263
  Action Checks · local or self-hosted
115
264
  totem checks run --file <action.json>
116
265
  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>
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>
121
274
 
122
275
  Procurement demonstration
123
- totem rfq "${RFQ_EXAMPLE}" # sandbox/self-hosted demo only
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.
124
281
 
125
282
  Shared options:
126
- --base-url <url> Totem URL (default: TOTEM_URL or ${DEFAULT_BASE_URL})
127
- --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
128
288
  -h, --help Show this help
129
289
  -v, --version Print the CLI version
130
290
 
131
291
  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)
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
139
303
 
140
304
  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
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
144
311
 
145
312
  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.
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.
150
320
 
151
321
  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.
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.
157
334
 
158
335
  Authentication:
159
336
  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.
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.
346
+
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.
172
357
  `);
173
358
  }
174
359
 
@@ -176,10 +361,14 @@ function fail(message) {
176
361
  process.stderr.write(
177
362
  `${styled("Totem: command failed", "1;38;2;255;114;114", process.stderr)}\n`,
178
363
  );
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
- );
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
+ });
183
372
  process.exitCode = 1;
184
373
  }
185
374
 
@@ -201,8 +390,23 @@ function failUnknownOutcome(message, idempotencyKey, json, machineStdout = false
201
390
  fail(`${message} The outcome is unknown; retry with --idempotency-key "${idempotencyKey}".`);
202
391
  }
203
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
+
204
407
  function parseArguments(values) {
205
408
  const options = { json: false, positionals: [] };
409
+ let parsingOptions = true;
206
410
  const valuedOptions = new Set([
207
411
  "--base-url",
208
412
  "--idempotency-key",
@@ -219,7 +423,14 @@ function parseArguments(values) {
219
423
  ]);
220
424
  for (let index = 0; index < values.length; index += 1) {
221
425
  const value = values[index];
222
- if (value === "--") continue;
426
+ if (parsingOptions && value === "--") {
427
+ parsingOptions = false;
428
+ continue;
429
+ }
430
+ if (!parsingOptions) {
431
+ options.positionals.push(value);
432
+ continue;
433
+ }
223
434
  if (value === "--json") {
224
435
  options.json = true;
225
436
  continue;
@@ -235,7 +446,9 @@ function parseArguments(values) {
235
446
  if (valuedOptions.has(value)) {
236
447
  const next = values[index + 1];
237
448
  if (!next || next.startsWith("--")) throw new Error(`${value} requires a value.`);
238
- 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;
239
452
  index += 1;
240
453
  continue;
241
454
  }
@@ -247,8 +460,13 @@ function parseArguments(values) {
247
460
 
248
461
  function safeBaseUrl(value) {
249
462
  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.");
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.");
252
470
  }
253
471
  const local = ["localhost", "127.0.0.1", "::1", "[::1]"].includes(url.hostname);
254
472
  if (url.protocol !== "https:" && !local) {
@@ -260,6 +478,21 @@ function safeBaseUrl(value) {
260
478
  return url;
261
479
  }
262
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
+
263
496
  function requireLocalActionChecks(baseUrl) {
264
497
  if (baseUrl.origin === DEFAULT_BASE_URL) {
265
498
  throw new Error(
@@ -326,13 +559,13 @@ function actionCheckSessionCookie() {
326
559
  return value;
327
560
  }
328
561
 
329
- function errorFrom(payload, fallback) {
330
- if (!payload || typeof payload !== "object") return fallback;
562
+ function errorFrom(payload, fallback, secrets = []) {
563
+ if (!payload || typeof payload !== "object") return redactCliSecrets(fallback, secrets);
331
564
  const error = payload.error;
332
- if (!error || typeof error !== "object") return fallback;
565
+ if (!error || typeof error !== "object") return redactCliSecrets(fallback, secrets);
333
566
  const code = typeof error.code === "string" ? error.code : "REQUEST_FAILED";
334
567
  const message = typeof error.message === "string" ? error.message : fallback;
335
- return `${code}: ${message}`;
568
+ return redactCliSecrets(`${code}: ${message}`, secrets);
336
569
  }
337
570
 
338
571
  function redactCliSecrets(value, secrets) {
@@ -344,6 +577,30 @@ function redactCliSecrets(value, secrets) {
344
577
  return redacted;
345
578
  }
346
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
+
347
604
  function writeActionCheckFailure(payload, fallback, status, secrets = []) {
348
605
  const error = record(record(payload)?.error);
349
606
  const code = redactCliSecrets(
@@ -387,10 +644,33 @@ function terminalText(value, fallback = "unknown") {
387
644
  return sanitized.slice(0, 2_000) || fallback;
388
645
  }
389
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
+
390
653
  function record(value) {
391
654
  return value && typeof value === "object" && !Array.isArray(value) ? value : undefined;
392
655
  }
393
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
+
394
674
  function requiredOption(options, key, flag, pattern = SAFE_IDENTIFIER_PATTERN) {
395
675
  const value = options[key]?.trim();
396
676
  if (!value || value.length > 200 || (pattern && !pattern.test(value))) {
@@ -535,6 +815,7 @@ function receiverChallenge(input) {
535
815
  challenge.nonce.length < 32 ||
536
816
  challenge.nonce.length > 256 ||
537
817
  !/^[A-Za-z0-9_-]+$/.test(challenge.nonce) ||
818
+ !["SANDBOX", "PRODUCTION"].includes(challenge.environment) ||
538
819
  typeof challenge.requestedAction !== "string" ||
539
820
  !ACTION_PATTERN.test(challenge.requestedAction) ||
540
821
  typeof challenge.requestDigest !== "string" ||
@@ -568,6 +849,7 @@ function receiverChallenge(input) {
568
849
  issuerOrganizationId: challenge.issuerOrganizationId,
569
850
  verifierAgentId: challenge.verifierAgentId,
570
851
  presenterAgentId: challenge.presenterAgentId,
852
+ environment: challenge.environment,
571
853
  requestedAction: challenge.requestedAction,
572
854
  requestDigest: challenge.requestDigest.toLowerCase(),
573
855
  ...(hasAmount
@@ -617,6 +899,7 @@ function boundPresentationClaims(token, challenge, credentialId, expiresAt) {
617
899
  claims.credentialId !== credentialId ||
618
900
  claims.agentId !== challenge.presenterAgentId ||
619
901
  claims.organizationId !== challenge.issuerOrganizationId ||
902
+ claims.environment !== challenge.environment ||
620
903
  claims.requestedAction !== challenge.requestedAction ||
621
904
  claims.requestDigest !== challenge.requestDigest ||
622
905
  claims.transactionAmount !== expectedAmount ||
@@ -688,25 +971,21 @@ async function runChallenge(options, baseUrl) {
688
971
  let transactionAmount;
689
972
  let currency;
690
973
  if (hasAmount) {
691
- transactionAmount = Number(options.amount);
692
- 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) {
693
980
  throw new Error("--amount must be a finite number from 0 through 1000000000000.");
694
981
  }
695
982
  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.");
983
+ if (!ISO_CURRENCY_CODES.has(currency)) {
984
+ throw new Error("--currency must be a recognized three-letter ISO 4217 currency code.");
698
985
  }
699
986
  }
700
987
 
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
- }
988
+ const idempotencyKey = validIdempotencyKey(options.idempotency_key ?? `cli:${randomUUID()}`);
710
989
  if (!options.json) process.stderr.write(`Totem retry key: ${idempotencyKey}\n`);
711
990
 
712
991
  let result;
@@ -728,15 +1007,39 @@ async function runChallenge(options, baseUrl) {
728
1007
  failUnknownOutcome(message, idempotencyKey, options.json, true);
729
1008
  return;
730
1009
  }
731
- const challenge = record(result.payload?.challenge);
732
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
+ }
733
1031
  failCommand(
734
- errorFrom(result.payload, `Challenge creation returned HTTP ${result.response.status}.`),
1032
+ errorFrom(result.payload, `Challenge creation returned HTTP ${result.response.status}.`, [
1033
+ key,
1034
+ ]),
735
1035
  options.json,
736
1036
  );
737
1037
  return;
738
1038
  }
739
- if (!challenge || typeof challenge.id !== "string" || typeof challenge.nonce !== "string") {
1039
+ let challenge;
1040
+ try {
1041
+ challenge = receiverChallenge(result.payload);
1042
+ } catch {
740
1043
  failUnknownOutcome(
741
1044
  "Totem returned an invalid challenge response.",
742
1045
  idempotencyKey,
@@ -745,7 +1048,28 @@ async function runChallenge(options, baseUrl) {
745
1048
  );
746
1049
  return;
747
1050
  }
748
- 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]));
749
1073
  else {
750
1074
  writeHumanResult(
751
1075
  `Totem challenge · ${terminalText(challenge.id)}`,
@@ -764,8 +1088,11 @@ async function runChallenge(options, baseUrl) {
764
1088
  ["Expires", challenge.expiresAt],
765
1089
  ["Protection", "Recipient-bound · request-bound · single-use"],
766
1090
  ],
767
- "Run again with --json to save the nonce-bearing challenge for the presenting agent.",
1091
+ "Save the complete JSON artifact below as the receiver-owned challenge, then send a copy to the presenting agent.",
1092
+ "success",
1093
+ [key],
768
1094
  );
1095
+ writeHumanArtifact(result.payload, [key]);
769
1096
  }
770
1097
  }
771
1098
 
@@ -780,6 +1107,7 @@ async function runPresent(options, baseUrl) {
780
1107
  const nonce = challenge.nonce;
781
1108
  if (
782
1109
  typeof challengeId !== "string" ||
1110
+ challengeId.length > 200 ||
783
1111
  !SAFE_IDENTIFIER_PATTERN.test(challengeId) ||
784
1112
  typeof nonce !== "string" ||
785
1113
  nonce.length < 32 ||
@@ -789,17 +1117,27 @@ async function runPresent(options, baseUrl) {
789
1117
  throw new Error("Challenge JSON is missing a valid challenge id or nonce.");
790
1118
  }
791
1119
 
792
- const result = await requestJson({
793
- baseUrl,
794
- path: "/api/v1/presentations",
795
- key,
796
- body: { credentialId, challengeId, nonce },
797
- });
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
+ }
798
1136
  const presentation = record(result.payload?.presentation);
799
1137
  if (!result.response.ok) {
800
1138
  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);
1139
+ if (typeof apiError?.code === "string" && HANDSHAKE_REJECTION_CODES.has(apiError.code)) {
1140
+ if (options.json) writeJson(redactJsonSecrets(result.payload, [key]));
803
1141
  else {
804
1142
  writeHumanResult(
805
1143
  "Totem presentation · REJECTED",
@@ -810,28 +1148,41 @@ async function runPresent(options, baseUrl) {
810
1148
  ],
811
1149
  "Return to the receiver for a fresh challenge before trying again.",
812
1150
  "danger",
1151
+ [key],
813
1152
  );
814
1153
  }
815
1154
  process.exitCode = 2;
816
1155
  return;
817
1156
  }
818
1157
  failCommand(
819
- errorFrom(result.payload, `Presentation creation returned HTTP ${result.response.status}.`),
1158
+ errorFrom(result.payload, `Presentation creation returned HTTP ${result.response.status}.`, [
1159
+ key,
1160
+ ]),
820
1161
  options.json,
821
1162
  );
822
1163
  return;
823
1164
  }
824
1165
  if (
825
1166
  !presentation ||
826
- typeof presentation.challengeId !== "string" ||
827
- typeof presentation.credentialId !== "string" ||
1167
+ result.response.status !== 201 ||
1168
+ presentation.challengeId !== challengeId ||
1169
+ presentation.credentialId !== credentialId ||
828
1170
  typeof presentation.token !== "string" ||
829
- 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()
830
1177
  ) {
831
- 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
+ );
832
1183
  return;
833
1184
  }
834
- if (options.json) writeJson(result.payload);
1185
+ if (options.json) writeJson(redactJsonSecrets(result.payload, [key]));
835
1186
  else {
836
1187
  writeHumanResult(
837
1188
  `Totem presentation · ${terminalText(presentation.challengeId)}`,
@@ -844,8 +1195,11 @@ async function runPresent(options, baseUrl) {
844
1195
  ["Expires", presentation.expiresAt],
845
1196
  ["Protection", "Challenge-bound · recipient-bound · one-minute proof"],
846
1197
  ],
847
- "Run again with --json to save the token-bearing presentation for the verifier.",
1198
+ "Save the complete JSON artifact below and return it to the verifier before the proof expires.",
1199
+ "success",
1200
+ [key],
848
1201
  );
1202
+ writeHumanArtifact(result.payload, [key]);
849
1203
  }
850
1204
  }
851
1205
 
@@ -887,17 +1241,27 @@ async function runVerify(options, baseUrl) {
887
1241
  }
888
1242
  const claims = boundPresentationClaims(token, challenge, credentialId, presentation.expiresAt);
889
1243
 
890
- const result = await requestJson({
891
- baseUrl,
892
- path: "/api/v1/presentations/verify",
893
- key,
894
- body: { challengeId: challenge.id, presentation: token },
895
- });
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
+ }
896
1260
  const payload = record(result.payload);
897
1261
  if (!payload || typeof payload.verified !== "boolean" || typeof payload.result !== "string") {
898
1262
  const apiError = record(payload?.error);
899
- if (typeof apiError?.code === "string" && PRESENTATION_REJECTION_CODES.has(apiError.code)) {
900
- 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]));
901
1265
  else {
902
1266
  writeHumanResult(
903
1267
  "Totem verification · REJECTED",
@@ -909,18 +1273,22 @@ async function runVerify(options, baseUrl) {
909
1273
  ],
910
1274
  "Create a fresh challenge and verify the exact request again before taking action.",
911
1275
  "danger",
1276
+ [key, token],
912
1277
  );
913
1278
  }
914
1279
  process.exitCode = 2;
915
1280
  return;
916
1281
  }
917
- failCommand(
918
- errorFrom(
919
- result.payload,
920
- `Presentation verification returned HTTP ${result.response.status}.`,
921
- ),
922
- options.json,
1282
+ const message = errorFrom(
1283
+ result.payload,
1284
+ `Presentation verification returned HTTP ${result.response.status}.`,
1285
+ [key, token],
923
1286
  );
1287
+ if (result.response.status >= 500 || result.response.ok) {
1288
+ failHandshakeUnknownOutcome("verification", message, options.json);
1289
+ } else {
1290
+ failCommand(message, options.json);
1291
+ }
924
1292
  return;
925
1293
  }
926
1294
 
@@ -972,7 +1340,7 @@ async function runVerify(options, baseUrl) {
972
1340
  }
973
1341
  }
974
1342
 
975
- if (options.json) writeJson(payload);
1343
+ if (options.json) writeJson(redactJsonSecrets(payload, [key, token]));
976
1344
  else {
977
1345
  const approvedResult = payload.verified && payload.result === "APPROVED" && result.response.ok;
978
1346
  writeHumanResult(
@@ -982,12 +1350,7 @@ async function runVerify(options, baseUrl) {
982
1350
  ["Agent", payload.agent?.name ?? payload.agent?.id],
983
1351
  ["Agent ID", payload.agent?.id],
984
1352
  ["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
- ],
1353
+ ["Organization proof", organizationProofText(payload.organization)],
991
1354
  ["Action", payload.authorization?.requestedAction],
992
1355
  [
993
1356
  "Authority",
@@ -1011,6 +1374,7 @@ async function runVerify(options, baseUrl) {
1011
1374
  ? "Continue only with the unchanged request bytes shown by this approval."
1012
1375
  : "Do not process the request; resolve the reason and create a fresh proof.",
1013
1376
  approvedResult ? "success" : "danger",
1377
+ [key, token],
1014
1378
  );
1015
1379
  }
1016
1380
  if (payload.verified && payload.result === "APPROVED" && result.response.ok) return;
@@ -1127,14 +1491,14 @@ async function runChecks(options, baseUrl) {
1127
1491
  });
1128
1492
  if (!payload) return;
1129
1493
  const suite = record(payload.suite);
1130
- if (!suite || typeof suite.id !== "string" || typeof suite.conclusion !== "string") {
1494
+ if (!validActionCheckSuite(suite)) {
1131
1495
  writeActionCheckOperationalError("Totem returned an invalid action check suite.");
1132
1496
  return;
1133
1497
  }
1134
- writeJson({ suite });
1498
+ writeJson(redactJsonSecrets({ suite }, [key]));
1135
1499
  if (suite.status === "STALE" || ["BLOCK", "REQUIRES_APPROVAL"].includes(suite.conclusion)) {
1136
1500
  process.exitCode = 2;
1137
- } else if (suite.conclusion === "ERROR") {
1501
+ } else if (suite.status !== "COMPLETED" || suite.conclusion === "ERROR") {
1138
1502
  process.exitCode = 1;
1139
1503
  }
1140
1504
  return;
@@ -1153,11 +1517,11 @@ async function runChecks(options, baseUrl) {
1153
1517
  });
1154
1518
  if (!payload) return;
1155
1519
  const suite = record(payload.suite);
1156
- if (!suite || typeof suite.id !== "string") {
1520
+ if (!validActionCheckSuite(suite, suiteId)) {
1157
1521
  writeActionCheckOperationalError("Totem returned an invalid action check suite.");
1158
1522
  return;
1159
1523
  }
1160
- writeJson({ suite });
1524
+ writeJson(redactJsonSecrets({ suite }, [key]));
1161
1525
  return;
1162
1526
  }
1163
1527
 
@@ -1175,11 +1539,11 @@ async function runChecks(options, baseUrl) {
1175
1539
  });
1176
1540
  if (!payload) return;
1177
1541
  const suite = record(payload.suite);
1178
- if (!suite || typeof suite.id !== "string") {
1542
+ if (!validActionCheckSuite(suite, suiteId)) {
1179
1543
  writeActionCheckOperationalError("Totem returned an invalid action check decision response.");
1180
1544
  return;
1181
1545
  }
1182
- writeJson({ suite });
1546
+ writeJson(redactJsonSecrets({ suite }, [cookie]));
1183
1547
  if (decision === "REJECT") process.exitCode = 2;
1184
1548
  return;
1185
1549
  }
@@ -1203,13 +1567,17 @@ async function runChecks(options, baseUrl) {
1203
1567
  typeof grant.token !== "string" ||
1204
1568
  grant.token.length < 32 ||
1205
1569
  grant.token.length > 512 ||
1206
- typeof grant.expiresAt !== "string" ||
1207
- !suite
1570
+ !validIsoTimestamp(grant.expiresAt) ||
1571
+ !validActionCheckSuite(suite, suiteId)
1208
1572
  ) {
1209
1573
  writeActionCheckOperationalError("Totem returned an invalid execution grant response.");
1210
1574
  return;
1211
1575
  }
1212
- writeJson({ grant: { token: grant.token, expiresAt: grant.expiresAt, suite } });
1576
+ writeJson(
1577
+ redactJsonSecrets({ grant: { token: grant.token, expiresAt: grant.expiresAt, suite } }, [
1578
+ key,
1579
+ ]),
1580
+ );
1213
1581
  return;
1214
1582
  }
1215
1583
 
@@ -1233,19 +1601,27 @@ async function runChecks(options, baseUrl) {
1233
1601
  const suite = record(payload.suite);
1234
1602
  const receipt = record(payload.receipt);
1235
1603
  if (
1236
- !suite ||
1604
+ !validActionCheckSuite(suite, suiteId) ||
1237
1605
  !receipt ||
1238
1606
  typeof receipt.id !== "string" ||
1239
- typeof receipt.outcome !== "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)
1240
1611
  ) {
1241
1612
  writeActionCheckOperationalError("Totem returned an invalid execution receipt response.");
1242
1613
  return;
1243
1614
  }
1244
- writeJson({
1245
- suite,
1246
- receipt,
1247
- ...(typeof payload.receiptId === "string" ? { receiptId: payload.receiptId } : {}),
1248
- });
1615
+ writeJson(
1616
+ redactJsonSecrets(
1617
+ {
1618
+ suite,
1619
+ receipt,
1620
+ ...(typeof payload.receiptId === "string" ? { receiptId: payload.receiptId } : {}),
1621
+ },
1622
+ [key, grant],
1623
+ ),
1624
+ );
1249
1625
  if (["FAILED", "UNKNOWN", "COMPENSATED"].includes(receipt.outcome)) process.exitCode = 1;
1250
1626
  return;
1251
1627
  }
@@ -1264,8 +1640,11 @@ async function runRfq(options, baseUrl) {
1264
1640
  }
1265
1641
  const requestParts = options.positionals.slice(1);
1266
1642
  if (requestParts.length === 0) throw new Error('Provide an RFQ request after the "rfq" command.');
1267
- const idempotencyKey = options.idempotency_key ?? `cli:${randomUUID()}`;
1268
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()}`);
1269
1648
  process.stderr.write(`Totem retry key: ${idempotencyKey}\n`);
1270
1649
 
1271
1650
  let result;
@@ -1285,6 +1664,7 @@ async function runRfq(options, baseUrl) {
1285
1664
  : "Could not reach Totem.",
1286
1665
  idempotencyKey,
1287
1666
  options.json,
1667
+ options.json,
1288
1668
  );
1289
1669
  return;
1290
1670
  }
@@ -1292,18 +1672,28 @@ async function runRfq(options, baseUrl) {
1292
1672
  const message = errorFrom(
1293
1673
  result.payload,
1294
1674
  `The RFQ endpoint returned HTTP ${result.response.status}.`,
1675
+ [key],
1295
1676
  );
1296
- if (result.response.status >= 500) failUnknownOutcome(message, idempotencyKey, options.json);
1297
- 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
+ }
1298
1682
  return;
1299
1683
  }
1300
1684
  const run = record(result.payload?.run);
1301
1685
  if (!run) {
1302
- 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
+ );
1303
1692
  return;
1304
1693
  }
1305
1694
  if (options.json) {
1306
- writeJson(result.payload);
1695
+ writeJson(redactJsonSecrets(result.payload, [key]));
1696
+ if (run.status !== "SUCCEEDED") process.exitCode = 1;
1307
1697
  return;
1308
1698
  }
1309
1699
 
@@ -1338,12 +1728,14 @@ async function runRfq(options, baseUrl) {
1338
1728
  ? "Review the supplier response and retain the run ID as the audit reference."
1339
1729
  : "Open the Totem operator run for the bounded trace before retrying.",
1340
1730
  succeeded ? "success" : "danger",
1731
+ [key],
1341
1732
  );
1342
1733
  if (run.status !== "SUCCEEDED") process.exitCode = 1;
1343
1734
  }
1344
1735
 
1345
1736
  async function main() {
1346
1737
  let options;
1738
+ const jsonRequested = process.argv.slice(2).includes("--json");
1347
1739
  try {
1348
1740
  options = parseArguments(process.argv.slice(2));
1349
1741
  const command = options.positionals[0];
@@ -1368,8 +1760,7 @@ async function main() {
1368
1760
  const message = error instanceof Error ? error.message : "The Totem command failed.";
1369
1761
  const command = options?.positionals?.[0] ?? process.argv[2];
1370
1762
  if (command === "checks") writeActionCheckOperationalError(message);
1371
- else if (command && command !== "rfq") failCommand(message, options?.json);
1372
- else fail(message);
1763
+ else failCommand(message, options?.json ?? jsonRequested);
1373
1764
  }
1374
1765
  }
1375
1766
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "totemlayer",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Cross-platform CLI for TotemLayer agent identity and authorization",
5
5
  "type": "module",
6
6
  "bin": {