@pugi/cli 0.1.0-beta.35 → 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.
@@ -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
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Permission gate — Leak L6 canonical 4-mode enforcement.
2
+ * Permission gate — Wave 7 canonical 6-mode enforcement (CC parity).
3
3
  *
4
4
  * Single dispatch entry point. Every tool call goes through `gate()`
5
5
  * before the executor runs the tool body; the executor surfaces the
@@ -9,23 +9,32 @@
9
9
  *
10
10
  * Routing matrix (mode × class):
11
11
  *
12
- * | read | write | dispatch
13
- * plan | allow | deny | deny
14
- * ask | ask | ask | ask
15
- * allow | allow | allow | allow
16
- * bypass | allow | allow | allow (plus: hooks bypassed)
12
+ * | read | write | dispatch
13
+ * default | ask | ask | ask
14
+ * acceptEdits | allow | allow* | ask
15
+ * plan | allow | deny | deny
16
+ * auto | ask† | ask† | ask†
17
+ * dontAsk | allow | allow | allow
18
+ * bypassPermissions | allow | allow | allow (hooks bypassed)
17
19
  *
18
- * In ask mode the gate consults a session-scoped `always-allow` cache
19
- * keyed by tool name (set when the operator picks "always-allow-tool"
20
- * in the prompt). The cache is in-memory only — restarting the session
21
- * resets it, by design (every-session-fresh ask consent).
20
+ * * acceptEdits allows file-write tools (write/edit/multi_edit) only;
21
+ * bash and other write-class tools still ask.
22
+ * auto-mode consults the classifier (`auto-classifier.ts`): safe
23
+ * regex allowlist allow; destructive regex denylist → deny;
24
+ * anything else → ask.
22
25
  *
23
- * Bypass mode does NOT take a different code path in this module — the
24
- * `hooksBypassed` flag in the decision payload signals the executor /
25
- * hook layer to skip policy hooks. The classification logic is the
26
- * same as `allow` because the gate doesn't own hook execution; the
27
- * caller decides what to do with the bypass signal.
26
+ * Protected paths (`.git`, `~/.ssh`, `.pugi/settings.json`, …) NEVER
27
+ * auto-approve regardless of mode — the gate refuses them even in
28
+ * `dontAsk` and `bypassPermissions`. The bypassPermissions circuit-
29
+ * breaker fires on rm -rf / fork-bomb / dd if=/ patterns против root /
30
+ * home / workspace.
31
+ *
32
+ * Ask-mode session cache (`AskAlwaysCache`) survives — `default` and
33
+ * `auto` consult it; `plan` ignores it (structural contract); the
34
+ * permissive modes already allow / refuse без the cache.
28
35
  */
36
+ import { classifyAutoMode } from './auto-classifier.js';
37
+ import { evaluateCircuitBreaker } from './circuit-breaker.js';
29
38
  import { getToolClass } from './tool-class.js';
