@denial-web/clawguard 0.1.9 → 0.1.10

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
@@ -99,6 +99,21 @@ npx @denial-web/clawguard approvals decide ./.clawguard/approvals.jsonl --id <ap
99
99
  npx @denial-web/clawguard approvals decide ./.clawguard/approvals.jsonl --id <approval-id> --decision deny --reason "Unexpected shell access" --out ./.clawguard/decisions.jsonl
100
100
  ```
101
101
 
102
+ To turn Telegram replies into decision records, ask the owner to reply with one of:
103
+
104
+ ```text
105
+ approve <approval-id> optional reason
106
+ deny <approval-id> optional reason
107
+ ```
108
+
109
+ Then poll Telegram updates:
110
+
111
+ ```bash
112
+ TELEGRAM_BOT_TOKEN=123456:token npx @denial-web/clawguard approvals poll-telegram ./.clawguard/approvals.jsonl --decisions ./.clawguard/decisions.jsonl
113
+ ```
114
+
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
+
102
117
  When testing the published package, run `npx` from outside this repository. From inside the ClawGuard source checkout, use the local commands instead:
103
118
 
104
119
  ```bash
@@ -93,6 +93,24 @@ clawguard approvals decide ./.clawguard/approvals.jsonl \
93
93
 
94
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
95
 
96
+ ### Telegram Reply Polling
97
+
98
+ The first reply bridge is Telegram polling. Owners can respond to the approval message with:
99
+
100
+ ```text
101
+ approve <approval-id> optional reason
102
+ deny <approval-id> optional reason
103
+ ```
104
+
105
+ Command:
106
+
107
+ ```bash
108
+ TELEGRAM_BOT_TOKEN=123456:token clawguard approvals poll-telegram ./.clawguard/approvals.jsonl \
109
+ --decisions ./.clawguard/decisions.jsonl
110
+ ```
111
+
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
+
96
114
  ### Skill Folder Scan
97
115
 
98
116
  Command:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@denial-web/clawguard",
3
- "version": "0.1.9",
3
+ "version": "0.1.10",
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
@@ -39,7 +39,8 @@ if (![
39
39
  "install",
40
40
  "approvals-send",
41
41
  "approvals-watch",
42
- "approvals-decide"
42
+ "approvals-decide",
43
+ "approvals-poll-telegram"
43
44
  ].includes(command)) {
44
45
  console.error(`Unknown command: ${command}`);
45
46
  printHelp();
@@ -82,6 +83,17 @@ try {
82
83
  process.exit(0);
83
84
  }
84
85
 
86
+ if (command === "approvals-poll-telegram") {
87
+ const pollOptions = parseApprovalTelegramPollOptions(optionValues);
88
+ const result = await pollTelegramApprovals(pollOptions);
89
+ if (pollOptions.json) {
90
+ console.log(JSON.stringify(result, null, 2));
91
+ } else {
92
+ printApprovalTelegramPollResult(result);
93
+ }
94
+ process.exit(0);
95
+ }
96
+
85
97
  const cliOptions = parseOptions(optionValues);
86
98
  cliOptions.framework = framework;
87
99
  const loadedConfig = await loadConfig(cliOptions.target, cliOptions.configPath);
@@ -145,6 +157,7 @@ Usage:
145
157
  clawguard approvals send <approval.json|approvals.jsonl> --via telegram --chat-id <id>
146
158
  clawguard approvals watch <approvals.jsonl> --via telegram --chat-id <id>
147
159
  clawguard approvals decide <approval.json|approvals.jsonl> --id <id> --decision approve|deny
160
+ clawguard approvals poll-telegram <approvals.jsonl> --decisions <decisions.jsonl>
148
161
  clawguard scan-workspace <path> [--json] [--policy <preset>]
149
162
  npm run scan -- <path>
150
163
 
@@ -182,8 +195,12 @@ Options:
182
195
  --id <id> Approval id for send or decide.
183
196
  --decision <value> Approval decision: approve, deny.
184
197
  --out <path> Decision JSONL output file.
198
+ --decisions <path> Decision JSONL output file for reply polling.
185
199
  --actor <name> Decision actor. Default: local-user.
186
200
  --reason <text> Decision reason.
201
+ --offset-state <path> Telegram update offset state file.
202
+ --telegram-updates-file <path>
203
+ Read Telegram updates from a JSON file for tests or offline replay.
187
204
 
188
205
  Gate exit codes:
189
206
  0 = allow
@@ -200,6 +217,7 @@ Examples:
200
217
  npx @denial-web/clawguard approvals send ./.clawguard/approvals.jsonl --via telegram --chat-id 123456789
201
218
  npx @denial-web/clawguard approvals watch ./.clawguard/approvals.jsonl --via telegram --chat-id 123456789
202
219
  npx @denial-web/clawguard approvals decide ./.clawguard/approvals.jsonl --id <id> --decision approve
220
+ npx @denial-web/clawguard approvals poll-telegram ./.clawguard/approvals.jsonl --decisions ./.clawguard/decisions.jsonl
203
221
  npm run scan -- examples/risky-skill
204
222
  npm run scan -- examples/metadata-mismatch-skill --policy governed --fail-on-policy
205
223
  npm run scan -- examples/metadata-mismatch-skill --html clawguard.html
@@ -307,6 +325,14 @@ function parseCommand(values) {
307
325
  };
308
326
  }
