@denial-web/clawguard 0.1.12 → 0.1.14

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,14 @@ 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
+ To prove the full approval loop locally without Telegram, WhatsApp, OpenClaw, or Hermes credentials, run:
72
+
73
+ ```bash
74
+ npx @denial-web/clawguard approvals demo-flow --keep
75
+ ```
76
+
77
+ The demo creates a harmless temporary skill, writes a pending approval, records a local owner approval, applies the decision, and installs the skill into a temporary trusted folder. Remove `--keep` when you want ClawGuard to clean up the temporary workspace automatically.
78
+
71
79
  Check the approval setup and print the exact command flow:
72
80
 
73
81
  ```bash
@@ -78,6 +78,16 @@ TELEGRAM_BOT_TOKEN=123456:token clawguard approvals watch ./.clawguard/approvals
78
78
 
79
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
80
 
81
+ ### Local Approval Demo
82
+
83
+ For demos, onboarding, and smoke tests, users can prove the full approval loop without OpenClaw, Hermes, Telegram, WhatsApp, or Slack credentials:
84
+
85
+ ```bash
86
+ clawguard approvals demo-flow --keep
87
+ ```
88
+
89
+ The demo creates a harmless temporary skill, scans it with the governed policy, forces an approval request with `--approval-mode always`, writes a local `approve` decision, applies that decision, and copies the skill into a temporary trusted folder. By default it removes the temporary workspace after the run. Use `--keep` when recording a demo or inspecting the generated approval and decision logs.
90
+
81
91
  ### Approval Doctor
82
92
 
83
93
  Before wiring a real agent into the approval loop, users can run:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@denial-web/clawguard",
3
- "version": "0.1.12",
3
+ "version": "0.1.14",
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
@@ -3,6 +3,7 @@
3
3
  import { promises as fs } from "node:fs";
4
4
  import { randomUUID } from "node:crypto";
5
5
  import { execFile } from "node:child_process";
6
+ import os from "node:os";
6
7
  import path from "node:path";
7
8
  import { promisify } from "node:util";
8
9
  import { loadConfig, mergeConfig, parseSize } from "./config.js";
@@ -42,7 +43,8 @@ if (![
42
43
  "approvals-decide",
43
44
  "approvals-poll-telegram",
44
45
  "approvals-apply",
45
- "approvals-doctor"
46
+ "approvals-doctor",
47
+ "approvals-demo-flow"
46
48
  ].includes(command)) {
47
49
  console.error(`Unknown command: ${command}`);
48
50
  printHelp();
@@ -118,6 +120,17 @@ try {
118
120
  process.exit(result.ok ? 0 : 1);
119
121
  }
120
122
 
123
+ if (command === "approvals-demo-flow") {
124
+ const demoOptions = parseApprovalDemoFlowOptions(optionValues);
125
+ const result = await runApprovalDemoFlow(demoOptions);
126
+ if (demoOptions.json) {
127
+ console.log(JSON.stringify(result, null, 2));
128
+ } else {
129
+ printApprovalDemoFlowResult(result);
130
+ }
131
+ process.exit(result.ok ? 0 : 1);
132
+ }
133
+
121
134
  const cliOptions = parseOptions(optionValues);
122
135
  cliOptions.framework = framework;
123
136
  const loadedConfig = await loadConfig(cliOptions.target, cliOptions.configPath);
@@ -184,6 +197,7 @@ Usage:
184
197
  clawguard approvals poll-telegram <approvals.jsonl> --decisions <decisions.jsonl>
185
198
  clawguard approvals apply <approvals.jsonl> --id <id> --decisions <decisions.jsonl>
186
199
  clawguard approvals doctor [--chat-id <id>]
200
+ clawguard approvals demo-flow [--keep]
187
201
  clawguard scan-workspace <path> [--json] [--policy <preset>]
188
202
  npm run scan -- <path>
189
203
 
@@ -229,6 +243,8 @@ Options:
229
243
  Read Telegram updates from a JSON file for tests or offline replay.
230
244
  --check-telegram In approvals doctor, call Telegram getMe to verify the bot token.
231
245
  --framework <name> In approvals doctor, show openclaw or hermes commands. Default: openclaw.
246
+ In approvals demo-flow, label the demo as openclaw or hermes.
247
+ --keep In approvals demo-flow, keep the temporary demo workspace.
232
248
 
233
249
  Gate exit codes:
234
250
  0 = allow
@@ -248,6 +264,7 @@ Examples:
248
264
  npx @denial-web/clawguard approvals poll-telegram ./.clawguard/approvals.jsonl --decisions ./.clawguard/decisions.jsonl
249
265
  npx @denial-web/clawguard approvals apply ./.clawguard/approvals.jsonl --id <id> --decisions ./.clawguard/decisions.jsonl
250
266
  npx @denial-web/clawguard approvals doctor --chat-id 123456789
267
+ npx @denial-web/clawguard approvals demo-flow --keep
251
268
  npm run scan -- examples/risky-skill
252
269
  npm run scan -- examples/metadata-mismatch-skill --policy governed --fail-on-policy
253
270
  npm run scan -- examples/metadata-mismatch-skill --html clawguard.html
@@ -379,6 +396,14 @@ function parseCommand(values) {
379
396
  };
380
397
  }
381
398
 
399
+ if (rawCommand === "approvals" && values[1] === "demo-flow") {
400
+ return {
401
+ command: "approvals-demo-flow",
402
+ framework: undefined,
403
+ optionValues: values.slice(2)
404
+ };
405
+ }
406
+
382
407
  if (["openclaw", "hermes"].includes(rawCommand)) {
383
408
  const nestedCommand = values[1];
384
409
 
@@ -752,6 +777,154 @@ async function runApprovalDoctor(options) {
752
777
  };
753
778
  }
754
779
 
780
+ async function runApprovalDemoFlow(options) {
781
+ const workspace = await fs.mkdtemp(path.join(os.tmpdir(), "clawguard-demo-flow-"));
782
+ const candidatePath = path.join(workspace, "candidate-skill");
783
+ const installDir = path.join(workspace, "trusted-skills");
784
+ const approvalPath = path.join(workspace, ".clawguard", "approvals.jsonl");
785
+ const decisionsPath = path.join(workspace, ".clawguard", "decisions.jsonl");
786
+ const steps = [];
787
+
788
+ await fs.mkdir(candidatePath, { recursive: true });
789
+ await fs.writeFile(path.join(candidatePath, "SKILL.md"), [
790
+ "# ClawGuard Demo Skill",
791
+ "",
792
+ "A harmless local skill used to prove the approval gate flow.",
793
+ "",
794
+ "It does not execute code, fetch network resources, or install dependencies.",
795
+ ""
796
+ ].join("\n"));
797
+ steps.push({
798
+ name: "create-demo-skill",
799
+ status: "pass",
800
+ detail: candidatePath
801
+ });
802
+
803
+ const scan = await scanTarget(candidatePath, {
804
+ policy: options.policy
805
+ });
806
+ steps.push({
807
+ name: "scan",
808
+ status: "pass",
809
+ detail: `${formatDecision(scan.policy.decision)} / ${scan.level.toUpperCase()} (${scan.score}/100)`
810
+ });
811
+
812
+ const install = await handleInstall(scan, {
813
+ target: candidatePath,
814
+ installDir,
815
+ installName: "demo-skill",
816
+ dryRun: false,
817
+ approvalOut: approvalPath,
818
+ approvalMode: "always",
819
+ framework: options.framework
820
+ });
821
+
822
+ if (!install.approvalRequest) {
823
+ throw new Error("Demo flow expected an approval request but none was created.");
824
+ }
825
+
826
+ steps.push({
827
+ name: "write-approval",
828
+ status: "pass",
829
+ detail: install.approvalRequest.id
830
+ });
831
+
832
+ const approval = await readApprovalRequest(approvalPath, install.approvalRequest.id);
833
+ const decisionResult = await decideApproval({
834
+ approvalPath,
835
+ id: approval.id,
836
+ decision: "approve",
837
+ outPath: decisionsPath,
838
+ actor: "clawguard-demo-flow",
839
+ reason: "Local demo approval.",
840
+ json: false
841
+ });
842
+ steps.push({
843
+ name: "record-owner-decision",
844
+ status: "pass",
845
+ detail: formatDecision(decisionResult.decision.decision)
846
+ });
847
+
848
+ const apply = await applyApprovalDecision({
849
+ approvalPath,
850
+ id: approval.id,
851
+ decisionsPath,
852
+ dryRun: false,
853
+ json: false
854
+ });
855
+ steps.push({
856
+ name: "apply-decision",
857
+ status: apply.installed ? "pass" : "fail",
858
+ detail: apply.reason
859
+ });
860
+
861
+ const installedSkillPath = path.join(install.destination, "SKILL.md");
862
+ const installedSkill = await fs.readFile(installedSkillPath, "utf8");
863
+ const result = {
864
+ ok: apply.installed && installedSkill.includes("ClawGuard Demo Skill"),
865
+ cleanedUp: false,
866
+ kept: options.keep,
867
+ framework: options.framework,
868
+ policy: options.policy,
869
+ workspace,
870
+ paths: {
871
+ candidate: candidatePath,
872
+ installDir,
873
+ destination: install.destination,
874
+ installedSkill: installedSkillPath,
875
+ approvalPath,
876
+ decisionsPath
877
+ },
878
+ scan: {
879
+ decision: scan.policy.decision,
880
+ risk: {
881
+ level: scan.level,
882
+ score: scan.score
883
+ },
884
+ findings: scan.findings.length
885
+ },
886
+ approval: {
887
+ id: approval.id,
888
+ status: approval.status,
889
+ decision: approval.decision
890
+ },
891
+ decision: {
892
+ id: decisionResult.decision.id,
893
+ decision: decisionResult.decision.decision,
894
+ status: decisionResult.decision.status,
895
+ actor: decisionResult.decision.actor
896
+ },
897
+ apply: {
898
+ installed: apply.installed,
899
+ skipped: apply.skipped,
900
+ reason: apply.reason
901
+ },
902
+ steps
903
+ };
904
+
905
+ if (!options.keep) {
906
+ try {
907
+ await fs.rm(workspace, { recursive: true, force: true });
908
+ result.cleanedUp = true;
909
+ steps.push({
910
+ name: "cleanup",
911
+ status: "pass",
912
+ detail: "Temporary workspace removed."
913
+ });
914
+ } catch (error) {
915
+ result.ok = false;
916
+ result.cleanupError = error.message;
917
+ steps.push({
918
+ name: "cleanup",
919
+ status: "fail",
920
+ detail: error.message
921
+ });
922
+ }
923
+ }
924
+
925
+ return result;
926
+ }
927
+
755
928
  function checkNodeVersion() {
756
929
  const major = Number.parseInt(process.versions.node.split(".")[0], 10);
757
930
  return {
@@ -1186,6 +1359,30 @@ function printApprovalDoctorResult(result) {
1186
1359
  console.log(`4. ${result.commands.applyDecision}`);
1187
1360
  }
1188
1361
 
1362
+ function printApprovalDemoFlowResult(result) {
1363
+ console.log("ClawGuard approvals demo-flow");
1364
+ console.log(`Framework: ${displayFramework(result.framework)}`);
1365
+ console.log(`Policy: ${result.policy}`);
1366
+ console.log(`Ready: ${result.ok ? "yes" : "no"}`);
1367
+ console.log(`Workspace: ${result.workspace}${result.cleanedUp ? " (cleaned up)" : ""}`);
1368
+ console.log(`Approval id: ${result.approval.id}`);
1369
+ console.log(`Scan: ${formatDecision(result.scan.decision)} / ${result.scan.risk.level.toUpperCase()} (${result.scan.risk.score}/100)`);
1370
+ console.log(`Decision: ${formatDecision(result.decision.decision)}`);
1371
+ console.log(`Installed: ${result.apply.installed ? "yes" : "no"}`);
1372
+
1373
+ console.log("\nSteps:");
1374
+ for (const step of result.steps) {
1375
+ console.log(`- [${step.status.toUpperCase()}] ${step.name}: ${step.detail}`);
1376
+ }
1377
+
1378
+ if (!result.cleanedUp) {
1379
+ console.log("\nArtifacts:");
1380
+ console.log(`Approval queue: ${result.paths.approvalPath}`);
1381
+ console.log(`Decision log: ${result.paths.decisionsPath}`);
1382
+ console.log(`Installed skill: ${result.paths.installedSkill}`);
1383
+ }
1384
+ }
1385
+
1189
1386
  async function readApprovalRequest(approvalPath, id) {
1190
1387
  const resolvedPath = path.resolve(approvalPath);
1191
1388
  const approvals = await readApprovalRequests(resolvedPath);
@@ -1623,6 +1820,10 @@ function commandLabel(commandName) {
1623
1820
  return "Approvals doctor";
1624
1821
  }
1625
1822
 
1823
+ if (commandName === "approvals-demo-flow") {
1824
+ return "Approvals demo flow";
1825
+ }
1826
+
1626
1827
  if (commandName === "gate") {
1627
1828
  return "Gate";
1628
1829
  }
@@ -2432,6 +2633,57 @@ function parseApprovalDoctorOptions(values) {
2432
2633
  return options;
2433
2634
  }
2434
2635
 
2636
+ function parseApprovalDemoFlowOptions(values) {
2637
+ const options = {
2638
+ framework: "openclaw",
2639
+ policy: "governed",
2640
+ keep: false,
2641
+ json: false
2642
+ };
2643
+
2644
+ for (let index = 0; index < values.length; index += 1) {
2645
+ const value = values[index];
2646
+
2647
+ if (value === "--json") {
2648
+ options.json = true;
2649
+ continue;
2650
+ }
2651
+
2652
+ if (value === "--keep") {
2653
+ options.keep = true;
2654
+ continue;
2655
+ }
2656
+
2657
+ if (value === "--framework") {
2658
+ options.framework = requireNextValue(values, index, "--framework");
2659
+ index += 1;
2660
+ continue;
2661
+ }
2662
+
2663
+ if (value === "--policy") {
2664
+ options.policy = requireNextValue(values, index, "--policy");
2665
+ index += 1;
2666
+ continue;
2667
+ }
2668
+
2669
+ if (value.startsWith("--")) {
2670
+ throw new Error(`Unknown option: ${value}`);
2671
+ }
2672
+
2673
+ throw new Error(`Unexpected argument for approvals demo-flow: ${value}`);
2674
+ }
2675
+
2676
+ if (!["openclaw", "hermes"].includes(options.framework)) {
2677
+ throw new Error("Invalid --framework value. Use one of: openclaw, hermes");
2678
+ }
2679
+
2680
+ if (!policyPresets.includes(options.policy)) {
2681
+ throw new Error(`Invalid --policy value. Use one of: ${policyPresets.join(", ")}`);
2682
+ }
2683
+
2684
+ return options;
2685
+ }
2686
+
2435
2687
  async function writeReportFile(outputPath, content) {
2436
2688
  const resolvedPath = path.resolve(outputPath);
2437
2689
  await fs.mkdir(path.dirname(resolvedPath), { recursive: true });
package/web/app.js CHANGED
@@ -40,6 +40,10 @@ const elements = {
40
40
  installVerdict: document.querySelector("#install-verdict"),
41
41
  installMessage: document.querySelector("#install-message"),
42
42
  installCommand: document.querySelector("#install-command"),
43
+ approvalTitle: document.querySelector("#approval-title"),
44
+ approvalSummary: document.querySelector("#approval-summary"),
45
+ approvalCommand: document.querySelector("#approval-command"),
46
+ demoCommand: document.querySelector("#demo-command"),
43
47
  actions: document.querySelector("#actions"),
44
48
  critical: document.querySelector("#critical-count"),
45
49
  high: document.querySelector("#high-count"),
@@ -239,6 +243,7 @@ function renderResult(result) {
239
243
  elements.downloadHtml.disabled = false;
240
244
 
241
245
  renderInstallGate(result);
246
+ renderApprovalFlow(result);
242
247
  renderFindings(scan.findings ?? []);
243
248
  }
244
249
 
@@ -268,6 +273,50 @@ function renderInstallGate(result) {
268
273
  elements.installMessage.textContent = "ClawGuard would require review, sandboxing, or approval before copying files.";
269
274
  }
270
275
 
276
+ function renderApprovalFlow(result) {
277
+ const scan = result.scan;
278
+ const policy = scan.policy ?? {};
279
+ const decision = policy.decision ?? "allow";
280
+ const target = installTargetFor(result);
281
+ const installName = safeInstallName(result.displayTarget ?? "skill");
282
+ const preset = policy.preset ?? elements.policy.value;
283
+ const framework = "openclaw";
284
+
285
+ elements.demoCommand.textContent = "npx @denial-web/clawguard approvals demo-flow --keep";
286
+ elements.approvalCommand.textContent = [
287
+ "npx",
288
+ "@denial-web/clawguard",
289
+ framework,
290
+ "install",
291
+ target,
292
+ "--to",
293
+ "./.agents/skills",
294
+ "--name",
295
+ installName,
296
+ "--policy",
297
+ preset,
298
+ "--approval-out",
299
+ "./.clawguard/approvals.jsonl",
300
+ "--approval-mode",
301
+ "always"
302
+ ].join(" ");
303
+
304
+ if (decision === "allow") {
305
+ elements.approvalTitle.textContent = "Approval can still be required";
306
+ elements.approvalSummary.textContent = "Even a clean scan can pause before trust when you use approval-mode always. That gives the owner final control over autonomous skill installs.";
307
+ return;
308
+ }
309
+
310
+ if (decision === "block") {
311
+ elements.approvalTitle.textContent = "Blocked before trust";
312
+ elements.approvalSummary.textContent = "A blocked result should not be copied into a trusted skill folder. Send the report to the owner instead of applying the install.";
313
+ return;
314
+ }
315
+
316
+ elements.approvalTitle.textContent = "Pause and ask the owner";
317
+ elements.approvalSummary.textContent = "Warn, review, sandbox, and dual-approval decisions create a pending approval request before any files are copied into the trusted skill folder.";
318
+ }
319
+
271
320
  function renderFindings(findings) {
272
321
  if (findings.length === 0) {
273
322
  elements.findings.className = "findings empty-state";
package/web/index.html CHANGED
@@ -86,6 +86,28 @@
86
86
  </div>
87
87
  </section>
88
88
 
89
+ <section class="approval-panel" aria-label="Approval loop demo">
90
+ <div class="approval-copy">
91
+ <p class="eyebrow">Approval Loop Demo</p>
92
+ <h2 id="approval-title">Prove the full guarded install loop</h2>
93
+ <p id="approval-summary">Create a harmless temporary skill, scan it, write an approval request, record an owner approval, and install only after the decision is applied.</p>
94
+ </div>
95
+ <div class="approval-flow" aria-label="Approval flow">
96
+ <span>install hook</span>
97
+ <span>policy gate</span>
98
+ <span>owner approval</span>
99
+ <span>apply</span>
100
+ </div>
101
+ <div class="command-stack">
102
+ <div class="command-box">
103
+ <code id="demo-command">npx @denial-web/clawguard approvals demo-flow --keep</code>
104
+ </div>
105
+ <div class="command-box">
106
+ <code id="approval-command">npx @denial-web/clawguard openclaw install ./skill --to ./.agents/skills --approval-out ./.clawguard/approvals.jsonl --approval-mode always</code>
107
+ </div>
108
+ </div>
109
+ </section>
110
+
89
111
  <section class="metrics" aria-label="Finding summary">
90
112
  <div><span>Critical</span><strong id="critical-count">0</strong></div>
91
113
  <div><span>High</span><strong id="high-count">0</strong></div>
package/web/styles.css CHANGED
@@ -264,7 +264,7 @@ input[type="file"] {
264
264
  align-items: stretch;
265
265
  }
266
266
 
267
- .score-ring, .decision, .install-panel, .metrics div, .metadata-grid div, .finding-card, .empty-state {
267
+ .score-ring, .decision, .install-panel, .approval-panel, .metrics div, .metadata-grid div, .finding-card, .empty-state {
268
268
  border: 1px solid var(--line);
269
269
  border-radius: 8px;
270
270
  background: var(--panel);
@@ -339,6 +339,51 @@ input[type="file"] {
339
339
  overflow-wrap: anywhere;
340
340
  }
341
341
 
342
+ .approval-panel {
343
+ display: grid;
344
+ grid-template-columns: minmax(220px, 0.9fr) minmax(160px, 0.65fr) minmax(0, 1.25fr);
345
+ gap: 14px;
346
+ align-items: stretch;
347
+ padding: 16px;
348
+ }
349
+
350
+ .approval-copy h2 {
351
+ margin-top: 5px;
352
+ font-size: 22px;
353
+ text-transform: uppercase;
354
+ }
355
+
356
+ #approval-summary {
357
+ margin-top: 6px;
358
+ color: var(--muted);
359
+ line-height: 1.4;
360
+ }
361
+
362
+ .approval-flow {
363
+ display: grid;
364
+ gap: 8px;
365
+ }
366
+
367
+ .approval-flow span {
368
+ min-height: 34px;
369
+ display: flex;
370
+ align-items: center;
371
+ border: 1px solid var(--line);
372
+ border-radius: 8px;
373
+ background: #edf1ea;
374
+ color: var(--ink);
375
+ padding: 7px 10px;
376
+ font-size: 12px;
377
+ font-weight: 900;
378
+ text-transform: uppercase;
379
+ }
380
+
381
+ .command-stack {
382
+ min-width: 0;
383
+ display: grid;
384
+ gap: 10px;
385
+ }
386
+
342
387
  .action-tags {
343
388
  display: flex;
344
389
  flex-wrap: wrap;
@@ -459,7 +504,7 @@ input[type="file"] {
459
504
  }
460
505
 
461
506
  @media (max-width: 900px) {
462
- .workbench, .score-panel, .install-panel {
507
+ .workbench, .score-panel, .install-panel, .approval-panel {
463
508
  grid-template-columns: 1fr;
464
509
  }
465
510