@denial-web/clawguard 0.1.7 → 0.1.8

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,18 @@ 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
+
83
95
  When testing the published package, run `npx` from outside this repository. From inside the ClawGuard source checkout, use the local commands instead:
84
96
 
85
97
  ```bash
@@ -68,6 +68,16 @@ 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
+
71
81
  ### Skill Folder Scan
72
82
 
73
83
  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.8",
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,7 @@ 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 (!["scan", "scan-workspace", "gate", "install", "approvals-send", "approvals-watch"].includes(command)) {
36
36
  console.error(`Unknown command: ${command}`);
37
37
  printHelp();
38
38
  process.exit(1);
@@ -50,6 +50,19 @@ try {
50
50
  process.exit(0);
51
51
  }
52
52
 
53
+ if (command === "approvals-watch") {
54
+ const watchOptions = parseApprovalWatchOptions(optionValues);
55
+ const result = await watchApprovals(watchOptions, {
56
+ onSend: watchOptions.json ? undefined : printApprovalWatchSend
57
+ });
58
+ if (watchOptions.json) {
59
+ console.log(JSON.stringify(result, null, 2));
60
+ } else {
61
+ printApprovalWatchResult(result);
62
+ }
63
+ process.exit(0);
64
+ }
65
+
53
66
  const cliOptions = parseOptions(optionValues);
54
67
  cliOptions.framework = framework;
55
68
  const loadedConfig = await loadConfig(cliOptions.target, cliOptions.configPath);
@@ -111,6 +124,7 @@ Usage:
111
124
  clawguard hermes install <path> --to <dir> [--approval-out <path>]
112
125
  clawguard approvals send <approval.json|approvals.jsonl> --via openclaw --channel <name> --target <id>
113
126
  clawguard approvals send <approval.json|approvals.jsonl> --via telegram --chat-id <id>
127
+ clawguard approvals watch <approvals.jsonl> --via telegram --chat-id <id>
114
128
  clawguard scan-workspace <path> [--json] [--policy <preset>]
115
129
  npm run scan -- <path>
116
130
 
@@ -142,6 +156,9 @@ Options:
142
156
  --sender-arg <value> Extra argument before the generated sender command. Repeatable.
143
157
  --bot-token <token> Telegram bot token. Default: TELEGRAM_BOT_TOKEN.
144
158
  --chat-id <id> Telegram chat id. Alias for --target with --via telegram.
159
+ --interval <ms> Approval watch poll interval. Default: 2000.
160
+ --state <path> Approval watch sent-id state file.
161
+ --once Run approval watch once and exit.
145
162
 
146
163
  Gate exit codes:
147
164
  0 = allow
@@ -156,6 +173,7 @@ Examples:
156
173
  npx @denial-web/clawguard hermes install ./skills/my-skill --to ~/.hermes/skills --approval-out ./.clawguard/approvals.jsonl
157
174
  npx @denial-web/clawguard approvals send ./.clawguard/approvals.jsonl --via openclaw --channel telegram --target 123456789
158
175
  npx @denial-web/clawguard approvals send ./.clawguard/approvals.jsonl --via telegram --chat-id 123456789
176
+ npx @denial-web/clawguard approvals watch ./.clawguard/approvals.jsonl --via telegram --chat-id 123456789
159
177
  npm run scan -- examples/risky-skill
160
178
  npm run scan -- examples/metadata-mismatch-skill --policy governed --fail-on-policy
161
179
  npm run scan -- examples/metadata-mismatch-skill --html clawguard.html
@@ -247,6 +265,14 @@ function parseCommand(values) {
247
265
  };
248
266
  }
249
267
 
268
+ if (rawCommand === "approvals" && values[1] === "watch") {
269
+ return {
270
+ command: "approvals-watch",
271
+ framework: undefined,
272
+ optionValues: values.slice(2)
273
+ };
274
+ }
275
+
250
276
  if (["openclaw", "hermes"].includes(rawCommand)) {
251
277
  const nestedCommand = values[1];
252
278
 
@@ -282,6 +308,10 @@ function parseCommand(values) {
282
308
 
283
309
  async function sendApproval(options) {
284
310
  const approval = await readApprovalRequest(options.approvalPath, options.id);
311
+ return sendApprovalRequest(approval, options);
312
+ }
313
+
314
+ async function sendApprovalRequest(approval, options) {
285
315
  const message = String(approval.message ?? "").trim();
286
316
 
287
317
  if (!message) {
@@ -341,6 +371,67 @@ async function sendApproval(options) {
341
371
  return result;
342
372
  }
343
373
 
374
+ async function watchApprovals(options, hooks = {}) {
375
+ const statePath = path.resolve(options.statePath ?? `${options.approvalPath}.sent.json`);
376
+ const persistedIds = await readApprovalWatchState(statePath);
377
+ const sessionIds = new Set();
378
+ const result = {
379
+ approvalPath: path.resolve(options.approvalPath),
380
+ statePath,
381
+ once: options.once,
382
+ intervalMs: options.intervalMs,
383
+ dryRun: options.dryRun,
384
+ checked: 0,
385
+ matched: 0,
386
+ sent: 0,
387
+ skipped: 0,
388
+ deliveries: []
389
+ };
390
+
391
+ do {
392
+ const approvals = await readApprovalRequestsIfPresent(options.approvalPath);
393
+ result.checked += approvals.length;
394
+
395
+ for (const approval of approvals) {
396
+ if (approval.status !== "pending") {
397
+ result.skipped += 1;
398
+ continue;
399
+ }
400
+
401
+ if (!approval.id) {
402
+ result.skipped += 1;
403
+ continue;
404
+ }
405
+
406
+ if (persistedIds.has(approval.id) || sessionIds.has(approval.id)) {
407
+ result.skipped += 1;
408
+ continue;
409
+ }
410
+
411
+ result.matched += 1;
412
+ const delivery = await sendApprovalRequest(approval, options);
413
+ result.deliveries.push(delivery);
414
+ sessionIds.add(approval.id);
415
+
416
+ if (delivery.sent) {
417
+ result.sent += 1;
418
+ persistedIds.add(approval.id);
419
+ await writeApprovalWatchState(statePath, persistedIds);
420
+ }
421
+
422
+ if (hooks.onSend) {
423
+ hooks.onSend(delivery);
424
+ }
425
+ }
426
+
427
+ if (options.once) {
428
+ return result;
429
+ }
430
+
431
+ await sleep(options.intervalMs);
432
+ } while (true);
433
+ }
434
+
344
435
  async function sendTelegramApproval(approval, message, options) {
345
436
  const botToken = options.botToken ?? process.env.TELEGRAM_BOT_TOKEN;
346
437
 
@@ -421,12 +512,30 @@ function printApprovalSendResult(result) {
421
512
  }
422
513
  }
423
514
 
515
+ function printApprovalWatchSend(result) {
516
+ console.log(`ClawGuard approval watch sent: ${result.approval.id}`);
517
+ console.log(`Via: ${result.via}`);
518
+ console.log(`Target: ${result.target}`);
519
+ console.log(`Sent: ${result.sent ? "yes" : "no"}`);
520
+ if (result.dryRun && result.endpoint) {
521
+ console.log(`Endpoint: ${result.endpoint}`);
522
+ }
523
+ }
524
+
525
+ function printApprovalWatchResult(result) {
526
+ console.log(`ClawGuard approval watch: ${result.approvalPath}`);
527
+ console.log(`State: ${result.statePath}`);
528
+ console.log(`Once: ${result.once ? "yes" : "no"}`);
529
+ console.log(`Dry run: ${result.dryRun ? "yes" : "no"}`);
530
+ console.log(`Checked: ${result.checked}`);
531
+ console.log(`Matched pending: ${result.matched}`);
532
+ console.log(`Sent: ${result.sent}`);
533
+ console.log(`Skipped: ${result.skipped}`);
534
+ }
535
+
424
536
  async function readApprovalRequest(approvalPath, id) {
425
537
  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)];
538
+ const approvals = await readApprovalRequests(resolvedPath);
430
539
 
431
540
  if (approvals.length === 0) {
432
541
  throw new Error(`No approval requests found in ${resolvedPath}`);
@@ -447,6 +556,59 @@ async function readApprovalRequest(approvalPath, id) {
447
556
  return approval;
448
557
  }
449
558
 
559
+ async function readApprovalRequestsIfPresent(approvalPath) {
560
+ const resolvedPath = path.resolve(approvalPath);
561
+
562
+ try {
563
+ return await readApprovalRequests(resolvedPath);
564
+ } catch (error) {
565
+ if (error.code === "ENOENT") {
566
+ return [];
567
+ }
568
+
569
+ throw error;
570
+ }
571
+ }
572
+
573
+ async function readApprovalRequests(resolvedPath) {
574
+ const content = await fs.readFile(resolvedPath, "utf8");
575
+ const approvals = resolvedPath.endsWith(".jsonl")
576
+ ? content.split(/\r?\n/).filter(Boolean).map((line) => JSON.parse(line))
577
+ : [JSON.parse(content)];
578
+
579
+ for (const approval of approvals) {
580
+ if (approval.schemaVersion !== "clawguard.approval.v1") {
581
+ throw new Error("Unsupported approval request schema.");
582
+ }
583
+ }
584
+
585
+ return approvals;
586
+ }
587
+
588
+ async function readApprovalWatchState(statePath) {
589
+ try {
590
+ const content = await fs.readFile(statePath, "utf8");
591
+ const state = JSON.parse(content);
592
+ const ids = Array.isArray(state) ? state : state.sentIds;
593
+ return new Set(Array.isArray(ids) ? ids : []);
594
+ } catch (error) {
595
+ if (error.code === "ENOENT") {
596
+ return new Set();
597
+ }
598
+
599
+ throw error;
600
+ }
601
+ }
602
+
603
+ async function writeApprovalWatchState(statePath, sentIds) {
604
+ await fs.mkdir(path.dirname(statePath), { recursive: true });
605
+ await fs.writeFile(statePath, `${JSON.stringify({
606
+ schemaVersion: "clawguard.approval-watch-state.v1",
607
+ updatedAt: new Date().toISOString(),
608
+ sentIds: [...sentIds].sort()
609
+ }, null, 2)}\n`);
610
+ }
611
+
450
612
  function printGateResult(result, options) {
451
613
  const decision = result.policy.decision;
452
614
  console.log(`ClawGuard gate: ${result.target}`);
@@ -761,6 +923,14 @@ async function assertDestinationAvailable(destination) {
761
923
  }
762
924
 
763
925
  function commandLabel(commandName) {
926
+ if (commandName === "approvals-send") {
927
+ return "Approval send";
928
+ }
929
+
930
+ if (commandName === "approvals-watch") {
931
+ return "Approval watch";
932
+ }
933
+
764
934
  if (commandName === "gate") {
765
935
  return "Gate";
766
936
  }
@@ -801,6 +971,12 @@ function redactTelegramToken(value) {
801
971
  return String(value).replace(/\/bot[^/]+\/sendMessage$/, "/bot<redacted>/sendMessage");
802
972
  }
803
973
 
974
+ function sleep(ms) {
975
+ return new Promise((resolve) => {
976
+ setTimeout(resolve, ms);
977
+ });
978
+ }
979
+
804
980
  function gateExitCode(decision) {
805
981
  if (decision === "allow") {
806
982
  return 0;
@@ -1079,6 +1255,144 @@ function parseApprovalSendOptions(values) {
1079
1255
  return options;
1080
1256
  }
1081
1257
 
1258
+ function parseApprovalWatchOptions(values) {
1259
+ const options = {
1260
+ approvalPath: undefined,
1261
+ via: "telegram",
1262
+ channel: undefined,
1263
+ target: undefined,
1264
+ chatId: undefined,
1265
+ botToken: undefined,
1266
+ telegramApiBase: undefined,
1267
+ senderBin: undefined,
1268
+ senderArgs: [],
1269
+ dryRun: false,
1270
+ json: false,
1271
+ once: false,
1272
+ intervalMs: 2000,
1273
+ statePath: undefined
1274
+ };
1275
+ const paths = [];
1276
+
1277
+ for (let index = 0; index < values.length; index += 1) {
1278
+ const value = values[index];
1279
+
1280
+ if (value === "--json") {
1281
+ options.json = true;
1282
+ continue;
1283
+ }
1284
+
1285
+ if (value === "--dry-run") {
1286
+ options.dryRun = true;
1287
+ continue;
1288
+ }
1289
+
1290
+ if (value === "--once") {
1291
+ options.once = true;
1292
+ continue;
1293
+ }
1294
+
1295
+ if (value === "--interval") {
1296
+ const interval = Number.parseInt(requireNextValue(values, index, "--interval"), 10);
1297
+ if (!Number.isSafeInteger(interval) || interval < 250) {
1298
+ throw new Error("--interval must be an integer of at least 250 milliseconds.");
1299
+ }
1300
+ options.intervalMs = interval;
1301
+ index += 1;
1302
+ continue;
1303
+ }
1304
+
1305
+ if (value === "--state") {
1306
+ options.statePath = requireNextValue(values, index, "--state");
1307
+ index += 1;
1308
+ continue;
1309
+ }
1310
+
1311
+ if (value === "--via") {
1312
+ options.via = requireNextValue(values, index, "--via");
1313
+ index += 1;
1314
+ continue;
1315
+ }
1316
+
1317
+ if (value === "--channel") {
1318
+ options.channel = requireNextValue(values, index, "--channel");
1319
+ index += 1;
1320
+ continue;
1321
+ }
1322
+
1323
+ if (value === "--target") {
1324
+ options.target = requireNextValue(values, index, "--target");
1325
+ index += 1;
1326
+ continue;
1327
+ }
1328
+
1329
+ if (value === "--chat-id") {
1330
+ options.chatId = requireNextValue(values, index, "--chat-id");
1331
+ index += 1;
1332
+ continue;
1333
+ }
1334
+
1335
+ if (value === "--bot-token") {
1336
+ options.botToken = requireNextValue(values, index, "--bot-token");
1337
+ index += 1;
1338
+ continue;
1339
+ }
1340
+
1341
+ if (value === "--telegram-api-base") {
1342
+ options.telegramApiBase = requireNextValue(values, index, "--telegram-api-base");
1343
+ index += 1;
1344
+ continue;
1345
+ }
1346
+
1347
+ if (value === "--sender-bin") {
1348
+ options.senderBin = requireNextValue(values, index, "--sender-bin");
1349
+ index += 1;
1350
+ continue;
1351
+ }
1352
+
1353
+ if (value === "--sender-arg") {
1354
+ options.senderArgs.push(requireNextValue(values, index, "--sender-arg"));
1355
+ index += 1;
1356
+ continue;
1357
+ }
1358
+
1359
+ if (value.startsWith("--")) {
1360
+ throw new Error(`Unknown option: ${value}`);
1361
+ }
1362
+
1363
+ paths.push(value);
1364
+ }
1365
+
1366
+ options.approvalPath = paths[0];
1367
+
1368
+ if (!options.approvalPath) {
1369
+ throw new Error("approvals watch requires <approval.jsonl>.");
1370
+ }
1371
+
1372
+ if (!["openclaw", "telegram"].includes(options.via)) {
1373
+ throw new Error("Invalid --via value. Use one of: openclaw, telegram");
1374
+ }
1375
+
1376
+ if (options.via === "openclaw" && !options.channel) {
1377
+ throw new Error("approvals watch --via openclaw requires --channel <name>.");
1378
+ }
1379
+
1380
+ if (options.via === "openclaw" && !options.target) {
1381
+ throw new Error("approvals watch --via openclaw requires --target <id>.");
1382
+ }
1383
+
1384
+ if (options.via === "telegram") {
1385
+ options.chatId = options.chatId ?? options.target;
1386
+ if (!options.chatId) {
1387
+ throw new Error("approvals watch --via telegram requires --chat-id <id>.");
1388
+ }
1389
+ options.channel = "telegram";
1390
+ options.target = options.chatId;
1391
+ }
1392
+
1393
+ return options;
1394
+ }
1395
+
1082
1396
  async function writeReportFile(outputPath, content) {
1083
1397
  const resolvedPath = path.resolve(outputPath);
1084
1398
  await fs.mkdir(path.dirname(resolvedPath), { recursive: true });