@pugi/cli 0.1.0-beta.36 → 0.1.0-beta.38

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.
@@ -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';
@@ -1,102 +1,174 @@
1
1
  /**
2
- * Permission modes — canonical 4-mode taxonomy (Leak L6).
2
+ * Permission modes — Wave 7 canonical 6-mode taxonomy (Claude Code parity).
3
3
  *
4
- * Pugi historically shipped a 6-mode taxonomy in `@pugi/sdk`
5
- * (`plan | ask | acceptEdits | auto | dontAsk | bypassPermissions`)
6
- * which the legacy `core/permission.ts` engine maps tools onto. Claude
7
- * Code, Codex, and the openclaude / openwork leaks all converge on a
8
- * smaller, sharper 4-mode set:
4
+ * Pugi α6 shipped a 4-mode taxonomy (`plan | ask | allow | bypass`) that
5
+ * proved fine для daily use but diverged from Claude Code's 6-mode
6
+ * surface (`default | acceptEdits | plan | auto | dontAsk |
7
+ * bypassPermissions`). Wave 7 Sprint 1 epic #2 closes the parity gap so
8
+ * operators coming from Claude Code see the same mode names + same
9
+ * Shift+Tab cycle.
9
10
  *
10
- * - `plan` — read-only proposal mode. Write/dispatch tools refused
11
- * with a deterministic sentinel; the model is expected
12
- * to surface a plan, not execute it.
13
- * - `ask` — every tool execution prompts the operator. Default
14
- * mode for new operators; the safe ground state.
15
- * - `allow` — every tool executes without per-call prompts, BUT
16
- * the policy hook layer (skill-steering, denial audit,
17
- * destructive deny-list) still fires.
18
- * - `bypass` — same as allow but ALSO skips policy hooks. Power-user
19
- * mode for trusted scripted runs; surface a banner on
20
- * entry so an operator who flips here by accident sees
21
- * they have disengaged the audit layer.
11
+ * Canonical 6 modes (Wave 7):
22
12
  *
23
- * This module owns the union type, the canonical default, and the
24
- * mode-resolution helper. The runtime gate (`gate.ts`) consumes it; the
25
- * legacy 6-mode SDK enum remains the system-of-record for bash-class
26
- * decisions inside `core/permission.ts` the canonical 4-mode layer
27
- * sits in front and short-circuits the dispatch decision before bash
28
- * classification ever runs.
13
+ * - `default` — every tool call asks the operator. Safe
14
+ * ground state, replaces α6 `ask`.
15
+ * - `acceptEdits` — auto-allow write/edit on workspace files,
16
+ * ask for everything else (bash, dispatch).
17
+ * Matches CC's "trust file edits только".
18
+ * - `plan` — read-only proposal mode. Write/dispatch
19
+ * refused with deterministic sentinel; the
20
+ * model surfaces a plan, не executes.
21
+ * - `auto` — classifier decides per-call. Phase 1
22
+ * (this PR) ships regex allowlist (safe
23
+ * commands) + regex denylist (destructive).
24
+ * Anything else falls back to ask.
25
+ * - `dontAsk` — auto-allow everything except `permissions.deny`
26
+ * list. Replaces α6 `allow`.
27
+ * - `bypassPermissions` — skip ALL checks including deny list +
28
+ * policy hooks. Has a circuit-breaker for
29
+ * catastrophic patterns (rm -rf /, fork bomb,
30
+ * dd if=/) that refuses regardless of mode.
31
+ * Replaces α6 `bypass`.
32
+ *
33
+ * Backwards-compat aliases: the α6 short names (`ask`, `allow`, `bypass`)
34
+ * map to the new canonical names via `MODE_ALIASES`. `parsePermissionMode`
35
+ * resolves aliases so existing session.json files keep working.
36
+ *
37
+ * Rename mapping (α6 → α7):
38
+ * ask → default
39
+ * allow → dontAsk
40
+ * bypass → bypassPermissions
41
+ * (plan, acceptEdits, auto unchanged / new)
29
42
  */
