@yagni-app/code 1.0.6 → 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 +84 -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/condensedTools.d.ts +12 -1
- package/dist/extension/condensedTools.js +17 -9
- package/dist/extension/index.d.ts +13 -0
- package/dist/extension/index.js +230 -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 +97 -2
- package/dist/extension/permission/gate.js +375 -26
- 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/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 +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/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 {
|
|
@@ -419,16 +545,162 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
419
545
|
return "no";
|
|
420
546
|
return ctx.signal?.aborted ? "aborted" : "dismissed";
|
|
421
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
|
+
};
|
|
422
581
|
const buildAskTitle = (command, rationale, riskLevel) => {
|
|
423
582
|
const risk = riskLevel ? ` (risk: ${riskLevel})` : "";
|
|
424
583
|
return `Guardian asks${risk}\n${rationale}\n$ ${boundedCommand(command)}`;
|
|
425
584
|
};
|
|
426
|
-
|
|
585
|
+
const gateToolCall = async (event, ctx, slot) => {
|
|
427
586
|
// Snapshot the mode ONCE: /mode can flip mid-await, and post-await reads
|
|
428
587
|
// of the closure variable would disagree with the decision already made.
|
|
429
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;
|
|
430
592
|
try {
|
|
431
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
|
+
}
|
|
432
704
|
// YAG-506: PreToolUse hooks run BEFORE decideGate. They can short-circuit
|
|
433
705
|
// (allow/deny/ask) or fall through to the normal gate logic. The result
|
|
434
706
|
// is cached in preToolUseResult so the "ask" check below does NOT
|
|
@@ -439,6 +711,9 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
439
711
|
try {
|
|
440
712
|
preToolUseResult = await hookRunner.preToolUse(event.toolName, input, cwd, ctx?.isProjectTrusted()) ?? undefined;
|
|
441
713
|
if (preToolUseResult) {
|
|
714
|
+
if (preToolUseResult.decision === "allow" || preToolUseResult.decision === "deny") {
|
|
715
|
+
slot.source = "hook";
|
|
716
|
+
}
|
|
442
717
|
if (preToolUseResult.decision === "deny") {
|
|
443
718
|
return { block: true, reason: preToolUseResult.reason };
|
|
444
719
|
}
|
|
@@ -495,7 +770,7 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
495
770
|
if (modeAtEntry === "auto" && command) {
|
|
496
771
|
const grant = matchesGrant(command, grants, resolveRepoKeyFor(cwd));
|
|
497
772
|
if (grant) {
|
|
498
|
-
emitGateEvent({ ...eventBase, outcome: "prefix_allow", consulted: false });
|
|
773
|
+
emitGateEvent(slot, { ...eventBase, outcome: "prefix_allow", consulted: false });
|
|
499
774
|
return {};
|
|
500
775
|
}
|
|
501
776
|
}
|
|
@@ -503,7 +778,20 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
503
778
|
// Skipped in plan mode: a cached approval can cover a write command,
|
|
504
779
|
// and plan mode's contract is no mutations without Guardian review.
|
|
505
780
|
if (modeAtEntry !== "plan" && command && approvedCommands.has(cacheKey(cwd, command))) {
|
|
506
|
-
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 });
|
|
507
795
|
return {};
|
|
508
796
|
}
|
|
509
797
|
const guardianAvailable = Boolean(guardianState && !guardianDisabled && guardianReview);
|
|
@@ -532,15 +820,15 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
532
820
|
if (resolution === "yes") {
|
|
533
821
|
guardianState.resetTurn();
|
|
534
822
|
rememberApproved(cwd, command);
|
|
535
|
-
emitGateEvent({ ...eventBase, outcome: "breaker_ask_approved", consulted: false });
|
|
823
|
+
emitGateEvent(slot, { ...eventBase, outcome: "breaker_ask_approved", consulted: false });
|
|
536
824
|
return {};
|
|
537
825
|
}
|
|
538
826
|
if (resolution === "aborted") {
|
|
539
|
-
emitGateEvent({ ...eventBase, outcome: "aborted", consulted: false });
|
|
827
|
+
emitGateEvent(slot, { ...eventBase, outcome: "aborted", consulted: false });
|
|
540
828
|
return { block: true };
|
|
541
829
|
}
|
|
542
830
|
}
|
|
543
|
-
emitGateEvent({ ...eventBase, outcome: "breaker_blocked", consulted: false });
|
|
831
|
+
emitGateEvent(slot, { ...eventBase, outcome: "breaker_blocked", consulted: false });
|
|
544
832
|
if (ctx?.hasUI)
|
|
545
833
|
ctx.ui.notify(breaker.reason ?? "Guardian circuit breaker tripped.", "warning");
|
|
546
834
|
return { block: true, reason: breaker.reason };
|
|
@@ -591,7 +879,7 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
591
879
|
// telemetry can count salvage hits (the rawOutput is already
|
|
592
880
|
// scrubbed + capped at the source).
|
|
593
881
|
emitDiag("allow", verdict.rationale, reviewResult.repaired ? reviewResult.rawOutput : undefined);
|
|
594
|
-
emitGateEvent({
|
|
882
|
+
emitGateEvent(slot, {
|
|
595
883
|
...eventBase,
|
|
596
884
|
outcome: "allow",
|
|
597
885
|
riskLevel: verdict.riskLevel,
|
|
@@ -605,7 +893,7 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
605
893
|
guardianState.recordReview("deny");
|
|
606
894
|
const rationale = verdict.rationale;
|
|
607
895
|
emitDiag("deny", rationale, reviewResult.repaired ? reviewResult.rawOutput : undefined);
|
|
608
|
-
emitGateEvent({
|
|
896
|
+
emitGateEvent(slot, {
|
|
609
897
|
...eventBase,
|
|
610
898
|
outcome: "deny",
|
|
611
899
|
riskLevel: verdict.riskLevel,
|
|
@@ -632,7 +920,7 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
632
920
|
emitDiag("ask", verdict.rationale, reviewResult.repaired ? reviewResult.rawOutput : undefined);
|
|
633
921
|
if (!ctx?.hasUI) {
|
|
634
922
|
// Headless (includes every /go child stage): fail closed.
|
|
635
|
-
emitGateEvent({
|
|
923
|
+
emitGateEvent(slot, {
|
|
636
924
|
...eventBase,
|
|
637
925
|
outcome: "ask_headless_blocked",
|
|
638
926
|
riskLevel: verdict.riskLevel,
|
|
@@ -653,7 +941,7 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
653
941
|
if (hookResult) {
|
|
654
942
|
if (hookResult.decision === "allow") {
|
|
655
943
|
rememberApproved(cwd, command);
|
|
656
|
-
emitGateEvent({
|
|
944
|
+
emitGateEvent(slot, {
|
|
657
945
|
...eventBase,
|
|
658
946
|
outcome: "ask_approved",
|
|
659
947
|
riskLevel: verdict.riskLevel,
|
|
@@ -664,7 +952,7 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
664
952
|
return {};
|
|
665
953
|
}
|
|
666
954
|
if (hookResult.decision === "deny") {
|
|
667
|
-
emitGateEvent({
|
|
955
|
+
emitGateEvent(slot, {
|
|
668
956
|
...eventBase,
|
|
669
957
|
outcome: "ask_denied",
|
|
670
958
|
riskLevel: verdict.riskLevel,
|
|
@@ -681,15 +969,25 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
681
969
|
}
|
|
682
970
|
}
|
|
683
971
|
// Offer "don't ask again" only when the grant would actually
|
|
684
|
-
// 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).
|
|
685
977
|
const grantCandidate = validateGrant(command, effectivePolicy.execPolicy ?? DEFAULT_EXEC_POLICY, resolveRepoKeyFor(cwd));
|
|
978
|
+
const ruleCandidate = grantCandidate
|
|
979
|
+
? `Bash(${grantCandidate.pattern.join(" ")}:*)`
|
|
980
|
+
: null;
|
|
686
981
|
const rememberLabel = grantCandidate
|
|
687
982
|
? `Yes, and don't ask again for \`${describePrefix(grantCandidate.pattern)}\` in this repo`
|
|
688
983
|
: null;
|
|
689
|
-
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);
|
|
690
988
|
if (resolution === "yes") {
|
|
691
989
|
rememberApproved(cwd, command);
|
|
692
|
-
emitGateEvent({
|
|
990
|
+
emitGateEvent(slot, {
|
|
693
991
|
...eventBase,
|
|
694
992
|
outcome: "ask_approved",
|
|
695
993
|
riskLevel: verdict.riskLevel,
|
|
@@ -699,6 +997,44 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
699
997
|
});
|
|
700
998
|
return {};
|
|
701
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
|
+
}
|
|
702
1038
|
if (resolution === "remember" && grantCandidate) {
|
|
703
1039
|
const grantRecord = {
|
|
704
1040
|
...grantCandidate,
|
|
@@ -712,7 +1048,7 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
712
1048
|
catch {
|
|
713
1049
|
// Fail-soft: the in-memory grant still applies this session.
|
|
714
1050
|
}
|
|
715
|
-
emitGateEvent({
|
|
1051
|
+
emitGateEvent(slot, {
|
|
716
1052
|
...eventBase,
|
|
717
1053
|
outcome: "ask_approved_remembered",
|
|
718
1054
|
riskLevel: verdict.riskLevel,
|
|
@@ -725,10 +1061,10 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
725
1061
|
if (resolution === "aborted") {
|
|
726
1062
|
// The user is abandoning the turn — no steering text (do not
|
|
727
1063
|
// tell an aborting model it was "denied").
|
|
728
|
-
emitGateEvent({ ...eventBase, outcome: "aborted", durationMs, consulted: true });
|
|
1064
|
+
emitGateEvent(slot, { ...eventBase, outcome: "aborted", durationMs, consulted: true });
|
|
729
1065
|
return { block: true };
|
|
730
1066
|
}
|
|
731
|
-
emitGateEvent({
|
|
1067
|
+
emitGateEvent(slot, {
|
|
732
1068
|
...eventBase,
|
|
733
1069
|
outcome: "ask_denied",
|
|
734
1070
|
riskLevel: verdict.riskLevel,
|
|
@@ -757,7 +1093,7 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
757
1093
|
// killed. (Belt and braces with reviewCommand's own aborted
|
|
758
1094
|
// detection — an aborted child can die in shapes that look like
|
|
759
1095
|
// other errors.)
|
|
760
|
-
emitGateEvent({ ...eventBase, outcome: "aborted", durationMs, consulted: false });
|
|
1096
|
+
emitGateEvent(slot, { ...eventBase, outcome: "aborted", durationMs, consulted: false });
|
|
761
1097
|
return { block: true };
|
|
762
1098
|
}
|
|
763
1099
|
if (modeAtEntry === "review" && decision.confirm) {
|
|
@@ -773,14 +1109,14 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
773
1109
|
const resolution = await askUser(ctx, `Guardian unavailable (${errorMsg}).\nRun this command anyway?\n$ ${boundedCommand(command)}`, null);
|
|
774
1110
|
if (resolution === "yes") {
|
|
775
1111
|
rememberApproved(cwd, command);
|
|
776
|
-
emitGateEvent({ ...eventBase, outcome: "ask_approved", guardianError: error, durationMs, consulted: false });
|
|
1112
|
+
emitGateEvent(slot, { ...eventBase, outcome: "ask_approved", guardianError: error, durationMs, consulted: false });
|
|
777
1113
|
return {};
|
|
778
1114
|
}
|
|
779
1115
|
if (resolution === "aborted") {
|
|
780
|
-
emitGateEvent({ ...eventBase, outcome: "aborted", durationMs, consulted: false });
|
|
1116
|
+
emitGateEvent(slot, { ...eventBase, outcome: "aborted", durationMs, consulted: false });
|
|
781
1117
|
return { block: true };
|
|
782
1118
|
}
|
|
783
|
-
emitGateEvent({ ...eventBase, outcome: "ask_denied", guardianError: error, durationMs, consulted: false });
|
|
1119
|
+
emitGateEvent(slot, { ...eventBase, outcome: "ask_denied", guardianError: error, durationMs, consulted: false });
|
|
784
1120
|
const timeoutNote = error === "timeout" ? " The timeout is not evidence the command is unsafe." : "";
|
|
785
1121
|
return {
|
|
786
1122
|
block: true,
|
|
@@ -789,7 +1125,7 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
789
1125
|
}
|
|
790
1126
|
else {
|
|
791
1127
|
const errorMsg = guardianErrorMessage(error);
|
|
792
|
-
emitGateEvent({ ...eventBase, outcome: error, durationMs, consulted: false });
|
|
1128
|
+
emitGateEvent(slot, { ...eventBase, outcome: error, durationMs, consulted: false });
|
|
793
1129
|
if (ctx?.hasUI)
|
|
794
1130
|
ctx.ui.notify(`Guardian unavailable: ${errorMsg}`, "warning");
|
|
795
1131
|
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 +1149,7 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
813
1149
|
// the Guardian to verify the command is non-mutating, the plan-mode
|
|
814
1150
|
// contract (no changes) cannot be upheld. The user can switch to
|
|
815
1151
|
// /mode auto or /mode review to proceed.
|
|
816
|
-
emitGateEvent({ ...eventBase, outcome: "breaker_blocked", consulted: false });
|
|
1152
|
+
emitGateEvent(slot, { ...eventBase, outcome: "breaker_blocked", consulted: false });
|
|
817
1153
|
return { block: true, reason: "Guardian unavailable in plan mode. Switch to /mode auto to run commands, or /mode review to approve manually." };
|
|
818
1154
|
}
|
|
819
1155
|
// review mode with Guardian disabled/capped: fall through to confirm.
|
|
@@ -823,6 +1159,11 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
823
1159
|
if (preToolUseResult?.decision === "ask") {
|
|
824
1160
|
decision = { block: false, confirm: true };
|
|
825
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
|
+
}
|
|
826
1167
|
if (decision.confirm) {
|
|
827
1168
|
// YAG-506: PermissionRequest hooks run before the confirm dialog.
|
|
828
1169
|
if (hookRunner) {
|
|
@@ -830,6 +1171,8 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
830
1171
|
const cwd = ctx?.cwd ?? ".";
|
|
831
1172
|
const hookResult = await hookRunner.permissionRequest(event.toolName, input, cwd, ctx?.isProjectTrusted());
|
|
832
1173
|
if (hookResult) {
|
|
1174
|
+
if (hookResult.decision === "allow" || hookResult.decision === "deny")
|
|
1175
|
+
slot.source = "hook";
|
|
833
1176
|
if (hookResult.decision === "allow")
|
|
834
1177
|
return {};
|
|
835
1178
|
if (hookResult.decision === "deny") {
|
|
@@ -864,9 +1207,12 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
864
1207
|
const choice = await ctx.ui.select(effectivePolicy.alwaysConfirmTools?.includes(event.toolName)
|
|
865
1208
|
? externalTrackerPrompt(event.toolName, input)
|
|
866
1209
|
: "YAGNI Code review mode", options);
|
|
867
|
-
if (choice === yes)
|
|
1210
|
+
if (choice === yes) {
|
|
1211
|
+
slot.source = "user_temporary";
|
|
868
1212
|
return {};
|
|
1213
|
+
}
|
|
869
1214
|
if (blessable && choice === remember) {
|
|
1215
|
+
slot.source = "user_permanent";
|
|
870
1216
|
// Session-scoped bless, then a fire-and-forget decision capture so the
|
|
871
1217
|
// approved tool call is never delayed by the capture prompt.
|
|
872
1218
|
blessStore.add(event.toolName, input);
|
|
@@ -876,6 +1222,7 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
876
1222
|
return {};
|
|
877
1223
|
}
|
|
878
1224
|
// "No" or a dismissed dialog (undefined) → held.
|
|
1225
|
+
slot.source = choice === undefined ? "user_abort" : "user_reject";
|
|
879
1226
|
return { block: true, reason: `declined ${event.toolName} in review mode` };
|
|
880
1227
|
}
|
|
881
1228
|
return {};
|
|
@@ -889,7 +1236,8 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
889
1236
|
}
|
|
890
1237
|
return {};
|
|
891
1238
|
}
|
|
892
|
-
}
|
|
1239
|
+
};
|
|
1240
|
+
wireToolCallGate(pi, gateToolCall, deps.onToolDecision);
|
|
893
1241
|
// Model awareness: every agent turn carries a hidden mode-context message so
|
|
894
1242
|
// the model knows what it can do (auto: verify proactively, review: wait for
|
|
895
1243
|
// approval, plan: hold writes). The context hook strips stale mode context
|
|
@@ -937,6 +1285,7 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
937
1285
|
// A mode change is a trust-posture change: session ask-approvals do
|
|
938
1286
|
// not carry across it (grants persist but are suppressed in review).
|
|
939
1287
|
approvedCommands.clear();
|
|
1288
|
+
ruleAskApprovals.clear();
|
|
940
1289
|
}
|
|
941
1290
|
mode = arg;
|
|
942
1291
|
paintMode(ctx);
|