@yagni-app/code 1.0.5 → 1.0.7
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/README.md +89 -27
- package/dist/claudeCompat.d.ts +59 -0
- package/dist/claudeCompat.js +109 -2
- package/dist/claudePlugins.d.ts +45 -3
- package/dist/claudePlugins.js +129 -19
- package/dist/cli.js +28 -4
- package/dist/doctor.d.ts +42 -4
- package/dist/doctor.js +150 -15
- package/dist/extension/condensedTools.d.ts +12 -1
- package/dist/extension/condensedTools.js +17 -9
- package/dist/extension/index.d.ts +18 -5
- package/dist/extension/index.js +319 -72
- package/dist/extension/mcp/approval.d.ts +45 -0
- package/dist/extension/mcp/approval.js +164 -0
- package/dist/extension/mcp/auth.d.ts +124 -0
- package/dist/extension/mcp/auth.js +560 -0
- package/dist/extension/mcp/authStore.d.ts +61 -0
- package/dist/extension/mcp/authStore.js +105 -0
- package/dist/extension/mcp/callbackPage.d.ts +31 -0
- package/dist/extension/mcp/callbackPage.js +222 -0
- package/dist/extension/mcp/cliConfig.d.ts +12 -0
- package/dist/extension/mcp/cliConfig.js +12 -0
- package/dist/extension/mcp/config.d.ts +153 -0
- package/dist/extension/mcp/config.js +381 -0
- package/dist/extension/mcp/log.d.ts +28 -0
- package/dist/extension/mcp/log.js +82 -0
- package/dist/extension/mcp/manager.d.ts +100 -0
- package/dist/extension/mcp/manager.js +273 -0
- package/dist/extension/mcp/names.d.ts +25 -0
- package/dist/extension/mcp/names.js +40 -0
- package/dist/extension/mcp/panel.d.ts +33 -0
- package/dist/extension/mcp/panel.js +268 -0
- package/dist/extension/mcp/prompts.d.ts +23 -0
- package/dist/extension/mcp/prompts.js +93 -0
- package/dist/extension/mcp/startup.d.ts +55 -0
- package/dist/extension/mcp/startup.js +152 -0
- package/dist/extension/mcp/tools.d.ts +31 -0
- package/dist/extension/mcp/tools.js +117 -0
- package/dist/extension/mcp/transports.d.ts +17 -0
- package/dist/extension/mcp/transports.js +44 -0
- package/dist/extension/permission/gate.d.ts +104 -2
- package/dist/extension/permission/gate.js +387 -31
- package/dist/extension/permission/guardian.d.ts +24 -5
- package/dist/extension/permission/guardian.js +162 -24
- package/dist/extension/permissionRules/bashFileArgs.d.ts +39 -0
- package/dist/extension/permissionRules/bashFileArgs.js +236 -0
- package/dist/extension/permissionRules/engine.d.ts +50 -0
- package/dist/extension/permissionRules/engine.js +238 -0
- package/dist/extension/permissionRules/loadConfig.d.ts +53 -0
- package/dist/extension/permissionRules/loadConfig.js +90 -0
- package/dist/extension/permissionRules/parser.d.ts +38 -0
- package/dist/extension/permissionRules/parser.js +136 -0
- package/dist/extension/permissionRules/pathRules.d.ts +58 -0
- package/dist/extension/permissionRules/pathRules.js +120 -0
- package/dist/extension/permissionRules/shellRules.d.ts +52 -0
- package/dist/extension/permissionRules/shellRules.js +221 -0
- package/dist/extension/pipeline/invocation.d.ts +3 -6
- package/dist/extension/pipeline/invocation.js +3 -6
- package/dist/extension/pipeline/personas.js +5 -0
- package/dist/extension/pipeline/runner.d.ts +0 -1
- package/dist/extension/pipeline/runner.js +6 -14
- package/dist/extension/plugins/inventory.d.ts +88 -0
- package/dist/extension/plugins/inventory.js +144 -0
- package/dist/extension/plugins/panel.d.ts +45 -0
- package/dist/extension/plugins/panel.js +293 -0
- package/dist/extension/sandbox/bash.d.ts +99 -0
- package/dist/extension/sandbox/bash.js +190 -0
- package/dist/extension/sandbox/config.d.ts +114 -0
- package/dist/extension/sandbox/config.js +366 -0
- package/dist/extension/sandbox/manager.d.ts +98 -0
- package/dist/extension/sandbox/manager.js +216 -0
- package/dist/extension/sandbox/panel.d.ts +111 -0
- package/dist/extension/sandbox/panel.js +342 -0
- package/dist/extension/sandbox/session.d.ts +85 -0
- package/dist/extension/sandbox/session.js +775 -0
- package/dist/extension/telemetry/attrs.d.ts +96 -0
- package/dist/extension/telemetry/attrs.js +149 -0
- package/dist/extension/telemetry/config.d.ts +99 -0
- package/dist/extension/telemetry/config.js +193 -0
- package/dist/extension/telemetry/index.d.ts +7 -0
- package/dist/extension/telemetry/index.js +7 -0
- package/dist/extension/telemetry/probe.d.ts +29 -0
- package/dist/extension/telemetry/probe.js +122 -0
- package/dist/extension/telemetry/register.d.ts +40 -0
- package/dist/extension/telemetry/register.js +192 -0
- package/dist/extension/telemetry/sdk.d.ts +63 -0
- package/dist/extension/telemetry/sdk.js +207 -0
- package/dist/extension/telemetry/tracker.d.ts +131 -0
- package/dist/extension/telemetry/tracker.js +551 -0
- package/dist/extension/vendor/IGNORE-LICENSE-MIT +21 -0
- package/dist/extension/vendor/ignore.d.ts +86 -0
- package/dist/extension/vendor/ignore.js +788 -0
- package/dist/goHeadless.d.ts +1 -1
- package/dist/goHeadless.js +2 -2
- package/dist/launch.d.ts +4 -3
- package/dist/launch.js +7 -4
- package/dist/mcpCommand.d.ts +122 -0
- package/dist/mcpCommand.js +787 -0
- package/dist/otel.d.ts +69 -63
- package/dist/otel.js +154 -119
- package/dist/paths.d.ts +13 -0
- package/dist/paths.js +18 -0
- package/dist/pluginCommand.d.ts +43 -0
- package/dist/pluginCommand.js +499 -0
- package/dist/pluginStore.d.ts +170 -0
- package/dist/pluginStore.js +554 -0
- package/dist/upgrade.d.ts +11 -2
- package/dist/upgrade.js +48 -8
- package/package.json +20 -3
- package/dist/extension/mcpTools.d.ts +0 -57
- package/dist/extension/mcpTools.js +0 -132
|
@@ -27,8 +27,10 @@
|
|
|
27
27
|
* the context so the model doesn't keep believing it is restricted.
|
|
28
28
|
*/
|
|
29
29
|
import { describePrefix, matchesGrant, validateGrant, } from "./approvedPrefixes.js";
|
|
30
|
+
import { logEvent } from "../errorSink.js";
|
|
30
31
|
import { makeBlessStore as defaultMakeBlessStore } from "../bless.js";
|
|
31
32
|
import { classifyCommand, DEFAULT_EXEC_POLICY } from "./execPolicy.js";
|
|
33
|
+
import { evaluateRules } from "../permissionRules/engine.js";
|
|
32
34
|
import { isDebug } from "../diagnostics.js";
|
|
33
35
|
import { buildDiagnosticEvent, checkCircuitBreaker, DEFAULT_GUARDIAN_LIMITS, } from "./guardian.js";
|
|
34
36
|
export function createModeHolder(initial = "auto") {
|
|
@@ -49,6 +51,52 @@ export const DEFAULT_PERMISSION_POLICY = {
|
|
|
49
51
|
reviewConfirmTools: ["write", "edit", "bash", "file_ticket", "update_ticket_status"],
|
|
50
52
|
alwaysConfirmTools: ["file_ticket", "update_ticket_status"],
|
|
51
53
|
};
|
|
54
|
+
export function allowRuleFloorVerdict(toolName, params, mode, policy) {
|
|
55
|
+
// Floor 0 — plan mode: the mode's contract (no mutations without Guardian
|
|
56
|
+
// review; grants are already skipped in plan) applies BEFORE any allow
|
|
57
|
+
// rule. Side-effect tools route to the existing plan-mode gate below; a
|
|
58
|
+
// non-side-effect tool (read/grep/ask_yagni/…) allows — plan mode never
|
|
59
|
+
// held reads. Deny/ask rules fired before this point (they only restrict).
|
|
60
|
+
if (mode === "plan" && sideEffectToolsFor(policy).has(toolName)) {
|
|
61
|
+
return { kind: "hold" };
|
|
62
|
+
}
|
|
63
|
+
// Floor 1 — the exec-policy forbidden band (bash only). An allow rule can
|
|
64
|
+
// never un-forbid a destructive command.
|
|
65
|
+
if (toolName === "bash") {
|
|
66
|
+
const cmdRaw = params.command;
|
|
67
|
+
const command = typeof cmdRaw === "string" ? cmdRaw.trim() : "";
|
|
68
|
+
if (command) {
|
|
69
|
+
try {
|
|
70
|
+
const execPolicy = policy.execPolicy ?? DEFAULT_EXEC_POLICY;
|
|
71
|
+
const classification = classifyCommand(command, execPolicy);
|
|
72
|
+
if (classification.decision === "forbidden") {
|
|
73
|
+
return {
|
|
74
|
+
kind: "block",
|
|
75
|
+
reason: `${classification.justification}. Do not attempt the same outcome via a workaround or indirect execution — use a materially safer alternative, or ask the user.`,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
// classifyCommand threw — no floor opinion from here (the normal gate
|
|
81
|
+
// path re-runs classification with its own fail-closed handling).
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
// Floor 2 — alwaysConfirmTools keeps its fresh-consent contract
|
|
86
|
+
// (file_ticket / update_ticket_status) in every mode.
|
|
87
|
+
if (policy.alwaysConfirmTools?.includes(toolName)) {
|
|
88
|
+
return { kind: "confirm" };
|
|
89
|
+
}
|
|
90
|
+
return { kind: "allow" };
|
|
91
|
+
}
|
|
92
|
+
/** The side-effect tool set for a policy (plan-mode hold decision). */
|
|
93
|
+
function sideEffectToolsFor(policy) {
|
|
94
|
+
return new Set([
|
|
95
|
+
...policy.planBlockTools,
|
|
96
|
+
...policy.reviewConfirmTools,
|
|
97
|
+
...(policy.alwaysConfirmTools ?? []),
|
|
98
|
+
]);
|
|
99
|
+
}
|
|
52
100
|
/**
|
|
53
101
|
* Pure permission decision for one tool call under a mode + policy. Auto allows
|
|
54
102
|
* ordinary tools; plan blocks the write/exec set; review marks writes for confirmation
|
|
@@ -92,7 +140,7 @@ export function decideGate(toolName, params, mode, policy) {
|
|
|
92
140
|
}
|
|
93
141
|
return { block: false };
|
|
94
142
|
}
|
|
95
|
-
if (policy.planBlockTools.includes(toolName)) {
|
|
143
|
+
if (policy.planBlockTools.includes(toolName) || policy.isMutating?.(toolName)) {
|
|
96
144
|
return {
|
|
97
145
|
block: true,
|
|
98
146
|
reason: `plan mode: ${toolName} is a write or exec action and is held. Switch to /mode auto to apply changes.`,
|
|
@@ -142,7 +190,7 @@ export function decideGate(toolName, params, mode, policy) {
|
|
|
142
190
|
if (mode === "auto")
|
|
143
191
|
return { block: false };
|
|
144
192
|
// review
|
|
145
|
-
if (policy.reviewConfirmTools.includes(toolName)) {
|
|
193
|
+
if (policy.reviewConfirmTools.includes(toolName) || policy.isMutating?.(toolName)) {
|
|
146
194
|
if (policy.isBlessed?.(toolName, params))
|
|
147
195
|
return { block: false };
|
|
148
196
|
return { block: false, confirm: true };
|
|
@@ -288,6 +336,56 @@ function guardianErrorMessage(error) {
|
|
|
288
336
|
default: return "unknown error";
|
|
289
337
|
}
|
|
290
338
|
}
|
|
339
|
+
/**
|
|
340
|
+
* The pi tool_call handler wiring (crash-honest attribution). Extracted so
|
|
341
|
+
* the telemetry contract is testable against the production path: a thrown
|
|
342
|
+
* gate records reject — never accept — logs a gate_crashed line (metadata
|
|
343
|
+
* only), and rethrows so pi's handling of a crashed handler is unchanged.
|
|
344
|
+
*/
|
|
345
|
+
export function wireToolCallGate(pi, run, onToolDecision) {
|
|
346
|
+
pi.on("tool_call", async (event, ctx) => {
|
|
347
|
+
const slot = { source: "config" };
|
|
348
|
+
let result;
|
|
349
|
+
let crashed = false;
|
|
350
|
+
try {
|
|
351
|
+
result = await run(event, ctx, slot);
|
|
352
|
+
return result;
|
|
353
|
+
}
|
|
354
|
+
catch (err) {
|
|
355
|
+
crashed = true;
|
|
356
|
+
logEvent({
|
|
357
|
+
source: "permission-rules",
|
|
358
|
+
level: "error",
|
|
359
|
+
event: "gate_crashed",
|
|
360
|
+
fields: {
|
|
361
|
+
tool: event.toolName,
|
|
362
|
+
errorClass: err instanceof Error ? err.constructor.name : String(err),
|
|
363
|
+
},
|
|
364
|
+
});
|
|
365
|
+
throw err;
|
|
366
|
+
}
|
|
367
|
+
finally {
|
|
368
|
+
if (onToolDecision) {
|
|
369
|
+
try {
|
|
370
|
+
const input = (event.input ?? {});
|
|
371
|
+
const filePath = typeof input.path === "string" ? input.path : typeof input.file_path === "string" ? input.file_path : undefined;
|
|
372
|
+
onToolDecision({
|
|
373
|
+
toolName: event.toolName,
|
|
374
|
+
...(event.toolCallId ? { toolCallId: event.toolCallId } : {}),
|
|
375
|
+
// An explicit block is a reject; an allow is an accept; a CRASH
|
|
376
|
+
// is a reject (fail-closed attribution) — never an accept.
|
|
377
|
+
decision: result?.block || crashed ? "reject" : "accept",
|
|
378
|
+
source: slot.source,
|
|
379
|
+
...(filePath ? { filePath } : {}),
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
catch {
|
|
383
|
+
// Telemetry must never affect the gate.
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
});
|
|
388
|
+
}
|
|
291
389
|
/**
|
|
292
390
|
* Wire the tool_call gate + the /mode command onto a shared mode holder. Default
|
|
293
391
|
* auto, so absent any /mode this is a no-op over today's behavior.
|
|
@@ -300,6 +398,8 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
300
398
|
deps.modeHolder?.onSet((m) => {
|
|
301
399
|
if (m !== mode)
|
|
302
400
|
approvedCommands.clear();
|
|
401
|
+
if (m !== mode)
|
|
402
|
+
ruleAskApprovals.clear();
|
|
303
403
|
mode = m;
|
|
304
404
|
});
|
|
305
405
|
// The session bless store is created lazily on the first tool_call (it needs
|
|
@@ -314,6 +414,8 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
314
414
|
// and dropping it here silently reverts every custom policy to the
|
|
315
415
|
// default (round-2 review blocker).
|
|
316
416
|
execPolicy: basePolicy.execPolicy,
|
|
417
|
+
// Same carry-through for the mutating-tool predicate (dynamic mcp__* tools).
|
|
418
|
+
isMutating: basePolicy.isMutating,
|
|
317
419
|
isBlessed: basePolicy.isBlessed ?? ((tool, params) => blessStore?.isBlessed(tool, params) ?? false),
|
|
318
420
|
};
|
|
319
421
|
const sideEffects = sideEffectTools(effectivePolicy);
|
|
@@ -347,6 +449,23 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
347
449
|
// LRU-capped, cleared on every /mode transition.
|
|
348
450
|
const APPROVED_CACHE_MAX = 50;
|
|
349
451
|
const approvedCommands = new Map();
|
|
452
|
+
// Session cache for ASK-RULE approvals (the rules analog of the exact-command
|
|
453
|
+
// approval cache above): a user "yes" on a permission-rule ask covers an
|
|
454
|
+
// identical later (tool, rule, input) call for the rest of the session, so a
|
|
455
|
+
// retrying model cannot re-prompt the same question in a loop. Keyed by
|
|
456
|
+
// tool + rule raw + the primary input param; cleared with the other caches
|
|
457
|
+
// on every /mode transition (mode changes re-ask — the safe direction).
|
|
458
|
+
const ruleAskApprovals = new Map();
|
|
459
|
+
const ruleAskKey = (toolName, ruleRaw, params) => {
|
|
460
|
+
const primary = typeof params.command === "string"
|
|
461
|
+
? params.command
|
|
462
|
+
: typeof params.path === "string"
|
|
463
|
+
? params.path
|
|
464
|
+
: typeof params.url === "string"
|
|
465
|
+
? params.url
|
|
466
|
+
: JSON.stringify(params);
|
|
467
|
+
return `${toolName}\u0000${ruleRaw}\u0000${primary}`;
|
|
468
|
+
};
|
|
350
469
|
const cacheKey = (cwd, command) => `${cwd}\u0000${command}`;
|
|
351
470
|
const rememberApproved = (cwd, command) => {
|
|
352
471
|
const key = cacheKey(cwd, command);
|
|
@@ -370,7 +489,16 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
370
489
|
// calls can start their reviews in the same millisecond, and a timestamp
|
|
371
490
|
// alone would silently drop the second review's spend as a "duplicate".
|
|
372
491
|
let reviewSpendSeq = 0;
|
|
373
|
-
const
|
|
492
|
+
const GUARDIAN_OUTCOME_SOURCE = {
|
|
493
|
+
ask_approved: "user_temporary",
|
|
494
|
+
breaker_ask_approved: "user_temporary",
|
|
495
|
+
ask_approved_remembered: "user_permanent",
|
|
496
|
+
ask_denied: "user_reject",
|
|
497
|
+
};
|
|
498
|
+
const emitGateEvent = (slot, event) => {
|
|
499
|
+
const mapped = GUARDIAN_OUTCOME_SOURCE[event.outcome];
|
|
500
|
+
if (mapped)
|
|
501
|
+
slot.source = mapped;
|
|
374
502
|
if (!deps.onGuardianEvent)
|
|
375
503
|
return;
|
|
376
504
|
try {
|
|
@@ -417,16 +545,162 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
417
545
|
return "no";
|
|
418
546
|
return ctx.signal?.aborted ? "aborted" : "dismissed";
|
|
419
547
|
};
|
|
548
|
+
/**
|
|
549
|
+
* variant: the Guardian ask dialog with an optional third option
|
|
550
|
+
* (persist a user-level permission rule). Same semantics as askUser.
|
|
551
|
+
*/
|
|
552
|
+
const askUserWithOptions = async (ctx, title, rememberLabel, ruleLabel) => {
|
|
553
|
+
if (ctx.signal?.aborted)
|
|
554
|
+
return "aborted";
|
|
555
|
+
const options = [
|
|
556
|
+
ASK_YES,
|
|
557
|
+
...(rememberLabel ? [rememberLabel] : []),
|
|
558
|
+
...(ruleLabel ? [ruleLabel] : []),
|
|
559
|
+
ASK_NO,
|
|
560
|
+
];
|
|
561
|
+
let choice;
|
|
562
|
+
try {
|
|
563
|
+
choice = await ctx.ui.select(title, options, {
|
|
564
|
+
...(ctx.signal ? { signal: ctx.signal } : {}),
|
|
565
|
+
timeout: ASK_TIMEOUT_MS,
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
catch {
|
|
569
|
+
choice = undefined;
|
|
570
|
+
}
|
|
571
|
+
if (choice === ASK_YES)
|
|
572
|
+
return "yes";
|
|
573
|
+
if (rememberLabel !== null && choice === rememberLabel)
|
|
574
|
+
return "remember";
|
|
575
|
+
if (ruleLabel !== null && choice === ruleLabel)
|
|
576
|
+
return "rule";
|
|
577
|
+
if (choice === ASK_NO)
|
|
578
|
+
return "no";
|
|
579
|
+
return ctx.signal?.aborted ? "aborted" : "dismissed";
|
|
580
|
+
};
|
|
420
581
|
const buildAskTitle = (command, rationale, riskLevel) => {
|
|
421
582
|
const risk = riskLevel ? ` (risk: ${riskLevel})` : "";
|
|
422
583
|
return `Guardian asks${risk}\n${rationale}\n$ ${boundedCommand(command)}`;
|
|
423
584
|
};
|
|
424
|
-
|
|
585
|
+
const gateToolCall = async (event, ctx, slot) => {
|
|
425
586
|
// Snapshot the mode ONCE: /mode can flip mid-await, and post-await reads
|
|
426
587
|
// of the closure variable would disagree with the decision already made.
|
|
427
588
|
const modeAtEntry = mode;
|
|
589
|
+
// set when an allow-rule verdict hits an alwaysConfirmTools tool
|
|
590
|
+
// (Floor 2) — forces the confirm flow below instead of short-circuiting.
|
|
591
|
+
let ruleAskConfirm = false;
|
|
428
592
|
try {
|
|
429
593
|
const input = event.input ?? {};
|
|
594
|
+
// settings permission rules run BEFORE hooks (Claude Code
|
|
595
|
+
// semantics: a deny rule blocks even when a hook would allow). Order
|
|
596
|
+
// deny → ask → allow; the verdict is final for deny/ask, and allow
|
|
597
|
+
// short-circuits everything below EXCEPT the two hard floors.
|
|
598
|
+
const rulesDeps = deps.permissionRules;
|
|
599
|
+
// The guard + evaluation share ONE try: a poisoned rules array can
|
|
600
|
+
// throw at the `.length` guard just as easily as inside evaluateRules,
|
|
601
|
+
// and both are engine errors — both must log engine_error and degrade
|
|
602
|
+
// to the normal gate, never escape to the outer fail-open catch.
|
|
603
|
+
let ruleVerdict = null;
|
|
604
|
+
try {
|
|
605
|
+
if (rulesDeps && rulesDeps.length > 0) {
|
|
606
|
+
const cwd = ctx?.cwd ?? ".";
|
|
607
|
+
ruleVerdict = evaluateRules(rulesDeps, {
|
|
608
|
+
toolName: event.toolName,
|
|
609
|
+
params: input,
|
|
610
|
+
cwd,
|
|
611
|
+
isProjectTrusted: (() => { try {
|
|
612
|
+
return ctx?.isProjectTrusted() ?? true;
|
|
613
|
+
}
|
|
614
|
+
catch {
|
|
615
|
+
return true;
|
|
616
|
+
} })(),
|
|
617
|
+
userStateHome: deps.rulesUserStateHome ?? cwd,
|
|
618
|
+
projectRoot: deps.rulesProjectRoot ?? null,
|
|
619
|
+
...(deps.rulesHomeDir ? { homeDir: deps.rulesHomeDir } : {}),
|
|
620
|
+
});
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
catch (err) {
|
|
624
|
+
// Fail-soft: a rule-engine error never blocks or allows — but it
|
|
625
|
+
// must not be SILENT: for a would-be deny this degrades to the
|
|
626
|
+
// normal gate (likely an allow), so the trail needs the failure.
|
|
627
|
+
// Error class only — never the thrown message (it can carry
|
|
628
|
+
// command content) or the user content.
|
|
629
|
+
logEvent({
|
|
630
|
+
source: "permission-rules",
|
|
631
|
+
level: "warn",
|
|
632
|
+
event: "engine_error",
|
|
633
|
+
fields: {
|
|
634
|
+
tool: event.toolName,
|
|
635
|
+
error: err instanceof Error ? err.constructor.name : typeof err,
|
|
636
|
+
},
|
|
637
|
+
});
|
|
638
|
+
}
|
|
639
|
+
if (ruleVerdict) {
|
|
640
|
+
try {
|
|
641
|
+
deps.onRuleVerdict?.({ ...ruleVerdict, toolName: event.toolName, cwd: ctx?.cwd ?? "." });
|
|
642
|
+
}
|
|
643
|
+
catch { /* logging must never affect the gate */ }
|
|
644
|
+
if (ruleVerdict.verdict === "deny") {
|
|
645
|
+
const origin = ruleVerdict.rule.source === "project" ? "the project's settings" : "your user settings";
|
|
646
|
+
return {
|
|
647
|
+
block: true,
|
|
648
|
+
reason: `${event.toolName} was denied by a permission rule in ${origin} (${ruleVerdict.rule.raw}). Do not attempt the same outcome via a workaround or indirect execution — ask the user to change the rule if this action is genuinely needed.`,
|
|
649
|
+
};
|
|
650
|
+
}
|
|
651
|
+
if (ruleVerdict.verdict === "ask") {
|
|
652
|
+
// Ask is final: nothing below may auto-allow it. With a UI, the
|
|
653
|
+
// user arbitrates; headless (incl. /go children) fails closed.
|
|
654
|
+
if (!ctx?.hasUI) {
|
|
655
|
+
return {
|
|
656
|
+
block: true,
|
|
657
|
+
reason: `${event.toolName} requires user approval (permission rule ${ruleVerdict.rule.raw}); no UI available — the call was held.`,
|
|
658
|
+
};
|
|
659
|
+
}
|
|
660
|
+
if (ctx.signal?.aborted)
|
|
661
|
+
return { block: true };
|
|
662
|
+
// Session approval cache: an identical (tool, rule, input) "yes"
|
|
663
|
+
// earlier this session covers this call — a retrying model must
|
|
664
|
+
// not re-prompt the same question (dialog-storm guard).
|
|
665
|
+
const askKey = ruleAskKey(event.toolName, ruleVerdict.rule.raw, input);
|
|
666
|
+
if (ruleAskApprovals.has(askKey))
|
|
667
|
+
return {};
|
|
668
|
+
const origin = ruleVerdict.rule.source === "project" ? "the project's settings" : "your user settings";
|
|
669
|
+
const choice = await askUser(ctx, `Permission rule (ask) in ${origin}:\n${ruleVerdict.rule.raw}\nAllow ${event.toolName}?`, null);
|
|
670
|
+
if (choice === "yes") {
|
|
671
|
+
if (ruleAskApprovals.size > APPROVED_CACHE_MAX)
|
|
672
|
+
ruleAskApprovals.clear();
|
|
673
|
+
ruleAskApprovals.set(askKey, true);
|
|
674
|
+
return {};
|
|
675
|
+
}
|
|
676
|
+
if (choice === "aborted")
|
|
677
|
+
return { block: true };
|
|
678
|
+
return {
|
|
679
|
+
block: true,
|
|
680
|
+
reason: `The user declined ${event.toolName} (permission rule ${ruleVerdict.rule.raw}). Ask what they would like to do differently, or take a different approach.`,
|
|
681
|
+
};
|
|
682
|
+
}
|
|
683
|
+
// verdict === "allow": the floors decide whether the allow
|
|
684
|
+
// short-circuits. Linear by construction — allowRuleFloorVerdict
|
|
685
|
+
// owns the three invariants (plan-mode hold, forbidden band,
|
|
686
|
+
// alwaysConfirmTools confirm) and is unit-tested directly.
|
|
687
|
+
const floor = allowRuleFloorVerdict(event.toolName, input, modeAtEntry, effectivePolicy);
|
|
688
|
+
if (floor.kind === "block") {
|
|
689
|
+
return { block: true, reason: floor.reason };
|
|
690
|
+
}
|
|
691
|
+
if (floor.kind === "confirm") {
|
|
692
|
+
// Defer to the existing confirm flow via the ruleAskConfirm flag
|
|
693
|
+
// (set below) — alwaysConfirmTools keeps its fresh-consent contract.
|
|
694
|
+
ruleAskConfirm = true;
|
|
695
|
+
}
|
|
696
|
+
else if (floor.kind === "hold") {
|
|
697
|
+
// Plan mode's no-mutation contract outranks the allow rule —
|
|
698
|
+
// fall through to the normal plan-mode gate below.
|
|
699
|
+
}
|
|
700
|
+
else {
|
|
701
|
+
return {};
|
|
702
|
+
}
|
|
703
|
+
}
|
|
430
704
|
// YAG-506: PreToolUse hooks run BEFORE decideGate. They can short-circuit
|
|
431
705
|
// (allow/deny/ask) or fall through to the normal gate logic. The result
|
|
432
706
|
// is cached in preToolUseResult so the "ask" check below does NOT
|
|
@@ -437,6 +711,9 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
437
711
|
try {
|
|
438
712
|
preToolUseResult = await hookRunner.preToolUse(event.toolName, input, cwd, ctx?.isProjectTrusted()) ?? undefined;
|
|
439
713
|
if (preToolUseResult) {
|
|
714
|
+
if (preToolUseResult.decision === "allow" || preToolUseResult.decision === "deny") {
|
|
715
|
+
slot.source = "hook";
|
|
716
|
+
}
|
|
440
717
|
if (preToolUseResult.decision === "deny") {
|
|
441
718
|
return { block: true, reason: preToolUseResult.reason };
|
|
442
719
|
}
|
|
@@ -493,7 +770,7 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
493
770
|
if (modeAtEntry === "auto" && command) {
|
|
494
771
|
const grant = matchesGrant(command, grants, resolveRepoKeyFor(cwd));
|
|
495
772
|
if (grant) {
|
|
496
|
-
emitGateEvent({ ...eventBase, outcome: "prefix_allow", consulted: false });
|
|
773
|
+
emitGateEvent(slot, { ...eventBase, outcome: "prefix_allow", consulted: false });
|
|
497
774
|
return {};
|
|
498
775
|
}
|
|
499
776
|
}
|
|
@@ -501,7 +778,20 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
501
778
|
// Skipped in plan mode: a cached approval can cover a write command,
|
|
502
779
|
// and plan mode's contract is no mutations without Guardian review.
|
|
503
780
|
if (modeAtEntry !== "plan" && command && approvedCommands.has(cacheKey(cwd, command))) {
|
|
504
|
-
emitGateEvent({ ...eventBase, outcome: "cached_allow", consulted: false });
|
|
781
|
+
emitGateEvent(slot, { ...eventBase, outcome: "cached_allow", consulted: false });
|
|
782
|
+
return {};
|
|
783
|
+
}
|
|
784
|
+
// 2b. Sandbox auto-allow (Claude's autoAllowBashIfSandboxed):
|
|
785
|
+
// the command will run inside the OS sandbox, so pre-approval is
|
|
786
|
+
// unnecessary. Positioned AFTER grants/cache (user intent outranks
|
|
787
|
+
// mechanism) and BEFORE the cap/breaker (a capped Guardian must not
|
|
788
|
+
// block a sandbox-safe command). Never active in plan mode — the
|
|
789
|
+
// session-side predicate is not consulted there at all, and even a
|
|
790
|
+
// true answer cannot widen plan's no-mutation contract.
|
|
791
|
+
if (modeAtEntry !== "plan" &&
|
|
792
|
+
command &&
|
|
793
|
+
deps.sandboxAutoAllow?.(event.toolName, input)) {
|
|
794
|
+
emitGateEvent(slot, { ...eventBase, outcome: "sandbox_auto_allow", consulted: false });
|
|
505
795
|
return {};
|
|
506
796
|
}
|
|
507
797
|
const guardianAvailable = Boolean(guardianState && !guardianDisabled && guardianReview);
|
|
@@ -530,15 +820,15 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
530
820
|
if (resolution === "yes") {
|
|
531
821
|
guardianState.resetTurn();
|
|
532
822
|
rememberApproved(cwd, command);
|
|
533
|
-
emitGateEvent({ ...eventBase, outcome: "breaker_ask_approved", consulted: false });
|
|
823
|
+
emitGateEvent(slot, { ...eventBase, outcome: "breaker_ask_approved", consulted: false });
|
|
534
824
|
return {};
|
|
535
825
|
}
|
|
536
826
|
if (resolution === "aborted") {
|
|
537
|
-
emitGateEvent({ ...eventBase, outcome: "aborted", consulted: false });
|
|
827
|
+
emitGateEvent(slot, { ...eventBase, outcome: "aborted", consulted: false });
|
|
538
828
|
return { block: true };
|
|
539
829
|
}
|
|
540
830
|
}
|
|
541
|
-
emitGateEvent({ ...eventBase, outcome: "breaker_blocked", consulted: false });
|
|
831
|
+
emitGateEvent(slot, { ...eventBase, outcome: "breaker_blocked", consulted: false });
|
|
542
832
|
if (ctx?.hasUI)
|
|
543
833
|
ctx.ui.notify(breaker.reason ?? "Guardian circuit breaker tripped.", "warning");
|
|
544
834
|
return { block: true, reason: breaker.reason };
|
|
@@ -575,6 +865,7 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
575
865
|
void Promise.resolve(deps.onGuardianReview(buildDiagnosticEvent(outcome, {
|
|
576
866
|
durationMs,
|
|
577
867
|
tier: guardianTier,
|
|
868
|
+
...(reviewResult.repaired ? { repaired: true } : {}),
|
|
578
869
|
...(rationale ? { rationale } : {}),
|
|
579
870
|
...(rawOutput ? { rawOutput } : {}),
|
|
580
871
|
debug: isDebug(),
|
|
@@ -583,8 +874,12 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
583
874
|
const verdict = reviewResult.verdict;
|
|
584
875
|
if (verdict?.outcome === "allow") {
|
|
585
876
|
guardianState.recordReview("allow");
|
|
586
|
-
|
|
587
|
-
|
|
877
|
+
// A repaired verdict is a REAL allow: it flows through the normal
|
|
878
|
+
// path; the diag event carries `repaired` + the pre-repair shape so
|
|
879
|
+
// telemetry can count salvage hits (the rawOutput is already
|
|
880
|
+
// scrubbed + capped at the source).
|
|
881
|
+
emitDiag("allow", verdict.rationale, reviewResult.repaired ? reviewResult.rawOutput : undefined);
|
|
882
|
+
emitGateEvent(slot, {
|
|
588
883
|
...eventBase,
|
|
589
884
|
outcome: "allow",
|
|
590
885
|
riskLevel: verdict.riskLevel,
|
|
@@ -597,8 +892,8 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
597
892
|
if (verdict?.outcome === "deny") {
|
|
598
893
|
guardianState.recordReview("deny");
|
|
599
894
|
const rationale = verdict.rationale;
|
|
600
|
-
emitDiag("deny", rationale);
|
|
601
|
-
emitGateEvent({
|
|
895
|
+
emitDiag("deny", rationale, reviewResult.repaired ? reviewResult.rawOutput : undefined);
|
|
896
|
+
emitGateEvent(slot, {
|
|
602
897
|
...eventBase,
|
|
603
898
|
outcome: "deny",
|
|
604
899
|
riskLevel: verdict.riskLevel,
|
|
@@ -622,10 +917,10 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
622
917
|
}
|
|
623
918
|
if (verdict?.outcome === "ask") {
|
|
624
919
|
guardianState.recordReview("ask");
|
|
625
|
-
emitDiag("ask", verdict.rationale);
|
|
920
|
+
emitDiag("ask", verdict.rationale, reviewResult.repaired ? reviewResult.rawOutput : undefined);
|
|
626
921
|
if (!ctx?.hasUI) {
|
|
627
922
|
// Headless (includes every /go child stage): fail closed.
|
|
628
|
-
emitGateEvent({
|
|
923
|
+
emitGateEvent(slot, {
|
|
629
924
|
...eventBase,
|
|
630
925
|
outcome: "ask_headless_blocked",
|
|
631
926
|
riskLevel: verdict.riskLevel,
|
|
@@ -646,7 +941,7 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
646
941
|
if (hookResult) {
|
|
647
942
|
if (hookResult.decision === "allow") {
|
|
648
943
|
rememberApproved(cwd, command);
|
|
649
|
-
emitGateEvent({
|
|
944
|
+
emitGateEvent(slot, {
|
|
650
945
|
...eventBase,
|
|
651
946
|
outcome: "ask_approved",
|
|
652
947
|
riskLevel: verdict.riskLevel,
|
|
@@ -657,7 +952,7 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
657
952
|
return {};
|
|
658
953
|
}
|
|
659
954
|
if (hookResult.decision === "deny") {
|
|
660
|
-
emitGateEvent({
|
|
955
|
+
emitGateEvent(slot, {
|
|
661
956
|
...eventBase,
|
|
662
957
|
outcome: "ask_denied",
|
|
663
958
|
riskLevel: verdict.riskLevel,
|
|
@@ -674,15 +969,25 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
674
969
|
}
|
|
675
970
|
}
|
|
676
971
|
// Offer "don't ask again" only when the grant would actually
|
|
677
|
-
// cover this command (grant-time validation).
|
|
972
|
+
// cover this command (grant-time validation). adds a
|
|
973
|
+
// third option: persist a user-level permission rule (survives
|
|
974
|
+
// across repos, unlike the repo-scoped grant) — offered only when
|
|
975
|
+
// a grantCandidate also exists (the same prefix discipline; the
|
|
976
|
+
// rule is the same pattern in settings form).
|
|
678
977
|
const grantCandidate = validateGrant(command, effectivePolicy.execPolicy ?? DEFAULT_EXEC_POLICY, resolveRepoKeyFor(cwd));
|
|
978
|
+
const ruleCandidate = grantCandidate
|
|
979
|
+
? `Bash(${grantCandidate.pattern.join(" ")}:*)`
|
|
980
|
+
: null;
|
|
679
981
|
const rememberLabel = grantCandidate
|
|
680
982
|
? `Yes, and don't ask again for \`${describePrefix(grantCandidate.pattern)}\` in this repo`
|
|
681
983
|
: null;
|
|
682
|
-
const
|
|
984
|
+
const ruleLabel = grantCandidate && deps.persistUserRule
|
|
985
|
+
? `Yes, and always allow \`${grantCandidate.pattern.join(" ")}\` in my user settings`
|
|
986
|
+
: null;
|
|
987
|
+
const resolution = await askUserWithOptions(ctx, buildAskTitle(command, verdict.rationale, verdict.riskLevel), rememberLabel, ruleLabel);
|
|
683
988
|
if (resolution === "yes") {
|
|
684
989
|
rememberApproved(cwd, command);
|
|
685
|
-
emitGateEvent({
|
|
990
|
+
emitGateEvent(slot, {
|
|
686
991
|
...eventBase,
|
|
687
992
|
outcome: "ask_approved",
|
|
688
993
|
riskLevel: verdict.riskLevel,
|
|
@@ -692,6 +997,44 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
692
997
|
});
|
|
693
998
|
return {};
|
|
694
999
|
}
|
|
1000
|
+
if (resolution === "rule" && ruleCandidate && deps.persistUserRule) {
|
|
1001
|
+
let persisted = false;
|
|
1002
|
+
try {
|
|
1003
|
+
deps.persistUserRule(ruleCandidate);
|
|
1004
|
+
persisted = true;
|
|
1005
|
+
// Also covers this session like a grant would:
|
|
1006
|
+
rememberApproved(cwd, command);
|
|
1007
|
+
}
|
|
1008
|
+
catch (err) {
|
|
1009
|
+
// Fail-soft: the in-memory approval still applies THIS
|
|
1010
|
+
// session. But the user just made an explicit durable choice
|
|
1011
|
+
// in the dialog — a silent failure would leave them believing
|
|
1012
|
+
// a rule exists that will not survive restart. Log the
|
|
1013
|
+
// failure (rule string only, it is user-configured text, plus
|
|
1014
|
+
// error class — never the thrown message) and tell the user.
|
|
1015
|
+
logEvent({
|
|
1016
|
+
source: "permission-rules",
|
|
1017
|
+
level: "warn",
|
|
1018
|
+
event: "user_rule_save_failed",
|
|
1019
|
+
fields: {
|
|
1020
|
+
rule: ruleCandidate,
|
|
1021
|
+
error: err instanceof Error ? err.constructor.name : typeof err,
|
|
1022
|
+
},
|
|
1023
|
+
});
|
|
1024
|
+
if (ctx?.hasUI) {
|
|
1025
|
+
ctx.ui.notify(`Could not save the permission rule to your settings — it applies to this session only.`, "warning");
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
emitGateEvent(slot, {
|
|
1029
|
+
...eventBase,
|
|
1030
|
+
outcome: persisted ? "ask_approved_remembered" : "ask_approved",
|
|
1031
|
+
riskLevel: verdict.riskLevel,
|
|
1032
|
+
rationale: verdict.rationale,
|
|
1033
|
+
durationMs,
|
|
1034
|
+
consulted: true,
|
|
1035
|
+
});
|
|
1036
|
+
return {};
|
|
1037
|
+
}
|
|
695
1038
|
if (resolution === "remember" && grantCandidate) {
|
|
696
1039
|
const grantRecord = {
|
|
697
1040
|
...grantCandidate,
|
|
@@ -705,7 +1048,7 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
705
1048
|
catch {
|
|
706
1049
|
// Fail-soft: the in-memory grant still applies this session.
|
|
707
1050
|
}
|
|
708
|
-
emitGateEvent({
|
|
1051
|
+
emitGateEvent(slot, {
|
|
709
1052
|
...eventBase,
|
|
710
1053
|
outcome: "ask_approved_remembered",
|
|
711
1054
|
riskLevel: verdict.riskLevel,
|
|
@@ -718,10 +1061,10 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
718
1061
|
if (resolution === "aborted") {
|
|
719
1062
|
// The user is abandoning the turn — no steering text (do not
|
|
720
1063
|
// tell an aborting model it was "denied").
|
|
721
|
-
emitGateEvent({ ...eventBase, outcome: "aborted", durationMs, consulted: true });
|
|
1064
|
+
emitGateEvent(slot, { ...eventBase, outcome: "aborted", durationMs, consulted: true });
|
|
722
1065
|
return { block: true };
|
|
723
1066
|
}
|
|
724
|
-
emitGateEvent({
|
|
1067
|
+
emitGateEvent(slot, {
|
|
725
1068
|
...eventBase,
|
|
726
1069
|
outcome: "ask_denied",
|
|
727
1070
|
riskLevel: verdict.riskLevel,
|
|
@@ -750,7 +1093,7 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
750
1093
|
// killed. (Belt and braces with reviewCommand's own aborted
|
|
751
1094
|
// detection — an aborted child can die in shapes that look like
|
|
752
1095
|
// other errors.)
|
|
753
|
-
emitGateEvent({ ...eventBase, outcome: "aborted", durationMs, consulted: false });
|
|
1096
|
+
emitGateEvent(slot, { ...eventBase, outcome: "aborted", durationMs, consulted: false });
|
|
754
1097
|
return { block: true };
|
|
755
1098
|
}
|
|
756
1099
|
if (modeAtEntry === "review" && decision.confirm) {
|
|
@@ -766,14 +1109,14 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
766
1109
|
const resolution = await askUser(ctx, `Guardian unavailable (${errorMsg}).\nRun this command anyway?\n$ ${boundedCommand(command)}`, null);
|
|
767
1110
|
if (resolution === "yes") {
|
|
768
1111
|
rememberApproved(cwd, command);
|
|
769
|
-
emitGateEvent({ ...eventBase, outcome: "ask_approved", guardianError: error, durationMs, consulted: false });
|
|
1112
|
+
emitGateEvent(slot, { ...eventBase, outcome: "ask_approved", guardianError: error, durationMs, consulted: false });
|
|
770
1113
|
return {};
|
|
771
1114
|
}
|
|
772
1115
|
if (resolution === "aborted") {
|
|
773
|
-
emitGateEvent({ ...eventBase, outcome: "aborted", durationMs, consulted: false });
|
|
1116
|
+
emitGateEvent(slot, { ...eventBase, outcome: "aborted", durationMs, consulted: false });
|
|
774
1117
|
return { block: true };
|
|
775
1118
|
}
|
|
776
|
-
emitGateEvent({ ...eventBase, outcome: "ask_denied", guardianError: error, durationMs, consulted: false });
|
|
1119
|
+
emitGateEvent(slot, { ...eventBase, outcome: "ask_denied", guardianError: error, durationMs, consulted: false });
|
|
777
1120
|
const timeoutNote = error === "timeout" ? " The timeout is not evidence the command is unsafe." : "";
|
|
778
1121
|
return {
|
|
779
1122
|
block: true,
|
|
@@ -782,7 +1125,7 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
782
1125
|
}
|
|
783
1126
|
else {
|
|
784
1127
|
const errorMsg = guardianErrorMessage(error);
|
|
785
|
-
emitGateEvent({ ...eventBase, outcome: error, durationMs, consulted: false });
|
|
1128
|
+
emitGateEvent(slot, { ...eventBase, outcome: error, durationMs, consulted: false });
|
|
786
1129
|
if (ctx?.hasUI)
|
|
787
1130
|
ctx.ui.notify(`Guardian unavailable: ${errorMsg}`, "warning");
|
|
788
1131
|
const timeoutNote = error === "timeout" ? " Do not assume the command is unsafe from the timeout alone; you may retry once or ask the user." : "";
|
|
@@ -806,7 +1149,7 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
806
1149
|
// the Guardian to verify the command is non-mutating, the plan-mode
|
|
807
1150
|
// contract (no changes) cannot be upheld. The user can switch to
|
|
808
1151
|
// /mode auto or /mode review to proceed.
|
|
809
|
-
emitGateEvent({ ...eventBase, outcome: "breaker_blocked", consulted: false });
|
|
1152
|
+
emitGateEvent(slot, { ...eventBase, outcome: "breaker_blocked", consulted: false });
|
|
810
1153
|
return { block: true, reason: "Guardian unavailable in plan mode. Switch to /mode auto to run commands, or /mode review to approve manually." };
|
|
811
1154
|
}
|
|
812
1155
|
// review mode with Guardian disabled/capped: fall through to confirm.
|
|
@@ -816,6 +1159,11 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
816
1159
|
if (preToolUseResult?.decision === "ask") {
|
|
817
1160
|
decision = { block: false, confirm: true };
|
|
818
1161
|
}
|
|
1162
|
+
// an allow-rule verdict on an alwaysConfirmTools tool forced
|
|
1163
|
+
// the confirm flow (Floor 2 above) — same treatment as a hook "ask".
|
|
1164
|
+
if (ruleAskConfirm) {
|
|
1165
|
+
decision = { block: false, confirm: true };
|
|
1166
|
+
}
|
|
819
1167
|
if (decision.confirm) {
|
|
820
1168
|
// YAG-506: PermissionRequest hooks run before the confirm dialog.
|
|
821
1169
|
if (hookRunner) {
|
|
@@ -823,6 +1171,8 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
823
1171
|
const cwd = ctx?.cwd ?? ".";
|
|
824
1172
|
const hookResult = await hookRunner.permissionRequest(event.toolName, input, cwd, ctx?.isProjectTrusted());
|
|
825
1173
|
if (hookResult) {
|
|
1174
|
+
if (hookResult.decision === "allow" || hookResult.decision === "deny")
|
|
1175
|
+
slot.source = "hook";
|
|
826
1176
|
if (hookResult.decision === "allow")
|
|
827
1177
|
return {};
|
|
828
1178
|
if (hookResult.decision === "deny") {
|
|
@@ -857,9 +1207,12 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
857
1207
|
const choice = await ctx.ui.select(effectivePolicy.alwaysConfirmTools?.includes(event.toolName)
|
|
858
1208
|
? externalTrackerPrompt(event.toolName, input)
|
|
859
1209
|
: "YAGNI Code review mode", options);
|
|
860
|
-
if (choice === yes)
|
|
1210
|
+
if (choice === yes) {
|
|
1211
|
+
slot.source = "user_temporary";
|
|
861
1212
|
return {};
|
|
1213
|
+
}
|
|
862
1214
|
if (blessable && choice === remember) {
|
|
1215
|
+
slot.source = "user_permanent";
|
|
863
1216
|
// Session-scoped bless, then a fire-and-forget decision capture so the
|
|
864
1217
|
// approved tool call is never delayed by the capture prompt.
|
|
865
1218
|
blessStore.add(event.toolName, input);
|
|
@@ -869,6 +1222,7 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
869
1222
|
return {};
|
|
870
1223
|
}
|
|
871
1224
|
// "No" or a dismissed dialog (undefined) → held.
|
|
1225
|
+
slot.source = choice === undefined ? "user_abort" : "user_reject";
|
|
872
1226
|
return { block: true, reason: `declined ${event.toolName} in review mode` };
|
|
873
1227
|
}
|
|
874
1228
|
return {};
|
|
@@ -882,7 +1236,8 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
882
1236
|
}
|
|
883
1237
|
return {};
|
|
884
1238
|
}
|
|
885
|
-
}
|
|
1239
|
+
};
|
|
1240
|
+
wireToolCallGate(pi, gateToolCall, deps.onToolDecision);
|
|
886
1241
|
// Model awareness: every agent turn carries a hidden mode-context message so
|
|
887
1242
|
// the model knows what it can do (auto: verify proactively, review: wait for
|
|
888
1243
|
// approval, plan: hold writes). The context hook strips stale mode context
|
|
@@ -930,6 +1285,7 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
930
1285
|
// A mode change is a trust-posture change: session ask-approvals do
|
|
931
1286
|
// not carry across it (grants persist but are suppressed in review).
|
|
932
1287
|
approvedCommands.clear();
|
|
1288
|
+
ruleAskApprovals.clear();
|
|
933
1289
|
}
|
|
934
1290
|
mode = arg;
|
|
935
1291
|
paintMode(ctx);
|