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