30
39
  export const ASK_OPTIONS = Object.freeze([
31
40
  'allow-once',
@@ -41,12 +50,12 @@ export function createAskAlwaysCache() {
41
50
  }
42
51
  /**
43
52
  * Apply the operator's answer to an `ask` decision. Caller invokes this
44
- * after the operator picks an option so the cache stays in sync.
45
- * Returns the effective decision: `allow-once` / `always-this-tool`
46
- * become `allow`; `deny-once` / `always-deny-this-tool` become `deny`.
53
+ * after the operator picks an option так cache stays в sync. Returns
54
+ * the effective decision: `allow-once` / `always-this-tool` become
55
+ * `allow`; `deny-once` / `always-deny-this-tool` become `deny`.
47
56
  *
48
57
  * `always-*` answers persist to the cache and short-circuit the next
49
- * gate call for the same tool name within the same session.
58
+ * gate call для the same tool name внутри session.
50
59
  */
51
60
  export function applyAskAnswer(cache, toolName, answer) {
52
61
  switch (answer) {
@@ -64,6 +73,17 @@ export function applyAskAnswer(cache, toolName, answer) {
64
73
  return { decision: 'deny', reason: `Denied for ${toolName} this session` };
65
74
  }
66
75
  }
76
+ /**
77
+ * Tools that `acceptEdits` mode auto-allows. Restricted к file-edit
78
+ * surfaces — bash / dispatch / etc fall through к the ask branch so
79
+ * the operator stays in control of "execute arbitrary command" and
80
+ * "spawn child agent" actions.
81
+ */
82
+ const ACCEPT_EDITS_AUTO_ALLOW = new Set([
83
+ 'write',
84
+ 'edit',
85
+ 'multi_edit',
86
+ ]);
67
87
  /**
68
88
  * Permission-denied sentinel. Distinguishable from other tool errors
69
89
  * (parse errors, IO failures) so the caller can route the message back
@@ -75,14 +95,14 @@ export class PermissionDenied extends Error {
75
95
  toolName;
76
96
  toolClass;
77
97
  /**
78
- * Human-friendly reason surfaced in logs / hook payloads. Distinct
98
+ * Human-friendly reason surfaced в logs / hook payloads. Distinct
79
99
  * from `message` so the spec layer can pattern-match the canonical
80
100
  * `PERMISSION_DENIED:` sentinel verbatim while operators see the
81
- * full explanation in console output.
101
+ * full explanation в console output.
82
102
  */
83
103
  reason;
84
104
  constructor(toolName, toolClass, mode, reason) {
85
- // The base Error.message is the canonical sentinel so default
105
+ // The base Error.message is the canonical sentinel так default
86
106
  // toString() / re-throw paths preserve the format the model and
87
107
  // the spec layer pattern-match against.
88
108
  super(`PERMISSION_DENIED: ${toolName} blocked in ${mode} mode. Operator can switch with /permissions <mode>.`);
@@ -92,10 +112,10 @@ export class PermissionDenied extends Error {
92
112
  this.reason = reason;
93
113
  }
94
114
  /**
95
- * Render the sentinel message the executor surfaces to the model.
96
- * The string format is stable so a parent agent / E2E spec can
115
+ * Render the sentinel message the executor surfaces к the model.
116
+ * The string format stable так a parent agent / E2E spec can
97
117
  * pattern-match `PERMISSION_DENIED: <tool> blocked in <mode> mode.`
98
- * verbatim. Equivalent to `this.message`; kept as a method so
118
+ * verbatim. Equivalent to `this.message`; kept как method так
99
119
  * downstream callers can use whichever spelling reads better at the
100
120
  * site.
101
121
  */
@@ -111,31 +131,48 @@ export class PermissionDenied extends Error {
111
131
  * Argument bag mirrors the executor entry shape:
112
132
  * - `toolName` is the registered tool key (e.g. `read`, `write`,
113
133
  * `mcp__github__list_issues`).
114
- * - `args` is the raw arg payload. Currently unused in the routing
115
- * decision the matrix only cares about class. Plumbed in
116
- * because future "always-allow-this-pattern" rules (e.g.
117
- * `git status` auto-allow) will consume it without changing the
118
- * callsite contract.
134
+ * - `args` is the raw arg payload. The gate inspects `args.command`
135
+ * when present (bash tool) for the circuit-breaker + auto-mode
136
+ * classifier. Other tools pass through unused — the contract is
137
+ * stable on purpose.
119
138
  * - `ctx` carries mode + session-scoped state.
120
139
  */
121
- export function gate(toolName,
122
- // Reserved for future pattern-based rules (always-allow `git status`).
123
- // Suppress unused-argument lint — the contract is stable on purpose.
124
- _args, ctx) {
140
+ export function gate(toolName, args, ctx) {
125
141
  const toolClass = getToolClass(toolName);
126
142
  const cache = ctx.alwaysCache;
143
+ const command = extractCommand(args, ctx.commandPreview);
144
+ // Bypass-mode circuit breaker: catastrophic patterns refuse FIRST,
145
+ // before any other routing. Same rule applies when the operator
146
+ // wired `dontAsk` and a destructive command sneaks through — defence
147
+ // in depth.
148
+ if ((ctx.permissionMode === 'bypassPermissions' || ctx.permissionMode === 'dontAsk')
149
+ && command) {
150
+ const breaker = evaluateCircuitBreaker(command);
151
+ if (breaker.tripped) {
152
+ return {
153
+ decision: 'deny',
154
+ reason: `Circuit-breaker tripped: ${breaker.reason}. Refused в ${ctx.permissionMode} mode regardless of policy.`,
155
+ circuitBreakerReason: breaker.reason,
156
+ };
157
+ }
158
+ }
127
159
  // Ask-mode session memory: an explicit "always-deny" beats any other
128
160
  // routing because the operator has actively refused this tool.
129
161
  if (cache?.alwaysDenied.has(toolName)) {
130
162
  return {
131
163
  decision: 'deny',
132
- reason: `Tool ${toolName} denied for the session via /permissions ask`,
164
+ reason: `Tool ${toolName} denied for the session via /permissions`,
133
165
  };
134
166
  }
135
- // "Always-allow" in ask mode skips the prompt for subsequent calls.
136
- // Plan mode IGNORES the always-allow cache because plan mode's
137
- // contract is structural (read-only), not consent-based.
138
- if (cache?.alwaysAllowed.has(toolName) && ctx.permissionMode === 'ask') {
167
+ // "Always-allow" в ask-style modes skips the prompt for subsequent
168
+ // calls. `plan` mode IGNORES the always-allow cache because plan
169
+ // mode's contract is structural (read-only), not consent-based.
170
+ // `bypassPermissions` / `dontAsk` already allow so the cache is moot.
171
+ // `acceptEdits` and `auto` honour the cache like `default` does.
172
+ if (cache?.alwaysAllowed.has(toolName)
173
+ && (ctx.permissionMode === 'default'
174
+ || ctx.permissionMode === 'acceptEdits'
175
+ || ctx.permissionMode === 'auto')) {
139
176
  return {
140
177
  decision: 'allow',
141
178
  reason: `Tool ${toolName} always-allowed for this session`,
@@ -148,36 +185,90 @@ _args, ctx) {
148
185
  }
149
186
  return {
150
187
  decision: 'deny',
151
- reason: `Plan mode: ${toolClass} tools blocked. Switch with /permissions allow.`,
188
+ reason: `Plan mode: ${toolClass} tools blocked. Switch with /permissions dontAsk.`,
152
189
  };
153
190
  }
154
- case 'ask': {
155
- return {
156
- decision: 'ask',
157
- reason: `Ask mode: prompt before ${toolName}`,
158
- question: buildAskQuestion(toolName, toolClass, ctx.target),
159
- options: ASK_OPTIONS,
160
- toolClass,
161
- };
191
+ case 'default': {
192
+ return askDecision(toolName, toolClass, ctx.target, 'default');
193
+ }
194
+ case 'acceptEdits': {
195
+ // File-edit surfaces auto-execute; everything else asks.
196
+ if (toolClass === 'read') {
197
+ return { decision: 'allow', reason: `acceptEdits: read allowed (${toolName})` };
198
+ }
199
+ if (toolClass === 'write' && ACCEPT_EDITS_AUTO_ALLOW.has(toolName)) {
200
+ return {
201
+ decision: 'allow',
202
+ reason: `acceptEdits: file-edit auto-allowed (${toolName})`,
203
+ };
204
+ }
205
+ return askDecision(toolName, toolClass, ctx.target, 'acceptEdits');
162
206
  }
163
- case 'allow': {
207
+ case 'auto': {
208
+ // Phase 1 classifier — regex allowlist / denylist для bash;
209
+ // other tools always fall back to ask. The classifier surfaces
210
+ // an explicit verdict so the spec layer can assert per-pattern.
211
+ if (toolName === 'bash' && command) {
212
+ const verdict = classifyAutoMode(command);
213
+ if (verdict.verdict === 'allow') {
214
+ return {
215
+ decision: 'allow',
216
+ reason: `auto-mode: ${verdict.reason}`,
217
+ };
218
+ }
219
+ if (verdict.verdict === 'deny') {
220
+ return {
221
+ decision: 'deny',
222
+ reason: `auto-mode: ${verdict.reason}. Switch with /permissions default to override.`,
223
+ };
224
+ }
225
+ // verdict === 'ask' — fall through.
226
+ }
227
+ return askDecision(toolName, toolClass, ctx.target, 'auto');
228
+ }
229
+ case 'dontAsk': {
164
230
  return {
165
231
  decision: 'allow',
166
- reason: `Allow mode: ${toolName} executed`,
232
+ reason: `dontAsk mode: ${toolName} executed (deny-list still applies)`,
167
233
  };
168
234
  }
169
- case 'bypass': {
235
+ case 'bypassPermissions': {
170
236
  return {
171
237
  decision: 'allow',
172
- reason: `Bypass mode: ${toolName} executed (policy hooks skipped)`,
238
+ reason: `bypassPermissions: ${toolName} executed (policy hooks skipped)`,
173
239
  hooksBypassed: true,
174
240
  };
175
241
  }
176
242
  }
177
243
  }
244
+ function askDecision(toolName, toolClass, target, mode) {
245
+ return {
246
+ decision: 'ask',
247
+ reason: `${mode} mode: prompt before ${toolName}`,
248
+ question: buildAskQuestion(toolName, toolClass, target),
249
+ options: ASK_OPTIONS,
250
+ toolClass,
251
+ };
252
+ }
253
+ /**
254
+ * Best-effort extract of the bash command string from the raw tool
255
+ * args. Returns `null` when the shape doesn't match — auto-mode and
256
+ * the circuit-breaker treat null как "no preview available" and fall
257
+ * back to safe defaults (ask / no-trip).
258
+ */
259
+ function extractCommand(args, fallback) {
260
+ if (typeof fallback === 'string' && fallback.length > 0)
261
+ return fallback;
262
+ if (args && typeof args === 'object') {
263
+ const maybe = args.command;
264
+ if (typeof maybe === 'string' && maybe.length > 0)
265
+ return maybe;
266
+ }
267
+ return null;
268
+ }
178
269
  /**
179
270
  * Build the operator-facing question string for an ask-mode prompt.
180
- * Kept in one place so the wording stays consistent across the REPL
271
+ * Kept в one place так the wording stays consistent across the REPL
181
272
  * Ink modal and the simpler stdin fallback.
182
273
  */
183
274
  function buildAskQuestion(toolName, toolClass, target) {
@@ -11,7 +11,9 @@
11
11
  * invisible to consumers — those files are an implementation detail
12
12
  * the engine adapter does not need to know about.
13
13
  */
14
- export { DEFAULT_PERMISSION_MODE, PERMISSION_MODE_GLOSS, PERMISSION_MODES, isPermissionMode, parsePermissionMode, toLegacyMode, } from './mode.js';
14
+ export { DEFAULT_PERMISSION_MODE, PERMISSION_MODE_GLOSS, PERMISSION_MODES, isPermissionMode, nextPermissionMode, parsePermissionMode, toLegacyMode, } from './mode.js';
15
+ export { classifyAutoMode, listAutoAllowPatterns, listAutoDenyPatterns, } from './auto-classifier.js';
16
+ export { evaluateCircuitBreaker, listCircuitBreakerPatterns, } from './circuit-breaker.js';
15
17
  export { getToolClass, listBuiltInToolClasses, } from './tool-class.js';
16
18
  export { ASK_OPTIONS, PermissionDenied, applyAskAnswer, createAskAlwaysCache, gate, } from './gate.js';
17
19
  export { getCurrentMode, getGlobalDefaultMode, getPreviousMode, globalConfigPath, resolveMode, sessionStatePath, setCurrentMode, setGlobalDefaultMode, setPreviousMode, } from './state.js';