@denial-web/clawguard 0.1.0 → 0.1.2

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
@@ -1,6 +1,6 @@
1
1
  # ClawGuard
2
2
 
3
- Independent governance and security scanner for OpenClaw-style skills and MCP tool configs.
3
+ Security gate and governance scanner for OpenClaw-style skills, ClawHub installs, MCP configs, and agent tools.
4
4
 
5
5
  ClawGuard helps developers answer one simple question before enabling a skill:
6
6
 
@@ -8,6 +8,8 @@ ClawGuard helps developers answer one simple question before enabling a skill:
8
8
 
9
9
  This project is compatible with OpenClaw-style skill directories, but it is not affiliated with OpenClaw.
10
10
 
11
+ ClawGuard is user-triggered today: run it before install, in CI, or as a local review step. It does not yet automatically intercept OpenClaw or ClawHub installs.
12
+
11
13
  ## Demo Preview
12
14
 
13
15
  [Watch the repeatable demo video](docs/assets/clawguard-demo.mp4), or regenerate it locally with `npm run demo:capture`.
@@ -41,7 +43,23 @@ Run ClawGuard directly from npm:
41
43
  npx @denial-web/clawguard scan ./path/to/skill
42
44
  ```
43
45
 
44
- Or run the local checkout:
46
+ Use gate mode before installing or trusting a skill:
47
+
48
+ ```bash
49
+ npx @denial-web/clawguard gate ./path/to/skill --policy governed
50
+ ```
51
+
52
+ Gate mode exits with `0` for allow, `1` for warn/review/sandbox decisions, and `2` for block.
53
+
54
+ Use install mode to copy a skill only after the policy gate allows it:
55
+
56
+ ```bash
57
+ npx @denial-web/clawguard install ./path/to/skill --to ./.agents/skills --policy governed
58
+ ```
59
+
60
+ Install mode never executes scanned files or installs dependencies. It refuses warn/review/sandbox/block decisions before copying files.
61
+
62
+ When testing the published package, run `npx` from outside this repository. From inside the ClawGuard source checkout, use the local commands instead:
45
63
 
46
64
  ```bash
47
65
  npm test
@@ -172,6 +190,8 @@ Findings:
172
190
  ## Roadmap
173
191
 
174
192
  - `clawguard scan <path>` CLI
193
+ - `clawguard gate <path>` policy gate
194
+ - `clawguard install <path> --to <dir>` guarded copy installer
175
195
  - OpenClaw `SKILL.md` metadata mismatch checks
176
196
  - `.clawguard.json` policy/config support
177
197
  - MCP/plugin config scanning
@@ -1,6 +1,6 @@
1
1
  # ClawGuard Architecture
2
2
 
3
- ClawGuard is an independent static governance layer for OpenClaw-style skills, ClawHub packages, and MCP/tool configs. It should stay compatible with OpenClaw without pretending to be OpenClaw.
3
+ ClawGuard is an independent static governance gate for OpenClaw-style skills, ClawHub packages, and MCP/tool configs. It should stay compatible with OpenClaw without pretending to be OpenClaw.
4
4
 
5
5
  ## Mission
6
6
 
@@ -18,11 +18,13 @@ The product should be small, explainable, and useful in three moments:
18
18
 
19
19
  1. CLI
20
20
 
21
- Current surface: `clawguard scan <path>`.
21
+ Current surfaces: `clawguard scan <path>`, `clawguard gate <path>`, and `clawguard install <path> --to <dir>`.
22
22
 
23
23
  Target surface:
24
24
 
25
25
  - `clawguard scan <path>`
26
+ - `clawguard gate <path>`
27
+ - `clawguard install <path> --to <dir>`
26
28
  - `clawguard scan-skill <skill-dir>`
27
29
  - `clawguard scan-workspace <workspace-dir>`
28
30
  - `clawguard scan-mcp <config-path>`
@@ -47,7 +49,17 @@ The product should be small, explainable, and useful in three moments:
47
49
 
48
50
  6. Install gate
49
51
 
