@denial-web/clawguard 0.1.7 → 0.1.9

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 CHANGED
@@ -80,6 +80,25 @@ If you want ClawGuard to own the approval channel separately, send directly thro
80
80
  TELEGRAM_BOT_TOKEN=123456:token npx @denial-web/clawguard approvals send ./.clawguard/approvals.jsonl --via telegram --chat-id 123456789
81
81
  ```
82
82
 
83
+ For an automatic bridge, keep a watcher running. It forwards each new pending approval once and stores sent approval ids in `./.clawguard/approvals.jsonl.sent.json` by default:
84
+
85
+ ```bash
86
+ TELEGRAM_BOT_TOKEN=123456:token npx @denial-web/clawguard approvals watch ./.clawguard/approvals.jsonl --via telegram --chat-id 123456789
87
+ ```
88
+
89
+ Use `--once --dry-run` to verify the message flow without sending anything:
90
+
91
+ ```bash
92
+ TELEGRAM_BOT_TOKEN=123456:token npx @denial-web/clawguard approvals watch ./.clawguard/approvals.jsonl --via telegram --chat-id 123456789 --once --dry-run
93
+ ```
94
+
95
+ Record the owner's decision in a durable decision log:
96
+
97
+ ```bash
98
+ npx @denial-web/clawguard approvals decide ./.clawguard/approvals.jsonl --id <approval-id> --decision approve --out ./.clawguard/decisions.jsonl
99
+ npx @denial-web/clawguard approvals decide ./.clawguard/approvals.jsonl --id <approval-id> --decision deny --reason "Unexpected shell access" --out ./.clawguard/decisions.jsonl
100
+ ```
101
+
83
102
  When testing the published package, run `npx` from outside this repository. From inside the ClawGuard source checkout, use the local commands instead:
84
103
 
85
104
  ```bash
@@ -68,6 +68,31 @@ TELEGRAM_BOT_TOKEN=123456:token clawguard approvals send ./.clawguard/approvals.
68
68
 
69
69
  That path is better when the user wants the approval channel to stay independent from the agent runtime. Use `--dry-run` first to verify the redacted endpoint and message payload before sending.
70
70
 
71
+ For long-running installs, ClawGuard can watch the approval queue and forward each new pending request once:
72
+
73
+ ```bash
74
+ TELEGRAM_BOT_TOKEN=123456:token clawguard approvals watch ./.clawguard/approvals.jsonl \
75
+ --via telegram \
76
+ --chat-id 123456789
77
+ ```
78
+
79
+ The watcher keeps search and discovery unrestricted. It only reacts after a guarded install writes a pending approval request. By default it records sent ids in `./.clawguard/approvals.jsonl.sent.json`, so restarting the bridge does not resend the same request. Use `--once --dry-run` for setup checks and CI smoke tests.
80
+
81
+ ### Approval Decisions
82
+
83
+ Owner decisions are stored separately from approval requests. This keeps the original scan evidence immutable and gives messaging bridges a simple append-only target:
84
+
85
+ ```bash
86
+ clawguard approvals decide ./.clawguard/approvals.jsonl \
87
+ --id <approval-id> \
88
+ --decision approve \
89
+ --actor owner \
90
+ --reason "Reviewed and acceptable" \
91
+ --out ./.clawguard/decisions.jsonl
92
+ ```
93
+
94
+ The decision log uses `schemaVersion: "clawguard.decision.v1"` and stores the approval id, normalized decision, actor, reason, target, destination, risk summary, policy summary, and source approval path. Later Telegram, WhatsApp, OpenClaw, or Hermes bridges should write this same decision format after parsing owner replies.
95
+
71
96
  ### Skill Folder Scan
72
97
 
73
98
  Command:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@denial-web/clawguard",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
4
4
  "description": "Explainable security scanner for OpenClaw-style skills and MCP tool configs.",
5
5
  "type": "module",
