@pugi/cli 0.1.0-beta.18 → 0.1.0-beta.19

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.
@@ -9,7 +9,9 @@ import { webSearchTool } from '../../tools/web-search.js';
9
9
  import { agentTool } from '../../tools/agent-tool.js';
10
10
  import { multiEdit } from '../../tools/multi-edit.js';
11
11
  import { buildMcpToolDefs, defaultNonInteractiveMcpPrompt, dispatchMcpTool, MCP_TOOL_PREFIX, } from '../../tools/mcp-tool.js';
12
+ import { buildDenialContext, DENIAL_REMINDER_THRESHOLD, } from '../denial-tracking/state.js';
12
13
  import { stripInternalFields } from './strip-internal-fields.js';
14
+ import { applyAskAnswer, gate as permissionGate, getToolClass, PermissionDenied, } from '../permissions/index.js';
13
15
  /**
14
16
  * Tool-bridge: turns the abstract tool registry into:
15
17
  * 1. An OpenAI-shaped tools schema for `EngineLoopClient.send`.
@@ -433,6 +435,26 @@ export function buildToolsSchema(kind, options = { allowFetch: false, allowSearc
433
435
  parameters: stripInternalFields(tool.parameters),
434
436
  }));
435
437
  }
438
+ /**
439
+ * α7 L11: tolerant args-parse for the denial fingerprint. Unlike
440
+ * `parseArgs` (which throws on malformed JSON so the model sees a
441
+ * parse error), this swallows failures and returns `{}` — the denial
442
+ * tracker needs SOME key even when the raw payload is unparseable,
443
+ * because malformed-args spam is itself a pattern operators want to
444
+ * see in `/permissions denials`.
445
+ */
446
+ function safeParseForTracking(raw) {
447
+ if (!raw || raw.trim() === '')
448
+ return {};
449
+ try {
450
+ return JSON.parse(raw);
451
+ }
452
+ catch {
453
+ // Use the raw string as the fingerprint payload so repeated
454
+ // identical malformed dispatches still cluster.
455
+ return { _rawArgs: raw.slice(0, 512) };
456
+ }
457
+ }
436
458
  function parseArgs(raw) {
437
459
  if (!raw || raw.trim() === '')
438
460
  return {};
@@ -469,10 +491,48 @@ function requireString(obj, key) {
469
491
  throw new Error(`tool argument "${key}" must be a string`);
470
492
  }
471
493
  export function buildExecutor(input) {
472
- const { kind, ctx, hooks, sessionId, askUserBridge, interactive, allowFetch, allowSearch, agentDispatch, mcpRegistry } = input;
494
+ const { kind, ctx, hooks, sessionId, askUserBridge, interactive, allowFetch, allowSearch, agentDispatch, mcpRegistry, permissionMode, permissionAlwaysCache, permissionAsk, } = input;
473
495
  const mcpPrompt = input.mcpPrompt ?? defaultNonInteractiveMcpPrompt;
474
496
  const workspaceRoot = input.workspaceRoot ?? ctx.root;
475
497
  const planMode = kind === 'plan';
498
+ const denialTracking = input.denialTracking;
499
+ // α7 L11: helper that records a denial (when tracking is wired) and
500
+ // ALWAYS returns an Error whose message includes a compact
501
+ // `<denial-context>` reminder when the same (tool, args) pair has
502
+ // already been refused at least once before in this session.
503
+ //
504
+ // The reminder is appended to the THROWN message — the engine loop
505
+ // appends thrown messages to the transcript as tool-result strings,
506
+ // so the model sees the aggregate the next time it considers a
507
+ // dispatch. Without this every retry would only see the latest
508
+ // single-turn reason and could loop indefinitely.
509
+ //
510
+ // Best-effort: a hash/clone failure inside the tracker MUST NOT
511
+ // mask the original refusal. The catch path falls back to a bare
512
+ // Error with the reason text.
513
+ const recordDenial = (toolName, args, reason) => {
514
+ if (!denialTracking)
515
+ return new Error(reason);
516
+ try {
517
+ const record = denialTracking.recordDenial(toolName, args, reason);
518
+ // Only inject the reminder once the threshold is hit — the very
519
+ // first denial is the model's first chance to learn, no need to
520
+ // shout. From the 2nd repeat onwards the model has demonstrated
521
+ // it is not learning from the single-turn sentinel, so we splice
522
+ // the aggregate context.
523
+ if (record.count >= DENIAL_REMINDER_THRESHOLD) {
524
+ const reminder = buildDenialContext(denialTracking);
525
+ if (reminder.length > 0) {
526
+ return new Error(`${reason}\n\n${reminder}`);
527
+ }
528
+ }
529
+ }
530
+ catch {
531
+ // Tracking is best-effort. Fall through to the bare Error so
532
+ // the refusal still propagates.
533
+ }
534
+ return new Error(reason);
535
+ };
476
536
  return async ({ name, arguments: argsRaw }) => {
477
537
  // β4 M1/M3: MCP tool names live outside WIRED_TOOLS. They are
478
538
  // validated lazily by the dispatcher (the registry knows which
@@ -480,15 +540,64 @@ export function buildExecutor(input) {
480
540
  // so a bad `mcp__bogus__foo` does not collide with the native
481
541
  // unknown-tool branch.
482
542
  const isMcpName = name.startsWith(MCP_TOOL_PREFIX);
543
+ // α7 L11: parse-or-empty args once up-front so every deny path
544
+ // below can fingerprint the call against the denial tracker. We
545
+ // tolerate parse failure — `{}` keys still produce a stable hash
546
+ // (the model may have sent malformed JSON, but the refusal is
547
+ // semantic, not parse-driven).
548
+ const argsForTracking = safeParseForTracking(argsRaw);
483
549
  if (!isMcpName && !WIRED_TOOLS.has(name)) {
484
- throw new Error(`unknown tool: ${name}`);
550
+ throw recordDenial(name, argsForTracking, `unknown tool: ${name}`);
485
551
  }
486
- if (planMode) {
552
+ // Leak L6 — canonical 4-mode permission gate. Routes the dispatch
553
+ // decision BEFORE the legacy plan-mode-only enforcement so the new
554
+ // surface is the source of truth when the caller opted in. Absent
555
+ // `permissionMode` falls through to the legacy plan-mode branch
556
+ // (existing semantics preserved for callsites that have not
557
+ // migrated yet).
558
+ let hooksBypassed = false;
559
+ if (permissionMode) {
560
+ const decision = permissionGate(name, argsRaw, {
561
+ permissionMode,
562
+ ...(permissionAlwaysCache ? { alwaysCache: permissionAlwaysCache } : {}),
563
+ });
564
+ if (decision.decision === 'deny') {
565
+ throw new PermissionDenied(name, getToolClass(name), permissionMode, decision.reason);
566
+ }
567
+ if (decision.decision === 'ask') {
568
+ if (!permissionAsk) {
569
+ // Non-interactive caller (CI / pipes / agent-as-tool) cannot
570
+ // surface a prompt. Collapse to deny so the loop receives a
571
+ // deterministic refusal instead of hanging.
572
+ throw new PermissionDenied(name, decision.toolClass, permissionMode, `Ask mode: no operator prompt available for ${name} (non-interactive caller)`);
573
+ }
574
+ const answer = await permissionAsk({
575
+ toolName: name,
576
+ toolClass: decision.toolClass,
577
+ question: decision.question,
578
+ options: decision.options,
579
+ });
580
+ const verdict = permissionAlwaysCache
581
+ ? applyAskAnswer(permissionAlwaysCache, name, answer)
582
+ : applyAskAnswer({ alwaysAllowed: new Set(), alwaysDenied: new Set() }, name, answer);
583
+ if (verdict.decision === 'deny') {
584
+ throw new PermissionDenied(name, decision.toolClass, permissionMode, verdict.reason);
585
+ }
586
+ // verdict.decision === 'allow' falls through to dispatch.
587
+ }
588
+ else {
589
+ // allow — honour the bypass flag for the hook layer below.
590
+ hooksBypassed = decision.hooksBypassed === true;
591
+ }
592
+ }
593
+ else if (planMode) {
594
+ // Legacy plan-mode enforcement (kind === 'plan') stays in place
595
+ // for callers that have not opted into the canonical gate.
487
596
  // MCP tools are uniformly refused in plan mode (see schema-side
488
597
  // rationale in buildToolsSchema). Native tools split via
489
598
  // READ_ONLY_TOOLS as before.
490
599
  if (isMcpName || !READ_ONLY_TOOLS.has(name)) {
491
- throw new Error(`PLAN_MODE_REFUSED: ${name} is not allowed in plan mode`);
600
+ throw recordDenial(name, argsForTracking, `PLAN_MODE_REFUSED: ${name} is not allowed in plan mode`);
492
601
  }
493
602
  }
494
603
  // α6.9: refuse cancelled-token tool dispatch BEFORE PreToolUse
@@ -497,13 +606,18 @@ export function buildExecutor(input) {
497
606
  // by `runEngineLoop` as a terminal-cancel signal so the loop
498
607
  // returns control to the caller rather than retrying the model.
499
608
  if (ctx.cancellation && ctx.cancellation.isAborted) {
500
- throw new Error(`OPERATOR_ABORTED: ${name} refused — operator cancelled the dispatch.`);
609
+ throw recordDenial(name, argsForTracking, `OPERATOR_ABORTED: ${name} refused — operator cancelled the dispatch.`);
501
610
  }
502
611
  // Fire PreToolUse hooks. The match grammar takes the tool name and
503
612
  // (when extractable) the target path. Each new tool dispatch starts a
504
613
  // fresh dedup batch so a hook fires once per dispatch, not once per
505
614
  // session.
506
- if (hooks && sessionId) {
615
+ //
616
+ // Leak L6 — bypass mode skips the entire hook layer (PreToolUse +
617
+ // PostToolUse + PostToolUseFailure). The gate's allow decision
618
+ // carries the `hooksBypassed` flag; we honour it here so the
619
+ // executor stays single-pass.
620
+ if (hooks && sessionId && !hooksBypassed) {
507
621
  hooks.resetBatch();
508
622
  const path = extractToolPath(name, argsRaw);
509
623
  const preCtx = {
@@ -523,7 +637,11 @@ export function buildExecutor(input) {
523
637
  const hook = matchingPreHooks[i];
524
638
  const result = preResults[i];
525
639
  if (hook && result && hook.onFailure === 'block' && !result.ok) {
526
- throw new Error(`HOOK_BLOCKED: PreToolUse hook (${hook.run.slice(0, 80)}) refused ${name} (exit=${result.exitCode})`);
640
+ // α7 L11: record the PreToolUse hook denial so the model
641
+ // sees the pattern reminder on subsequent turns. Without
642
+ // this the model would re-issue the same refused call and
643
+ // burn a turn each time before noticing the loop.
644
+ throw recordDenial(name, argsForTracking, `HOOK_BLOCKED: PreToolUse hook (${hook.run.slice(0, 80)}) refused ${name} (exit=${result.exitCode})`);
527
645
  }
528
646
  }
529
647
  }
@@ -580,7 +698,7 @@ export function buildExecutor(input) {
580
698
  // model spawn a write-capable child and break the read-only
581
699
  // contract.
582
700
  if (planMode) {
583
- throw new Error('PLAN_MODE_REFUSED: agent is not allowed in plan mode');
701
+ throw recordDenial(name, argsForTracking, 'PLAN_MODE_REFUSED: agent is not allowed in plan mode');
584
702
  }
585
703
  return dispatchAgent(args, agentDispatch);
586
704
  }
@@ -588,7 +706,7 @@ export function buildExecutor(input) {
588
706
  };
589
707
  try {
590
708
  const result = await dispatch();
591
- if (hooks && sessionId) {
709
+ if (hooks && sessionId && !hooksBypassed) {
592
710
  const path = extractToolPath(name, argsRaw);
593
711
  await hooks.fire({
594
712
  sessionId,
@@ -601,6 +719,27 @@ export function buildExecutor(input) {
601
719
  return result;
602
720
  }
603
721
  catch (error) {
722
+ // Leak L6 — surface the PermissionDenied sentinel as a model-
723
+ // readable message instead of leaking the raw Error type. The
724
+ // string format is stable so the engine adapter / spec layer
725
+ // can pattern-match against it.
726
+ if (error instanceof PermissionDenied) {
727
+ // PostToolUseFailure fires for visibility unless bypass is on.
728
+ if (hooks && sessionId && !hooksBypassed) {
729
+ await hooks.fire({
730
+ sessionId,
731
+ event: 'PostToolUseFailure',
732
+ tool: name,
733
+ payload: {
734
+ tool: name,
735
+ arguments: argsRaw,
736
+ ok: false,
737
+ error: error.toModelMessage(),
738
+ },
739
+ });
740
+ }
741
+ throw new Error(error.toModelMessage());
742
+ }
604
743
  // α6.9: re-shape OperatorAbortedError throws from the
605
744
  // file-tools layer into the same `OPERATOR_ABORTED:` sentinel
606
745
  // the upstream cancellation gate uses so `runEngineLoop` sees
@@ -608,7 +747,7 @@ export function buildExecutor(input) {
608
747
  // the abort landed pre-dispatch or mid-tool (e.g. inside the
609
748
  // grep file-loop).
610
749
  if (error instanceof OperatorAbortedError) {
611
- if (hooks && sessionId) {
750
+ if (hooks && sessionId && !hooksBypassed) {
612
751
  const path = extractToolPath(name, argsRaw);
613
752
  await hooks.fire({
614
753
  sessionId,
@@ -623,7 +762,7 @@ export function buildExecutor(input) {
623
762
  },
624
763
  });
625
764
  }
626
- throw new Error(`OPERATOR_ABORTED: ${name} aborted mid-execution.`);
765
+ throw recordDenial(name, argsForTracking, `OPERATOR_ABORTED: ${name} aborted mid-execution.`);
627
766
  }
628
767
  // Leak L1 (2026-05-27): re-shape StaleReadError into a
629
768
  // deterministic STALE_READ:<reason> sentinel so the model's
@@ -634,7 +773,7 @@ export function buildExecutor(input) {
634
773
  // operator can build a "warn me when stale edits keep happening"
635
774
  // hook (likely a concurrency / multi-agent indicator).
636
775
  if (error instanceof StaleReadError) {
637
- if (hooks && sessionId) {
776
+ if (hooks && sessionId && !hooksBypassed) {
638
777
  const path = extractToolPath(name, argsRaw);
639
778
  await hooks.fire({
640
779
  sessionId,
@@ -649,9 +788,9 @@ export function buildExecutor(input) {
649
788
  },
650
789
  });
651
790
  }
652
- throw new Error(`STALE_READ: ${name} on ${error.path} refused (${error.reason}). Re-read the file with the \`read\` tool, then retry the ${name}.`);
791
+ throw recordDenial(name, argsForTracking, `STALE_READ: ${name} on ${error.path} refused (${error.reason}). Re-read the file with the \`read\` tool, then retry the ${name}.`);
653
792
  }
654
- if (hooks && sessionId) {
793
+ if (hooks && sessionId && !hooksBypassed) {
655
794
  const path = extractToolPath(name, argsRaw);
656
795
  await hooks.fire({
657
796
  sessionId,
@@ -0,0 +1,187 @@
1
+ /**
2
+ * Permission gate — Leak L6 canonical 4-mode enforcement.
3
+ *
4
+ * Single dispatch entry point. Every tool call goes through `gate()`
5
+ * before the executor runs the tool body; the executor surfaces the
6
+ * `PermissionDenied` error as a model-readable sentinel so the model
7
+ * can either reformulate the request or wait for the operator to
8
+ * change the mode.
9
+ *
10
+ * Routing matrix (mode × class):
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)
17
+ *
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).
22
+ *
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.
28
+ */
29
+ import { getToolClass } from './tool-class.js';
30
+ export const ASK_OPTIONS = Object.freeze([
31
+ 'allow-once',
32
+ 'always-this-tool',
33
+ 'deny-once',
34
+ 'always-deny-this-tool',
35
+ ]);
36
+ export function createAskAlwaysCache() {
37
+ return {
38
+ alwaysAllowed: new Set(),
39
+ alwaysDenied: new Set(),
40
+ };
41
+ }
42
+ /**
43
+ * 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`.
47
+ *
48
+ * `always-*` answers persist to the cache and short-circuit the next
49
+ * gate call for the same tool name within the same session.
50
+ */
51
+ export function applyAskAnswer(cache, toolName, answer) {
52
+ switch (answer) {
53
+ case 'allow-once':
54
+ return { decision: 'allow', reason: `Allowed once for ${toolName}` };
55
+ case 'always-this-tool':
56
+ cache.alwaysAllowed.add(toolName);
57
+ cache.alwaysDenied.delete(toolName);
58
+ return { decision: 'allow', reason: `Allowed for ${toolName} this session` };
59
+ case 'deny-once':
60
+ return { decision: 'deny', reason: `Denied once for ${toolName}` };
61
+ case 'always-deny-this-tool':
62
+ cache.alwaysDenied.add(toolName);
63
+ cache.alwaysAllowed.delete(toolName);
64
+ return { decision: 'deny', reason: `Denied for ${toolName} this session` };
65
+ }
66
+ }
67
+ /**
68
+ * Permission-denied sentinel. Distinguishable from other tool errors
69
+ * (parse errors, IO failures) so the caller can route the message back
70
+ * to the model with the canonical recovery hint.
71
+ */
72
+ export class PermissionDenied extends Error {
73
+ name = 'PermissionDenied';
74
+ mode;
75
+ toolName;
76
+ toolClass;
77
+ /**
78
+ * Human-friendly reason surfaced in logs / hook payloads. Distinct
79
+ * from `message` so the spec layer can pattern-match the canonical
80
+ * `PERMISSION_DENIED:` sentinel verbatim while operators see the
81
+ * full explanation in console output.
82
+ */
83
+ reason;
84
+ constructor(toolName, toolClass, mode, reason) {
85
+ // The base Error.message is the canonical sentinel so default
86
+ // toString() / re-throw paths preserve the format the model and
87
+ // the spec layer pattern-match against.
88
+ super(`PERMISSION_DENIED: ${toolName} blocked in ${mode} mode. Operator can switch with /permissions <mode>.`);
89
+ this.mode = mode;
90
+ this.toolName = toolName;
91
+ this.toolClass = toolClass;
92
+ this.reason = reason;
93
+ }
94
+ /**
95
+ * Render the sentinel message the executor surfaces to the model.
96
+ * The string format is stable so a parent agent / E2E spec can
97
+ * pattern-match `PERMISSION_DENIED: <tool> blocked in <mode> mode.`
98
+ * verbatim. Equivalent to `this.message`; kept as a method so
99
+ * downstream callers can use whichever spelling reads better at the
100
+ * site.
101
+ */
102
+ toModelMessage() {
103
+ return this.message;
104
+ }
105
+ }
106
+ /**
107
+ * Core dispatch gate. Pure function — no IO, no side effects beyond
108
+ * mutating the caller-supplied `alwaysCache`. Safe to call from any
109
+ * layer (engine adapter, agent-as-tool bridge, doctor command).
110
+ *
111
+ * Argument bag mirrors the executor entry shape:
112
+ * - `toolName` is the registered tool key (e.g. `read`, `write`,
113
+ * `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.
119
+ * - `ctx` carries mode + session-scoped state.
120
+ */
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) {
125
+ const toolClass = getToolClass(toolName);
126
+ const cache = ctx.alwaysCache;
127
+ // Ask-mode session memory: an explicit "always-deny" beats any other
128
+ // routing because the operator has actively refused this tool.
129
+ if (cache?.alwaysDenied.has(toolName)) {
130
+ return {
131
+ decision: 'deny',
132
+ reason: `Tool ${toolName} denied for the session via /permissions ask`,
133
+ };
134
+ }
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') {
139
+ return {
140
+ decision: 'allow',
141
+ reason: `Tool ${toolName} always-allowed for this session`,
142
+ };
143
+ }
144
+ switch (ctx.permissionMode) {
145
+ case 'plan': {
146
+ if (toolClass === 'read') {
147
+ return { decision: 'allow', reason: `Plan mode: read tools allowed (${toolName})` };
148
+ }
149
+ return {
150
+ decision: 'deny',
151
+ reason: `Plan mode: ${toolClass} tools blocked. Switch with /permissions allow.`,
152
+ };
153
+ }
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
+ };
162
+ }
163
+ case 'allow': {
164
+ return {
165
+ decision: 'allow',
166
+ reason: `Allow mode: ${toolName} executed`,
167
+ };
168
+ }
169
+ case 'bypass': {
170
+ return {
171
+ decision: 'allow',
172
+ reason: `Bypass mode: ${toolName} executed (policy hooks skipped)`,
173
+ hooksBypassed: true,
174
+ };
175
+ }
176
+ }
177
+ }
178
+ /**
179
+ * Build the operator-facing question string for an ask-mode prompt.
180
+ * Kept in one place so the wording stays consistent across the REPL
181
+ * Ink modal and the simpler stdin fallback.
182
+ */
183
+ function buildAskQuestion(toolName, toolClass, target) {
184
+ const suffix = target ? ` on ${target}` : '';
185
+ return `Allow ${toolName} (${toolClass})${suffix}?`;
186
+ }
187
+ //# sourceMappingURL=gate.js.map
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Permission gate (Leak L6) public surface.
3
+ *
4
+ * Re-exports the canonical 4-mode types, the tool-class classifier,
5
+ * the dispatch gate, and the workspace + global session-state helpers
6
+ * so callers import from one place:
7
+ *
8
+ * import { gate, resolveMode, PermissionDenied } from '<...>/permissions/index.js';
9
+ *
10
+ * Keeps the internal file split (mode / tool-class / gate / state)
11
+ * invisible to consumers — those files are an implementation detail
12
+ * the engine adapter does not need to know about.
13
+ */
14
+ export { DEFAULT_PERMISSION_MODE, PERMISSION_MODE_GLOSS, PERMISSION_MODES, isPermissionMode, parsePermissionMode, toLegacyMode, } from './mode.js';
15
+ export { getToolClass, listBuiltInToolClasses, } from './tool-class.js';
16
+ export { ASK_OPTIONS, PermissionDenied, applyAskAnswer, createAskAlwaysCache, gate, } from './gate.js';
17
+ export { getCurrentMode, getGlobalDefaultMode, globalConfigPath, resolveMode, sessionStatePath, setCurrentMode, setGlobalDefaultMode, } from './state.js';
18
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Permission modes — canonical 4-mode taxonomy (Leak L6).
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:
9
+ *
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.
22
+ *
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.
29
+ */
30
+ /**
31
+ * Closed list — useful for input validation and slash-command help.
32
+ */
33
+ export const PERMISSION_MODES = Object.freeze([
34
+ 'plan',
35
+ 'ask',
36
+ 'allow',
37
+ 'bypass',
38
+ ]);
39
+ /**
40
+ * 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.
45
+ */
46
+ export const DEFAULT_PERMISSION_MODE = 'ask';
47
+ /**
48
+ * Type guard for arbitrary string input (CLI flag, session.json
49
+ * deserialization). Returns false for casing variants — caller is
50
+ * expected to lowercase before testing.
51
+ */
52
+ export function isPermissionMode(value) {
53
+ return typeof value === 'string' && PERMISSION_MODES.includes(value);
54
+ }
55
+ /**
56
+ * Parse + validate a mode string. Returns null for invalid input so the
57
+ * caller can surface a typed error (`unknown mode: <value>`) instead of
58
+ * throwing from a parse helper.
59
+ */
60
+ export function parsePermissionMode(value) {
61
+ const lower = value.trim().toLowerCase();
62
+ return isPermissionMode(lower) ? lower : null;
63
+ }
64
+ /**
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)
74
+ *
75
+ * 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.
78
+ */
79
+ export function toLegacyMode(mode) {
80
+ switch (mode) {
81
+ case 'plan':
82
+ return 'plan';
83
+ case 'ask':
84
+ return 'ask';
85
+ case 'allow':
86
+ return 'auto';
87
+ case 'bypass':
88
+ return 'bypassPermissions';
89
+ }
90
+ }
91
+ /**
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.
95
+ */
96
+ 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.',
101
+ });
102
+ //# sourceMappingURL=mode.js.map