30
43
  /**
31
- * Closed list — useful for input validation and slash-command help.
44
+ * Closed list — used by Shift+Tab cycle (in order), input validation,
45
+ * and slash-command help. Order matches Claude Code's documented
46
+ * Shift+Tab progression: default → acceptEdits → plan → auto → dontAsk
47
+ * → bypassPermissions → wrap к default.
32
48
  */
33
49
  export const PERMISSION_MODES = Object.freeze([
50
+ 'default',
51
+ 'acceptEdits',
34
52
  'plan',
35
- 'ask',
36
- 'allow',
37
- 'bypass',
53
+ 'auto',
54
+ 'dontAsk',
55
+ 'bypassPermissions',
38
56
  ]);
39
57
  /**
40
58
  * Default mode applied when no `--mode` flag, no per-workspace session
41
- * state, and no `defaultPermissionMode` in `~/.pugi/config.json`. We
42
- * default cautious (`ask`) an operator who has not configured anything
43
- * is treated as a new operator who deserves visibility into every tool
44
- * call.
59
+ * state, and no `defaultPermissionMode` в `~/.pugi/config.json`. We
60
+ * default cautious (`default` mode = prompt every call) an operator
61
+ * who has not configured anything is treated as a new operator who
62
+ * deserves visibility into every tool call.
45
63
  */
46
- export const DEFAULT_PERMISSION_MODE = 'ask';
64
+ export const DEFAULT_PERMISSION_MODE = 'default';
47
65
  /**
48
- * Type guard for arbitrary string input (CLI flag, session.json
66
+ * Backwards-compat aliases: α6 short names map to α7 canonical names.
67
+ * `parsePermissionMode` consults this table так existing session.json
68
+ * files + scripts that pass `--mode ask` keep working without breaking.
69
+ *
70
+ * Aliases are one-way: persistence writes the canonical name, so a
71
+ * session that started on `ask` is migrated to `default` on next save.
72
+ */
73
+ const MODE_ALIASES = Object.freeze({
74
+ ask: 'default',
75
+ allow: 'dontAsk',
76
+ bypass: 'bypassPermissions',
77
+ });
78
+ /**
79
+ * Type guard для arbitrary string input (CLI flag, session.json
49
80
  * deserialization). Returns false for casing variants — caller is
50
- * expected to lowercase before testing.
81
+ * expected to lowercase before testing. Aliases are NOT accepted by
82
+ * this predicate; use `parsePermissionMode` for alias resolution.
51
83
  */
52
84
  export function isPermissionMode(value) {
53
85
  return typeof value === 'string' && PERMISSION_MODES.includes(value);
54
86
  }
55
87
  /**
56
- * Parse + validate a mode string. Returns null for invalid input so the
88
+ * Parse + validate a mode string. Returns null для invalid input so the
57
89
  * caller can surface a typed error (`unknown mode: <value>`) instead of
58
90
  * throwing from a parse helper.
91
+ *
92
+ * Resolves α6 aliases (`ask` → `default`, `allow` → `dontAsk`,
93
+ * `bypass` → `bypassPermissions`) for backwards compatibility.
94
+ *
95
+ * Case-handling: lowercases for canonical names but matches camelCase
96
+ * names case-insensitively too (так `acceptedits` resolves to
97
+ * `acceptEdits`). The aliases table covers the legacy lowercase tokens.
59
98
  */
60
99
  export function parsePermissionMode(value) {
61
- const lower = value.trim().toLowerCase();
62
- return isPermissionMode(lower) ? lower : null;
100
+ const trimmed = value.trim();
101
+ if (trimmed.length === 0)
102
+ return null;
103
+ // Direct canonical match first (preserves camelCase capitalisation).
104
+ if (isPermissionMode(trimmed))
105
+ return trimmed;
106
+ // Case-insensitive canonical match — operator typed `acceptedits`.
107
+ const lower = trimmed.toLowerCase();
108
+ const canonical = PERMISSION_MODES.find((m) => m.toLowerCase() === lower);
109
+ if (canonical)
110
+ return canonical;
111
+ // α6 alias fallthrough.
112
+ const aliased = MODE_ALIASES[lower];
113
+ if (aliased)
114
+ return aliased;
115
+ return null;
63
116
  }