309
327
 
328
+ if (rawCommand === "approvals" && values[1] === "poll-telegram") {
329
+ return {
330
+ command: "approvals-poll-telegram",
331
+ framework: undefined,
332
+ optionValues: values.slice(2)
333
+ };
334
+ }
335
+
310
336
  if (["openclaw", "hermes"].includes(rawCommand)) {
311
337
  const nestedCommand = values[1];
312
338
 
@@ -471,8 +497,7 @@ async function decideApproval(options) {
471
497
  const outputPath = path.resolve(options.outPath ?? `${options.approvalPath}.decisions.jsonl`);
472
498
  const decision = createApprovalDecision(approval, options);
473
499
 
474
- await fs.mkdir(path.dirname(outputPath), { recursive: true });
475
- await fs.appendFile(outputPath, `${JSON.stringify(decision)}\n`);
500
+ await appendApprovalDecision(outputPath, decision);
476
501
 
477
502
  return {
478
503
  approval: {
@@ -487,6 +512,155 @@ async function decideApproval(options) {
487
512
  };
488
513
  }
489
514
 
515
+ async function appendApprovalDecision(outputPath, decision) {
516
+ await fs.mkdir(path.dirname(outputPath), { recursive: true });
517
+ await fs.appendFile(outputPath, `${JSON.stringify(decision)}\n`);
518
+ }
519
+
520
+ async function pollTelegramApprovals(options) {
521
+ const offsetStatePath = path.resolve(options.offsetStatePath ?? `${options.decisionsPath}.telegram-state.json`);
522
+ const offset = await readTelegramOffsetState(offsetStatePath);
523
+ const updates = await fetchTelegramUpdates(options, offset);
524
+ const outputPath = path.resolve(options.decisionsPath);
525
+ const result = {
526
+ approvalPath: path.resolve(options.approvalPath),
527
+ decisionsPath: outputPath,
528
+ offsetStatePath,
529
+ dryRun: options.dryRun,
530
+ checked: updates.length,
531
+ commands: 0,
532
+ decided: 0,
533
+ skipped: 0,
534
+ errors: [],
535
+ decisions: [],
536
+ nextOffset: offset
537
+ };
538
+
539
+ for (const update of updates) {
540
+ const parsed = parseTelegramApprovalUpdate(update);
541
+
542
+ if (!parsed) {
543
+ result.skipped += 1;
544
+ result.nextOffset = nextTelegramOffset(result.nextOffset, update.update_id);
545
+ continue;
546
+ }
547
+
548
+ result.commands += 1;
549
+ result.nextOffset = nextTelegramOffset(result.nextOffset, update.update_id);
550
+
551
+ try {
552
+ const approval = await readApprovalRequest(options.approvalPath, parsed.approvalId);
553
+ const decision = createApprovalDecision(approval, {
554
+ approvalPath: options.approvalPath,
555
+ decision: parsed.decision,
556
+ actor: parsed.actor,
557
+ reason: parsed.reason
558
+ });
559
+ result.decisions.push(decision);
560
+
561
+ if (!options.dryRun) {
562
+ await appendApprovalDecision(outputPath, decision);
563
+ result.decided += 1;
564
+ }
565
+ } catch (error) {
566
+ result.errors.push({
567
+ updateId: update.update_id,
568
+ approvalId: parsed.approvalId,
569
+ message: error.message
570
+ });
571
+ }
572
+ }
573
+
574
+ if (!options.dryRun && result.nextOffset !== offset) {
575
+ await writeTelegramOffsetState(offsetStatePath, result.nextOffset);
576
+ }
577
+
578
+ return result;
579
+ }
580
+
581
+ async function fetchTelegramUpdates(options, offset) {
582
+ if (options.telegramUpdatesPath) {
583
+ const content = await fs.readFile(path.resolve(options.telegramUpdatesPath), "utf8");
584
+ const payload = JSON.parse(content);
585
+ const updates = Array.isArray(payload) ? payload : payload.result;
586
+
587
+ if (!Array.isArray(updates)) {
588
+ throw new Error("Telegram updates file must be an array or an object with a result array.");
589
+ }
590
+
591
+ return updates.filter((update) => !offset || !Number.isSafeInteger(update.update_id) || update.update_id >= offset);
592
+ }
593
+
594
+ const botToken = options.botToken ?? process.env.TELEGRAM_BOT_TOKEN;
595
+
596
+ if (!botToken) {
597
+ throw new Error("Telegram poll requires --bot-token or TELEGRAM_BOT_TOKEN.");
598
+ }
599
+
600
+ const apiBase = options.telegramApiBase ?? "https://api.telegram.org";
601
+ const endpoint = new URL(`${apiBase.replace(/\/$/, "")}/bot${botToken}/getUpdates`);
602
+ endpoint.searchParams.set("timeout", String(options.timeoutSeconds));
603
+
604
+ if (offset) {
605
+ endpoint.searchParams.set("offset", String(offset));
606
+ }
607
+
608
+ const response = await fetch(endpoint);
609
+ const text = await response.text();
610
+ let payload;
611
+
612
+ try {
613
+ payload = text ? JSON.parse(text) : null;
614
+ } catch {
615
+ throw new Error(`Telegram poll returned invalid JSON: ${text}`);
616
+ }
617
+
618
+ if (!response.ok || payload?.ok === false) {
619
+ throw new Error(`Telegram poll failed with HTTP ${response.status}: ${text}`);
620
+ }
621
+
622
+ if (!Array.isArray(payload?.result)) {
623
+ throw new Error("Telegram poll response did not include a result array.");
624
+ }
625
+
626
+ return payload.result;
627
+ }
628
+
629
+ function parseTelegramApprovalUpdate(update) {
630
+ const message = update.message ?? update.edited_message ?? update.channel_post;
631
+ const text = message?.text;
632
+
633
+ if (!text) {
634
+ return undefined;
635
+ }
636
+
637
+ const match = text.trim().match(/^\/?(approve|approved|deny|denied)\s+([A-Za-z0-9_.:@-]+)(?:\s+(.+))?$/i);
638
+
639
+ if (!match) {
640
+ return undefined;
641
+ }
642
+
643
+ const decision = normalizeApprovalDecision(match[1].toLowerCase());
644
+ const actorName = message.from?.username ?? message.from?.id ?? message.chat?.username ?? message.chat?.id ?? "telegram-user";
645
+ const reason = match[3]?.trim();
646
+
647
+ return {
648
+ updateId: update.update_id,
649
+ approvalId: match[2],
650
+ decision,
651
+ actor: `telegram:${actorName}`,
652
+ reason
653
+ };
654
+ }
655
+
656
+ function nextTelegramOffset(currentOffset, updateId) {
657
+ if (!Number.isSafeInteger(updateId)) {
658
+ return currentOffset;
659
+ }
660
+
661
+ return Math.max(currentOffset ?? 0, updateId + 1);
662
+ }
663
+
490
664
  function createApprovalDecision(approval, options) {
491
665
  const decision = normalizeApprovalDecision(options.decision);
492
666
  const status = decision === "approve" ? "approved" : "denied";
@@ -624,6 +798,25 @@ function printApprovalDecisionResult(result) {
624
798
  console.log(`Output: ${result.outputPath}`);
625
799
  }
626
800
 
801
+ function printApprovalTelegramPollResult(result) {
802
+ console.log(`ClawGuard Telegram approval poll: ${result.approvalPath}`);
803
+ console.log(`Decisions: ${result.decisionsPath}`);
804
+ console.log(`Offset state: ${result.offsetStatePath}`);
805
+ console.log(`Dry run: ${result.dryRun ? "yes" : "no"}`);
806
+ console.log(`Updates checked: ${result.checked}`);
807
+ console.log(`Commands found: ${result.commands}`);
808
+ console.log(`Decisions written: ${result.decided}`);
809
+ console.log(`Skipped: ${result.skipped}`);
810
+ console.log(`Next offset: ${result.nextOffset ?? "none"}`);
811
+
812
+ if (result.errors.length > 0) {
813
+ console.log("Errors:");
814
+ for (const error of result.errors) {
815
+ console.log(`- update ${error.updateId}: ${error.message}`);
816
+ }
817
+ }
818
+ }
819
+
627
820
  async function readApprovalRequest(approvalPath, id) {
628
821
  const resolvedPath = path.resolve(approvalPath);
629
822
  const approvals = await readApprovalRequests(resolvedPath);
@@ -700,6 +893,29 @@ async function writeApprovalWatchState(statePath, sentIds) {
700
893
  }, null, 2)}\n`);
701
894
  }
702
895
 
896
+ async function readTelegramOffsetState(statePath) {
897
+ try {
898
+ const content = await fs.readFile(statePath, "utf8");
899
+ const state = JSON.parse(content);
900
+ return Number.isSafeInteger(state.nextOffset) ? state.nextOffset : undefined;
901
+ } catch (error) {
902
+ if (error.code === "ENOENT") {
903
+ return undefined;
904
+ }
905
+
906
+ throw error;
907
+ }
908
+ }
909
+
910
+ async function writeTelegramOffsetState(statePath, nextOffset) {
911
+ await fs.mkdir(path.dirname(statePath), { recursive: true });
912
+ await fs.writeFile(statePath, `${JSON.stringify({
913
+ schemaVersion: "clawguard.telegram-offset.v1",
914
+ updatedAt: new Date().toISOString(),
915
+ nextOffset
916
+ }, null, 2)}\n`);
917
+ }
918
+
703
919
  function printGateResult(result, options) {
704
920
  const decision = result.policy.decision;
705
921
  console.log(`ClawGuard gate: ${result.target}`);
@@ -1026,6 +1242,10 @@ function commandLabel(commandName) {
1026
1242
  return "Approval decision";
1027
1243
  }
1028
1244
 
1245
+ if (commandName === "approvals-poll-telegram") {
1246
+ return "Telegram approval poll";
1247
+ }
1248
+
1029
1249
  if (commandName === "gate") {
1030
1250
  return "Gate";
1031
1251
  }
@@ -1580,6 +1800,99 @@ function parseApprovalDecisionOptions(values) {
1580
1800
  return options;
1581
1801
  }
1582
1802
 
1803
+ function parseApprovalTelegramPollOptions(values) {
1804
+ const options = {
1805
+ approvalPath: undefined,
1806
+ decisionsPath: undefined,
1807
+ offsetStatePath: undefined,
1808
+ botToken: undefined,
1809
+ telegramApiBase: undefined,
1810
+ telegramUpdatesPath: undefined,
1811
+ timeoutSeconds: 0,
1812
+ dryRun: false,
1813
+ json: false
1814
+ };
1815
+ const paths = [];
1816
+
1817
+ for (let index = 0; index < values.length; index += 1) {
1818
+ const value = values[index];
1819
+
1820
+ if (value === "--json") {
1821
+ options.json = true;
1822
+ continue;
1823
+ }
1824
+
1825
+ if (value === "--dry-run") {
1826
+ options.dryRun = true;
1827
+ continue;
1828
+ }
1829
+
1830
+ if (value === "--decisions") {
1831
+ options.decisionsPath = requireNextValue(values, index, "--decisions");
1832
+ index += 1;
1833
+ continue;
1834
+ }
1835
+
1836
+ if (value === "--out") {
1837
+ options.decisionsPath = requireNextValue(values, index, "--out");
1838
+ index += 1;
1839
+ continue;
1840
+ }
1841
+
1842
+ if (value === "--offset-state") {
1843
+ options.offsetStatePath = requireNextValue(values, index, "--offset-state");
1844
+ index += 1;
1845
+ continue;
1846
+ }
1847
+
1848
+ if (value === "--bot-token") {
1849
+ options.botToken = requireNextValue(values, index, "--bot-token");
1850
+ index += 1;
1851
+ continue;
1852
+ }
1853
+
1854
+ if (value === "--telegram-api-base") {
1855
+ options.telegramApiBase = requireNextValue(values, index, "--telegram-api-base");
1856
+ index += 1;
1857
+ continue;
1858
+ }
1859
+
1860
+ if (value === "--telegram-updates-file") {
1861
+ options.telegramUpdatesPath = requireNextValue(values, index, "--telegram-updates-file");
1862
+ index += 1;
1863
+ continue;
1864
+ }
1865
+
1866
+ if (value === "--timeout") {
1867
+ const timeout = Number.parseInt(requireNextValue(values, index, "--timeout"), 10);
1868
+ if (!Number.isSafeInteger(timeout) || timeout < 0) {
1869
+ throw new Error("--timeout must be a non-negative integer.");
1870
+ }
1871
+ options.timeoutSeconds = timeout;
1872
+ index += 1;
1873
+ continue;
1874
+ }
1875
+
1876
+ if (value.startsWith("--")) {
1877
+ throw new Error(`Unknown option: ${value}`);
1878
+ }
1879
+
1880
+ paths.push(value);
1881
+ }
1882
+
1883
+ options.approvalPath = paths[0];
1884
+
1885
+ if (!options.approvalPath) {
1886
+ throw new Error("approvals poll-telegram requires <approval.json|approvals.jsonl>.");
1887
+ }
1888
+
1889
+ if (!options.decisionsPath) {
1890
+ throw new Error("approvals poll-telegram requires --decisions <path>.");
1891
+ }
1892
+
1893
+ return options;
1894
+ }
1895
+
1583
1896
  async function writeReportFile(outputPath, content) {
1584
1897
  const resolvedPath = path.resolve(outputPath);
1585
1898
  await fs.mkdir(path.dirname(resolvedPath), { recursive: true });