6
6
  "repository": {
package/src/cli.js CHANGED
@@ -32,7 +32,15 @@ if (args.length === 0 || args.includes("--help") || args.includes("-h")) {
32
32
  const commandContext = parseCommand(args);
33
33
  const { command, framework, optionValues } = commandContext;
34
34
 
35
- if (!["scan", "scan-workspace", "gate", "install", "approvals-send"].includes(command)) {
35
+ if (![
36
+ "scan",
37
+ "scan-workspace",
38
+ "gate",
39
+ "install",
40
+ "approvals-send",
41
+ "approvals-watch",
42
+ "approvals-decide"
43
+ ].includes(command)) {
36
44
  console.error(`Unknown command: ${command}`);
37
45
  printHelp();
38
46
  process.exit(1);
@@ -50,6 +58,30 @@ try {
50
58
  process.exit(0);
51
59
  }
52
60
 
61
+ if (command === "approvals-watch") {
62
+ const watchOptions = parseApprovalWatchOptions(optionValues);
63
+ const result = await watchApprovals(watchOptions, {
64
+ onSend: watchOptions.json ? undefined : printApprovalWatchSend
65
+ });
66
+ if (watchOptions.json) {
67
+ console.log(JSON.stringify(result, null, 2));
68
+ } else {
69
+ printApprovalWatchResult(result);
70
+ }
71
+ process.exit(0);
72
+ }
73
+
74
+ if (command === "approvals-decide") {
75
+ const decisionOptions = parseApprovalDecisionOptions(optionValues);
76
+ const result = await decideApproval(decisionOptions);
77
+ if (decisionOptions.json) {
78
+ console.log(JSON.stringify(result, null, 2));
79
+ } else {
80
+ printApprovalDecisionResult(result);
81
+ }
82
+ process.exit(0);
83
+ }
84
+
53
85
  const cliOptions = parseOptions(optionValues);
54
86
  cliOptions.framework = framework;
55
87
  const loadedConfig = await loadConfig(cliOptions.target, cliOptions.configPath);
@@ -111,6 +143,8 @@ Usage:
111
143
  clawguard hermes install <path> --to <dir> [--approval-out <path>]
112
144
  clawguard approvals send <approval.json|approvals.jsonl> --via openclaw --channel <name> --target <id>
113
145
  clawguard approvals send <approval.json|approvals.jsonl> --via telegram --chat-id <id>
146
+ clawguard approvals watch <approvals.jsonl> --via telegram --chat-id <id>
147
+ clawguard approvals decide <approval.json|approvals.jsonl> --id <id> --decision approve|deny
114
148
  clawguard scan-workspace <path> [--json] [--policy <preset>]
115
149
  npm run scan -- <path>
116
150
 
@@ -142,6 +176,14 @@ Options:
142
176
  --sender-arg <value> Extra argument before the generated sender command. Repeatable.
143
177
  --bot-token <token> Telegram bot token. Default: TELEGRAM_BOT_TOKEN.
144
178
  --chat-id <id> Telegram chat id. Alias for --target with --via telegram.
179
+ --interval <ms> Approval watch poll interval. Default: 2000.
180
+ --state <path> Approval watch sent-id state file.
181
+ --once Run approval watch once and exit.
182
+ --id <id> Approval id for send or decide.
183
+ --decision <value> Approval decision: approve, deny.
184
+ --out <path> Decision JSONL output file.
185
+ --actor <name> Decision actor. Default: local-user.
186
+ --reason <text> Decision reason.
145
187
 
146
188
  Gate exit codes:
147
189
  0 = allow
@@ -156,6 +198,8 @@ Examples:
156
198
  npx @denial-web/clawguard hermes install ./skills/my-skill --to ~/.hermes/skills --approval-out ./.clawguard/approvals.jsonl
157
199
  npx @denial-web/clawguard approvals send ./.clawguard/approvals.jsonl --via openclaw --channel telegram --target 123456789
158
200
  npx @denial-web/clawguard approvals send ./.clawguard/approvals.jsonl --via telegram --chat-id 123456789
201
+ npx @denial-web/clawguard approvals watch ./.clawguard/approvals.jsonl --via telegram --chat-id 123456789
202
+ npx @denial-web/clawguard approvals decide ./.clawguard/approvals.jsonl --id <id> --decision approve
159
203
  npm run scan -- examples/risky-skill
160
204
  npm run scan -- examples/metadata-mismatch-skill --policy governed --fail-on-policy
161
205
  npm run scan -- examples/metadata-mismatch-skill --html clawguard.html
@@ -247,6 +291,22 @@ function parseCommand(values) {
247
291
  };
248
292
  }
249
293
 
294
+ if (rawCommand === "approvals" && values[1] === "watch") {
295
+ return {
296
+ command: "approvals-watch",
297
+ framework: undefined,
298
+ optionValues: values.slice(2)
299
+ };
300
+ }
301
+
302
+ if (rawCommand === "approvals" && values[1] === "decide") {
303
+ return {
304
+ command: "approvals-decide",
305
+ framework: undefined,
306
+ optionValues: values.slice(2)
307
+ };
308
+ }
309
+
250
310
  if (["openclaw", "hermes"].includes(rawCommand)) {
251
311
  const nestedCommand = values[1];
252
312
 
@@ -282,6 +342,10 @@ function parseCommand(values) {
282
342
 
283
343
  async function sendApproval(options) {
284
344
  const approval = await readApprovalRequest(options.approvalPath, options.id);
345
+ return sendApprovalRequest(approval, options);
346
+ }
347
+
348
+ async function sendApprovalRequest(approval, options) {
285
349
  const message = String(approval.message ?? "").trim();
286
350
 
287
351
  if (!message) {
@@ -341,6 +405,113 @@ async function sendApproval(options) {
341
405
  return result;
342
406
  }
343
407
 
408
+ async function watchApprovals(options, hooks = {}) {
409
+ const statePath = path.resolve(options.statePath ?? `${options.approvalPath}.sent.json`);
410
+ const persistedIds = await readApprovalWatchState(statePath);
411
+ const sessionIds = new Set();
412
+ const result = {
413
+ approvalPath: path.resolve(options.approvalPath),
414
+ statePath,
415
+ once: options.once,
416
+ intervalMs: options.intervalMs,
417
+ dryRun: options.dryRun,
418
+ checked: 0,
419
+ matched: 0,
420
+ sent: 0,
421
+ skipped: 0,
422
+ deliveries: []
423
+ };
424
+
425
+ do {
426
+ const approvals = await readApprovalRequestsIfPresent(options.approvalPath);
427
+ result.checked += approvals.length;
428
+
429
+ for (const approval of approvals) {
430
+ if (approval.status !== "pending") {
431
+ result.skipped += 1;
432
+ continue;
433
+ }
434
+
435
+ if (!approval.id) {
436
+ result.skipped += 1;
437
+ continue;
438
+ }
439
+
440
+ if (persistedIds.has(approval.id) || sessionIds.has(approval.id)) {
441
+ result.skipped += 1;
442
+ continue;
443
+ }
444
+
445
+ result.matched += 1;
446
+ const delivery = await sendApprovalRequest(approval, options);
447
+ result.deliveries.push(delivery);
448
+ sessionIds.add(approval.id);
449
+
450
+ if (delivery.sent) {
451
+ result.sent += 1;
452
+ persistedIds.add(approval.id);
453
+ await writeApprovalWatchState(statePath, persistedIds);
454
+ }
455
+
456
+ if (hooks.onSend) {
457
+ hooks.onSend(delivery);
458
+ }
459
+ }
460
+
461
+ if (options.once) {
462
+ return result;
463
+ }
464
+
465
+ await sleep(options.intervalMs);
466
+ } while (true);
467
+ }
468
+
469
+ async function decideApproval(options) {
470
+ const approval = await readApprovalRequest(options.approvalPath, options.id);
471
+ const outputPath = path.resolve(options.outPath ?? `${options.approvalPath}.decisions.jsonl`);
472
+ const decision = createApprovalDecision(approval, options);
473
+
474
+ await fs.mkdir(path.dirname(outputPath), { recursive: true });
475
+ await fs.appendFile(outputPath, `${JSON.stringify(decision)}\n`);
476
+
477
+ return {
478
+ approval: {
479
+ id: approval.id,
480
+ status: approval.status,
481
+ decision: approval.decision,
482
+ risk: approval.risk,
483
+ framework: approval.framework
484
+ },
485
+ outputPath,
486
+ decision
487
+ };
488
+ }
489
+
490
+ function createApprovalDecision(approval, options) {
491
+ const decision = normalizeApprovalDecision(options.decision);
492
+ const status = decision === "approve" ? "approved" : "denied";
493
+
494
+ return {
495
+ schemaVersion: "clawguard.decision.v1",
496
+ id: randomUUID(),
497
+ approvalId: approval.id,
498
+ status,
499
+ decision,
500
+ decidedAt: new Date().toISOString(),
501
+ actor: options.actor,
502
+ reason: options.reason,
503
+ framework: approval.framework,
504
+ target: approval.target,
505
+ destination: approval.destination,
506
+ risk: approval.risk,
507
+ policy: approval.policy,
508
+ source: {
509
+ path: path.resolve(options.approvalPath),
510
+ approvalCreatedAt: approval.createdAt
511
+ }
512
+ };
513
+ }
514
+
344
515
  async function sendTelegramApproval(approval, message, options) {
345
516
  const botToken = options.botToken ?? process.env.TELEGRAM_BOT_TOKEN;
346
517
 
@@ -421,12 +592,41 @@ function printApprovalSendResult(result) {
421
592
  }
422
593
  }
423
594
 
595
+ function printApprovalWatchSend(result) {
596
+ console.log(`ClawGuard approval watch sent: ${result.approval.id}`);
597
+ console.log(`Via: ${result.via}`);
598
+ console.log(`Target: ${result.target}`);
599
+ console.log(`Sent: ${result.sent ? "yes" : "no"}`);
600
+ if (result.dryRun && result.endpoint) {
601
+ console.log(`Endpoint: ${result.endpoint}`);
602
+ }
603
+ }
604
+
605
+ function printApprovalWatchResult(result) {
606
+ console.log(`ClawGuard approval watch: ${result.approvalPath}`);
607
+ console.log(`State: ${result.statePath}`);
608
+ console.log(`Once: ${result.once ? "yes" : "no"}`);
609
+ console.log(`Dry run: ${result.dryRun ? "yes" : "no"}`);
610
+ console.log(`Checked: ${result.checked}`);
611
+ console.log(`Matched pending: ${result.matched}`);
612
+ console.log(`Sent: ${result.sent}`);
613
+ console.log(`Skipped: ${result.skipped}`);
614
+ }
615
+
616
+ function printApprovalDecisionResult(result) {
617
+ console.log(`ClawGuard approval decision: ${result.approval.id}`);
618
+ console.log(`Decision: ${formatDecision(result.decision.decision)}`);
619
+ console.log(`Status: ${result.decision.status}`);
620
+ console.log(`Actor: ${result.decision.actor}`);
621
+ if (result.decision.reason) {
622
+ console.log(`Reason: ${result.decision.reason}`);
623
+ }
624
+ console.log(`Output: ${result.outputPath}`);
625
+ }
626
+
424
627
  async function readApprovalRequest(approvalPath, id) {
425
628
  const resolvedPath = path.resolve(approvalPath);
426
- const content = await fs.readFile(resolvedPath, "utf8");
427
- const approvals = resolvedPath.endsWith(".jsonl")
428
- ? content.split(/\r?\n/).filter(Boolean).map((line) => JSON.parse(line))
429
- : [JSON.parse(content)];
629
+ const approvals = await readApprovalRequests(resolvedPath);
430
630
 
431
631
  if (approvals.length === 0) {
432
632
  throw new Error(`No approval requests found in ${resolvedPath}`);
@@ -447,6 +647,59 @@ async function readApprovalRequest(approvalPath, id) {
447
647
  return approval;
448
648
  }
449
649
 
650
+ async function readApprovalRequestsIfPresent(approvalPath) {
651
+ const resolvedPath = path.resolve(approvalPath);
652
+
653
+ try {
654
+ return await readApprovalRequests(resolvedPath);
655
+ } catch (error) {
656
+ if (error.code === "ENOENT") {
657
+ return [];
658
+ }
659
+
660
+ throw error;
661
+ }
662
+ }
663
+
664
+ async function readApprovalRequests(resolvedPath) {
665
+ const content = await fs.readFile(resolvedPath, "utf8");
666
+ const approvals = resolvedPath.endsWith(".jsonl")
667
+ ? content.split(/\r?\n/).filter(Boolean).map((line) => JSON.parse(line))
668
+ : [JSON.parse(content)];
669
+
670
+ for (const approval of approvals) {
671
+ if (approval.schemaVersion !== "clawguard.approval.v1") {
672
+ throw new Error("Unsupported approval request schema.");
673
+ }
674
+ }
675
+
676
+ return approvals;
677
+ }
678
+
679
+ async function readApprovalWatchState(statePath) {
680
+ try {
681
+ const content = await fs.readFile(statePath, "utf8");
682
+ const state = JSON.parse(content);
683
+ const ids = Array.isArray(state) ? state : state.sentIds;
684
+ return new Set(Array.isArray(ids) ? ids : []);
685
+ } catch (error) {
686
+ if (error.code === "ENOENT") {
687
+ return new Set();
688
+ }
689
+
690
+ throw error;
691
+ }
692
+ }
693
+
694
+ async function writeApprovalWatchState(statePath, sentIds) {
695
+ await fs.mkdir(path.dirname(statePath), { recursive: true });
696
+ await fs.writeFile(statePath, `${JSON.stringify({
697
+ schemaVersion: "clawguard.approval-watch-state.v1",
698
+ updatedAt: new Date().toISOString(),
699
+ sentIds: [...sentIds].sort()
700
+ }, null, 2)}\n`);
701
+ }
702
+
450
703
  function printGateResult(result, options) {
451
704
  const decision = result.policy.decision;
452
705
  console.log(`ClawGuard gate: ${result.target}`);
@@ -761,6 +1014,18 @@ async function assertDestinationAvailable(destination) {
761
1014
  }
762
1015
 
763
1016
  function commandLabel(commandName) {
1017
+ if (commandName === "approvals-send") {
1018
+ return "Approval send";
1019
+ }
1020
+
1021
+ if (commandName === "approvals-watch") {
1022
+ return "Approval watch";
1023
+ }
1024
+
1025
+ if (commandName === "approvals-decide") {
1026
+ return "Approval decision";
1027
+ }
1028
+
764
1029
  if (commandName === "gate") {
765
1030
  return "Gate";
766
1031
  }
@@ -801,6 +1066,24 @@ function redactTelegramToken(value) {
801
1066
  return String(value).replace(/\/bot[^/]+\/sendMessage$/, "/bot<redacted>/sendMessage");
802
1067
  }
803
1068
 
1069
+ function sleep(ms) {
1070
+ return new Promise((resolve) => {
1071
+ setTimeout(resolve, ms);
1072
+ });
1073
+ }
1074
+
1075
+ function normalizeApprovalDecision(value) {
1076
+ if (value === "approved") {
1077
+ return "approve";
1078
+ }
1079
+
1080
+ if (value === "denied") {
1081
+ return "deny";
1082
+ }
1083
+
1084
+ return value;
1085
+ }
1086
+
804
1087
  function gateExitCode(decision) {
805
1088
  if (decision === "allow") {
806
1089
  return 0;
@@ -1079,6 +1362,224 @@ function parseApprovalSendOptions(values) {
1079
1362
  return options;
1080
1363
  }
1081
1364
 
1365
+ function parseApprovalWatchOptions(values) {
1366
+ const options = {
1367
+ approvalPath: undefined,
1368
+ via: "telegram",
1369
+ channel: undefined,
1370
+ target: undefined,
1371
+ chatId: undefined,
1372
+ botToken: undefined,
1373
+ telegramApiBase: undefined,
1374
+ senderBin: undefined,
1375
+ senderArgs: [],
1376
+ dryRun: false,
1377
+ json: false,
1378
+ once: false,
1379
+ intervalMs: 2000,
1380
+ statePath: undefined
1381
+ };
1382
+ const paths = [];
1383
+
1384
+ for (let index = 0; index < values.length; index += 1) {
1385
+ const value = values[index];
1386
+
1387
+ if (value === "--json") {
1388
+ options.json = true;
1389
+ continue;
1390
+ }
1391
+
1392
+ if (value === "--dry-run") {
1393
+ options.dryRun = true;
1394
+ continue;
1395
+ }
1396
+
1397
+ if (value === "--once") {
1398
+ options.once = true;
1399
+ continue;
1400
+ }
1401
+
1402
+ if (value === "--interval") {
1403
+ const interval = Number.parseInt(requireNextValue(values, index, "--interval"), 10);
1404
+ if (!Number.isSafeInteger(interval) || interval < 250) {
1405
+ throw new Error("--interval must be an integer of at least 250 milliseconds.");
1406
+ }
1407
+ options.intervalMs = interval;
1408
+ index += 1;
1409
+ continue;
1410
+ }
1411
+
1412
+ if (value === "--state") {
1413
+ options.statePath = requireNextValue(values, index, "--state");
1414
+ index += 1;
1415
+ continue;
1416
+ }
1417
+
1418
+ if (value === "--via") {
1419
+ options.via = requireNextValue(values, index, "--via");
1420
+ index += 1;
1421
+ continue;
1422
+ }
1423
+
1424
+ if (value === "--channel") {
1425
+ options.channel = requireNextValue(values, index, "--channel");
1426
+ index += 1;
1427
+ continue;
1428
+ }
1429
+
1430
+ if (value === "--target") {
1431
+ options.target = requireNextValue(values, index, "--target");
1432
+ index += 1;
1433
+ continue;
1434
+ }
1435
+
1436
+ if (value === "--chat-id") {
1437
+ options.chatId = requireNextValue(values, index, "--chat-id");
1438
+ index += 1;
1439
+ continue;
1440
+ }
1441
+
1442
+ if (value === "--bot-token") {
1443
+ options.botToken = requireNextValue(values, index, "--bot-token");
1444
+ index += 1;
1445
+ continue;
1446
+ }
1447
+
1448
+ if (value === "--telegram-api-base") {
1449
+ options.telegramApiBase = requireNextValue(values, index, "--telegram-api-base");
1450
+ index += 1;
1451
+ continue;
1452
+ }
1453
+
1454
+ if (value === "--sender-bin") {
1455
+ options.senderBin = requireNextValue(values, index, "--sender-bin");
1456
+ index += 1;
1457
+ continue;
1458
+ }
1459
+
1460
+ if (value === "--sender-arg") {
1461
+ options.senderArgs.push(requireNextValue(values, index, "--sender-arg"));
1462
+ index += 1;
1463
+ continue;
1464
+ }
1465
+
1466
+ if (value.startsWith("--")) {
1467
+ throw new Error(`Unknown option: ${value}`);
1468
+ }
1469
+
1470
+ paths.push(value);
1471
+ }
1472
+
1473
+ options.approvalPath = paths[0];
1474
+
1475
+ if (!options.approvalPath) {
1476
+ throw new Error("approvals watch requires <approval.jsonl>.");
1477
+ }
1478
+
1479
+ if (!["openclaw", "telegram"].includes(options.via)) {
1480
+ throw new Error("Invalid --via value. Use one of: openclaw, telegram");
1481
+ }
1482
+
1483
+ if (options.via === "openclaw" && !options.channel) {
1484
+ throw new Error("approvals watch --via openclaw requires --channel <name>.");
1485
+ }
1486
+
1487
+ if (options.via === "openclaw" && !options.target) {
1488
+ throw new Error("approvals watch --via openclaw requires --target <id>.");
1489
+ }
1490
+
1491
+ if (options.via === "telegram") {
1492
+ options.chatId = options.chatId ?? options.target;
1493
+ if (!options.chatId) {
1494
+ throw new Error("approvals watch --via telegram requires --chat-id <id>.");
1495
+ }
1496
+ options.channel = "telegram";
1497
+ options.target = options.chatId;
1498
+ }
1499
+
1500
+ return options;
1501
+ }
1502
+
1503
+ function parseApprovalDecisionOptions(values) {
1504
+ const options = {
1505
+ approvalPath: undefined,
1506
+ id: undefined,
1507
+ decision: undefined,
1508
+ outPath: undefined,
1509
+ actor: "local-user",
1510
+ reason: undefined,
1511
+ json: false
1512
+ };
1513
+ const paths = [];
1514
+
1515
+ for (let index = 0; index < values.length; index += 1) {
1516
+ const value = values[index];
1517
+
1518
+ if (value === "--json") {
1519
+ options.json = true;
1520
+ continue;
1521
+ }
1522
+
1523
+ if (value === "--id") {
1524
+ options.id = requireNextValue(values, index, "--id");
1525
+ index += 1;
1526
+ continue;
1527
+ }
1528
+
1529
+ if (value === "--decision") {
1530
+ options.decision = requireNextValue(values, index, "--decision");
1531
+ index += 1;
1532
+ continue;
1533
+ }
1534
+
1535
+ if (value === "--out") {
1536
+ options.outPath = requireNextValue(values, index, "--out");
1537
+ index += 1;
1538
+ continue;
1539
+ }
1540
+
1541
+ if (value === "--actor") {
1542
+ options.actor = requireNextValue(values, index, "--actor");
1543
+ index += 1;
1544
+ continue;
1545
+ }
1546
+
1547
+ if (value === "--reason") {
1548
+ options.reason = requireNextValue(values, index, "--reason");
1549
+ index += 1;
1550
+ continue;
1551
+ }
1552
+
1553
+ if (value.startsWith("--")) {
1554
+ throw new Error(`Unknown option: ${value}`);
1555
+ }
1556
+
1557
+ paths.push(value);
1558
+ }
1559
+
1560
+ options.approvalPath = paths[0];
1561
+
1562
+ if (!options.approvalPath) {
1563
+ throw new Error("approvals decide requires <approval.json|approvals.jsonl>.");
1564
+ }
1565
+
1566
+ if (!options.id) {
1567
+ throw new Error("approvals decide requires --id <id>.");
1568
+ }
1569
+
1570
+ if (!options.decision) {
1571
+ throw new Error("approvals decide requires --decision approve|deny.");
1572
+ }
1573
+
1574
+ options.decision = normalizeApprovalDecision(options.decision);
1575
+
1576
+ if (!["approve", "deny"].includes(options.decision)) {
1577
+ throw new Error("Invalid --decision value. Use one of: approve, deny");
1578
+ }
1579
+
1580
+ return options;
1581
+ }
1582
+
1082
1583
  async function writeReportFile(outputPath, content) {
1083
1584
  const resolvedPath = path.resolve(outputPath);
1084
1585
  await fs.mkdir(path.dirname(resolvedPath), { recursive: true });