64
117
  /**
65
- * Map the canonical 4-mode taxonomy to the legacy 6-mode SDK enum used
66
- * by `core/permission.ts::evaluateBashPermission` and friends. The map
67
- * is intentionally surjective on a narrower target the canonical
68
- * layer is the new public contract, the legacy layer is plumbing.
69
- *
70
- * plan -> 'plan' (read-only)
71
- * ask -> 'ask' (prompt every action)
72
- * allow -> 'auto' (allow non-destructive; deny destructive)
73
- * bypass -> 'bypassPermissions' (allow everything except destructive override)
118
+ * Wave 7 Shift+Tab cycle advance to the next mode in the canonical
119
+ * order, wrapping from the last back to the first. Pure helper so the
120
+ * TUI binding can call it without re-implementing the cycle logic.
121
+ */
122
+ export function nextPermissionMode(current) {
123
+ const idx = PERMISSION_MODES.indexOf(current);
124
+ if (idx === -1)
125
+ return DEFAULT_PERMISSION_MODE;
126
+ const next = PERMISSION_MODES[(idx + 1) % PERMISSION_MODES.length];
127
+ return next ?? DEFAULT_PERMISSION_MODE;
128
+ }
129
+ /**
130
+ * Map the canonical 6-mode taxonomy to the legacy SDK enum used by
131
+ * `@pugi/sdk::permissionModeSchema`. The SDK enum already contains all
132
+ * 6 names so the map is identity для the modes that align; the two
133
+ * α6-only legacy names (`ask`, `allow`) are not part of the canonical
134
+ * set anymore — `default` and `dontAsk` are их replacements.
74
135
  *
75
136
  * Callers that need the legacy enum (existing bash classifier, settings
76
- * persistence) should funnel through this helper so the mapping is in
77
- * one place.
137
+ * persistence) should funnel through this helper so the mapping stays
138
+ * в one place.
78
139
  */
79
140
  export function toLegacyMode(mode) {
80
141
  switch (mode) {
142
+ case 'default':
143
+ // SDK enum doesn't carry `default`; the closest legacy semantic is
144
+ // `ask` (prompt-every-call). Persistence layers that round-trip
145
+ // через the SDK enum get back `ask`, which `parsePermissionMode`
146
+ // re-maps to `default` via the alias table. Round-trip safe.
147
+ return 'ask';
148
+ case 'acceptEdits':
149
+ return 'acceptEdits';
81
150
  case 'plan':
82
151
  return 'plan';
83
- case 'ask':
84
- return 'ask';
85
- case 'allow':
152
+ case 'auto':
86
153
  return 'auto';
87
- case 'bypass':
154
+ case 'dontAsk':
155
+ return 'dontAsk';
156
+ case 'bypassPermissions':
88
157
  return 'bypassPermissions';
89
158
  }
90
159
  }
91
160
  /**
92
- * One-line human-readable summary surfaced by the `/permissions` table
93
- * and `pugi --help` text. Kept inline so the strings stay localizable
94
- * via a single edit point.
161
+ * One-line human-readable summary surfaced by the `/permissions` table,
162
+ * the Ink picker, and `pugi --help` text. Each line carries a safety
163
+ * hint ("safe-by-default" / "use carefully" / "power-user only") so an
164
+ * operator скимming the picker knows the risk profile at a glance.
95
165
  */
96
166
  export const PERMISSION_MODE_GLOSS = Object.freeze({
97
- plan: 'Read-only propose, never execute. Write + dispatch tools refused.',
98
- ask: 'Prompt before every tool call. Default for new operators.',
99
- allow: 'Execute tools without prompts. Policy hooks still fire.',
100
- bypass: 'Execute tools without prompts AND skip policy hooks. Power-user only.',
167
+ default: 'Prompt before every tool call. Safe-by-default for new operators.',
168
+ acceptEdits: 'Auto-allow file edit/write; ask for bash + dispatch. Safe-by-default.',
169
+ plan: 'Read-only propose, never execute. Write + dispatch refused.',
170
+ auto: 'Classifier decides per call (safe regex allowlist; falls back к ask). Use carefully.',
171
+ dontAsk: 'Execute tools without prompts; deny-list still applies. Use carefully.',
172
+ bypassPermissions: 'Skip ALL checks AND policy hooks; circuit-breaker on catastrophic patterns. Power-user only.',
101
173
  });