50
- Optional wrapper or integration pattern around OpenClaw/ClawHub install/update flows. It should scan a downloaded bundle before the user enables it.
52
+ Current surfaces: `clawguard gate <path>` and `clawguard install <path> --to <dir>`.
53
+
54
+ Gate mode maps scan results into allow, warn, sandbox, or block decisions and exits with install-wrapper friendly codes:
55
+
56
+ - `0`: allow
57
+ - `1`: warn, manual review, sandbox required, or dual approval
58
+ - `2`: block
59
+
60
+ Install mode is a conservative wrapper: it scans first, copies only on `allow`, refuses non-allow decisions before copying, rejects symlink sources, and never executes scanned files or dependency install scripts.
61
+
62
+ Future surface: direct integration pattern around OpenClaw/ClawHub install/update flows. It should scan a downloaded bundle before the user enables it.
51
63
 
52
64
  ## Trust Boundaries
53
65
 
@@ -99,14 +99,24 @@ Build:
99
99
  - Decisions: allow, warn, manual review, sandbox required, dual approval, block.
100
100
  - `.clawguard.json` config.
101
101
  - Suppressions with reason and optional expiry.
102
+ - Install gate command with policy exit codes.
103
+ - Guarded install command that copies only after an `allow` decision.
102
104
  - Policy check command for saved reports.
103
105
 
104
106
  Success demo:
105
107
 
106
108
  ```bash
109
+ clawguard gate ./skills/my-skill --policy governed
110
+ clawguard install ./skills/my-skill --to ./.agents/skills --policy governed
107
111
  clawguard scan ./skills --policy governed --fail-on-policy
108
112
  ```
109
113
 
114
+ Gate exit codes:
115
+
116
+ - `0`: allow
117
+ - `1`: warn, manual review, sandbox required, or dual approval
118
+ - `2`: block
119
+
110
120
  ## Phase 4: Reports and CI
111
121
 
112
122
  Goal: make ClawGuard easy to adopt by maintainers.
@@ -25,8 +25,8 @@ Use this before sharing ClawGuard publicly.
25
25
 
26
26
  ## GitHub Repository
27
27
 
28
- - [ ] Repo description: `Governance and security scanner for OpenClaw skills, ClawHub installs, MCP configs, and skill dependencies.`
29
- - [ ] Topics: `openclaw`, `clawhub`, `mcp`, `security`, `ai-agents`, `scanner`, `governance`, `supply-chain`.
28
+ - [x] Repo description: `Governance and security scanner for OpenClaw skills, ClawHub installs, MCP configs, and skill dependencies.`
29
+ - [x] Topics: `openclaw`, `clawhub`, `mcp`, `security`, `ai-agents`, `scanner`, `governance`, `supply-chain`.
30
30
  - [x] License is visible.
31
31
  - [x] Security policy is visible.
32
32
  - [x] GitHub Action example is documented.
@@ -60,5 +60,4 @@ Include:
60
60
 
61
61
  - Record a short GIF or video using [docs/DEMO_SCRIPT.md](DEMO_SCRIPT.md).
62
62
  - Regenerate demo assets with `npm run demo:capture` after visual UI changes.
63
- - Apply the repository description and topics in GitHub after the repo is created.
64
63
  - Validate against real installed skill folders once a public skill archive or local ClawHub install is available.
@@ -23,7 +23,7 @@ Provider: GitHub Actions
23
23
  Organization or user: denial-web
24
24
  Repository: clawguard
25
25
  Workflow filename: publish.yml
26
- Environment name: leave blank
26
+ Environment name: blank
27
27
  ```
28
28
 
29
29
  After the trusted publisher is connected, publish from GitHub Actions:
@@ -62,5 +62,13 @@ The release event will trigger `.github/workflows/publish.yml`.
62
62
  After publishing, test the package from npm:
63
63
 
64
64
  ```bash
65
+ cd /private/tmp
65
66
  npx @denial-web/clawguard scan examples/risky-skill
