@denial-web/clawguard 0.1.4 → 0.1.6

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
@@ -68,6 +68,18 @@ npx @denial-web/clawguard hermes install ./candidate-skill --to ~/.hermes/skills
68
68
 
69
69
  The approval JSONL payload is designed for a bot or daemon to forward to WhatsApp, Telegram, Slack, Discord, or another owner channel before any files are copied into a trusted skill folder.
70
70
 
71
+ If OpenClaw already has messaging configured, ClawGuard can hand the approval message to OpenClaw:
72
+
73
+ ```bash
74
+ npx @denial-web/clawguard approvals send ./.clawguard/approvals.jsonl --via openclaw --channel telegram --target 123456789
75
+ ```
76
+
77
+ If you want ClawGuard to own the approval channel separately, send directly through Telegram:
78
+
79
+ ```bash
80
+ TELEGRAM_BOT_TOKEN=123456:token npx @denial-web/clawguard approvals send ./.clawguard/approvals.jsonl --via telegram --chat-id 123456789
81
+ ```
82
+
71
83
  When testing the published package, run `npx` from outside this repository. From inside the ClawGuard source checkout, use the local commands instead:
72
84
 
73
85
  ```bash
@@ -39,6 +39,35 @@ trusted skill folder
39
39
 
40
40
  If `--approval-out` is set, non-allow decisions create a pending approval JSON payload instead of copying files. With `--approval-mode always`, even allow decisions pause for explicit owner approval. A messaging adapter can forward the `message` field to WhatsApp, Telegram, Slack, Discord, or another owner channel.
41
41
 
42
+ ### Approval Message Delivery
43
+
44
+ Option A uses OpenClaw's native messaging command after OpenClaw is already configured by the user:
45
+
46
+ ```bash
47
+ clawguard approvals send ./.clawguard/approvals.jsonl \
48
+ --via openclaw \
49
+ --channel telegram \
50
+ --target 123456789
51
+ ```
52
+
53
+ The adapter calls:
54
+
55
+ ```bash
56
+ openclaw message send --channel telegram --target 123456789 --message "<approval message>"
57
+ ```
58
+
59
+ This is the easiest path for OpenClaw users because ClawGuard does not need to own Telegram, WhatsApp, Slack, or Discord credentials.
60
+
61
+ Option B is a ClawGuard-owned Telegram sender:
62
+
63
+ ```bash
64
+ TELEGRAM_BOT_TOKEN=123456:token clawguard approvals send ./.clawguard/approvals.jsonl \
65
+ --via telegram \
66
+ --chat-id 123456789
67
+ ```
68
+
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
+
42
71
  ### Skill Folder Scan
43
72
 
44
73
  Command:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@denial-web/clawguard",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
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
@@ -2,7 +2,9 @@
2
2
 
3
3
  import { promises as fs } from "node:fs";
4
4
  import { randomUUID } from "node:crypto";
5
+ import { execFile } from "node:child_process";
5
6
  import path from "node:path";
7
+ import { promisify } from "node:util";
6
8
  import { loadConfig, mergeConfig, parseSize } from "./config.js";
7
9
  import { policyShouldFail } from "./policy.js";
8
10
  import { createHtmlReport } from "./reporters/html.js";
@@ -10,6 +12,7 @@ import { createSarifReport } from "./reporters/sarif.js";
10
12
  import { scanTarget } from "./scanner.js";
11
13
 
12
14
  const args = process.argv.slice(2);
15
+ const execFileAsync = promisify(execFile);
13
16
  const failLevels = ["none", "low", "medium", "high", "critical"];
14
17
  const policyPresets = ["personal", "governed", "enterprise"];
15
18
  const policyFailDecisions = ["warn", "manual_review", "sandbox_required", "dual_approval", "block"];
@@ -29,13 +32,24 @@ if (args.length === 0 || args.includes("--help") || args.includes("-h")) {
29
32
  const commandContext = parseCommand(args);
30
33
  const { command, framework, optionValues } = commandContext;
31
34
 
32
- if (!["scan", "scan-workspace", "gate", "install"].includes(command)) {
35
+ if (!["scan", "scan-workspace", "gate", "install", "approvals-send"].includes(command)) {
33
36
  console.error(`Unknown command: ${command}`);
34
37
  printHelp();
35
38
  process.exit(1);
36
39
  }
37
40
 
38
41
  try {
42
+ if (command === "approvals-send") {
43
+ const sendOptions = parseApprovalSendOptions(optionValues);
44
+ const result = await sendApproval(sendOptions);
45
+ if (sendOptions.json) {
46
+ console.log(JSON.stringify(result, null, 2));
47
+ } else {
48
+ printApprovalSendResult(result);
49
+ }
50
+ process.exit(0);
51
+ }
52
+
39
53
  const cliOptions = parseOptions(optionValues);
40
54
  cliOptions.framework = framework;
41
55
  const loadedConfig = await loadConfig(cliOptions.target, cliOptions.configPath);
@@ -95,6 +109,8 @@ Usage:
95
109
  clawguard install <path> --to <dir> [--policy <preset>] [--dry-run]
96
110
  clawguard openclaw install <path> --to <dir> [--approval-out <path>]
97
111
  clawguard hermes install <path> --to <dir> [--approval-out <path>]
112
+ clawguard approvals send <approval.json|approvals.jsonl> --via openclaw --channel <name> --target <id>
113
+ clawguard approvals send <approval.json|approvals.jsonl> --via telegram --chat-id <id>
98
114
  clawguard scan-workspace <path> [--json] [--policy <preset>]
99
115
  npm run scan -- <path>
100
116
 
@@ -119,6 +135,13 @@ Options:
119
135
  --approval-out <path> Write a pending approval JSON request before copying.
120
136
  Use .jsonl to append JSON lines for bot/daemon integrations.
121
137
  --approval-mode <mode> Approval mode: non-allow, always. Default: non-allow.
138
+ --via <adapter> Approval send adapter: openclaw, telegram.
139
+ --channel <name> Messaging channel for approval send, such as telegram.
140
+ --target <id> Messaging target/chat id for approval send.
141
+ --sender-bin <path> Sender binary. Default for --via openclaw: openclaw.
142
+ --sender-arg <value> Extra argument before the generated sender command. Repeatable.
143
+ --bot-token <token> Telegram bot token. Default: TELEGRAM_BOT_TOKEN.
144
+ --chat-id <id> Telegram chat id. Alias for --target with --via telegram.
122
145
 
123
146
  Gate exit codes:
124
147
  0 = allow
@@ -131,6 +154,8 @@ Examples:
131
154
  npx @denial-web/clawguard install ./skills/my-skill --to ./.agents/skills --policy governed
132
155
  npx @denial-web/clawguard openclaw install ./skills/my-skill --to ./.agents/skills --approval-out ./.clawguard/approvals.jsonl
133
156
  npx @denial-web/clawguard hermes install ./skills/my-skill --to ~/.hermes/skills --approval-out ./.clawguard/approvals.jsonl
157
+ npx @denial-web/clawguard approvals send ./.clawguard/approvals.jsonl --via openclaw --channel telegram --target 123456789
158
+ npx @denial-web/clawguard approvals send ./.clawguard/approvals.jsonl --via telegram --chat-id 123456789
134
159
  npm run scan -- examples/risky-skill
135
160
  npm run scan -- examples/metadata-mismatch-skill --policy governed --fail-on-policy
136
161
  npm run scan -- examples/metadata-mismatch-skill --html clawguard.html
@@ -214,6 +239,14 @@ function printHumanResult(result, options) {
214
239
  function parseCommand(values) {
215
240
  const rawCommand = values[0];
216
241
 
242
+ if (rawCommand === "approvals" && values[1] === "send") {
243
+ return {
244
+ command: "approvals-send",
245
+ framework: undefined,
246
+ optionValues: values.slice(2)
247
+ };
248
+ }
249
+
217
250
  if (["openclaw", "hermes"].includes(rawCommand)) {
218
251
  const nestedCommand = values[1];
219
252
 
@@ -247,6 +280,173 @@ function parseCommand(values) {
247
280
  };
248
281
  }
249
282
 
283
+ async function sendApproval(options) {
284
+ const approval = await readApprovalRequest(options.approvalPath, options.id);
285
+ const message = String(approval.message ?? "").trim();
286
+
287
+ if (!message) {
288
+ throw new Error("Approval request has no message field.");
289
+ }
290
+
291
+ if (options.via === "telegram") {
292
+ return sendTelegramApproval(approval, message, options);
293
+ }
294
+
295
+ if (options.via !== "openclaw") {
296
+ throw new Error("Only --via openclaw or --via telegram is supported right now.");
297
+ }
298
+
299
+ const senderBin = options.senderBin ?? "openclaw";
300
+ const commandArgs = [
301
+ ...options.senderArgs,
302
+ "message",
303
+ "send",
304
+ "--channel",
305
+ options.channel,
306
+ "--target",
307
+ options.target,
308
+ "--message",
309
+ message
310
+ ];
311
+ const result = {
312
+ approval: {
313
+ id: approval.id,
314
+ status: approval.status,
315
+ decision: approval.decision,
316
+ risk: approval.risk,
317
+ framework: approval.framework
318
+ },
319
+ via: options.via,
320
+ channel: options.channel,
321
+ target: options.target,
322
+ senderBin,
323
+ command: [senderBin, ...commandArgs],
324
+ dryRun: options.dryRun,
325
+ sent: false,
326
+ stdout: "",
327
+ stderr: ""
328
+ };
329
+
330
+ if (options.dryRun) {
331
+ return result;
332
+ }
333
+
334
+ const output = await execFileAsync(senderBin, commandArgs, {
335
+ maxBuffer: 1024 * 1024
336
+ });
337
+
338
+ result.sent = true;
339
+ result.stdout = output.stdout;
340
+ result.stderr = output.stderr;
341
+ return result;
342
+ }
343
+
344
+ async function sendTelegramApproval(approval, message, options) {
345
+ const botToken = options.botToken ?? process.env.TELEGRAM_BOT_TOKEN;
346
+
347
+ if (!botToken) {
348
+ throw new Error("Telegram send requires --bot-token or TELEGRAM_BOT_TOKEN.");
349
+ }
350
+
351
+ const apiBase = options.telegramApiBase ?? "https://api.telegram.org";
352
+ const endpoint = `${apiBase.replace(/\/$/, "")}/bot${botToken}/sendMessage`;
353
+ const body = {
354
+ chat_id: options.chatId,
355
+ text: message,
356
+ disable_web_page_preview: true
357
+ };
358
+ const result = {
359
+ approval: {
360
+ id: approval.id,
361
+ status: approval.status,
362
+ decision: approval.decision,
363
+ risk: approval.risk,
364
+ framework: approval.framework
365
+ },
366
+ via: "telegram",
367
+ channel: "telegram",
368
+ target: options.chatId,
369
+ endpoint: redactTelegramToken(endpoint),
370
+ request: body,
371
+ dryRun: options.dryRun,
372
+ sent: false,
373
+ response: null
374
+ };
375
+
376
+ if (options.dryRun) {
377
+ return result;
378
+ }
379
+
380
+ const response = await fetch(endpoint, {
381
+ method: "POST",
382
+ headers: {
383
+ "content-type": "application/json"
384
+ },
385
+ body: JSON.stringify(body)
386
+ });
387
+ const text = await response.text();
388
+ let payload;
389
+
390
+ try {
391
+ payload = text ? JSON.parse(text) : null;
392
+ } catch {
393
+ payload = text;
394
+ }
395
+
396
+ result.response = payload;
397
+
398
+ if (!response.ok) {
399
+ throw new Error(`Telegram send failed with HTTP ${response.status}: ${text}`);
400
+ }
401
+
402
+ result.sent = true;
403
+ return result;
404
+ }
405
+
406
+ function printApprovalSendResult(result) {
407
+ console.log(`ClawGuard approval send: ${result.approval.id}`);
408
+ console.log(`Via: ${result.via}`);
409
+ console.log(`Channel: ${result.channel}`);
410
+ console.log(`Target: ${result.target}`);
411
+ console.log(`Decision: ${formatDecision(result.approval.decision ?? "unknown")}`);
412
+ console.log(`Dry run: ${result.dryRun ? "yes" : "no"}`);
413
+ console.log(`Sent: ${result.sent ? "yes" : "no"}`);
414
+
415
+ if (result.dryRun) {
416
+ if (result.command) {
417
+ console.log(`Command: ${result.command.map(shellQuote).join(" ")}`);
418
+ } else if (result.endpoint) {
419
+ console.log(`Endpoint: ${result.endpoint}`);
420
+ }
421
+ }
422
+ }
423
+
424
+ async function readApprovalRequest(approvalPath, id) {
425
+ 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)];
430
+
431
+ if (approvals.length === 0) {
432
+ throw new Error(`No approval requests found in ${resolvedPath}`);
433
+ }
434
+
435
+ const approval = id
436
+ ? approvals.find((candidate) => candidate.id === id)
437
+ : approvals.at(-1);
438
+
439
+ if (!approval) {
440
+ throw new Error(`Approval request not found: ${id}`);
441
+ }
442
+
443
+ if (approval.schemaVersion !== "clawguard.approval.v1") {
444
+ throw new Error("Unsupported approval request schema.");
445
+ }
446
+
447
+ return approval;
448
+ }
449
+
250
450
  function printGateResult(result, options) {
251
451
  const decision = result.policy.decision;
252
452
  console.log(`ClawGuard gate: ${result.target}`);
@@ -588,6 +788,19 @@ function formatDecision(decision) {
588
788
  return decision.replaceAll("_", " ").toUpperCase();
589
789
  }
590
790
 
791
+ function shellQuote(value) {
792
+ const text = String(value);
793
+ if (/^[A-Za-z0-9_./:=@-]+$/.test(text)) {
794
+ return text;
795
+ }
796
+
797
+ return `'${text.replaceAll("'", "'\\''")}'`;
798
+ }
799
+
800
+ function redactTelegramToken(value) {
801
+ return String(value).replace(/\/bot[^/]+\/sendMessage$/, "/bot<redacted>/sendMessage");
802
+ }
803
+
591
804
  function gateExitCode(decision) {
592
805
  if (decision === "allow") {
593
806
  return 0;
@@ -745,6 +958,127 @@ function parseOptions(values) {
745
958
  return options;
746
959
  }
747
960
 
961
+ function parseApprovalSendOptions(values) {
962
+ const options = {
963
+ approvalPath: undefined,
964
+ id: undefined,
965
+ via: "openclaw",
966
+ channel: undefined,
967
+ target: undefined,
968
+ chatId: undefined,
969
+ botToken: undefined,
970
+ telegramApiBase: undefined,
971
+ senderBin: undefined,
972
+ senderArgs: [],
973
+ dryRun: false,
974
+ json: false
975
+ };
976
+ const paths = [];
977
+
978
+ for (let index = 0; index < values.length; index += 1) {
979
+ const value = values[index];
980
+
981
+ if (value === "--json") {
982
+ options.json = true;
983
+ continue;
984
+ }
985
+
986
+ if (value === "--dry-run") {
987
+ options.dryRun = true;
988
+ continue;
989
+ }
990
+
991
+ if (value === "--id") {
992
+ options.id = requireNextValue(values, index, "--id");
993
+ index += 1;
994
+ continue;
995
+ }
996
+
997
+ if (value === "--via") {
998
+ options.via = requireNextValue(values, index, "--via");
999
+ index += 1;
1000
+ continue;
1001
+ }
1002
+
1003
+ if (value === "--channel") {
1004
+ options.channel = requireNextValue(values, index, "--channel");
1005
+ index += 1;
1006
+ continue;
1007
+ }
1008
+
1009
+ if (value === "--target") {
1010
+ options.target = requireNextValue(values, index, "--target");
1011
+ index += 1;
1012
+ continue;
1013
+ }
1014
+
1015
+ if (value === "--chat-id") {
1016
+ options.chatId = requireNextValue(values, index, "--chat-id");
1017
+ index += 1;
1018
+ continue;
1019
+ }
1020
+
1021
+ if (value === "--bot-token") {
1022
+ options.botToken = requireNextValue(values, index, "--bot-token");
1023
+ index += 1;
1024
+ continue;
1025
+ }
1026
+
1027
+ if (value === "--telegram-api-base") {
1028
+ options.telegramApiBase = requireNextValue(values, index, "--telegram-api-base");
1029
+ index += 1;
1030
+ continue;
1031
+ }
1032
+
1033
+ if (value === "--sender-bin") {
1034
+ options.senderBin = requireNextValue(values, index, "--sender-bin");
1035
+ index += 1;
1036
+ continue;
1037
+ }
1038
+
1039
+ if (value === "--sender-arg") {
1040
+ options.senderArgs.push(requireNextValue(values, index, "--sender-arg"));
1041
+ index += 1;
1042
+ continue;
1043
+ }
1044
+
1045
+ if (value.startsWith("--")) {
1046
+ throw new Error(`Unknown option: ${value}`);
1047
+ }
1048
+
1049
+ paths.push(value);
1050
+ }
1051
+
1052
+ options.approvalPath = paths[0];
1053
+
1054
+ if (!options.approvalPath) {
1055
+ throw new Error("approvals send requires <approval.json|approvals.jsonl>.");
1056
+ }
1057
+
1058
+ if (!["openclaw", "telegram"].includes(options.via)) {
1059
+ throw new Error("Invalid --via value. Use one of: openclaw, telegram");
1060
+ }
1061
+
1062
+ if (options.via === "openclaw" && !options.channel) {
1063
+ throw new Error("approvals send requires --channel <name>.");
1064
+ }
1065
+
1066
+ if (options.via === "openclaw" && !options.target) {
1067
+ throw new Error("approvals send requires --target <id>.");
1068
+ }
1069
+
1070
+ if (options.via === "telegram") {
1071
+ options.chatId = options.chatId ?? options.target;
1072
+ if (!options.chatId) {
1073
+ throw new Error("approvals send --via telegram requires --chat-id <id>.");
1074
+ }
1075
+ options.channel = "telegram";
1076
+ options.target = options.chatId;
1077
+ }
1078
+
1079
+ return options;
1080
+ }
1081
+
748
1082
  async function writeReportFile(outputPath, content) {
749
1083
  const resolvedPath = path.resolve(outputPath);
750
1084
  await fs.mkdir(path.dirname(resolvedPath), { recursive: true });