102
174
  //# sourceMappingURL=mode.js.map
@@ -22,8 +22,27 @@ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from '
22
22
  import { dirname, resolve } from 'node:path';
23
23
  import { homedir } from 'node:os';
24
24
  import { z } from 'zod';
25
- import { DEFAULT_PERMISSION_MODE, isPermissionMode, parsePermissionMode, } from './mode.js';
26
- const permissionModeEnum = z.enum(['plan', 'ask', 'allow', 'bypass']);
25
+ import { DEFAULT_PERMISSION_MODE, parsePermissionMode, } from './mode.js';
26
+ /**
27
+ * Wave 7: zod enum for the canonical 6-mode taxonomy. Includes α6
28
+ * aliases (`ask`, `allow`, `bypass`) as accepted input — Zod parses
29
+ * them, the helpers below remap к canonical names before returning к
30
+ * the caller. Persistence always writes the canonical name so the file
31
+ * migrates forward on next save.
32
+ */
33
+ const permissionModeEnum = z.enum([
34
+ // Canonical Wave 7 names.
35
+ 'default',
36
+ 'acceptEdits',
37
+ 'plan',
38
+ 'auto',
39
+ 'dontAsk',
40
+ 'bypassPermissions',
41
+ // α6 backwards-compat aliases — resolved via parsePermissionMode.
42
+ 'ask',
43
+ 'allow',
44
+ 'bypass',
45
+ ]);
27
46
  const sessionStateSchema = z
28
47
  .object({
29
48
  permissionMode: permissionModeEnum.optional(),
@@ -74,7 +93,12 @@ export function getCurrentMode(workspaceRoot) {
74
93
  try {
75
94
  const raw = readFileSync(path, 'utf8');
76
95
  const parsed = sessionStateSchema.parse(JSON.parse(raw));
77
- return isPermissionMode(parsed.permissionMode) ? parsed.permissionMode : null;
96
+ if (typeof parsed.permissionMode !== 'string')
97
+ return null;
98
+ // Wave 7: parsePermissionMode resolves α6 aliases (`ask`, `allow`,
99
+ // `bypass`) to their canonical Wave 7 names. A session file written
100
+ // by α6.x is silently upgraded on read.
101
+ return parsePermissionMode(parsed.permissionMode);
78
102
  }
79
103
  catch {
80
104
  return null;
@@ -112,9 +136,9 @@ export function getPreviousMode(workspaceRoot) {
112
136
  try {
113
137
  const raw = readFileSync(path, 'utf8');
114
138
  const parsed = sessionStateSchema.parse(JSON.parse(raw));
115
- return isPermissionMode(parsed.previousPermissionMode)
116
- ? parsed.previousPermissionMode
117
- : null;
139
+ if (typeof parsed.previousPermissionMode !== 'string')
140
+ return null;
141
+ return parsePermissionMode(parsed.previousPermissionMode);
118
142
  }
119
143
  catch {
120
144
  return null;
@@ -157,7 +181,9 @@ export function getGlobalDefaultMode(homeDir = homedir()) {
157
181
  try {
158
182
  const raw = readFileSync(path, 'utf8');
159
183
  const parsed = globalConfigSchema.parse(JSON.parse(raw));
160
- return isPermissionMode(parsed.defaultPermissionMode) ? parsed.defaultPermissionMode : null;
184
+ if (typeof parsed.defaultPermissionMode !== 'string')
185
+ return null;
186
+ return parsePermissionMode(parsed.defaultPermissionMode);
161
187
  }
162
188
  catch {
163
189
  return null;