@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.
- package/dist/core/compact/auto-trigger.js +96 -0
- package/dist/core/compact/buffer-rewriter.js +115 -0
- package/dist/core/compact/summarizer.js +196 -0
- package/dist/core/compact/token-counter.js +108 -0
- package/dist/core/denial-tracking/index.js +8 -0
- package/dist/core/denial-tracking/state.js +264 -0
- package/dist/core/diagnostics/probes/denial-tracking.js +57 -0
- package/dist/core/diagnostics/probes/status-snapshot.js +442 -0
- package/dist/core/engine/native-pugi.js +20 -0
- package/dist/core/engine/tool-bridge.js +153 -14
- package/dist/core/permissions/gate.js +187 -0
- package/dist/core/permissions/index.js +18 -0
- package/dist/core/permissions/mode.js +102 -0
- package/dist/core/permissions/state.js +160 -0
- package/dist/core/permissions/tool-class.js +93 -0
- package/dist/core/repl/session.js +227 -9
- package/dist/core/repl/slash-commands.js +58 -4
- package/dist/runtime/cli.js +129 -0
- package/dist/runtime/commands/compact.js +296 -0
- package/dist/runtime/commands/doctor.js +12 -0
- package/dist/runtime/commands/permissions.js +87 -0
- package/dist/runtime/commands/status.js +178 -0
- package/dist/runtime/version.js +1 -1
- package/dist/tui/compact-banner.js +54 -0
- package/dist/tui/status-table.js +7 -0
- package/package.json +2 -2
|
@@ -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
|
|
550
|
+
throw recordDenial(name, argsForTracking, `unknown tool: ${name}`);
|
|
485
551
|
}
|
|
486
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|