66
67
  ```
68
+
69
+ When testing from outside the repository, point the scan command at a real skill path. For example:
70
+
71
+ ```bash
72
+ cd /private/tmp
73
+ npx @denial-web/clawguard scan /Users/hy/CascadeProjects/ClawGuard/examples/risky-skill
74
+ ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@denial-web/clawguard",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
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
@@ -27,7 +27,7 @@ if (args.length === 0 || args.includes("--help") || args.includes("-h")) {
27
27
 
28
28
  const command = args[0];
29
29
 
30
- if (!["scan", "scan-workspace"].includes(command)) {
30
+ if (!["scan", "scan-workspace", "gate", "install"].includes(command)) {
31
31
  console.error(`Unknown command: ${command}`);
32
32
  printHelp();
33
33
  process.exit(1);
@@ -54,15 +54,28 @@ try {
54
54
  await writeReportFile(options.htmlPath, createHtmlReport(result));
55
55
  }
56
56
 
57
- if (options.json) {
57
+ if (command === "install") {
58
+ const install = await handleInstall(result, options);
59
+ if (options.json) {
60
+ console.log(JSON.stringify(createInstallResult(result, install), null, 2));
61
+ } else {
62
+ printInstallResult(result, install);
63
+ }
64
+ } else if (command === "gate") {
65
+ if (options.json) {
66
+ console.log(JSON.stringify(createGateResult(result), null, 2));
67
+ } else {
68
+ printGateResult(result, options);
69
+ }
70
+ } else if (options.json) {
58
71
  console.log(JSON.stringify(result, null, 2));
59
72
  } else {
60
73
  printHumanResult(result, options);
61
74
  }
62
75
 
63
- process.exit(shouldFail(result, options) ? 2 : 0);
76
+ process.exit(["gate", "install"].includes(command) ? gateExitCode(result.policy.decision) : shouldFail(result, options) ? 2 : 0);
64
77
  } catch (error) {
65
- console.error(`Scan failed: ${error.message}`);
78
+ console.error(`${commandLabel(command)} failed: ${error.message}`);
66
79
  process.exit(1);
67
80
  }
68
81
 
@@ -71,6 +84,8 @@ function printHelp() {
71
84
 
72
85
  Usage:
73
86
  clawguard scan <path> [--json] [--policy <preset>] [--fail-on <level>]
87
+ clawguard gate <path> [--json] [--policy <preset>]
88
+ clawguard install <path> --to <dir> [--policy <preset>] [--dry-run]
74
89
  clawguard scan-workspace <path> [--json] [--policy <preset>]
75
90
  npm run scan -- <path>
76
91
 
@@ -89,8 +104,19 @@ Options:
89
104
  Default: manual_review.
90
105
  --max-file-size <size> Skip individual files larger than this size. Examples: 512kb, 1mb.
91
106
  Default: 1mb.
107
+ --to <dir> Install destination parent directory for install mode.
108
+ --name <name> Install folder/file name. Defaults to the source basename.
109
+ --dry-run Run install gate and show the destination without copying files.
110
+
111
+ Gate exit codes:
112
+ 0 = allow
113
+ 1 = warn, manual review, sandbox required, or dual approval
114
+ 2 = block
92
115
 
93
116
  Examples:
117
+ npx @denial-web/clawguard gate ./skills/my-skill
118
+ npx @denial-web/clawguard gate ./skills/my-skill --policy governed
119
+ npx @denial-web/clawguard install ./skills/my-skill --to ./.agents/skills --policy governed
94
120
  npm run scan -- examples/risky-skill
95
121
  npm run scan -- examples/metadata-mismatch-skill --policy governed --fail-on-policy
96
122
  npm run scan -- examples/metadata-mismatch-skill --html clawguard.html
@@ -171,6 +197,229 @@ function printHumanResult(result, options) {
171
197
  }
172
198
  }
173
199
 
200
+ function printGateResult(result, options) {
201
+ const decision = result.policy.decision;
202
+ console.log(`ClawGuard gate: ${result.target}`);
203
+ console.log(`Decision: ${formatDecision(decision)}`);
204
+ console.log(`Risk: ${result.level.toUpperCase()} (${result.score}/100)`);
205
+ console.log(`Policy: ${result.policy.preset}`);
206
+ console.log(`Exit code: ${gateExitCode(decision)}`);
207
+ console.log(`Reason: ${result.policy.reason}`);
208
+
209
+ if (result.configPath) {
210
+ console.log(`Config: ${result.configPath}`);
211
+ }
212
+
213
+ if (result.policy.requiredActions.length > 0) {
214
+ console.log(`Required actions: ${result.policy.requiredActions.join(", ")}`);
215
+ }
216
+
217
+ if (result.findings.length > 0) {
218
+ console.log(`Findings: ${result.findings.length}`);
219
+ const topFindings = result.findings.slice(0, 5);
220
+ for (const finding of topFindings) {
221
+ console.log(`- [${finding.severity.toUpperCase()}] ${finding.title}`);
222
+ console.log(` ${finding.file}:${finding.line}`);
223
+ }
224
+
225
+ if (result.findings.length > topFindings.length) {
226
+ console.log(`- ${result.findings.length - topFindings.length} more finding(s). Run scan for full details.`);
227
+ }
228
+ }
229
+
230
+ if (decision === "allow") {
231
+ console.log("\nGate result: safe to continue under the selected policy.");
232
+ } else if (decision === "block") {
233
+ console.log("\nGate result: block install or trust until reviewed.");
234
+ } else {
235
+ console.log("\nGate result: pause before install or trust.");
236
+ }
237
+ }
238
+
239
+ async function handleInstall(result, options) {
240
+ const decision = result.policy.decision;
241
+ const sourcePath = path.resolve(options.target);
242
+ const destination = resolveInstallDestination(sourcePath, options);
243
+ const install = {
244
+ destination,
245
+ dryRun: options.dryRun,
246
+ installed: false,
247
+ skipped: decision !== "allow"
248
+ };
249
+
250
+ if (decision !== "allow") {
251
+ return install;
252
+ }
253
+
254
+ if (!options.installDir) {
255
+ throw new Error("install requires --to <dir>. ClawGuard will not guess an install location.");
256
+ }
257
+
258
+ if (options.dryRun) {
259
+ return install;
260
+ }
261
+
262
+ await assertInstallableSource(sourcePath);
263
+ await assertDestinationAvailable(destination);
264
+ await fs.mkdir(path.dirname(destination), { recursive: true });
265
+ await fs.cp(sourcePath, destination, {
266
+ recursive: true,
267
+ errorOnExist: true,
268
+ force: false,
269
+ verbatimSymlinks: true
270
+ });
271
+
272
+ install.installed = true;
273
+ install.skipped = false;
274
+ return install;
275
+ }
276
+
277
+ function printInstallResult(result, install) {
278
+ const decision = result.policy.decision;
279
+ console.log(`ClawGuard install: ${result.target}`);
280
+ console.log(`Decision: ${formatDecision(decision)}`);
281
+ console.log(`Risk: ${result.level.toUpperCase()} (${result.score}/100)`);
282
+ console.log(`Policy: ${result.policy.preset}`);
283
+ console.log(`Exit code: ${gateExitCode(decision)}`);
284
+ console.log(`Destination: ${install.destination ?? "not selected"}`);
285
+ console.log(`Installed: ${install.installed ? "yes" : "no"}`);
286
+
287
+ if (install.dryRun) {
288
+ console.log("Dry run: yes");
289
+ }
290
+
291
+ if (result.policy.requiredActions.length > 0) {
292
+ console.log(`Required actions: ${result.policy.requiredActions.join(", ")}`);
293
+ }
294
+
295
+ if (decision === "allow" && install.installed) {
296
+ console.log("\nInstall result: copied after passing the selected policy.");
297
+ } else if (decision === "allow" && install.dryRun) {
298
+ console.log("\nInstall result: dry run passed; no files were copied.");
299
+ } else if (decision === "allow") {
300
+ console.log("\nInstall result: ready to copy after passing the selected policy.");
301
+ } else if (decision === "block") {
302
+ console.log("\nInstall result: blocked before copying files.");
303
+ } else {
304
+ console.log("\nInstall result: paused before copying files.");
305
+ }
306
+ }
307
+
308
+ function createInstallResult(result, install) {
309
+ return {
310
+ ...createGateResult(result),
311
+ destination: install.destination,
312
+ installed: install.installed,
313
+ dryRun: install.dryRun,
314
+ skipped: install.skipped
315
+ };
316
+ }
317
+
318
+ function createGateResult(result) {
319
+ return {
320
+ target: result.target,
321
+ decision: result.policy.decision,
322
+ exitCode: gateExitCode(result.policy.decision),
323
+ risk: {
324
+ level: result.level,
325
+ score: result.score
326
+ },
327
+ policy: {
328
+ preset: result.policy.preset,
329
+ reason: result.policy.reason,
330
+ requiredActions: result.policy.requiredActions
331
+ },
332
+ summary: result.summary,
333
+ findings: result.findings.map((finding) => ({
334
+ ruleId: finding.ruleId,
335
+ severity: finding.severity,
336
+ title: finding.title,
337
+ file: finding.file,
338
+ line: finding.line,
339
+ recommendation: finding.recommendation
340
+ }))
341
+ };
342
+ }
343
+
344
+ function resolveInstallDestination(sourcePath, options) {
345
+ if (!options.installDir) {
346
+ return undefined;
347
+ }
348
+
349
+ const installName = options.installName ?? path.basename(sourcePath);
350
+ return path.resolve(options.installDir, installName);
351
+ }
352
+
353
+ async function assertInstallableSource(sourcePath) {
354
+ const stats = await fs.lstat(sourcePath);
355
+
356
+ if (stats.isSymbolicLink()) {
357
+ throw new Error("install source cannot be a symlink");
358
+ }
359
+
360
+ if (stats.isDirectory()) {
361
+ await assertDirectoryHasNoSymlinks(sourcePath);
362
+ }
363
+ }
364
+
365
+ async function assertDirectoryHasNoSymlinks(directory) {
366
+ const entries = await fs.readdir(directory, { withFileTypes: true });
367
+
368
+ for (const entry of entries) {
369
+ const entryPath = path.join(directory, entry.name);
370
+
371
+ if (entry.isSymbolicLink()) {
372
+ throw new Error(`install source contains a symlink: ${entryPath}`);
373
+ }
374
+
375
+ if (entry.isDirectory()) {
376
+ await assertDirectoryHasNoSymlinks(entryPath);
377
+ }
378
+ }
379
+ }
380
+
381
+ async function assertDestinationAvailable(destination) {
382
+ try {
383
+ await fs.lstat(destination);
384
+ } catch (error) {
385
+ if (error.code === "ENOENT") {
386
+ return;
387
+ }
388
+
389
+ throw error;
390
+ }
391
+
392
+ throw new Error(`install destination already exists: ${destination}`);
393
+ }
394
+
395
+ function commandLabel(commandName) {
396
+ if (commandName === "gate") {
397
+ return "Gate";
398
+ }
399
+
400
+ if (commandName === "install") {
401
+ return "Install";
402
+ }
403
+
404
+ return "Scan";
405
+ }
406
+
407
+ function formatDecision(decision) {
408
+ return decision.replaceAll("_", " ").toUpperCase();
409
+ }
410
+
411
+ function gateExitCode(decision) {
412
+ if (decision === "allow") {
413
+ return 0;
414
+ }
415
+
416
+ if (decision === "block") {
417
+ return 2;
418
+ }
419
+
420
+ return 1;
421
+ }
422
+
174
423
  function parseOptions(values) {
175
424
  const options = {
176
425
  json: false,
@@ -182,6 +431,9 @@ function parseOptions(values) {
182
431
  policy: undefined,
183
432
  policyFailOn: undefined,
184
433
  maxFileSizeBytes: undefined,
434
+ installDir: undefined,
435
+ installName: undefined,
436
+ dryRun: false,
185
437
  target: "."
186
438
  };
187
439
  const paths = [];
@@ -258,6 +510,23 @@ function parseOptions(values) {
258
510
  continue;
259
511
  }
260
512
 
513
+ if (value === "--to") {
514
+ options.installDir = requireNextValue(values, index, "--to");
515
+ index += 1;
516
+ continue;
517
+ }
518
+
519
+ if (value === "--name") {
520
+ options.installName = requireNextValue(values, index, "--name");
521
+ index += 1;
522
+ continue;
523
+ }
524
+
525
+ if (value === "--dry-run") {
526
+ options.dryRun = true;
527
+ continue;
528
+ }
529
+
261
530
  if (value.startsWith("--")) {
262
531
  throw new Error(`Unknown option: ${value}`);
263
532
  }
package/src/config.js CHANGED
@@ -61,7 +61,10 @@ export function mergeConfig(config, cliOptions = {}) {
61
61
  json: Boolean(cliOptions.json),
62
62
  configPath: cliOptions.configPath,
63
63
  htmlPath: cliOptions.htmlPath,
64
- sarifPath: cliOptions.sarifPath
64
+ sarifPath: cliOptions.sarifPath,
65
+ installDir: cliOptions.installDir,
66
+ installName: cliOptions.installName,
67
+ dryRun: Boolean(cliOptions.dryRun)
65
68
  };
66
69
  }
67
70
 
@@ -1 +0,0 @@
1
- export const safe = true;