@denial-web/clawguard 0.1.1 → 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
@@ -51,6 +51,14 @@ npx @denial-web/clawguard gate ./path/to/skill --policy governed
51
51
 
52
52
  Gate mode exits with `0` for allow, `1` for warn/review/sandbox decisions, and `2` for block.
53
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
+
54
62
  When testing the published package, run `npx` from outside this repository. From inside the ClawGuard source checkout, use the local commands instead:
55
63
 
56
64
  ```bash
@@ -183,6 +191,7 @@ Findings:
183
191
 
184
192
  - `clawguard scan <path>` CLI
185
193
  - `clawguard gate <path>` policy gate
194
+ - `clawguard install <path> --to <dir>` guarded copy installer
186
195
  - OpenClaw `SKILL.md` metadata mismatch checks
187
196
  - `.clawguard.json` policy/config support
188
197
  - MCP/plugin config scanning
@@ -18,12 +18,13 @@ The product should be small, explainable, and useful in three moments:
18
18
 
19
19
  1. CLI
20
20
 
21
- Current surfaces: `clawguard scan <path>` and `clawguard gate <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
26
  - `clawguard gate <path>`
27
+ - `clawguard install <path> --to <dir>`
27
28
  - `clawguard scan-skill <skill-dir>`
28
29
  - `clawguard scan-workspace <workspace-dir>`
29
30
  - `clawguard scan-mcp <config-path>`
@@ -48,7 +49,7 @@ The product should be small, explainable, and useful in three moments:
48
49
 
49
50
  6. Install gate
50
51
 
51
- Current surface: `clawguard gate <path>`.
52
+ Current surfaces: `clawguard gate <path>` and `clawguard install <path> --to <dir>`.
52
53
 
53
54
  Gate mode maps scan results into allow, warn, sandbox, or block decisions and exits with install-wrapper friendly codes:
54
55
 
@@ -56,7 +57,9 @@ The product should be small, explainable, and useful in three moments:
56
57
  - `1`: warn, manual review, sandbox required, or dual approval
57
58
  - `2`: block
58
59
 
59
- Future surface: wrapper or integration pattern around OpenClaw/ClawHub install/update flows. It should scan a downloaded bundle before the user enables it.
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.
60
63
 
61
64
  ## Trust Boundaries
62
65
 
@@ -100,12 +100,14 @@ Build:
100
100
  - `.clawguard.json` config.
101
101
  - Suppressions with reason and optional expiry.
102
102
  - Install gate command with policy exit codes.
103
+ - Guarded install command that copies only after an `allow` decision.
103
104
  - Policy check command for saved reports.
104
105
 
105
106
  Success demo:
106
107
 
107
108
  ```bash
108
109
  clawguard gate ./skills/my-skill --policy governed
110
+ clawguard install ./skills/my-skill --to ./.agents/skills --policy governed
109
111
  clawguard scan ./skills --policy governed --fail-on-policy
110
112
  ```
111
113
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@denial-web/clawguard",
3
- "version": "0.1.1",
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", "gate"].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,7 +54,14 @@ try {
54
54
  await writeReportFile(options.htmlPath, createHtmlReport(result));
55
55
  }
56
56
 
57
- if (command === "gate") {
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") {
58
65
  if (options.json) {
59
66
  console.log(JSON.stringify(createGateResult(result), null, 2));
60
67
  } else {
@@ -66,9 +73,9 @@ try {
66
73
  printHumanResult(result, options);
67
74
  }
68
75
 
69
- process.exit(command === "gate" ? gateExitCode(result.policy.decision) : shouldFail(result, options) ? 2 : 0);
76
+ process.exit(["gate", "install"].includes(command) ? gateExitCode(result.policy.decision) : shouldFail(result, options) ? 2 : 0);
70
77
  } catch (error) {
71
- console.error(`${command === "gate" ? "Gate" : "Scan"} failed: ${error.message}`);
78
+ console.error(`${commandLabel(command)} failed: ${error.message}`);
72
79
  process.exit(1);
73
80
  }
74
81
 
@@ -78,6 +85,7 @@ function printHelp() {
78
85
  Usage:
79
86
  clawguard scan <path> [--json] [--policy <preset>] [--fail-on <level>]
80
87
  clawguard gate <path> [--json] [--policy <preset>]
88
+ clawguard install <path> --to <dir> [--policy <preset>] [--dry-run]
81
89
  clawguard scan-workspace <path> [--json] [--policy <preset>]
82
90
  npm run scan -- <path>
83
91
 
@@ -96,6 +104,9 @@ Options:
96
104
  Default: manual_review.
97
105
  --max-file-size <size> Skip individual files larger than this size. Examples: 512kb, 1mb.
98
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.
99
110
 
100
111
  Gate exit codes:
101
112
  0 = allow
@@ -105,6 +116,7 @@ Gate exit codes:
105
116
  Examples:
106
117
  npx @denial-web/clawguard gate ./skills/my-skill
107
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
108
120
  npm run scan -- examples/risky-skill
109
121
  npm run scan -- examples/metadata-mismatch-skill --policy governed --fail-on-policy
110
122
  npm run scan -- examples/metadata-mismatch-skill --html clawguard.html
@@ -224,6 +236,85 @@ function printGateResult(result, options) {
224
236
  }
225
237
  }
226
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
+
227
318
  function createGateResult(result) {
228
319
  return {
229
320
  target: result.target,
@@ -250,6 +341,69 @@ function createGateResult(result) {
250
341
  };
251
342
  }
252
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
+
253
407
  function formatDecision(decision) {
254
408
  return decision.replaceAll("_", " ").toUpperCase();
255
409
  }
@@ -277,6 +431,9 @@ function parseOptions(values) {
277
431
  policy: undefined,
278
432
  policyFailOn: undefined,
279
433
  maxFileSizeBytes: undefined,
434
+ installDir: undefined,
435
+ installName: undefined,
436
+ dryRun: false,
280
437
  target: "."
281
438
  };
282
439
  const paths = [];
@@ -353,6 +510,23 @@ function parseOptions(values) {
353
510
  continue;
354
511
  }
355
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
+
356
530
  if (value.startsWith("--")) {
357
531
  throw new Error(`Unknown option: ${value}`);
358
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