@yagni-app/code 1.0.6 → 1.0.8
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 +91 -46
- package/dist/claudeCompat.d.ts +59 -0
- package/dist/claudeCompat.js +109 -2
- package/dist/claudePlugins.d.ts +45 -5
- package/dist/claudePlugins.js +129 -21
- package/dist/cli.js +16 -4
- package/dist/doctor.d.ts +21 -8
- package/dist/doctor.js +53 -28
- package/dist/extension/askUserQuestionTool.js +7 -2
- package/dist/extension/condensedTools.d.ts +12 -1
- package/dist/extension/condensedTools.js +17 -9
- package/dist/extension/config.d.ts +6 -0
- package/dist/extension/hooks.d.ts +3 -3
- package/dist/extension/hooks.js +30 -5
- package/dist/extension/index.d.ts +19 -0
- package/dist/extension/index.js +248 -43
- package/dist/extension/mcp/cliConfig.d.ts +1 -1
- package/dist/extension/mcp/cliConfig.js +1 -1
- package/dist/extension/mcp/config.d.ts +24 -2
- package/dist/extension/mcp/config.js +75 -3
- package/dist/extension/mcp/manager.d.ts +3 -1
- package/dist/extension/mcp/manager.js +2 -2
- package/dist/extension/mcp/panel.d.ts +0 -1
- package/dist/extension/mcp/panel.js +13 -3
- package/dist/extension/mcp/startup.js +8 -6
- package/dist/extension/permission/gate.d.ts +101 -2
- package/dist/extension/permission/gate.js +485 -42
- 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 +64 -0
- package/dist/extension/permissionRules/loadConfig.js +105 -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 +60 -0
- package/dist/extension/permissionRules/pathRules.js +122 -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/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 +115 -0
- package/dist/extension/sandbox/config.js +388 -0
- package/dist/extension/sandbox/manager.d.ts +108 -0
- package/dist/extension/sandbox/manager.js +243 -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 +829 -0
- package/dist/extension/settingsFiles.d.ts +50 -0
- package/dist/extension/settingsFiles.js +206 -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 +103 -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 +47 -0
- package/dist/extension/telemetry/register.js +207 -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 +10 -1
- package/dist/mcpCommand.js +42 -10
- package/dist/otel.d.ts +67 -90
- package/dist/otel.js +152 -195
- 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.js +10 -1
- package/package.json +19 -3
|
@@ -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
|
|
@@ -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
|
|
@@ -349,6 +449,23 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
349
449
|
// LRU-capped, cleared on every /mode transition.
|
|
350
450
|
const APPROVED_CACHE_MAX = 50;
|
|
351
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
|
+
};
|
|
352
469
|
const cacheKey = (cwd, command) => `${cwd}\u0000${command}`;
|
|
353
470
|
const rememberApproved = (cwd, command) => {
|
|
354
471
|
const key = cacheKey(cwd, command);
|
|
@@ -372,7 +489,16 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
372
489
|
// calls can start their reviews in the same millisecond, and a timestamp
|
|
373
490
|
// alone would silently drop the second review's spend as a "duplicate".
|
|
374
491
|
let reviewSpendSeq = 0;
|
|
375
|
-
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;
|
|
376
502
|
if (!deps.onGuardianEvent)
|
|
377
503
|
return;
|
|
378
504
|
try {
|
|
@@ -387,48 +513,279 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
387
513
|
const flat = command.replace(/\s+/g, " ").trim();
|
|
388
514
|
return flat.length <= 240 ? flat : `${flat.slice(0, 237)}…`;
|
|
389
515
|
};
|
|
390
|
-
const ASK_TIMEOUT_MS = 120_000;
|
|
391
516
|
const ASK_YES = "Yes, run it";
|
|
392
517
|
const ASK_NO = "No";
|
|
518
|
+
/**
|
|
519
|
+
* The ask dialog now waits indefinitely, so an unanswered ask is a silent
|
|
520
|
+
* unbounded pause: a lost RPC client or an abandoned dialog hangs the
|
|
521
|
+
* session with nothing in the trail to explain why. Emit one sanitized
|
|
522
|
+
* event when the dialog OPENS — kind + tool ONLY. The sink's default-on
|
|
523
|
+
* contract is content-free (raw content is gated behind YAGNI_DEBUG), and
|
|
524
|
+
* even the rule string stays out: it is user-authored free text that can
|
|
525
|
+
* embed secret-bearing fragments (env assignments, URL credentials), and
|
|
526
|
+
* a rule ask is already fully identified by kind "rule" + the tool. Fail-
|
|
527
|
+
* soft; the sink never throws into the gate.
|
|
528
|
+
*/
|
|
529
|
+
const logAskOpened = (kind, toolName) => {
|
|
530
|
+
try {
|
|
531
|
+
logEvent({
|
|
532
|
+
source: "guardian",
|
|
533
|
+
level: "info",
|
|
534
|
+
event: "guardian_ask_opened",
|
|
535
|
+
fields: { kind, tool: toolName },
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
catch {
|
|
539
|
+
/* telemetry must never affect the gate */
|
|
540
|
+
}
|
|
541
|
+
};
|
|
542
|
+
/**
|
|
543
|
+
* The paired resolution event: with no dialog timeout, the pause DURATION
|
|
544
|
+
* and its OUTCOME are the story (answered vs ESC-dismissed vs turn-aborted),
|
|
545
|
+
* and a dismissed dialog would otherwise leave no trail line of its own.
|
|
546
|
+
* kind + tool + outcome only — all enum values, nothing content-bearing.
|
|
547
|
+
* durationMs makes the pause directly readable instead of timestamp-diffing
|
|
548
|
+
* two lines. Same fail-soft contract as the open event.
|
|
549
|
+
*/
|
|
550
|
+
const logAskResolved = (kind, toolName, outcome, openedAt) => {
|
|
551
|
+
try {
|
|
552
|
+
logEvent({
|
|
553
|
+
source: "guardian",
|
|
554
|
+
level: "info",
|
|
555
|
+
event: "guardian_ask_resolved",
|
|
556
|
+
fields: { kind, tool: toolName, outcome, durationMs: Date.now() - openedAt },
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
catch {
|
|
560
|
+
/* telemetry must never affect the gate */
|
|
561
|
+
}
|
|
562
|
+
};
|
|
563
|
+
/**
|
|
564
|
+
* The single choice-to-resolution mapping BOTH ask dialogs share — one
|
|
565
|
+
* place, so the two surfaces can never drift. The option labels the dialog
|
|
566
|
+
* actually offered are passed in; the rule rung is null when the plain
|
|
567
|
+
* dialog didn't offer it. An aborted turn wins over any choice value; a
|
|
568
|
+
* real user ESC maps to "dismissed" (the no-abort, no-answer bucket —
|
|
569
|
+
* ESC resolves undefined in pi). A THROWN select never reaches this
|
|
570
|
+
* function: the dialogs route it to thrownChoiceResolution, which is the
|
|
571
|
+
* ONLY source of outcome "error". Both fail closed identically
|
|
572
|
+
* downstream — only the telemetry distinguishes them.
|
|
573
|
+
*/
|
|
574
|
+
const resolveChoice = (choice, opts) => {
|
|
575
|
+
if (opts.aborted)
|
|
576
|
+
return "aborted";
|
|
577
|
+
if (choice === ASK_YES)
|
|
578
|
+
return "yes";
|
|
579
|
+
if (opts.rememberLabel !== null && choice === opts.rememberLabel)
|
|
580
|
+
return "remember";
|
|
581
|
+
if (opts.ruleLabel != null && choice === opts.ruleLabel)
|
|
582
|
+
return "rule";
|
|
583
|
+
if (choice === ASK_NO)
|
|
584
|
+
return "no";
|
|
585
|
+
// No abort and no recognizable answer — a real ESC (pi resolves a
|
|
586
|
+
// dismissed dialog as undefined) or any unmatched value. NEVER the
|
|
587
|
+
// thrown-select path: that maps to "error" in thrownChoiceResolution.
|
|
588
|
+
return "dismissed";
|
|
589
|
+
};
|
|
590
|
+
/**
|
|
591
|
+
* Map a THROWN select to its resolution: the UI/RPC layer failed (dead
|
|
592
|
+
* client, disposed dialog) — never a human answer. Distinct from ESC in
|
|
593
|
+
* the trail so a lost client is attributable; blocks exactly like a
|
|
594
|
+
* dismissal downstream (fail closed).
|
|
595
|
+
*/
|
|
596
|
+
const thrownChoiceResolution = (aborted) => aborted ? "aborted" : "error";
|
|
393
597
|
/**
|
|
394
598
|
* The single human-in-the-loop ask surface (YAG-510): used for ask
|
|
395
599
|
* verdicts, Guardian-unavailable/disabled fallbacks, and the breaker
|
|
396
600
|
* escalation — one UI, one cache, one event stream. Always passes the
|
|
397
|
-
* turn's abort signal (without it a turn-abort leaves the dialog hanging)
|
|
398
|
-
*
|
|
601
|
+
* turn's abort signal (without it a turn-abort leaves the dialog hanging).
|
|
602
|
+
*
|
|
603
|
+
* No dialog timeout — Claude Code parity: a permission ask waits
|
|
604
|
+
* indefinitely for the human. The only dismissal paths are the user
|
|
605
|
+
* answering, dismissing the dialog (ESC), the turn aborting (Ctrl-C /
|
|
606
|
+
* interrupt), or the UI layer failing (outcome "error", fail closed).
|
|
607
|
+
* The old 120s auto-fail-closed cap is gone; an unattended dialog pauses
|
|
608
|
+
* the session rather than denying the command.
|
|
399
609
|
*/
|
|
400
|
-
const askUser = async (ctx, title, rememberLabel) => {
|
|
610
|
+
const askUser = async (ctx, title, rememberLabel, kind = "guardian_verdict", toolName = "bash") => {
|
|
401
611
|
if (ctx.signal?.aborted)
|
|
402
612
|
return "aborted";
|
|
613
|
+
logAskOpened(kind, toolName);
|
|
614
|
+
const openedAt = Date.now();
|
|
403
615
|
const options = rememberLabel ? [ASK_YES, rememberLabel, ASK_NO] : [ASK_YES, ASK_NO];
|
|
404
616
|
let choice;
|
|
617
|
+
let selectThrew = false;
|
|
405
618
|
try {
|
|
406
619
|
choice = await ctx.ui.select(title, options, {
|
|
407
620
|
...(ctx.signal ? { signal: ctx.signal } : {}),
|
|
408
|
-
timeout: ASK_TIMEOUT_MS,
|
|
409
621
|
});
|
|
410
622
|
}
|
|
411
623
|
catch {
|
|
412
|
-
|
|
624
|
+
selectThrew = true;
|
|
413
625
|
}
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
626
|
+
const resolution = selectThrew
|
|
627
|
+
? thrownChoiceResolution(ctx.signal?.aborted ?? false)
|
|
628
|
+
: resolveChoice(choice, { rememberLabel, aborted: ctx.signal?.aborted ?? false });
|
|
629
|
+
logAskResolved(kind, toolName, resolution, openedAt);
|
|
630
|
+
return resolution;
|
|
631
|
+
};
|
|
632
|
+
/**
|
|
633
|
+
* variant: the Guardian ask dialog with an optional third option
|
|
634
|
+
* (persist a user-level permission rule). Same semantics as askUser
|
|
635
|
+
* (indefinite wait, turn-abort signal only). kind/toolName are threaded
|
|
636
|
+
* through — never hardcoded here — so a future non-bash caller cannot
|
|
637
|
+
* silently misattribute the dialog in the trail.
|
|
638
|
+
*/
|
|
639
|
+
const askUserWithOptions = async (ctx, title, rememberLabel, ruleLabel, kind = "guardian_verdict", toolName = "bash") => {
|
|
640
|
+
if (ctx.signal?.aborted)
|
|
641
|
+
return "aborted";
|
|
642
|
+
logAskOpened(kind, toolName);
|
|
643
|
+
const openedAt = Date.now();
|
|
644
|
+
const options = [
|
|
645
|
+
ASK_YES,
|
|
646
|
+
...(rememberLabel ? [rememberLabel] : []),
|
|
647
|
+
...(ruleLabel ? [ruleLabel] : []),
|
|
648
|
+
ASK_NO,
|
|
649
|
+
];
|
|
650
|
+
let choice;
|
|
651
|
+
let selectThrew = false;
|
|
652
|
+
try {
|
|
653
|
+
choice = await ctx.ui.select(title, options, {
|
|
654
|
+
...(ctx.signal ? { signal: ctx.signal } : {}),
|
|
655
|
+
});
|
|
656
|
+
}
|
|
657
|
+
catch {
|
|
658
|
+
selectThrew = true;
|
|
659
|
+
}
|
|
660
|
+
const resolution = selectThrew
|
|
661
|
+
? thrownChoiceResolution(ctx.signal?.aborted ?? false)
|
|
662
|
+
: resolveChoice(choice, { rememberLabel, ruleLabel, aborted: ctx.signal?.aborted ?? false });
|
|
663
|
+
logAskResolved(kind, toolName, resolution, openedAt);
|
|
664
|
+
return resolution;
|
|
421
665
|
};
|
|
422
666
|
const buildAskTitle = (command, rationale, riskLevel) => {
|
|
423
667
|
const risk = riskLevel ? ` (risk: ${riskLevel})` : "";
|
|
424
668
|
return `Guardian asks${risk}\n${rationale}\n$ ${boundedCommand(command)}`;
|
|
425
669
|
};
|
|
426
|
-
|
|
670
|
+
const gateToolCall = async (event, ctx, slot) => {
|
|
427
671
|
// Snapshot the mode ONCE: /mode can flip mid-await, and post-await reads
|
|
428
672
|
// of the closure variable would disagree with the decision already made.
|
|
429
673
|
const modeAtEntry = mode;
|
|
674
|
+
// set when an allow-rule verdict hits an alwaysConfirmTools tool
|
|
675
|
+
// (Floor 2) — forces the confirm flow below instead of short-circuiting.
|
|
676
|
+
let ruleAskConfirm = false;
|
|
430
677
|
try {
|
|
431
678
|
const input = event.input ?? {};
|
|
679
|
+
// settings permission rules run BEFORE hooks (Claude Code
|
|
680
|
+
// semantics: a deny rule blocks even when a hook would allow). Order
|
|
681
|
+
// deny → ask → allow; the verdict is final for deny/ask, and allow
|
|
682
|
+
// short-circuits everything below EXCEPT the two hard floors.
|
|
683
|
+
const rulesDeps = deps.permissionRules;
|
|
684
|
+
// The guard + evaluation share ONE try: a poisoned rules array can
|
|
685
|
+
// throw at the `.length` guard just as easily as inside evaluateRules,
|
|
686
|
+
// and both are engine errors — both must log engine_error and degrade
|
|
687
|
+
// to the normal gate, never escape to the outer fail-open catch.
|
|
688
|
+
let ruleVerdict = null;
|
|
689
|
+
try {
|
|
690
|
+
if (rulesDeps && rulesDeps.length > 0) {
|
|
691
|
+
const cwd = ctx?.cwd ?? ".";
|
|
692
|
+
ruleVerdict = evaluateRules(rulesDeps, {
|
|
693
|
+
toolName: event.toolName,
|
|
694
|
+
params: input,
|
|
695
|
+
cwd,
|
|
696
|
+
isProjectTrusted: (() => { try {
|
|
697
|
+
return ctx?.isProjectTrusted() ?? true;
|
|
698
|
+
}
|
|
699
|
+
catch {
|
|
700
|
+
return true;
|
|
701
|
+
} })(),
|
|
702
|
+
userStateHome: deps.rulesUserStateHome ?? cwd,
|
|
703
|
+
projectRoot: deps.rulesProjectRoot ?? null,
|
|
704
|
+
...(deps.rulesHomeDir ? { homeDir: deps.rulesHomeDir } : {}),
|
|
705
|
+
});
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
catch (err) {
|
|
709
|
+
// Fail-soft: a rule-engine error never blocks or allows — but it
|
|
710
|
+
// must not be SILENT: for a would-be deny this degrades to the
|
|
711
|
+
// normal gate (likely an allow), so the trail needs the failure.
|
|
712
|
+
// Error class only — never the thrown message (it can carry
|
|
713
|
+
// command content) or the user content.
|
|
714
|
+
logEvent({
|
|
715
|
+
source: "permission-rules",
|
|
716
|
+
level: "warn",
|
|
717
|
+
event: "engine_error",
|
|
718
|
+
fields: {
|
|
719
|
+
tool: event.toolName,
|
|
720
|
+
error: err instanceof Error ? err.constructor.name : typeof err,
|
|
721
|
+
},
|
|
722
|
+
});
|
|
723
|
+
}
|
|
724
|
+
if (ruleVerdict) {
|
|
725
|
+
try {
|
|
726
|
+
deps.onRuleVerdict?.({ ...ruleVerdict, toolName: event.toolName, cwd: ctx?.cwd ?? "." });
|
|
727
|
+
}
|
|
728
|
+
catch { /* logging must never affect the gate */ }
|
|
729
|
+
if (ruleVerdict.verdict === "deny") {
|
|
730
|
+
const origin = ruleVerdict.rule.source === "user" ? "your user settings" : ruleVerdict.rule.source === "local" ? "the project's local settings" : "the project's settings";
|
|
731
|
+
return {
|
|
732
|
+
block: true,
|
|
733
|
+
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.`,
|
|
734
|
+
};
|
|
735
|
+
}
|
|
736
|
+
if (ruleVerdict.verdict === "ask") {
|
|
737
|
+
// Ask is final: nothing below may auto-allow it. With a UI, the
|
|
738
|
+
// user arbitrates; headless (incl. /go children) fails closed.
|
|
739
|
+
if (!ctx?.hasUI) {
|
|
740
|
+
return {
|
|
741
|
+
block: true,
|
|
742
|
+
reason: `${event.toolName} requires user approval (permission rule ${ruleVerdict.rule.raw}); no UI available — the call was held.`,
|
|
743
|
+
};
|
|
744
|
+
}
|
|
745
|
+
if (ctx.signal?.aborted)
|
|
746
|
+
return { block: true };
|
|
747
|
+
// Session approval cache: an identical (tool, rule, input) "yes"
|
|
748
|
+
// earlier this session covers this call — a retrying model must
|
|
749
|
+
// not re-prompt the same question (dialog-storm guard).
|
|
750
|
+
const askKey = ruleAskKey(event.toolName, ruleVerdict.rule.raw, input);
|
|
751
|
+
if (ruleAskApprovals.has(askKey))
|
|
752
|
+
return {};
|
|
753
|
+
const origin = ruleVerdict.rule.source === "user" ? "your user settings" : ruleVerdict.rule.source === "local" ? "the project's local settings" : "the project's settings";
|
|
754
|
+
const choice = await askUser(ctx, `Permission rule (ask) in ${origin}:\n${ruleVerdict.rule.raw}\nAllow ${event.toolName}?`, null, "rule", event.toolName);
|
|
755
|
+
if (choice === "yes") {
|
|
756
|
+
if (ruleAskApprovals.size > APPROVED_CACHE_MAX)
|
|
757
|
+
ruleAskApprovals.clear();
|
|
758
|
+
ruleAskApprovals.set(askKey, true);
|
|
759
|
+
return {};
|
|
760
|
+
}
|
|
761
|
+
if (choice === "aborted")
|
|
762
|
+
return { block: true };
|
|
763
|
+
return {
|
|
764
|
+
block: true,
|
|
765
|
+
reason: `The user declined ${event.toolName} (permission rule ${ruleVerdict.rule.raw}). Ask what they would like to do differently, or take a different approach.`,
|
|
766
|
+
};
|
|
767
|
+
}
|
|
768
|
+
// verdict === "allow": the floors decide whether the allow
|
|
769
|
+
// short-circuits. Linear by construction — allowRuleFloorVerdict
|
|
770
|
+
// owns the three invariants (plan-mode hold, forbidden band,
|
|
771
|
+
// alwaysConfirmTools confirm) and is unit-tested directly.
|
|
772
|
+
const floor = allowRuleFloorVerdict(event.toolName, input, modeAtEntry, effectivePolicy);
|
|
773
|
+
if (floor.kind === "block") {
|
|
774
|
+
return { block: true, reason: floor.reason };
|
|
775
|
+
}
|
|
776
|
+
if (floor.kind === "confirm") {
|
|
777
|
+
// Defer to the existing confirm flow via the ruleAskConfirm flag
|
|
778
|
+
// (set below) — alwaysConfirmTools keeps its fresh-consent contract.
|
|
779
|
+
ruleAskConfirm = true;
|
|
780
|
+
}
|
|
781
|
+
else if (floor.kind === "hold") {
|
|
782
|
+
// Plan mode's no-mutation contract outranks the allow rule —
|
|
783
|
+
// fall through to the normal plan-mode gate below.
|
|
784
|
+
}
|
|
785
|
+
else {
|
|
786
|
+
return {};
|
|
787
|
+
}
|
|
788
|
+
}
|
|
432
789
|
// YAG-506: PreToolUse hooks run BEFORE decideGate. They can short-circuit
|
|
433
790
|
// (allow/deny/ask) or fall through to the normal gate logic. The result
|
|
434
791
|
// is cached in preToolUseResult so the "ask" check below does NOT
|
|
@@ -439,6 +796,9 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
439
796
|
try {
|
|
440
797
|
preToolUseResult = await hookRunner.preToolUse(event.toolName, input, cwd, ctx?.isProjectTrusted()) ?? undefined;
|
|
441
798
|
if (preToolUseResult) {
|
|
799
|
+
if (preToolUseResult.decision === "allow" || preToolUseResult.decision === "deny") {
|
|
800
|
+
slot.source = "hook";
|
|
801
|
+
}
|
|
442
802
|
if (preToolUseResult.decision === "deny") {
|
|
443
803
|
return { block: true, reason: preToolUseResult.reason };
|
|
444
804
|
}
|
|
@@ -495,7 +855,7 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
495
855
|
if (modeAtEntry === "auto" && command) {
|
|
496
856
|
const grant = matchesGrant(command, grants, resolveRepoKeyFor(cwd));
|
|
497
857
|
if (grant) {
|
|
498
|
-
emitGateEvent({ ...eventBase, outcome: "prefix_allow", consulted: false });
|
|
858
|
+
emitGateEvent(slot, { ...eventBase, outcome: "prefix_allow", consulted: false });
|
|
499
859
|
return {};
|
|
500
860
|
}
|
|
501
861
|
}
|
|
@@ -503,7 +863,20 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
503
863
|
// Skipped in plan mode: a cached approval can cover a write command,
|
|
504
864
|
// and plan mode's contract is no mutations without Guardian review.
|
|
505
865
|
if (modeAtEntry !== "plan" && command && approvedCommands.has(cacheKey(cwd, command))) {
|
|
506
|
-
emitGateEvent({ ...eventBase, outcome: "cached_allow", consulted: false });
|
|
866
|
+
emitGateEvent(slot, { ...eventBase, outcome: "cached_allow", consulted: false });
|
|
867
|
+
return {};
|
|
868
|
+
}
|
|
869
|
+
// 2b. Sandbox auto-allow (Claude's autoAllowBashIfSandboxed):
|
|
870
|
+
// the command will run inside the OS sandbox, so pre-approval is
|
|
871
|
+
// unnecessary. Positioned AFTER grants/cache (user intent outranks
|
|
872
|
+
// mechanism) and BEFORE the cap/breaker (a capped Guardian must not
|
|
873
|
+
// block a sandbox-safe command). Never active in plan mode — the
|
|
874
|
+
// session-side predicate is not consulted there at all, and even a
|
|
875
|
+
// true answer cannot widen plan's no-mutation contract.
|
|
876
|
+
if (modeAtEntry !== "plan" &&
|
|
877
|
+
command &&
|
|
878
|
+
deps.sandboxAutoAllow?.(event.toolName, input)) {
|
|
879
|
+
emitGateEvent(slot, { ...eventBase, outcome: "sandbox_auto_allow", consulted: false });
|
|
507
880
|
return {};
|
|
508
881
|
}
|
|
509
882
|
const guardianAvailable = Boolean(guardianState && !guardianDisabled && guardianReview);
|
|
@@ -528,19 +901,19 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
528
901
|
if (ctx?.hasUI && !breakerEscalationOffered && !ctx.signal?.aborted) {
|
|
529
902
|
breakerEscalationOffered = true;
|
|
530
903
|
const title = `Guardian denied ${guardianState.read().consecutiveDenials} commands in a row.\nAllow the latest command anyway?\n$ ${boundedCommand(command)}`;
|
|
531
|
-
const resolution = await askUser(ctx, title, null);
|
|
904
|
+
const resolution = await askUser(ctx, title, null, "breaker");
|
|
532
905
|
if (resolution === "yes") {
|
|
533
906
|
guardianState.resetTurn();
|
|
534
907
|
rememberApproved(cwd, command);
|
|
535
|
-
emitGateEvent({ ...eventBase, outcome: "breaker_ask_approved", consulted: false });
|
|
908
|
+
emitGateEvent(slot, { ...eventBase, outcome: "breaker_ask_approved", consulted: false });
|
|
536
909
|
return {};
|
|
537
910
|
}
|
|
538
911
|
if (resolution === "aborted") {
|
|
539
|
-
emitGateEvent({ ...eventBase, outcome: "aborted", consulted: false });
|
|
912
|
+
emitGateEvent(slot, { ...eventBase, outcome: "aborted", consulted: false });
|
|
540
913
|
return { block: true };
|
|
541
914
|
}
|
|
542
915
|
}
|
|
543
|
-
emitGateEvent({ ...eventBase, outcome: "breaker_blocked", consulted: false });
|
|
916
|
+
emitGateEvent(slot, { ...eventBase, outcome: "breaker_blocked", consulted: false });
|
|
544
917
|
if (ctx?.hasUI)
|
|
545
918
|
ctx.ui.notify(breaker.reason ?? "Guardian circuit breaker tripped.", "warning");
|
|
546
919
|
return { block: true, reason: breaker.reason };
|
|
@@ -591,7 +964,7 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
591
964
|
// telemetry can count salvage hits (the rawOutput is already
|
|
592
965
|
// scrubbed + capped at the source).
|
|
593
966
|
emitDiag("allow", verdict.rationale, reviewResult.repaired ? reviewResult.rawOutput : undefined);
|
|
594
|
-
emitGateEvent({
|
|
967
|
+
emitGateEvent(slot, {
|
|
595
968
|
...eventBase,
|
|
596
969
|
outcome: "allow",
|
|
597
970
|
riskLevel: verdict.riskLevel,
|
|
@@ -605,7 +978,7 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
605
978
|
guardianState.recordReview("deny");
|
|
606
979
|
const rationale = verdict.rationale;
|
|
607
980
|
emitDiag("deny", rationale, reviewResult.repaired ? reviewResult.rawOutput : undefined);
|
|
608
|
-
emitGateEvent({
|
|
981
|
+
emitGateEvent(slot, {
|
|
609
982
|
...eventBase,
|
|
610
983
|
outcome: "deny",
|
|
611
984
|
riskLevel: verdict.riskLevel,
|
|
@@ -632,7 +1005,7 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
632
1005
|
emitDiag("ask", verdict.rationale, reviewResult.repaired ? reviewResult.rawOutput : undefined);
|
|
633
1006
|
if (!ctx?.hasUI) {
|
|
634
1007
|
// Headless (includes every /go child stage): fail closed.
|
|
635
|
-
emitGateEvent({
|
|
1008
|
+
emitGateEvent(slot, {
|
|
636
1009
|
...eventBase,
|
|
637
1010
|
outcome: "ask_headless_blocked",
|
|
638
1011
|
riskLevel: verdict.riskLevel,
|
|
@@ -653,7 +1026,7 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
653
1026
|
if (hookResult) {
|
|
654
1027
|
if (hookResult.decision === "allow") {
|
|
655
1028
|
rememberApproved(cwd, command);
|
|
656
|
-
emitGateEvent({
|
|
1029
|
+
emitGateEvent(slot, {
|
|
657
1030
|
...eventBase,
|
|
658
1031
|
outcome: "ask_approved",
|
|
659
1032
|
riskLevel: verdict.riskLevel,
|
|
@@ -664,7 +1037,7 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
664
1037
|
return {};
|
|
665
1038
|
}
|
|
666
1039
|
if (hookResult.decision === "deny") {
|
|
667
|
-
emitGateEvent({
|
|
1040
|
+
emitGateEvent(slot, {
|
|
668
1041
|
...eventBase,
|
|
669
1042
|
outcome: "ask_denied",
|
|
670
1043
|
riskLevel: verdict.riskLevel,
|
|
@@ -681,15 +1054,25 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
681
1054
|
}
|
|
682
1055
|
}
|
|
683
1056
|
// Offer "don't ask again" only when the grant would actually
|
|
684
|
-
// cover this command (grant-time validation).
|
|
1057
|
+
// cover this command (grant-time validation). adds a
|
|
1058
|
+
// third option: persist a user-level permission rule (survives
|
|
1059
|
+
// across repos, unlike the repo-scoped grant) — offered only when
|
|
1060
|
+
// a grantCandidate also exists (the same prefix discipline; the
|
|
1061
|
+
// rule is the same pattern in settings form).
|
|
685
1062
|
const grantCandidate = validateGrant(command, effectivePolicy.execPolicy ?? DEFAULT_EXEC_POLICY, resolveRepoKeyFor(cwd));
|
|
1063
|
+
const ruleCandidate = grantCandidate
|
|
1064
|
+
? `Bash(${grantCandidate.pattern.join(" ")}:*)`
|
|
1065
|
+
: null;
|
|
686
1066
|
const rememberLabel = grantCandidate
|
|
687
1067
|
? `Yes, and don't ask again for \`${describePrefix(grantCandidate.pattern)}\` in this repo`
|
|
688
1068
|
: null;
|
|
689
|
-
const
|
|
1069
|
+
const ruleLabel = grantCandidate && deps.persistUserRule
|
|
1070
|
+
? `Yes, and always allow \`${grantCandidate.pattern.join(" ")}\` in this project's local settings`
|
|
1071
|
+
: null;
|
|
1072
|
+
const resolution = await askUserWithOptions(ctx, buildAskTitle(command, verdict.rationale, verdict.riskLevel), rememberLabel, ruleLabel, "guardian_verdict", "bash");
|
|
690
1073
|
if (resolution === "yes") {
|
|
691
1074
|
rememberApproved(cwd, command);
|
|
692
|
-
emitGateEvent({
|
|
1075
|
+
emitGateEvent(slot, {
|
|
693
1076
|
...eventBase,
|
|
694
1077
|
outcome: "ask_approved",
|
|
695
1078
|
riskLevel: verdict.riskLevel,
|
|
@@ -699,6 +1082,50 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
699
1082
|
});
|
|
700
1083
|
return {};
|
|
701
1084
|
}
|
|
1085
|
+
if (resolution === "rule" && ruleCandidate && deps.persistUserRule) {
|
|
1086
|
+
let persisted = false;
|
|
1087
|
+
try {
|
|
1088
|
+
deps.persistUserRule(ruleCandidate, cwd);
|
|
1089
|
+
persisted = true;
|
|
1090
|
+
// Also covers this session like a grant would:
|
|
1091
|
+
rememberApproved(cwd, command);
|
|
1092
|
+
}
|
|
1093
|
+
catch (err) {
|
|
1094
|
+
// Fail-soft: the in-memory approval covers ONLY this call —
|
|
1095
|
+
// rememberApproved sits after the throwing call, so a failed
|
|
1096
|
+
// persist does NOT remember the command for the session (a
|
|
1097
|
+
// narrower fallback than a grant, deliberately: the user's
|
|
1098
|
+
// durable choice did not land). But the user just made an
|
|
1099
|
+
// explicit durable choice in the dialog — a silent failure
|
|
1100
|
+
// would leave them believing a rule exists that will not
|
|
1101
|
+
// survive restart. Log the failure (rule string + the thrown
|
|
1102
|
+
// message — it is mutateConfigJson's own path/reason text,
|
|
1103
|
+
// never the raw parse error or file content) and tell the
|
|
1104
|
+
// user.
|
|
1105
|
+
logEvent({
|
|
1106
|
+
source: "permission-rules",
|
|
1107
|
+
level: "warn",
|
|
1108
|
+
event: "rule_save_failed",
|
|
1109
|
+
fields: {
|
|
1110
|
+
rule: ruleCandidate,
|
|
1111
|
+
destination: "local",
|
|
1112
|
+
error: err instanceof Error ? err.message : String(err),
|
|
1113
|
+
},
|
|
1114
|
+
});
|
|
1115
|
+
if (ctx?.hasUI) {
|
|
1116
|
+
ctx.ui.notify(`Could not save the permission rule to your settings — it did not persist; you'll be asked again on the next identical command.`, "warning");
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
emitGateEvent(slot, {
|
|
1120
|
+
...eventBase,
|
|
1121
|
+
outcome: persisted ? "ask_approved_remembered" : "ask_approved",
|
|
1122
|
+
riskLevel: verdict.riskLevel,
|
|
1123
|
+
rationale: verdict.rationale,
|
|
1124
|
+
durationMs,
|
|
1125
|
+
consulted: true,
|
|
1126
|
+
});
|
|
1127
|
+
return {};
|
|
1128
|
+
}
|
|
702
1129
|
if (resolution === "remember" && grantCandidate) {
|
|
703
1130
|
const grantRecord = {
|
|
704
1131
|
...grantCandidate,
|
|
@@ -712,7 +1139,7 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
712
1139
|
catch {
|
|
713
1140
|
// Fail-soft: the in-memory grant still applies this session.
|
|
714
1141
|
}
|
|
715
|
-
emitGateEvent({
|
|
1142
|
+
emitGateEvent(slot, {
|
|
716
1143
|
...eventBase,
|
|
717
1144
|
outcome: "ask_approved_remembered",
|
|
718
1145
|
riskLevel: verdict.riskLevel,
|
|
@@ -725,10 +1152,10 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
725
1152
|
if (resolution === "aborted") {
|
|
726
1153
|
// The user is abandoning the turn — no steering text (do not
|
|
727
1154
|
// tell an aborting model it was "denied").
|
|
728
|
-
emitGateEvent({ ...eventBase, outcome: "aborted", durationMs, consulted: true });
|
|
1155
|
+
emitGateEvent(slot, { ...eventBase, outcome: "aborted", durationMs, consulted: true });
|
|
729
1156
|
return { block: true };
|
|
730
1157
|
}
|
|
731
|
-
emitGateEvent({
|
|
1158
|
+
emitGateEvent(slot, {
|
|
732
1159
|
...eventBase,
|
|
733
1160
|
outcome: "ask_denied",
|
|
734
1161
|
riskLevel: verdict.riskLevel,
|
|
@@ -742,7 +1169,10 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
742
1169
|
reason: "The user declined this command. Ask what they would like to do differently, or take a different approach.",
|
|
743
1170
|
};
|
|
744
1171
|
}
|
|
745
|
-
// dismissed
|
|
1172
|
+
// dismissed or UI-layer error — neutral reason, no "denied"
|
|
1173
|
+
// spin. (No timeout can land here anymore: the dialog waits
|
|
1174
|
+
// indefinitely, so this is only a real ESC/dismiss — or the
|
|
1175
|
+
// select itself failed, which fails closed the same way.)
|
|
746
1176
|
return {
|
|
747
1177
|
block: true,
|
|
748
1178
|
reason: "The permission dialog was dismissed; the command was not run. Ask the user how to proceed.",
|
|
@@ -757,7 +1187,7 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
757
1187
|
// killed. (Belt and braces with reviewCommand's own aborted
|
|
758
1188
|
// detection — an aborted child can die in shapes that look like
|
|
759
1189
|
// other errors.)
|
|
760
|
-
emitGateEvent({ ...eventBase, outcome: "aborted", durationMs, consulted: false });
|
|
1190
|
+
emitGateEvent(slot, { ...eventBase, outcome: "aborted", durationMs, consulted: false });
|
|
761
1191
|
return { block: true };
|
|
762
1192
|
}
|
|
763
1193
|
if (modeAtEntry === "review" && decision.confirm) {
|
|
@@ -770,17 +1200,17 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
770
1200
|
// bounded per prompt so an outage can't become an ask storm.
|
|
771
1201
|
errorFallbackAsks += 1;
|
|
772
1202
|
const errorMsg = guardianErrorMessage(error);
|
|
773
|
-
const resolution = await askUser(ctx, `Guardian unavailable (${errorMsg}).\nRun this command anyway?\n$ ${boundedCommand(command)}`, null);
|
|
1203
|
+
const resolution = await askUser(ctx, `Guardian unavailable (${errorMsg}).\nRun this command anyway?\n$ ${boundedCommand(command)}`, null, "error_fallback");
|
|
774
1204
|
if (resolution === "yes") {
|
|
775
1205
|
rememberApproved(cwd, command);
|
|
776
|
-
emitGateEvent({ ...eventBase, outcome: "ask_approved", guardianError: error, durationMs, consulted: false });
|
|
1206
|
+
emitGateEvent(slot, { ...eventBase, outcome: "ask_approved", guardianError: error, durationMs, consulted: false });
|
|
777
1207
|
return {};
|
|
778
1208
|
}
|
|
779
1209
|
if (resolution === "aborted") {
|
|
780
|
-
emitGateEvent({ ...eventBase, outcome: "aborted", durationMs, consulted: false });
|
|
1210
|
+
emitGateEvent(slot, { ...eventBase, outcome: "aborted", durationMs, consulted: false });
|
|
781
1211
|
return { block: true };
|
|
782
1212
|
}
|
|
783
|
-
emitGateEvent({ ...eventBase, outcome: "ask_denied", guardianError: error, durationMs, consulted: false });
|
|
1213
|
+
emitGateEvent(slot, { ...eventBase, outcome: "ask_denied", guardianError: error, durationMs, consulted: false });
|
|
784
1214
|
const timeoutNote = error === "timeout" ? " The timeout is not evidence the command is unsafe." : "";
|
|
785
1215
|
return {
|
|
786
1216
|
block: true,
|
|
@@ -789,7 +1219,7 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
789
1219
|
}
|
|
790
1220
|
else {
|
|
791
1221
|
const errorMsg = guardianErrorMessage(error);
|
|
792
|
-
emitGateEvent({ ...eventBase, outcome: error, durationMs, consulted: false });
|
|
1222
|
+
emitGateEvent(slot, { ...eventBase, outcome: error, durationMs, consulted: false });
|
|
793
1223
|
if (ctx?.hasUI)
|
|
794
1224
|
ctx.ui.notify(`Guardian unavailable: ${errorMsg}`, "warning");
|
|
795
1225
|
const timeoutNote = error === "timeout" ? " Do not assume the command is unsafe from the timeout alone; you may retry once or ask the user." : "";
|
|
@@ -813,7 +1243,7 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
813
1243
|
// the Guardian to verify the command is non-mutating, the plan-mode
|
|
814
1244
|
// contract (no changes) cannot be upheld. The user can switch to
|
|
815
1245
|
// /mode auto or /mode review to proceed.
|
|
816
|
-
emitGateEvent({ ...eventBase, outcome: "breaker_blocked", consulted: false });
|
|
1246
|
+
emitGateEvent(slot, { ...eventBase, outcome: "breaker_blocked", consulted: false });
|
|
817
1247
|
return { block: true, reason: "Guardian unavailable in plan mode. Switch to /mode auto to run commands, or /mode review to approve manually." };
|
|
818
1248
|
}
|
|
819
1249
|
// review mode with Guardian disabled/capped: fall through to confirm.
|
|
@@ -823,6 +1253,11 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
823
1253
|
if (preToolUseResult?.decision === "ask") {
|
|
824
1254
|
decision = { block: false, confirm: true };
|
|
825
1255
|
}
|
|
1256
|
+
// an allow-rule verdict on an alwaysConfirmTools tool forced
|
|
1257
|
+
// the confirm flow (Floor 2 above) — same treatment as a hook "ask".
|
|
1258
|
+
if (ruleAskConfirm) {
|
|
1259
|
+
decision = { block: false, confirm: true };
|
|
1260
|
+
}
|
|
826
1261
|
if (decision.confirm) {
|
|
827
1262
|
// YAG-506: PermissionRequest hooks run before the confirm dialog.
|
|
828
1263
|
if (hookRunner) {
|
|
@@ -830,6 +1265,8 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
830
1265
|
const cwd = ctx?.cwd ?? ".";
|
|
831
1266
|
const hookResult = await hookRunner.permissionRequest(event.toolName, input, cwd, ctx?.isProjectTrusted());
|
|
832
1267
|
if (hookResult) {
|
|
1268
|
+
if (hookResult.decision === "allow" || hookResult.decision === "deny")
|
|
1269
|
+
slot.source = "hook";
|
|
833
1270
|
if (hookResult.decision === "allow")
|
|
834
1271
|
return {};
|
|
835
1272
|
if (hookResult.decision === "deny") {
|
|
@@ -864,9 +1301,12 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
864
1301
|
const choice = await ctx.ui.select(effectivePolicy.alwaysConfirmTools?.includes(event.toolName)
|
|
865
1302
|
? externalTrackerPrompt(event.toolName, input)
|
|
866
1303
|
: "YAGNI Code review mode", options);
|
|
867
|
-
if (choice === yes)
|
|
1304
|
+
if (choice === yes) {
|
|
1305
|
+
slot.source = "user_temporary";
|
|
868
1306
|
return {};
|
|
1307
|
+
}
|
|
869
1308
|
if (blessable && choice === remember) {
|
|
1309
|
+
slot.source = "user_permanent";
|
|
870
1310
|
// Session-scoped bless, then a fire-and-forget decision capture so the
|
|
871
1311
|
// approved tool call is never delayed by the capture prompt.
|
|
872
1312
|
blessStore.add(event.toolName, input);
|
|
@@ -876,6 +1316,7 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
876
1316
|
return {};
|
|
877
1317
|
}
|
|
878
1318
|
// "No" or a dismissed dialog (undefined) → held.
|
|
1319
|
+
slot.source = choice === undefined ? "user_abort" : "user_reject";
|
|
879
1320
|
return { block: true, reason: `declined ${event.toolName} in review mode` };
|
|
880
1321
|
}
|
|
881
1322
|
return {};
|
|
@@ -889,7 +1330,8 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
889
1330
|
}
|
|
890
1331
|
return {};
|
|
891
1332
|
}
|
|
892
|
-
}
|
|
1333
|
+
};
|
|
1334
|
+
wireToolCallGate(pi, gateToolCall, deps.onToolDecision);
|
|
893
1335
|
// Model awareness: every agent turn carries a hidden mode-context message so
|
|
894
1336
|
// the model knows what it can do (auto: verify proactively, review: wait for
|
|
895
1337
|
// approval, plan: hold writes). The context hook strips stale mode context
|
|
@@ -937,6 +1379,7 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
937
1379
|
// A mode change is a trust-posture change: session ask-approvals do
|
|
938
1380
|
// not carry across it (grants persist but are suppressed in review).
|
|
939
1381
|
approvedCommands.clear();
|
|
1382
|
+
ruleAskApprovals.clear();
|
|
940
1383
|
}
|
|
941
1384
|
mode = arg;
|
|
942
1385
|
paintMode(ctx);
|