@denial-web/clawguard 0.1.10 → 0.1.11

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
@@ -114,6 +114,14 @@ TELEGRAM_BOT_TOKEN=123456:token npx @denial-web/clawguard approvals poll-telegra
114
114
 
115
115
  The poller records the Telegram update offset in `./.clawguard/decisions.jsonl.telegram-state.json` by default so the same reply is not processed again.
116
116
 
117
+ Apply the recorded decision to continue or block the pending install:
118
+
119
+ ```bash
120
+ npx @denial-web/clawguard approvals apply ./.clawguard/approvals.jsonl --id <approval-id> --decisions ./.clawguard/decisions.jsonl
121
+ ```
122
+
123
+ If the latest decision is `approve`, ClawGuard copies the original scanned source to the original approved destination. If the latest decision is `deny`, it exits blocked without copying. If no decision exists yet, it stays paused.
124
+
117
125
  When testing the published package, run `npx` from outside this repository. From inside the ClawGuard source checkout, use the local commands instead:
118
126
 
119
127
  ```bash
@@ -111,6 +111,24 @@ TELEGRAM_BOT_TOKEN=123456:token clawguard approvals poll-telegram ./.clawguard/a
111
111
 
112
112
  The poller calls Telegram `getUpdates`, parses approval commands, looks up the referenced approval request, and appends a `clawguard.decision.v1` decision. It records the next Telegram update offset in `./.clawguard/decisions.jsonl.telegram-state.json` by default. For tests and offline replay, `--telegram-updates-file <path>` can read a captured Telegram-style update payload instead of calling the Telegram API.
113
113
 
114
+ ### Approval Apply
115
+
116
+ After a decision is recorded, ClawGuard can apply that decision to the original pending install:
117
+
118
+ ```bash
119
+ clawguard approvals apply ./.clawguard/approvals.jsonl \
120
+ --id <approval-id> \
121
+ --decisions ./.clawguard/decisions.jsonl
122
+ ```
123
+
124
+ Apply behavior:
125
+
126
+ - If the latest matching decision is `approve`, copy the original scanned `target` to the original approval `destination`.
127
+ - If the latest matching decision is `deny`, exit blocked and copy nothing.
128
+ - If no matching decision exists, exit paused and copy nothing.
129
+ - Never execute the skill or install dependencies during apply.
130
+ - Refuse symlinked install sources and existing destinations, matching normal install safety.
131
+
114
132
  ### Skill Folder Scan
115
133
 
116
134
  Command:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@denial-web/clawguard",
3
- "version": "0.1.10",
3
+ "version": "0.1.11",
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
@@ -40,7 +40,8 @@ if (![
40
40
  "approvals-send",
41
41
  "approvals-watch",
42
42
  "approvals-decide",
43
- "approvals-poll-telegram"
43
+ "approvals-poll-telegram",
44
+ "approvals-apply"
44
45
  ].includes(command)) {
45
46
  console.error(`Unknown command: ${command}`);
46
47
  printHelp();
@@ -94,6 +95,17 @@ try {
94
95
  process.exit(0);
95
96
  }
96
97
 
98
+ if (command === "approvals-apply") {
99
+ const applyOptions = parseApprovalApplyOptions(optionValues);
100
+ const result = await applyApprovalDecision(applyOptions);
101
+ if (applyOptions.json) {
102
+ console.log(JSON.stringify(result, null, 2));
103
+ } else {
104
+ printApprovalApplyResult(result);
105
+ }
106
+ process.exit(approvalApplyExitCode(result));
107
+ }
108
+
97
109
  const cliOptions = parseOptions(optionValues);
98
110
  cliOptions.framework = framework;
99
111
  const loadedConfig = await loadConfig(cliOptions.target, cliOptions.configPath);
@@ -158,6 +170,7 @@ Usage:
158
170
  clawguard approvals watch <approvals.jsonl> --via telegram --chat-id <id>
159
171
  clawguard approvals decide <approval.json|approvals.jsonl> --id <id> --decision approve|deny
160
172
  clawguard approvals poll-telegram <approvals.jsonl> --decisions <decisions.jsonl>
173
+ clawguard approvals apply <approvals.jsonl> --id <id> --decisions <decisions.jsonl>
161
174
  clawguard scan-workspace <path> [--json] [--policy <preset>]
162
175
  npm run scan -- <path>
163
176
 
@@ -218,6 +231,7 @@ Examples:
218
231
  npx @denial-web/clawguard approvals watch ./.clawguard/approvals.jsonl --via telegram --chat-id 123456789
219
232
  npx @denial-web/clawguard approvals decide ./.clawguard/approvals.jsonl --id <id> --decision approve
220
233
  npx @denial-web/clawguard approvals poll-telegram ./.clawguard/approvals.jsonl --decisions ./.clawguard/decisions.jsonl
234
+ npx @denial-web/clawguard approvals apply ./.clawguard/approvals.jsonl --id <id> --decisions ./.clawguard/decisions.jsonl
221
235
  npm run scan -- examples/risky-skill
222
236
  npm run scan -- examples/metadata-mismatch-skill --policy governed --fail-on-policy
223
237
  npm run scan -- examples/metadata-mismatch-skill --html clawguard.html
@@ -333,6 +347,14 @@ function parseCommand(values) {
333
347
  };
334
348
  }
335
349
 
350
+ if (rawCommand === "approvals" && values[1] === "apply") {
351
+ return {
352
+ command: "approvals-apply",
353
+ framework: undefined,
354
+ optionValues: values.slice(2)
355
+ };
356
+ }
357
+
336
358
  if (["openclaw", "hermes"].includes(rawCommand)) {
337
359
  const nestedCommand = values[1];
338
360
 
@@ -578,6 +600,97 @@ async function pollTelegramApprovals(options) {
578
600
  return result;
579
601
  }
580
602
 
603
+ async function applyApprovalDecision(options) {
604
+ const approval = await readApprovalRequest(options.approvalPath, options.id);
605
+ const decisionsPath = path.resolve(options.decisionsPath ?? `${options.approvalPath}.decisions.jsonl`);
606
+ const decision = await readLatestApprovalDecision(decisionsPath, approval.id);
607
+ const result = {
608
+ approval: {
609
+ id: approval.id,
610
+ status: approval.status,
611
+ decision: approval.decision,
612
+ risk: approval.risk,
613
+ framework: approval.framework
614
+ },
615
+ decision,
616
+ decisionsPath,
617
+ source: approval.target ? path.resolve(approval.target) : undefined,
618
+ destination: approval.destination ? path.resolve(approval.destination) : undefined,
619
+ dryRun: options.dryRun,
620
+ installed: false,
621
+ skipped: true,
622
+ reason: undefined
623
+ };
624
+
625
+ if (!decision) {
626
+ result.reason = "No decision has been recorded for this approval.";
627
+ return result;
628
+ }
629
+
630
+ if (decision.decision !== "approve") {
631
+ result.reason = decision.reason ?? "Approval was denied.";
632
+ return result;
633
+ }
634
+
635
+ if (!result.source) {
636
+ throw new Error("Approval request has no target path to install.");
637
+ }
638
+
639
+ if (!result.destination) {
640
+ throw new Error("Approval request has no destination path to install.");
641
+ }
642
+
643
+ if (options.dryRun) {
644
+ result.skipped = false;
645
+ result.reason = "Dry run passed; no files were copied.";
646
+ return result;
647
+ }
648
+
649
+ await assertInstallableSource(result.source);
650
+ await assertDestinationAvailable(result.destination);
651
+ await fs.mkdir(path.dirname(result.destination), { recursive: true });
652
+ await fs.cp(result.source, result.destination, {
653
+ recursive: true,
654
+ errorOnExist: true,
655
+ force: false,
656
+ verbatimSymlinks: true
657
+ });
658
+
659
+ result.installed = true;
660
+ result.skipped = false;
661
+ result.reason = "Copied after recorded approval.";
662
+ return result;
663
+ }
664
+
665
+ async function readLatestApprovalDecision(decisionsPath, approvalId) {
666
+ let decisions;
667
+
668
+ try {
669
+ decisions = await readApprovalDecisions(decisionsPath);
670
+ } catch (error) {
671
+ if (error.code === "ENOENT") {
672
+ return undefined;
673
+ }
674
+
675
+ throw error;
676
+ }
677
+
678
+ return decisions.filter((decision) => decision.approvalId === approvalId).at(-1);
679
+ }
680
+
681
+ async function readApprovalDecisions(decisionsPath) {
682
+ const content = await fs.readFile(decisionsPath, "utf8");
683
+ const decisions = content.split(/\r?\n/).filter(Boolean).map((line) => JSON.parse(line));
684
+
685
+ for (const decision of decisions) {
686
+ if (decision.schemaVersion !== "clawguard.decision.v1") {
687
+ throw new Error("Unsupported approval decision schema.");
688
+ }
689
+ }
690
+
691
+ return decisions;
692
+ }
693
+
581
694
  async function fetchTelegramUpdates(options, offset) {
582
695
  if (options.telegramUpdatesPath) {
583
696
  const content = await fs.readFile(path.resolve(options.telegramUpdatesPath), "utf8");
@@ -817,6 +930,17 @@ function printApprovalTelegramPollResult(result) {
817
930
  }
818
931
  }
819
932
 
933
+ function printApprovalApplyResult(result) {
934
+ console.log(`ClawGuard approval apply: ${result.approval.id}`);
935
+ console.log(`Decision: ${result.decision ? formatDecision(result.decision.decision) : "PENDING"}`);
936
+ console.log(`Source: ${result.source ?? "not recorded"}`);
937
+ console.log(`Destination: ${result.destination ?? "not recorded"}`);
938
+ console.log(`Dry run: ${result.dryRun ? "yes" : "no"}`);
939
+ console.log(`Installed: ${result.installed ? "yes" : "no"}`);
940
+ console.log(`Exit code: ${approvalApplyExitCode(result)}`);
941
+ console.log(`Reason: ${result.reason}`);
942
+ }
943
+
820
944
  async function readApprovalRequest(approvalPath, id) {
821
945
  const resolvedPath = path.resolve(approvalPath);
822
946
  const approvals = await readApprovalRequests(resolvedPath);
@@ -1246,6 +1370,10 @@ function commandLabel(commandName) {
1246
1370
  return "Telegram approval poll";
1247
1371
  }
1248
1372
 
1373
+ if (commandName === "approvals-apply") {
1374
+ return "Approval apply";
1375
+ }
1376
+
1249
1377
  if (commandName === "gate") {
1250
1378
  return "Gate";
1251
1379
  }
@@ -1324,6 +1452,18 @@ function installExitCode(decision, install) {
1324
1452
  return gateExitCode(decision);
1325
1453
  }
1326
1454
 
1455
+ function approvalApplyExitCode(result) {
1456
+ if (!result.decision) {
1457
+ return 1;
1458
+ }
1459
+
1460
+ if (result.decision.decision !== "approve") {
1461
+ return 2;
1462
+ }
1463
+
1464
+ return 0;
1465
+ }
1466
+
1327
1467
  function parseOptions(values) {
1328
1468
  const options = {
1329
1469
  json: false,
@@ -1893,6 +2033,67 @@ function parseApprovalTelegramPollOptions(values) {
1893
2033
  return options;
1894
2034
  }
1895
2035
 
2036
+ function parseApprovalApplyOptions(values) {
2037
+ const options = {
2038
+ approvalPath: undefined,
2039
+ id: undefined,
2040
+ decisionsPath: undefined,
2041
+ dryRun: false,
2042
+ json: false
2043
+ };
2044
+ const paths = [];
2045
+
2046
+ for (let index = 0; index < values.length; index += 1) {
2047
+ const value = values[index];
2048
+
2049
+ if (value === "--json") {
2050
+ options.json = true;
2051
+ continue;
2052
+ }
2053
+
2054
+ if (value === "--dry-run") {
2055
+ options.dryRun = true;
2056
+ continue;
2057
+ }
2058
+
2059
+ if (value === "--id") {
2060
+ options.id = requireNextValue(values, index, "--id");
2061
+ index += 1;
2062
+ continue;
2063
+ }
2064
+
2065
+ if (value === "--decisions") {
2066
+ options.decisionsPath = requireNextValue(values, index, "--decisions");
2067
+ index += 1;
2068
+ continue;
2069
+ }
2070
+
2071
+ if (value === "--out") {
2072
+ options.decisionsPath = requireNextValue(values, index, "--out");
2073
+ index += 1;
2074
+ continue;
2075
+ }
2076
+
2077
+ if (value.startsWith("--")) {
2078
+ throw new Error(`Unknown option: ${value}`);
2079
+ }
2080
+
2081
+ paths.push(value);
2082
+ }
2083
+
2084
+ options.approvalPath = paths[0];
2085
+
2086
+ if (!options.approvalPath) {
2087
+ throw new Error("approvals apply requires <approval.json|approvals.jsonl>.");
2088
+ }
2089
+
2090
+ if (!options.id) {
2091
+ throw new Error("approvals apply requires --id <id>.");
2092
+ }
2093
+
2094
+ return options;
2095
+ }
2096
+
1896
2097
  async function writeReportFile(outputPath, content) {
1897
2098
  const resolvedPath = path.resolve(outputPath);
1898
2099
  await fs.mkdir(path.dirname(resolvedPath), { recursive: true });