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.
- package/README.md +4 -2
- package/bin/totem.mjs +543 -152
- 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.
|
|
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.
|
|
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
|
|
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
|
|
26
|
-
"
|
|
27
|
-
"
|
|
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
|
-
"
|
|
35
|
-
"
|
|
36
|
-
|
|
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
|
-
|
|
80
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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>
|
|
118
|
-
|
|
119
|
-
totem checks
|
|
120
|
-
|
|
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
|
|
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
|
|
127
|
-
|
|
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
|
|
133
|
-
--action <action> Namespaced action
|
|
134
|
-
|
|
135
|
-
--request-
|
|
136
|
-
--
|
|
137
|
-
|
|
138
|
-
--
|
|
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;
|
|
142
|
-
|
|
143
|
-
--request-
|
|
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
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
|
161
|
-
checks run/view/grant/execute use TOTEM_API_KEY with an
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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 === "--")
|
|
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
|
-
|
|
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 (
|
|
251
|
-
|
|
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
|
-
|
|
692
|
-
if (
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
"
|
|
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
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
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" &&
|
|
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
|
-
|
|
827
|
-
|
|
1167
|
+
result.response.status !== 201 ||
|
|
1168
|
+
presentation.challengeId !== challengeId ||
|
|
1169
|
+
presentation.credentialId !== credentialId ||
|
|
828
1170
|
typeof presentation.token !== "string" ||
|
|
829
|
-
|
|
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
|
-
|
|
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
|
-
"
|
|
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
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
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" &&
|
|
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
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
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)
|
|
1297
|
-
|
|
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(
|
|
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
|
|
1372
|
-
else fail(message);
|
|
1763
|
+
else failCommand(message, options?.json ?? jsonRequested);
|
|
1373
1764
|
}
|
|
1374
1765
|
}
|
|
1375
1766
|
|