@denial-web/clawguard 0.1.3 → 0.1.5
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 +15 -0
- package/docs/INTEGRATION_SPEC.md +67 -0
- package/package.json +1 -1
- package/src/cli.js +441 -8
- package/src/config.js +3 -1
- package/web/index.html +2 -2
package/README.md
CHANGED
|
@@ -59,6 +59,21 @@ npx @denial-web/clawguard install ./path/to/skill --to ./.agents/skills --policy
|
|
|
59
59
|
|
|
60
60
|
Install mode never executes scanned files or installs dependencies. It refuses warn/review/sandbox/block decisions before copying files.
|
|
61
61
|
|
|
62
|
+
For agent systems that search and install skills automatically, keep discovery native and gate only the install step:
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
npx @denial-web/clawguard openclaw install ./candidate-skill --to ./.agents/skills --approval-out ./.clawguard/approvals.jsonl
|
|
66
|
+
npx @denial-web/clawguard hermes install ./candidate-skill --to ~/.hermes/skills --approval-out ./.clawguard/approvals.jsonl
|
|
67
|
+
```
|
|
68
|
+
|
|
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
|
+
|
|
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
|
+
|
|
62
77
|
When testing the published package, run `npx` from outside this repository. From inside the ClawGuard source checkout, use the local commands instead:
|
|
63
78
|
|
|
64
79
|
```bash
|
package/docs/INTEGRATION_SPEC.md
CHANGED
|
@@ -13,6 +13,59 @@ This spec defines how ClawGuard should work with OpenClaw, ClawHub, GitHub, web
|
|
|
13
13
|
|
|
14
14
|
## OpenClaw Integration
|
|
15
15
|
|
|
16
|
+
### Guarded Install With Owner Approval
|
|
17
|
+
|
|
18
|
+
Current wrapper pattern:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
clawguard openclaw install ./candidate-skill \
|
|
22
|
+
--to ./.agents/skills \
|
|
23
|
+
--approval-out ./.clawguard/approvals.jsonl
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
This does not block OpenClaw or ClawHub discovery. Search and candidate selection can stay native. ClawGuard sits between the downloaded candidate and the trusted skill folder:
|
|
27
|
+
|
|
28
|
+
```text
|
|
29
|
+
native search/discovery
|
|
30
|
+
↓
|
|
31
|
+
candidate skill bundle
|
|
32
|
+
↓
|
|
33
|
+
clawguard openclaw install
|
|
34
|
+
↓
|
|
35
|
+
allow / approval request / block
|
|
36
|
+
↓
|
|
37
|
+
trusted skill folder
|
|
38
|
+
```
|
|
39
|
+
|
|
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
|
+
|
|
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 planned as a ClawGuard-owned sender:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
clawguard approvals serve --telegram
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
That path is better when the user wants the approval channel to stay independent from the agent runtime.
|
|
68
|
+
|
|
16
69
|
### Skill Folder Scan
|
|
17
70
|
|
|
18
71
|
Command:
|
|
@@ -107,6 +160,20 @@ Current implementation:
|
|
|
107
160
|
- Reports missing lockfile, missing origin metadata, version drift, source drift, invalid metadata, and unusual source URLs.
|
|
108
161
|
- Adds a `clawhub` summary to JSON and HTML reports.
|
|
109
162
|
|
|
163
|
+
## Hermes Agent Integration
|
|
164
|
+
|
|
165
|
+
Current wrapper pattern:
|
|
166
|
+
|
|
167
|
+
```bash
|
|
168
|
+
clawguard hermes install ./candidate-skill \
|
|
169
|
+
--to ~/.hermes/skills \
|
|
170
|
+
--approval-out ./.clawguard/approvals.jsonl
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
The first integration target is the same as OpenClaw: do not interfere with search or discovery. Scan the candidate before it is copied into a trusted Hermes skill directory, and emit an approval request when policy says the owner should decide.
|
|
174
|
+
|
|
175
|
+
ClawGuard is independent and not affiliated with Hermes Agent or Nous Research.
|
|
176
|
+
|
|
110
177
|
### Metadata Comparison
|
|
111
178
|
|
|
112
179
|
ClawGuard should compare:
|
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
import { promises as fs } from "node:fs";
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
5
|
+
import { execFile } from "node:child_process";
|
|
4
6
|
import path from "node:path";
|
|
7
|
+
import { promisify } from "node:util";
|
|
5
8
|
import { loadConfig, mergeConfig, parseSize } from "./config.js";
|
|
6
9
|
import { policyShouldFail } from "./policy.js";
|
|
7
10
|
import { createHtmlReport } from "./reporters/html.js";
|
|
@@ -9,6 +12,7 @@ import { createSarifReport } from "./reporters/sarif.js";
|
|
|
9
12
|
import { scanTarget } from "./scanner.js";
|
|
10
13
|
|
|
11
14
|
const args = process.argv.slice(2);
|
|
15
|
+
const execFileAsync = promisify(execFile);
|
|
12
16
|
const failLevels = ["none", "low", "medium", "high", "critical"];
|
|
13
17
|
const policyPresets = ["personal", "governed", "enterprise"];
|
|
14
18
|
const policyFailDecisions = ["warn", "manual_review", "sandbox_required", "dual_approval", "block"];
|
|
@@ -25,18 +29,32 @@ if (args.length === 0 || args.includes("--help") || args.includes("-h")) {
|
|
|
25
29
|
process.exit(0);
|
|
26
30
|
}
|
|
27
31
|
|
|
28
|
-
const
|
|
32
|
+
const commandContext = parseCommand(args);
|
|
33
|
+
const { command, framework, optionValues } = commandContext;
|
|
29
34
|
|
|
30
|
-
if (!["scan", "scan-workspace", "gate", "install"].includes(command)) {
|
|
35
|
+
if (!["scan", "scan-workspace", "gate", "install", "approvals-send"].includes(command)) {
|
|
31
36
|
console.error(`Unknown command: ${command}`);
|
|
32
37
|
printHelp();
|
|
33
38
|
process.exit(1);
|
|
34
39
|
}
|
|
35
40
|
|
|
36
41
|
try {
|
|
37
|
-
|
|
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
|
+
|
|
53
|
+
const cliOptions = parseOptions(optionValues);
|
|
54
|
+
cliOptions.framework = framework;
|
|
38
55
|
const loadedConfig = await loadConfig(cliOptions.target, cliOptions.configPath);
|
|
39
56
|
const options = mergeConfig(loadedConfig.config, cliOptions);
|
|
57
|
+
options.framework = framework;
|
|
40
58
|
const result = await scanTarget(options.target, {
|
|
41
59
|
maxFileSizeBytes: options.maxFileSizeBytes,
|
|
42
60
|
maxFindingsPerRulePerFile: options.maxFindingsPerRulePerFile,
|
|
@@ -54,6 +72,8 @@ try {
|
|
|
54
72
|
await writeReportFile(options.htmlPath, createHtmlReport(result));
|
|
55
73
|
}
|
|
56
74
|
|
|
75
|
+
let exitCode;
|
|
76
|
+
|
|
57
77
|
if (command === "install") {
|
|
58
78
|
const install = await handleInstall(result, options);
|
|
59
79
|
if (options.json) {
|
|
@@ -61,6 +81,7 @@ try {
|
|
|
61
81
|
} else {
|
|
62
82
|
printInstallResult(result, install);
|
|
63
83
|
}
|
|
84
|
+
exitCode = installExitCode(result.policy.decision, install);
|
|
64
85
|
} else if (command === "gate") {
|
|
65
86
|
if (options.json) {
|
|
66
87
|
console.log(JSON.stringify(createGateResult(result), null, 2));
|
|
@@ -73,7 +94,7 @@ try {
|
|
|
73
94
|
printHumanResult(result, options);
|
|
74
95
|
}
|
|
75
96
|
|
|
76
|
-
process.exit(
|
|
97
|
+
process.exit(exitCode ?? (command === "gate" ? gateExitCode(result.policy.decision) : shouldFail(result, options) ? 2 : 0));
|
|
77
98
|
} catch (error) {
|
|
78
99
|
console.error(`${commandLabel(command)} failed: ${error.message}`);
|
|
79
100
|
process.exit(1);
|
|
@@ -86,6 +107,9 @@ Usage:
|
|
|
86
107
|
clawguard scan <path> [--json] [--policy <preset>] [--fail-on <level>]
|
|
87
108
|
clawguard gate <path> [--json] [--policy <preset>]
|
|
88
109
|
clawguard install <path> --to <dir> [--policy <preset>] [--dry-run]
|
|
110
|
+
clawguard openclaw install <path> --to <dir> [--approval-out <path>]
|
|
111
|
+
clawguard hermes install <path> --to <dir> [--approval-out <path>]
|
|
112
|
+
clawguard approvals send <approval.json|approvals.jsonl> --via openclaw --channel <name> --target <id>
|
|
89
113
|
clawguard scan-workspace <path> [--json] [--policy <preset>]
|
|
90
114
|
npm run scan -- <path>
|
|
91
115
|
|
|
@@ -107,6 +131,14 @@ Options:
|
|
|
107
131
|
--to <dir> Install destination parent directory for install mode.
|
|
108
132
|
--name <name> Install folder/file name. Defaults to the source basename.
|
|
109
133
|
--dry-run Run install gate and show the destination without copying files.
|
|
134
|
+
--approval-out <path> Write a pending approval JSON request before copying.
|
|
135
|
+
Use .jsonl to append JSON lines for bot/daemon integrations.
|
|
136
|
+
--approval-mode <mode> Approval mode: non-allow, always. Default: non-allow.
|
|
137
|
+
--via <adapter> Approval send adapter. Currently: openclaw.
|
|
138
|
+
--channel <name> Messaging channel for approval send, such as telegram.
|
|
139
|
+
--target <id> Messaging target/chat id for approval send.
|
|
140
|
+
--sender-bin <path> Sender binary. Default for --via openclaw: openclaw.
|
|
141
|
+
--sender-arg <value> Extra argument before the generated sender command. Repeatable.
|
|
110
142
|
|
|
111
143
|
Gate exit codes:
|
|
112
144
|
0 = allow
|
|
@@ -117,6 +149,9 @@ Examples:
|
|
|
117
149
|
npx @denial-web/clawguard gate ./skills/my-skill
|
|
118
150
|
npx @denial-web/clawguard gate ./skills/my-skill --policy governed
|
|
119
151
|
npx @denial-web/clawguard install ./skills/my-skill --to ./.agents/skills --policy governed
|
|
152
|
+
npx @denial-web/clawguard openclaw install ./skills/my-skill --to ./.agents/skills --approval-out ./.clawguard/approvals.jsonl
|
|
153
|
+
npx @denial-web/clawguard hermes install ./skills/my-skill --to ~/.hermes/skills --approval-out ./.clawguard/approvals.jsonl
|
|
154
|
+
npx @denial-web/clawguard approvals send ./.clawguard/approvals.jsonl --via openclaw --channel telegram --target 123456789
|
|
120
155
|
npm run scan -- examples/risky-skill
|
|
121
156
|
npm run scan -- examples/metadata-mismatch-skill --policy governed --fail-on-policy
|
|
122
157
|
npm run scan -- examples/metadata-mismatch-skill --html clawguard.html
|
|
@@ -197,6 +232,147 @@ function printHumanResult(result, options) {
|
|
|
197
232
|
}
|
|
198
233
|
}
|
|
199
234
|
|
|
235
|
+
function parseCommand(values) {
|
|
236
|
+
const rawCommand = values[0];
|
|
237
|
+
|
|
238
|
+
if (rawCommand === "approvals" && values[1] === "send") {
|
|
239
|
+
return {
|
|
240
|
+
command: "approvals-send",
|
|
241
|
+
framework: undefined,
|
|
242
|
+
optionValues: values.slice(2)
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (["openclaw", "hermes"].includes(rawCommand)) {
|
|
247
|
+
const nestedCommand = values[1];
|
|
248
|
+
|
|
249
|
+
if (!nestedCommand) {
|
|
250
|
+
return {
|
|
251
|
+
command: "",
|
|
252
|
+
framework: rawCommand,
|
|
253
|
+
optionValues: []
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (!["gate", "install"].includes(nestedCommand)) {
|
|
258
|
+
return {
|
|
259
|
+
command: `${rawCommand} ${nestedCommand}`,
|
|
260
|
+
framework: rawCommand,
|
|
261
|
+
optionValues: values.slice(2)
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return {
|
|
266
|
+
command: nestedCommand,
|
|
267
|
+
framework: rawCommand,
|
|
268
|
+
optionValues: values.slice(2)
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return {
|
|
273
|
+
command: rawCommand,
|
|
274
|
+
framework: undefined,
|
|
275
|
+
optionValues: values.slice(1)
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async function sendApproval(options) {
|
|
280
|
+
const approval = await readApprovalRequest(options.approvalPath, options.id);
|
|
281
|
+
const message = String(approval.message ?? "").trim();
|
|
282
|
+
|
|
283
|
+
if (!message) {
|
|
284
|
+
throw new Error("Approval request has no message field.");
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (options.via !== "openclaw") {
|
|
288
|
+
throw new Error("Only --via openclaw is supported right now.");
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const senderBin = options.senderBin ?? "openclaw";
|
|
292
|
+
const commandArgs = [
|
|
293
|
+
...options.senderArgs,
|
|
294
|
+
"message",
|
|
295
|
+
"send",
|
|
296
|
+
"--channel",
|
|
297
|
+
options.channel,
|
|
298
|
+
"--target",
|
|
299
|
+
options.target,
|
|
300
|
+
"--message",
|
|
301
|
+
message
|
|
302
|
+
];
|
|
303
|
+
const result = {
|
|
304
|
+
approval: {
|
|
305
|
+
id: approval.id,
|
|
306
|
+
status: approval.status,
|
|
307
|
+
decision: approval.decision,
|
|
308
|
+
risk: approval.risk,
|
|
309
|
+
framework: approval.framework
|
|
310
|
+
},
|
|
311
|
+
via: options.via,
|
|
312
|
+
channel: options.channel,
|
|
313
|
+
target: options.target,
|
|
314
|
+
senderBin,
|
|
315
|
+
command: [senderBin, ...commandArgs],
|
|
316
|
+
dryRun: options.dryRun,
|
|
317
|
+
sent: false,
|
|
318
|
+
stdout: "",
|
|
319
|
+
stderr: ""
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
if (options.dryRun) {
|
|
323
|
+
return result;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const output = await execFileAsync(senderBin, commandArgs, {
|
|
327
|
+
maxBuffer: 1024 * 1024
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
result.sent = true;
|
|
331
|
+
result.stdout = output.stdout;
|
|
332
|
+
result.stderr = output.stderr;
|
|
333
|
+
return result;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function printApprovalSendResult(result) {
|
|
337
|
+
console.log(`ClawGuard approval send: ${result.approval.id}`);
|
|
338
|
+
console.log(`Via: ${result.via}`);
|
|
339
|
+
console.log(`Channel: ${result.channel}`);
|
|
340
|
+
console.log(`Target: ${result.target}`);
|
|
341
|
+
console.log(`Decision: ${formatDecision(result.approval.decision ?? "unknown")}`);
|
|
342
|
+
console.log(`Dry run: ${result.dryRun ? "yes" : "no"}`);
|
|
343
|
+
console.log(`Sent: ${result.sent ? "yes" : "no"}`);
|
|
344
|
+
|
|
345
|
+
if (result.dryRun) {
|
|
346
|
+
console.log(`Command: ${result.command.map(shellQuote).join(" ")}`);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
async function readApprovalRequest(approvalPath, id) {
|
|
351
|
+
const resolvedPath = path.resolve(approvalPath);
|
|
352
|
+
const content = await fs.readFile(resolvedPath, "utf8");
|
|
353
|
+
const approvals = resolvedPath.endsWith(".jsonl")
|
|
354
|
+
? content.split(/\r?\n/).filter(Boolean).map((line) => JSON.parse(line))
|
|
355
|
+
: [JSON.parse(content)];
|
|
356
|
+
|
|
357
|
+
if (approvals.length === 0) {
|
|
358
|
+
throw new Error(`No approval requests found in ${resolvedPath}`);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const approval = id
|
|
362
|
+
? approvals.find((candidate) => candidate.id === id)
|
|
363
|
+
: approvals.at(-1);
|
|
364
|
+
|
|
365
|
+
if (!approval) {
|
|
366
|
+
throw new Error(`Approval request not found: ${id}`);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
if (approval.schemaVersion !== "clawguard.approval.v1") {
|
|
370
|
+
throw new Error("Unsupported approval request schema.");
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
return approval;
|
|
374
|
+
}
|
|
375
|
+
|
|
200
376
|
function printGateResult(result, options) {
|
|
201
377
|
const decision = result.policy.decision;
|
|
202
378
|
console.log(`ClawGuard gate: ${result.target}`);
|
|
@@ -243,10 +419,18 @@ async function handleInstall(result, options) {
|
|
|
243
419
|
const install = {
|
|
244
420
|
destination,
|
|
245
421
|
dryRun: options.dryRun,
|
|
422
|
+
framework: options.framework,
|
|
246
423
|
installed: false,
|
|
247
|
-
skipped: decision !== "allow"
|
|
424
|
+
skipped: decision !== "allow",
|
|
425
|
+
approvalRequest: null
|
|
248
426
|
};
|
|
249
427
|
|
|
428
|
+
if (shouldCreateApprovalRequest(decision, options)) {
|
|
429
|
+
install.approvalRequest = await writeApprovalRequest(result, install, options);
|
|
430
|
+
install.skipped = true;
|
|
431
|
+
return install;
|
|
432
|
+
}
|
|
433
|
+
|
|
250
434
|
if (decision !== "allow") {
|
|
251
435
|
return install;
|
|
252
436
|
}
|
|
@@ -277,10 +461,13 @@ async function handleInstall(result, options) {
|
|
|
277
461
|
function printInstallResult(result, install) {
|
|
278
462
|
const decision = result.policy.decision;
|
|
279
463
|
console.log(`ClawGuard install: ${result.target}`);
|
|
464
|
+
if (install.framework) {
|
|
465
|
+
console.log(`Framework: ${displayFramework(install.framework)}`);
|
|
466
|
+
}
|
|
280
467
|
console.log(`Decision: ${formatDecision(decision)}`);
|
|
281
468
|
console.log(`Risk: ${result.level.toUpperCase()} (${result.score}/100)`);
|
|
282
469
|
console.log(`Policy: ${result.policy.preset}`);
|
|
283
|
-
console.log(`Exit code: ${
|
|
470
|
+
console.log(`Exit code: ${installExitCode(decision, install)}`);
|
|
284
471
|
console.log(`Destination: ${install.destination ?? "not selected"}`);
|
|
285
472
|
console.log(`Installed: ${install.installed ? "yes" : "no"}`);
|
|
286
473
|
|
|
@@ -292,7 +479,11 @@ function printInstallResult(result, install) {
|
|
|
292
479
|
console.log(`Required actions: ${result.policy.requiredActions.join(", ")}`);
|
|
293
480
|
}
|
|
294
481
|
|
|
295
|
-
if (
|
|
482
|
+
if (install.approvalRequest) {
|
|
483
|
+
console.log(`Approval request: ${install.approvalRequest.path}`);
|
|
484
|
+
console.log(`Approval id: ${install.approvalRequest.id}`);
|
|
485
|
+
console.log("\nInstall result: pending user approval before copying files.");
|
|
486
|
+
} else if (decision === "allow" && install.installed) {
|
|
296
487
|
console.log("\nInstall result: copied after passing the selected policy.");
|
|
297
488
|
} else if (decision === "allow" && install.dryRun) {
|
|
298
489
|
console.log("\nInstall result: dry run passed; no files were copied.");
|
|
@@ -308,10 +499,13 @@ function printInstallResult(result, install) {
|
|
|
308
499
|
function createInstallResult(result, install) {
|
|
309
500
|
return {
|
|
310
501
|
...createGateResult(result),
|
|
502
|
+
exitCode: installExitCode(result.policy.decision, install),
|
|
503
|
+
framework: install.framework,
|
|
311
504
|
destination: install.destination,
|
|
312
505
|
installed: install.installed,
|
|
313
506
|
dryRun: install.dryRun,
|
|
314
|
-
skipped: install.skipped
|
|
507
|
+
skipped: install.skipped,
|
|
508
|
+
approvalRequest: install.approvalRequest
|
|
315
509
|
};
|
|
316
510
|
}
|
|
317
511
|
|
|
@@ -350,6 +544,106 @@ function resolveInstallDestination(sourcePath, options) {
|
|
|
350
544
|
return path.resolve(options.installDir, installName);
|
|
351
545
|
}
|
|
352
546
|
|
|
547
|
+
function shouldCreateApprovalRequest(decision, options) {
|
|
548
|
+
if (!options.approvalOut) {
|
|
549
|
+
return false;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
if (options.approvalMode === "always") {
|
|
553
|
+
return true;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
return decision !== "allow";
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
async function writeApprovalRequest(result, install, options) {
|
|
560
|
+
const request = createApprovalRequest(result, install, options);
|
|
561
|
+
const outputPath = path.resolve(options.approvalOut);
|
|
562
|
+
await fs.mkdir(path.dirname(outputPath), { recursive: true });
|
|
563
|
+
|
|
564
|
+
if (outputPath.endsWith(".jsonl")) {
|
|
565
|
+
await fs.appendFile(outputPath, `${JSON.stringify(request)}\n`);
|
|
566
|
+
} else {
|
|
567
|
+
await fs.writeFile(outputPath, `${JSON.stringify(request, null, 2)}\n`, { flag: "wx" });
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
return {
|
|
571
|
+
id: request.id,
|
|
572
|
+
path: outputPath,
|
|
573
|
+
status: request.status,
|
|
574
|
+
message: request.message
|
|
575
|
+
};
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
function createApprovalRequest(result, install, options) {
|
|
579
|
+
const id = randomUUID();
|
|
580
|
+
const decision = result.policy.decision;
|
|
581
|
+
const framework = options.framework ?? "generic";
|
|
582
|
+
const target = path.resolve(options.target);
|
|
583
|
+
const topFindings = result.findings.slice(0, 5).map((finding) => ({
|
|
584
|
+
ruleId: finding.ruleId,
|
|
585
|
+
severity: finding.severity,
|
|
586
|
+
title: finding.title,
|
|
587
|
+
file: finding.file,
|
|
588
|
+
line: finding.line,
|
|
589
|
+
recommendation: finding.recommendation
|
|
590
|
+
}));
|
|
591
|
+
|
|
592
|
+
return {
|
|
593
|
+
schemaVersion: "clawguard.approval.v1",
|
|
594
|
+
id,
|
|
595
|
+
status: "pending",
|
|
596
|
+
createdAt: new Date().toISOString(),
|
|
597
|
+
framework,
|
|
598
|
+
target,
|
|
599
|
+
destination: install.destination,
|
|
600
|
+
decision,
|
|
601
|
+
risk: {
|
|
602
|
+
level: result.level,
|
|
603
|
+
score: result.score
|
|
604
|
+
},
|
|
605
|
+
policy: {
|
|
606
|
+
preset: result.policy.preset,
|
|
607
|
+
reason: result.policy.reason,
|
|
608
|
+
requiredActions: result.policy.requiredActions
|
|
609
|
+
},
|
|
610
|
+
install: {
|
|
611
|
+
dryRun: install.dryRun,
|
|
612
|
+
installed: false,
|
|
613
|
+
skipped: true
|
|
614
|
+
},
|
|
615
|
+
summary: result.summary,
|
|
616
|
+
findings: topFindings,
|
|
617
|
+
message: createApprovalMessage({
|
|
618
|
+
framework,
|
|
619
|
+
target,
|
|
620
|
+
destination: install.destination,
|
|
621
|
+
decision,
|
|
622
|
+
risk: result.level,
|
|
623
|
+
score: result.score,
|
|
624
|
+
requiredActions: result.policy.requiredActions,
|
|
625
|
+
findings: topFindings
|
|
626
|
+
})
|
|
627
|
+
};
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
function createApprovalMessage(details) {
|
|
631
|
+
const findingLines = details.findings.length === 0
|
|
632
|
+
? "No findings were reported."
|
|
633
|
+
: details.findings.map((finding) => `- ${finding.severity.toUpperCase()}: ${finding.title}`).join("\n");
|
|
634
|
+
|
|
635
|
+
return [
|
|
636
|
+
`ClawGuard approval needed for ${displayFramework(details.framework)} skill install.`,
|
|
637
|
+
`Decision: ${formatDecision(details.decision)}`,
|
|
638
|
+
`Risk: ${details.risk.toUpperCase()} (${details.score}/100)`,
|
|
639
|
+
`Source: ${details.target}`,
|
|
640
|
+
`Destination: ${details.destination ?? "not selected"}`,
|
|
641
|
+
`Required actions: ${details.requiredActions.length > 0 ? details.requiredActions.join(", ") : "none"}`,
|
|
642
|
+
"Top findings:",
|
|
643
|
+
findingLines
|
|
644
|
+
].join("\n");
|
|
645
|
+
}
|
|
646
|
+
|
|
353
647
|
async function assertInstallableSource(sourcePath) {
|
|
354
648
|
const stats = await fs.lstat(sourcePath);
|
|
355
649
|
|
|
@@ -404,10 +698,31 @@ function commandLabel(commandName) {
|
|
|
404
698
|
return "Scan";
|
|
405
699
|
}
|
|
406
700
|
|
|
701
|
+
function displayFramework(value) {
|
|
702
|
+
if (value === "openclaw") {
|
|
703
|
+
return "OpenClaw";
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
if (value === "hermes") {
|
|
707
|
+
return "Hermes Agent";
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
return "agent";
|
|
711
|
+
}
|
|
712
|
+
|
|
407
713
|
function formatDecision(decision) {
|
|
408
714
|
return decision.replaceAll("_", " ").toUpperCase();
|
|
409
715
|
}
|
|
410
716
|
|
|
717
|
+
function shellQuote(value) {
|
|
718
|
+
const text = String(value);
|
|
719
|
+
if (/^[A-Za-z0-9_./:=@-]+$/.test(text)) {
|
|
720
|
+
return text;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
return `'${text.replaceAll("'", "'\\''")}'`;
|
|
724
|
+
}
|
|
725
|
+
|
|
411
726
|
function gateExitCode(decision) {
|
|
412
727
|
if (decision === "allow") {
|
|
413
728
|
return 0;
|
|
@@ -420,6 +735,14 @@ function gateExitCode(decision) {
|
|
|
420
735
|
return 1;
|
|
421
736
|
}
|
|
422
737
|
|
|
738
|
+
function installExitCode(decision, install) {
|
|
739
|
+
if (install.approvalRequest) {
|
|
740
|
+
return 1;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
return gateExitCode(decision);
|
|
744
|
+
}
|
|
745
|
+
|
|
423
746
|
function parseOptions(values) {
|
|
424
747
|
const options = {
|
|
425
748
|
json: false,
|
|
@@ -434,6 +757,9 @@ function parseOptions(values) {
|
|
|
434
757
|
installDir: undefined,
|
|
435
758
|
installName: undefined,
|
|
436
759
|
dryRun: false,
|
|
760
|
+
approvalOut: undefined,
|
|
761
|
+
approvalMode: "non-allow",
|
|
762
|
+
framework: undefined,
|
|
437
763
|
target: "."
|
|
438
764
|
};
|
|
439
765
|
const paths = [];
|
|
@@ -527,6 +853,22 @@ function parseOptions(values) {
|
|
|
527
853
|
continue;
|
|
528
854
|
}
|
|
529
855
|
|
|
856
|
+
if (value === "--approval-out") {
|
|
857
|
+
options.approvalOut = requireNextValue(values, index, "--approval-out");
|
|
858
|
+
index += 1;
|
|
859
|
+
continue;
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
if (value === "--approval-mode") {
|
|
863
|
+
const mode = requireNextValue(values, index, "--approval-mode");
|
|
864
|
+
if (!["non-allow", "always"].includes(mode)) {
|
|
865
|
+
throw new Error("Invalid --approval-mode value. Use one of: non-allow, always");
|
|
866
|
+
}
|
|
867
|
+
options.approvalMode = mode;
|
|
868
|
+
index += 1;
|
|
869
|
+
continue;
|
|
870
|
+
}
|
|
871
|
+
|
|
530
872
|
if (value.startsWith("--")) {
|
|
531
873
|
throw new Error(`Unknown option: ${value}`);
|
|
532
874
|
}
|
|
@@ -538,6 +880,97 @@ function parseOptions(values) {
|
|
|
538
880
|
return options;
|
|
539
881
|
}
|
|
540
882
|
|
|
883
|
+
function parseApprovalSendOptions(values) {
|
|
884
|
+
const options = {
|
|
885
|
+
approvalPath: undefined,
|
|
886
|
+
id: undefined,
|
|
887
|
+
via: "openclaw",
|
|
888
|
+
channel: undefined,
|
|
889
|
+
target: undefined,
|
|
890
|
+
senderBin: undefined,
|
|
891
|
+
senderArgs: [],
|
|
892
|
+
dryRun: false,
|
|
893
|
+
json: false
|
|
894
|
+
};
|
|
895
|
+
const paths = [];
|
|
896
|
+
|
|
897
|
+
for (let index = 0; index < values.length; index += 1) {
|
|
898
|
+
const value = values[index];
|
|
899
|
+
|
|
900
|
+
if (value === "--json") {
|
|
901
|
+
options.json = true;
|
|
902
|
+
continue;
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
if (value === "--dry-run") {
|
|
906
|
+
options.dryRun = true;
|
|
907
|
+
continue;
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
if (value === "--id") {
|
|
911
|
+
options.id = requireNextValue(values, index, "--id");
|
|
912
|
+
index += 1;
|
|
913
|
+
continue;
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
if (value === "--via") {
|
|
917
|
+
options.via = requireNextValue(values, index, "--via");
|
|
918
|
+
index += 1;
|
|
919
|
+
continue;
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
if (value === "--channel") {
|
|
923
|
+
options.channel = requireNextValue(values, index, "--channel");
|
|
924
|
+
index += 1;
|
|
925
|
+
continue;
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
if (value === "--target") {
|
|
929
|
+
options.target = requireNextValue(values, index, "--target");
|
|
930
|
+
index += 1;
|
|
931
|
+
continue;
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
if (value === "--sender-bin") {
|
|
935
|
+
options.senderBin = requireNextValue(values, index, "--sender-bin");
|
|
936
|
+
index += 1;
|
|
937
|
+
continue;
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
if (value === "--sender-arg") {
|
|
941
|
+
options.senderArgs.push(requireNextValue(values, index, "--sender-arg"));
|
|
942
|
+
index += 1;
|
|
943
|
+
continue;
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
if (value.startsWith("--")) {
|
|
947
|
+
throw new Error(`Unknown option: ${value}`);
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
paths.push(value);
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
options.approvalPath = paths[0];
|
|
954
|
+
|
|
955
|
+
if (!options.approvalPath) {
|
|
956
|
+
throw new Error("approvals send requires <approval.json|approvals.jsonl>.");
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
if (options.via !== "openclaw") {
|
|
960
|
+
throw new Error("Invalid --via value. Use: openclaw");
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
if (!options.channel) {
|
|
964
|
+
throw new Error("approvals send requires --channel <name>.");
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
if (!options.target) {
|
|
968
|
+
throw new Error("approvals send requires --target <id>.");
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
return options;
|
|
972
|
+
}
|
|
973
|
+
|
|
541
974
|
async function writeReportFile(outputPath, content) {
|
|
542
975
|
const resolvedPath = path.resolve(outputPath);
|
|
543
976
|
await fs.mkdir(path.dirname(resolvedPath), { recursive: true });
|
package/src/config.js
CHANGED
|
@@ -64,7 +64,9 @@ export function mergeConfig(config, cliOptions = {}) {
|
|
|
64
64
|
sarifPath: cliOptions.sarifPath,
|
|
65
65
|
installDir: cliOptions.installDir,
|
|
66
66
|
installName: cliOptions.installName,
|
|
67
|
-
dryRun: Boolean(cliOptions.dryRun)
|
|
67
|
+
dryRun: Boolean(cliOptions.dryRun),
|
|
68
|
+
approvalOut: cliOptions.approvalOut,
|
|
69
|
+
approvalMode: cliOptions.approvalMode ?? "non-allow"
|
|
68
70
|
};
|
|
69
71
|
}
|
|
70
72
|
|
package/web/index.html
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
<meta charset="utf-8">
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
6
|
<title>ClawGuard Web Demo</title>
|
|
7
|
-
<link rel="stylesheet" href="
|
|
7
|
+
<link rel="stylesheet" href="styles.css">
|
|
8
8
|
</head>
|
|
9
9
|
<body>
|
|
10
10
|
<main class="shell">
|
|
@@ -125,6 +125,6 @@
|
|
|
125
125
|
</section>
|
|
126
126
|
</section>
|
|
127
127
|
</main>
|
|
128
|
-
<script src="
|
|
128
|
+
<script src="app.js" type="module"></script>
|
|
129
129
|
</body>
|
|
130
130
|
</html>
|