@pugi/cli 0.1.0-beta.36 → 0.1.0-beta.37
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/dist/core/hooks/v2/event-emitter.js +115 -0
- package/dist/core/hooks/v2/executor.js +282 -0
- package/dist/core/hooks/v2/index.js +25 -0
- package/dist/core/hooks/v2/lifecycle.js +104 -0
- package/dist/core/hooks/v2/loader.js +216 -0
- package/dist/core/hooks/v2/matcher.js +125 -0
- package/dist/core/hooks/v2/trust.js +143 -0
- package/dist/core/hooks/v2/types.js +86 -0
- package/dist/core/mcp/orchestrator-tools.js +118 -51
- package/dist/core/permissions/auto-classifier.js +124 -0
- package/dist/core/permissions/circuit-breaker.js +83 -0
- package/dist/core/permissions/gate.js +144 -53
- package/dist/core/permissions/index.js +3 -1
- package/dist/core/permissions/mode.js +132 -60
- package/dist/core/permissions/state.js +33 -7
- package/dist/core/repl/slash-commands.js +16 -12
- package/dist/core/session.js +48 -0
- package/dist/runtime/cli.js +4 -4
- package/dist/runtime/commands/permissions.js +11 -9
- package/dist/runtime/commands/plan.js +4 -4
- package/dist/runtime/version.js +1 -1
- package/dist/tui/input-box.js +24 -1
- package/dist/tui/permissions-picker.js +14 -6
- package/dist/tui/repl.js +29 -1
- package/package.json +2 -2
|
@@ -29,10 +29,11 @@
|
|
|
29
29
|
* - PUGI_MCP_PUBLISH_ENABLED=1 — enables `pugi.publish`
|
|
30
30
|
* - PUGI_MCP_DEPLOY_ENABLED=1 — enables `pugi.deploy`
|
|
31
31
|
*
|
|
32
|
-
* `pugi.read` / `pugi.write`
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
32
|
+
* `pugi.read` / `pugi.write` do not require an env gate (read+write
|
|
33
|
+
* enforce workspace + protected-path containment). `pugi.dispatch`
|
|
34
|
+
* uses PUGI_MCP_EXEC_ENABLED (shared with `pugi.run`) because it
|
|
35
|
+
* shells the local `pugi` binary to drive the full engine loop
|
|
36
|
+
* client-side. All three still pass through the MCP-server
|
|
36
37
|
* permissionGate, so an operator running `pugi mcp serve` without
|
|
37
38
|
* `--allow-write` still sees `pugi.write` refused at dispatch.
|
|
38
39
|
*/
|
|
@@ -130,9 +131,15 @@ export function resolveWorkspacePathOrThrow(ctx, requested) {
|
|
|
130
131
|
* specific `pugi.run` call via the per-tool prompt without restarting
|
|
131
132
|
* the server.
|
|
132
133
|
*/
|
|
134
|
+
/**
|
|
135
|
+
* Allowed dispatch subcommands. Mirror of the `command` enum in the
|
|
136
|
+
* admin-api `EngineRequestDto` (apps/admin-api/src/pugi-engine/
|
|
137
|
+
* pugi-engine.controller.ts). Kept as a local literal so this surface
|
|
138
|
+
* stays decoupled from the admin-api package — the CLI must work
|
|
139
|
+
* standalone after `npm i -g @pugi/cli`.
|
|
140
|
+
*/
|
|
141
|
+
const ALLOWED_DISPATCH_COMMANDS = ['code', 'explain', 'fix', 'plan', 'build'];
|
|
133
142
|
export function buildOrchestratorTools(ctx) {
|
|
134
|
-
const fetchImpl = ctx.fetchImpl ??
|
|
135
|
-
((...args) => fetch(...args));
|
|
136
143
|
const execImpl = ctx.execFileImpl ?? execFileAsync;
|
|
137
144
|
const tools = [
|
|
138
145
|
{
|
|
@@ -290,62 +297,106 @@ export function buildOrchestratorTools(ctx) {
|
|
|
290
297
|
},
|
|
291
298
|
{
|
|
292
299
|
name: 'pugi.dispatch',
|
|
293
|
-
description: '
|
|
294
|
-
'
|
|
295
|
-
'
|
|
296
|
-
|
|
300
|
+
description: 'Run the Pugi engine loop end-to-end by shelling to `pugi <command> <prompt>` ' +
|
|
301
|
+
'(default command "code"). Drives the full client-side tool-use loop, so the ' +
|
|
302
|
+
'caller sees real file writes, real shell exec, real cost — not just one Anvil ' +
|
|
303
|
+
'turn. Workspace cwd must be `pugi init`-ed already; auth resolves through the ' +
|
|
304
|
+
'CLI (PUGI_API_KEY env or on-disk `pugi login` state). ' +
|
|
305
|
+
'Requires PUGI_MCP_EXEC_ENABLED=1 at server boot.',
|
|
306
|
+
permission: 'bash',
|
|
297
307
|
inputSchema: {
|
|
298
308
|
type: 'object',
|
|
299
309
|
additionalProperties: false,
|
|
300
310
|
required: ['prompt'],
|
|
301
311
|
properties: {
|
|
302
312
|
prompt: { type: 'string' },
|
|
303
|
-
|
|
313
|
+
command: {
|
|
304
314
|
type: 'string',
|
|
305
|
-
|
|
315
|
+
enum: ['code', 'explain', 'fix', 'plan', 'build'],
|
|
316
|
+
description: 'Pugi CLI subcommand. Default "code".',
|
|
306
317
|
},
|
|
307
|
-
|
|
318
|
+
cwd: {
|
|
308
319
|
type: 'string',
|
|
309
|
-
description: '
|
|
320
|
+
description: 'Optional workspace-relative cwd; defaults to the MCP workspace root. ' +
|
|
321
|
+
'Must already be `pugi init`-ed.',
|
|
322
|
+
},
|
|
323
|
+
timeoutMs: {
|
|
324
|
+
type: 'number',
|
|
325
|
+
minimum: 100,
|
|
326
|
+
maximum: 600000,
|
|
327
|
+
description: 'Hard timeout in ms (default 180000).',
|
|
310
328
|
},
|
|
311
329
|
},
|
|
312
330
|
},
|
|
313
331
|
async execute(args) {
|
|
332
|
+
if (!ctx.capabilities.exec) {
|
|
333
|
+
throw new Error('pugi.dispatch: PUGI_MCP_EXEC_ENABLED is not set. ' +
|
|
334
|
+
'Restart `pugi mcp serve` with PUGI_MCP_EXEC_ENABLED=1 to enable shell-driven dispatch.');
|
|
335
|
+
}
|
|
314
336
|
const prompt = requireString(args, 'prompt');
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
337
|
+
// Argv-injection guard. The `pugi` CLI parser (runtime/cli.ts) uses
|
|
338
|
+
// an allowlist of known global flags (`--remote`, `--allow-fetch`,
|
|
339
|
+
// `--allow-search`, `--triple`, etc.) and does not honour a `--`
|
|
340
|
+
// end-of-options sentinel. Passing a prompt that begins with `--`
|
|
341
|
+
// risks the parser swallowing it as a CLI flag (e.g. an attacker-
|
|
342
|
+
// controlled MCP client sending `prompt: "--allow-fetch"` to
|
|
343
|
+
// silently unlock a capability the operator did not intend to
|
|
344
|
+
// grant for this turn). Reject at the MCP boundary so we fail
|
|
345
|
+
// loud rather than silently shift CLI behaviour. Operators with
|
|
346
|
+
// legitimate prompts starting with `--` can prepend a space.
|
|
347
|
+
if (prompt.startsWith('--')) {
|
|
348
|
+
throw new Error('pugi.dispatch: prompt cannot start with "--" — the child CLI parser would ' +
|
|
349
|
+
'interpret it as a flag. Prepend a space (" --foo") or rephrase.');
|
|
319
350
|
}
|
|
320
|
-
const
|
|
351
|
+
const command = optionalString(args, 'command') ?? 'code';
|
|
352
|
+
if (!ALLOWED_DISPATCH_COMMANDS.includes(command)) {
|
|
353
|
+
throw new Error(`pugi.dispatch: invalid command "${command}" (allowed: ${ALLOWED_DISPATCH_COMMANDS.join(', ')})`);
|
|
354
|
+
}
|
|
355
|
+
const cwdInput = optionalString(args, 'cwd');
|
|
356
|
+
const cwd = cwdInput
|
|
357
|
+
? resolveWorkspacePathOrThrow(ctx, cwdInput).absolute
|
|
358
|
+
: ctx.workspaceRoot;
|
|
359
|
+
const timeoutMs = optionalNumber(args, 'timeoutMs', 180000);
|
|
321
360
|
const started = Date.now();
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
361
|
+
try {
|
|
362
|
+
const { stdout, stderr } = await execImpl(ctx.pugiBin, [command, prompt, '--no-tty'], {
|
|
363
|
+
cwd,
|
|
364
|
+
timeout: timeoutMs,
|
|
365
|
+
maxBuffer: 8 * 1024 * 1024,
|
|
366
|
+
// Auth-bearing envs are passed through here even though
|
|
367
|
+
// `sanitisedEnv()` strips them for `pugi.run`. Rationale:
|
|
368
|
+
// dispatch is explicitly an authenticated engine call, so
|
|
369
|
+
// the child must reach Anvil. The CLI prefers on-disk
|
|
370
|
+
// `pugi login` state when both are present.
|
|
371
|
+
env: dispatchEnv(),
|
|
372
|
+
});
|
|
373
|
+
return JSON.stringify({
|
|
374
|
+
command,
|
|
375
|
+
cwd,
|
|
376
|
+
exitCode: 0,
|
|
377
|
+
durationMs: Date.now() - started,
|
|
378
|
+
stdout: clamp(stdout, 16 * 1024),
|
|
379
|
+
stderr: clamp(stderr, 4 * 1024),
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
catch (err) {
|
|
383
|
+
const e = err;
|
|
384
|
+
// `||` chain (not `??`) so an empty / whitespace-only `e.stderr`
|
|
385
|
+
// does not swallow a spawn-side `e.message` like `"spawn pugi
|
|
386
|
+
// ENOENT"`. Operators need to distinguish "pugi binary missing"
|
|
387
|
+
// from "pugi ran and exited 1 silently."
|
|
388
|
+
const stderrText = e.stderr || e.message || '';
|
|
389
|
+
return JSON.stringify({
|
|
390
|
+
command,
|
|
391
|
+
cwd,
|
|
392
|
+
exitCode: typeof e.code === 'number' ? e.code : 1,
|
|
393
|
+
durationMs: Date.now() - started,
|
|
394
|
+
stdout: clamp(e.stdout ?? '', 16 * 1024),
|
|
395
|
+
stderr: clamp(stderrText, 4 * 1024),
|
|
396
|
+
...(e.signal ? { signal: e.signal } : {}),
|
|
397
|
+
...(e.killed ? { killed: true } : {}),
|
|
398
|
+
});
|
|
340
399
|
}
|
|
341
|
-
const parsed = (await response.json().catch(() => ({})));
|
|
342
|
-
return JSON.stringify({
|
|
343
|
-
response: typeof parsed.response === 'string' ? parsed.response : '',
|
|
344
|
-
toolCalls: Array.isArray(parsed.toolCalls) ? parsed.toolCalls : [],
|
|
345
|
-
cost: typeof parsed.cost === 'number' ? parsed.cost : 0,
|
|
346
|
-
durationMs,
|
|
347
|
-
fileChanges: Array.isArray(parsed.fileChanges) ? parsed.fileChanges : [],
|
|
348
|
-
});
|
|
349
400
|
},
|
|
350
401
|
},
|
|
351
402
|
{
|
|
@@ -557,13 +608,29 @@ function sanitisedEnv() {
|
|
|
557
608
|
}
|
|
558
609
|
return out;
|
|
559
610
|
}
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
611
|
+
function dispatchEnv() {
|
|
612
|
+
// Like sanitisedEnv() but threads PUGI_API_KEY / PUGI_API_URL through
|
|
613
|
+
// so the child `pugi <command>` invocation can resolve auth from env
|
|
614
|
+
// when on-disk `pugi login` state is unavailable (CI, fresh container).
|
|
615
|
+
const allow = [
|
|
616
|
+
'PATH',
|
|
617
|
+
'HOME',
|
|
618
|
+
'USER',
|
|
619
|
+
'SHELL',
|
|
620
|
+
'LANG',
|
|
621
|
+
'LC_ALL',
|
|
622
|
+
'TERM',
|
|
623
|
+
'NODE_OPTIONS',
|
|
624
|
+
'PUGI_API_KEY',
|
|
625
|
+
'PUGI_API_URL',
|
|
626
|
+
];
|
|
627
|
+
const out = {};
|
|
628
|
+
for (const key of allow) {
|
|
629
|
+
const value = process.env[key];
|
|
630
|
+
if (value !== undefined)
|
|
631
|
+
out[key] = value;
|
|
566
632
|
}
|
|
633
|
+
return out;
|
|
567
634
|
}
|
|
568
635
|
function extractGitHead(stdout) {
|
|
569
636
|
// Match "HEAD is now at <sha> …" or "<sha> commit message" — the
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auto-mode classifier — Wave 7 Phase 1 (regex allowlist + denylist).
|
|
3
|
+
*
|
|
4
|
+
* `permissionMode === 'auto'` is the Claude Code parity mode where the
|
|
5
|
+
* classifier decides safe-vs-unsafe per call. Phase 1 ships without
|
|
6
|
+
* ML — just two curated regex lists covering the 80% of "obviously
|
|
7
|
+
* safe" and "obviously catastrophic" patterns. Anything that doesn't
|
|
8
|
+
* match either list returns `ask`, so the operator stays in the loop
|
|
9
|
+
* для the ambiguous middle.
|
|
10
|
+
*
|
|
11
|
+
* Phase 2 (deferred, NOT in this PR): semantic classifier consulting
|
|
12
|
+
* the model with a tight system prompt. The interface (`AutoVerdict`)
|
|
13
|
+
* is stable so we can swap implementations without touching the gate.
|
|
14
|
+
*
|
|
15
|
+
* Design notes:
|
|
16
|
+
*
|
|
17
|
+
* - Patterns are conservative: a read-only command is only
|
|
18
|
+
* allow-listed когда its argv shape is unambiguous (no `-exec`,
|
|
19
|
+
* no `--delete`, no `|` к shell). When в doubt, fall back to ask.
|
|
20
|
+
* - The denylist matches catastrophic patterns even в auto-mode so
|
|
21
|
+
* a misclick can't shred the workspace. The circuit-breaker
|
|
22
|
+
* (`circuit-breaker.ts`) covers the same surface для bypass-mode;
|
|
23
|
+
* this denylist is the auto-mode equivalent.
|
|
24
|
+
* - All matches operate on the FULL command string, not parsed
|
|
25
|
+
* argv. This is deliberately permissive on whitespace but strict
|
|
26
|
+
* on operator characters (`|`, `&`, `;`, `>`, backticks) — a
|
|
27
|
+
* pipe-into-shell или command-chain forces fallback к ask.
|
|
28
|
+
*/
|
|
29
|
+
/**
|
|
30
|
+
* Catastrophic patterns — the auto-mode regex denylist. Each entry
|
|
31
|
+
* carries a human-readable reason surfaced в the deny payload so the
|
|
32
|
+
* operator + audit log see why the gate refused. Order matters: most-
|
|
33
|
+
* specific первой так "rm -rf /" reports as that, не the generic
|
|
34
|
+
* "rm -rf".
|
|
35
|
+
*/
|
|
36
|
+
const AUTO_DENY_PATTERNS = [
|
|
37
|
+
{ pattern: /\brm\s+(-[a-z]*r[a-z]*f|-[a-z]*f[a-z]*r)\b/i, reason: 'rm -rf (recursive force-delete)' },
|
|
38
|
+
{ pattern: /\bgit\s+push\s+(-{1,2}force\b|\-f\b)/i, reason: 'git push --force (history rewrite)' },
|
|
39
|
+
{ pattern: /\bgit\s+reset\s+--hard\b/i, reason: 'git reset --hard (uncommitted-work loss)' },
|
|
40
|
+
{ pattern: /\bdd\s+if=\/(dev|)/i, reason: 'dd if=/dev/* (raw device read/write)' },
|
|
41
|
+
{ pattern: /\bmkfs(\.|\s|$)/i, reason: 'mkfs (filesystem format)' },
|
|
42
|
+
{ pattern: /\bchmod\s+-R\s+777\b/i, reason: 'chmod -R 777 (world-writable recursive)' },
|
|
43
|
+
{ pattern: /\bchown\s+-R\b/i, reason: 'chown -R (recursive ownership change)' },
|
|
44
|
+
{ pattern: /:\(\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;?\s*:/, reason: 'fork bomb signature' },
|
|
45
|
+
{ pattern: /\bsudo\b/i, reason: 'sudo (privilege escalation)' },
|
|
46
|
+
{ pattern: /\b(npm|pnpm|yarn)\s+publish\b/i, reason: 'package publish (irreversible npm release)' },
|
|
47
|
+
{ pattern: /\b(curl|wget)\b[^|;&]*\|\s*(sh|bash|zsh)\b/i, reason: 'pipe-to-shell installer (curl … | sh)' },
|
|
48
|
+
];
|
|
49
|
+
/**
|
|
50
|
+
* Safe-by-default patterns — auto-mode regex allowlist. Each regex
|
|
51
|
+
* must match the FULL command (with `^…$` anchors) so a leading
|
|
52
|
+
* `sudo ls` или a trailing `; rm -rf /` does NOT slip through. The
|
|
53
|
+
* caller passes the trimmed command string; whitespace around argv
|
|
54
|
+
* tokens is tolerated.
|
|
55
|
+
*/
|
|
56
|
+
const AUTO_ALLOW_PATTERNS = [
|
|
57
|
+
{ pattern: /^ls(\s+-[a-zA-Z]+)*(\s+[^|;&`>$()\\]+)?$/, reason: 'ls (directory listing)' },
|
|
58
|
+
{ pattern: /^pwd\s*$/, reason: 'pwd (working directory)' },
|
|
59
|
+
{ pattern: /^cat\s+[^|;&`>$()\\]+$/, reason: 'cat (file read)' },
|
|
60
|
+
{ pattern: /^head(\s+-n?\s*\d+)?\s+[^|;&`>$()\\]+$/, reason: 'head (file preview)' },
|
|
61
|
+
{ pattern: /^tail(\s+-n?\s*\d+)?\s+[^|;&`>$()\\]+$/, reason: 'tail (file preview)' },
|
|
62
|
+
{ pattern: /^wc(\s+-[a-z]+)?\s+[^|;&`>$()\\]+$/, reason: 'wc (line/word count)' },
|
|
63
|
+
{ pattern: /^du\s+-sh?\s+[^|;&`>$()\\]+$/, reason: 'du -sh (disk usage summary)' },
|
|
64
|
+
{ pattern: /^df\s+-h\s*$/, reason: 'df -h (filesystem free space)' },
|
|
65
|
+
{ pattern: /^git\s+status(\s+--short|\s+-s)?\s*$/, reason: 'git status' },
|
|
66
|
+
{ pattern: /^git\s+diff(\s+[a-zA-Z0-9_./~^-]+)*\s*$/, reason: 'git diff (read-only)' },
|
|
67
|
+
{ pattern: /^git\s+log(\s+[a-zA-Z0-9_./~^-]+)*\s*$/, reason: 'git log (read-only)' },
|
|
68
|
+
{ pattern: /^git\s+branch(\s+-[a-z]+)?\s*$/, reason: 'git branch (read-only)' },
|
|
69
|
+
{ pattern: /^git\s+remote\s+-v\s*$/, reason: 'git remote -v (read-only)' },
|
|
70
|
+
{ pattern: /^pnpm\s+(typecheck|lint|test\s+--run|test\s+--watch=false)\s*$/, reason: 'pnpm read-only build check' },
|
|
71
|
+
{ pattern: /^npm\s+(--version|-v|run\s+typecheck|run\s+lint)\s*$/, reason: 'npm read-only check' },
|
|
72
|
+
{ pattern: /^node\s+--version\s*$/, reason: 'node --version' },
|
|
73
|
+
{ pattern: /^pnpm\s+--version\s*$/, reason: 'pnpm --version' },
|
|
74
|
+
{ pattern: /^which\s+[a-zA-Z0-9_-]+\s*$/, reason: 'which (command lookup)' },
|
|
75
|
+
{ pattern: /^find\s+\.\s+-type\s+f(\s+-name\s+[^|;&`>$()\\]+)?\s*$/, reason: 'find -type f (read-only)' },
|
|
76
|
+
{ pattern: /^(rg|ripgrep|grep)\s+(-[a-z]+\s+)*[^|;&`>$()\\]+(\s+[^|;&`>$()\\]+)?\s*$/, reason: 'grep/ripgrep (read-only search)' },
|
|
77
|
+
];
|
|
78
|
+
/**
|
|
79
|
+
* Classify an auto-mode command. Order:
|
|
80
|
+
* 1. Catastrophic deny patterns — surface the explicit deny reason.
|
|
81
|
+
* 2. Safe allow patterns — surface the matched reason.
|
|
82
|
+
* 3. Fallback к ask.
|
|
83
|
+
*
|
|
84
|
+
* The order matters: a destructive pattern that ALSO looks like a
|
|
85
|
+
* read-only token (e.g. `git diff ; rm -rf .`) hits deny first because
|
|
86
|
+
* the allow patterns require `^…$` anchors that the chained command
|
|
87
|
+
* fails to satisfy. Belt + suspenders.
|
|
88
|
+
*/
|
|
89
|
+
export function classifyAutoMode(command) {
|
|
90
|
+
const trimmed = command.trim();
|
|
91
|
+
if (trimmed.length === 0)
|
|
92
|
+
return { verdict: 'ask' };
|
|
93
|
+
for (const entry of AUTO_DENY_PATTERNS) {
|
|
94
|
+
if (entry.pattern.test(trimmed)) {
|
|
95
|
+
return {
|
|
96
|
+
verdict: 'deny',
|
|
97
|
+
reason: entry.reason,
|
|
98
|
+
pattern: entry.pattern.source,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
for (const entry of AUTO_ALLOW_PATTERNS) {
|
|
103
|
+
if (entry.pattern.test(trimmed)) {
|
|
104
|
+
return {
|
|
105
|
+
verdict: 'allow',
|
|
106
|
+
reason: entry.reason,
|
|
107
|
+
pattern: entry.pattern.source,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return { verdict: 'ask' };
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Diagnostic accessors — exposed для doctor surfaces + spec coverage.
|
|
115
|
+
* The arrays are frozen at module load so callers can iterate without
|
|
116
|
+
* mutating the source-of-truth.
|
|
117
|
+
*/
|
|
118
|
+
export function listAutoAllowPatterns() {
|
|
119
|
+
return AUTO_ALLOW_PATTERNS.map((e) => ({ pattern: e.pattern.source, reason: e.reason }));
|
|
120
|
+
}
|
|
121
|
+
export function listAutoDenyPatterns() {
|
|
122
|
+
return AUTO_DENY_PATTERNS.map((e) => ({ pattern: e.pattern.source, reason: e.reason }));
|
|
123
|
+
}
|
|
124
|
+
//# sourceMappingURL=auto-classifier.js.map
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* bypassPermissions circuit-breaker — Wave 7.
|
|
3
|
+
*
|
|
4
|
+
* `bypassPermissions` is "skip ALL checks" for trusted scripted runs.
|
|
5
|
+
* Even so, certain commands are catastrophic enough that the gate
|
|
6
|
+
* MUST refuse regardless of mode. This module owns that short list.
|
|
7
|
+
*
|
|
8
|
+
* The breaker is conservative on purpose:
|
|
9
|
+
* - rm -rf against `/`, `~`, or workspace root (`.`)
|
|
10
|
+
* - fork bomb signature (`:(){:|:&};:`)
|
|
11
|
+
* - dd if=/ (raw block-device read or write)
|
|
12
|
+
*
|
|
13
|
+
* False positives are acceptable here — an operator who really wants
|
|
14
|
+
* to nuke their root filesystem can switch to `dontAsk` and re-issue;
|
|
15
|
+
* the breaker is the "are you sure you typed this correctly?" guard,
|
|
16
|
+
* not a hard policy boundary.
|
|
17
|
+
*
|
|
18
|
+
* `evaluateCircuitBreaker` is pure regex matching — no IO, no state.
|
|
19
|
+
* It's called by the gate before any other routing so a bypass-mode
|
|
20
|
+
* session that types `rm -rf /` sees the deny path first.
|
|
21
|
+
*/
|
|
22
|
+
/**
|
|
23
|
+
* Pattern list — kept narrow on purpose. Each entry must match the
|
|
24
|
+
* canonical destructive shape; argv variants without the exact form
|
|
25
|
+
* fall through к the regular `dontAsk` / `bypassPermissions` allow
|
|
26
|
+
* path, which is what the operator opted into.
|
|
27
|
+
*/
|
|
28
|
+
const CIRCUIT_BREAKER_PATTERNS = [
|
|
29
|
+
// rm -rf against absolute root, $HOME, ~, $WORKSPACE_ROOT, or `.`
|
|
30
|
+
// with no further token. The negative lookahead на `[/~.]\S` makes
|
|
31
|
+
// sure `rm -rf /tmp/foo` (specific subtree) doesn't trip — only the
|
|
32
|
+
// catastrophic `rm -rf /` or `rm -rf ~` or `rm -rf .` shapes do.
|
|
33
|
+
{
|
|
34
|
+
pattern: /\brm\s+(-[a-z]*r[a-z]*f|-[a-z]*f[a-z]*r)\s+(\/|~|\$HOME|\$\{HOME\}|\.)\s*$/i,
|
|
35
|
+
reason: 'rm -rf against /, $HOME, ~, or workspace root',
|
|
36
|
+
},
|
|
37
|
+
// Fork bomb signature. Whitespace-tolerant но shape-strict.
|
|
38
|
+
{
|
|
39
|
+
pattern: /:\s*\(\s*\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;?\s*:/,
|
|
40
|
+
reason: 'fork bomb (`:(){ :|:& };:`)',
|
|
41
|
+
},
|
|
42
|
+
// dd to/from raw devices. Either direction is catastrophic enough
|
|
43
|
+
// to warrant the breaker (read of /dev/random into a workspace file
|
|
44
|
+
// can fill the disk, write to /dev/sda destroys the disk).
|
|
45
|
+
{
|
|
46
|
+
pattern: /\bdd\b[^|;&]*\b(if|of)=\/dev\//i,
|
|
47
|
+
reason: 'dd reading/writing /dev/* (catastrophic IO)',
|
|
48
|
+
},
|
|
49
|
+
// mkfs against any disk — single regex covers ext*, xfs, btrfs, vfat.
|
|
50
|
+
{
|
|
51
|
+
pattern: /\bmkfs(\.[a-z0-9]+)?\s+\/dev\//i,
|
|
52
|
+
reason: 'mkfs against /dev/* (filesystem format)',
|
|
53
|
+
},
|
|
54
|
+
];
|
|
55
|
+
/**
|
|
56
|
+
* Test the command against every circuit-breaker pattern. Returns the
|
|
57
|
+
* first match (most-catastrophic-first ordering is encoded в the array
|
|
58
|
+
* order); when no pattern matches, the breaker is `tripped: false` so
|
|
59
|
+
* the caller proceeds to the regular gate decision.
|
|
60
|
+
*
|
|
61
|
+
* Pure function — no IO, no module-scoped state. Safe to call from any
|
|
62
|
+
* surface (gate, doctor command, audit replay).
|
|
63
|
+
*/
|
|
64
|
+
export function evaluateCircuitBreaker(command) {
|
|
65
|
+
const trimmed = command.trim();
|
|
66
|
+
if (trimmed.length === 0)
|
|
67
|
+
return { tripped: false, reason: '' };
|
|
68
|
+
for (const entry of CIRCUIT_BREAKER_PATTERNS) {
|
|
69
|
+
if (entry.pattern.test(trimmed)) {
|
|
70
|
+
return { tripped: true, reason: entry.reason };
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return { tripped: false, reason: '' };
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Diagnostic accessor — exposed для doctor surfaces + spec coverage so
|
|
77
|
+
* the test layer can iterate the full list and assert each entry trips
|
|
78
|
+
* on representative input.
|
|
79
|
+
*/
|
|
80
|
+
export function listCircuitBreakerPatterns() {
|
|
81
|
+
return CIRCUIT_BREAKER_PATTERNS.map((e) => ({ pattern: e.pattern.source, reason: e.reason }));
|
|
82
|
+
}
|
|
83
|
+
//# sourceMappingURL=circuit-breaker.js.map
|