@denial-web/clawguard 0.1.1 → 0.1.3
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 +9 -0
- package/docs/ARCHITECTURE.md +6 -3
- package/docs/ARCHITECTURE_ROADMAP.md +2 -0
- package/package.json +1 -1
- package/scripts/capture-demo.js +2 -1
- package/src/cli.js +178 -4
- package/src/config.js +4 -1
- package/web/app.js +51 -0
- package/web/index.html +11 -0
- package/web/styles.css +39 -2
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
|
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
package/scripts/capture-demo.js
CHANGED
|
@@ -89,7 +89,8 @@ async function main() {
|
|
|
89
89
|
await pause(250);
|
|
90
90
|
await dependencyRisk.click();
|
|
91
91
|
await page.getByRole("heading", { name: "Dependency Risk" }).waitFor();
|
|
92
|
-
await page.
|
|
92
|
+
await page.getByRole("heading", { name: "Block", exact: true }).waitFor();
|
|
93
|
+
await page.getByRole("heading", { name: "Install blocked" }).waitFor();
|
|
93
94
|
await pause(700);
|
|
94
95
|
|
|
95
96
|
await page.screenshot({
|
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 === "
|
|
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(
|
|
76
|
+
process.exit(["gate", "install"].includes(command) ? gateExitCode(result.policy.decision) : shouldFail(result, options) ? 2 : 0);
|
|
70
77
|
} catch (error) {
|
|
71
|
-
console.error(`${command
|
|
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
|
|
package/web/app.js
CHANGED
|
@@ -37,6 +37,9 @@ const elements = {
|
|
|
37
37
|
level: document.querySelector("#level"),
|
|
38
38
|
decision: document.querySelector("#decision"),
|
|
39
39
|
reason: document.querySelector("#reason"),
|
|
40
|
+
installVerdict: document.querySelector("#install-verdict"),
|
|
41
|
+
installMessage: document.querySelector("#install-message"),
|
|
42
|
+
installCommand: document.querySelector("#install-command"),
|
|
40
43
|
actions: document.querySelector("#actions"),
|
|
41
44
|
critical: document.querySelector("#critical-count"),
|
|
42
45
|
high: document.querySelector("#high-count"),
|
|
@@ -235,9 +238,36 @@ function renderResult(result) {
|
|
|
235
238
|
elements.downloadHtml.textContent = "Download HTML";
|
|
236
239
|
elements.downloadHtml.disabled = false;
|
|
237
240
|
|
|
241
|
+
renderInstallGate(result);
|
|
238
242
|
renderFindings(scan.findings ?? []);
|
|
239
243
|
}
|
|
240
244
|
|
|
245
|
+
function renderInstallGate(result) {
|
|
246
|
+
const scan = result.scan;
|
|
247
|
+
const policy = scan.policy ?? {};
|
|
248
|
+
const decision = policy.decision ?? "allow";
|
|
249
|
+
const target = installTargetFor(result);
|
|
250
|
+
const installName = safeInstallName(result.displayTarget ?? "skill");
|
|
251
|
+
const command = `npx @denial-web/clawguard install ${target} --to ./.agents/skills --name ${installName} --policy ${policy.preset ?? elements.policy.value}`;
|
|
252
|
+
|
|
253
|
+
elements.installCommand.textContent = command;
|
|
254
|
+
|
|
255
|
+
if (decision === "allow") {
|
|
256
|
+
elements.installVerdict.textContent = "Install allowed";
|
|
257
|
+
elements.installMessage.textContent = "ClawGuard would copy this skill into the destination after the policy gate passes.";
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (decision === "block") {
|
|
262
|
+
elements.installVerdict.textContent = "Install blocked";
|
|
263
|
+
elements.installMessage.textContent = "ClawGuard would stop before copying files. Review the findings before trusting this skill.";
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
elements.installVerdict.textContent = "Install paused";
|
|
268
|
+
elements.installMessage.textContent = "ClawGuard would require review, sandboxing, or approval before copying files.";
|
|
269
|
+
}
|
|
270
|
+
|
|
241
271
|
function renderFindings(findings) {
|
|
242
272
|
if (findings.length === 0) {
|
|
243
273
|
elements.findings.className = "findings empty-state";
|
|
@@ -342,6 +372,27 @@ function safeFilename(value) {
|
|
|
342
372
|
.slice(0, 80) || "scan";
|
|
343
373
|
}
|
|
344
374
|
|
|
375
|
+
function safeInstallName(value) {
|
|
376
|
+
return String(value || "skill")
|
|
377
|
+
.toLowerCase()
|
|
378
|
+
.replace(/[^a-z0-9_.-]/g, "-")
|
|
379
|
+
.replace(/-+/g, "-")
|
|
380
|
+
.replace(/^-|-$/g, "")
|
|
381
|
+
.slice(0, 60) || "skill";
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function installTargetFor(result) {
|
|
385
|
+
if (result.source === "example" && result.example?.path) {
|
|
386
|
+
return result.example.path;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
if (result.source === "folder") {
|
|
390
|
+
return "./uploaded-skill";
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
return "./pasted-skill";
|
|
394
|
+
}
|
|
395
|
+
|
|
345
396
|
async function fetchJson(url, options = {}) {
|
|
346
397
|
const response = await fetch(url, {
|
|
347
398
|
headers: {
|
package/web/index.html
CHANGED
|
@@ -75,6 +75,17 @@
|
|
|
75
75
|
</div>
|
|
76
76
|
</section>
|
|
77
77
|
|
|
78
|
+
<section class="install-panel" aria-label="Pre-install gate">
|
|
79
|
+
<div>
|
|
80
|
+
<p class="eyebrow">Pre-Install Gate</p>
|
|
81
|
+
<h2 id="install-verdict">Waiting for scan</h2>
|
|
82
|
+
<p id="install-message">Run a scan to see whether ClawGuard would install, pause, or block before files are trusted.</p>
|
|
83
|
+
</div>
|
|
84
|
+
<div class="command-box">
|
|
85
|
+
<code id="install-command">npx @denial-web/clawguard install ./skill --to ./.agents/skills --policy governed</code>
|
|
86
|
+
</div>
|
|
87
|
+
</section>
|
|
88
|
+
|
|
78
89
|
<section class="metrics" aria-label="Finding summary">
|
|
79
90
|
<div><span>Critical</span><strong id="critical-count">0</strong></div>
|
|
80
91
|
<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, .metrics div, .metadata-grid div, .finding-card, .empty-state {
|
|
267
|
+
.score-ring, .decision, .install-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);
|
|
@@ -302,6 +302,43 @@ input[type="file"] {
|
|
|
302
302
|
text-transform: uppercase;
|
|
303
303
|
}
|
|
304
304
|
|
|
305
|
+
.install-panel {
|
|
306
|
+
display: grid;
|
|
307
|
+
grid-template-columns: minmax(220px, 0.8fr) minmax(0, 1.2fr);
|
|
308
|
+
gap: 14px;
|
|
309
|
+
align-items: stretch;
|
|
310
|
+
padding: 16px;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
.install-panel h2 {
|
|
314
|
+
margin-top: 5px;
|
|
315
|
+
font-size: 24px;
|
|
316
|
+
text-transform: uppercase;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
#install-message {
|
|
320
|
+
margin-top: 6px;
|
|
321
|
+
color: var(--muted);
|
|
322
|
+
line-height: 1.4;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
.command-box {
|
|
326
|
+
min-width: 0;
|
|
327
|
+
display: grid;
|
|
328
|
+
align-items: center;
|
|
329
|
+
border: 1px solid var(--line);
|
|
330
|
+
border-radius: 8px;
|
|
331
|
+
background: var(--field);
|
|
332
|
+
padding: 12px;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
.command-box code {
|
|
336
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
337
|
+
font-size: 13px;
|
|
338
|
+
line-height: 1.45;
|
|
339
|
+
overflow-wrap: anywhere;
|
|
340
|
+
}
|
|
341
|
+
|
|
305
342
|
.action-tags {
|
|
306
343
|
display: flex;
|
|
307
344
|
flex-wrap: wrap;
|
|
@@ -422,7 +459,7 @@ input[type="file"] {
|
|
|
422
459
|
}
|
|
423
460
|
|
|
424
461
|
@media (max-width: 900px) {
|
|
425
|
-
.workbench, .score-panel {
|
|
462
|
+
.workbench, .score-panel, .install-panel {
|
|
426
463
|
grid-template-columns: 1fr;
|
|
427
464
|
}
|
|
428
465
|
|