@yagni-app/code-staging 1.0.6-staging.1244.1 → 1.0.6-staging.1245.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/extension/index.js +99 -0
- package/dist/extension/permission/gate.d.ts +53 -0
- package/dist/extension/permission/gate.js +269 -2
- 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/vendor/IGNORE-LICENSE-MIT +21 -0
- package/dist/extension/vendor/ignore.d.ts +86 -0
- package/dist/extension/vendor/ignore.js +788 -0
- package/package.json +2 -2
package/dist/extension/index.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
+
import { existsSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
2
4
|
import { Text } from "@earendil-works/pi-tui";
|
|
3
5
|
import { DEFAULT_ADVISOR_LIMITS, formatAdvisorSubtotal, makeAdvisorState } from "./advisor.js";
|
|
4
6
|
import { makeChildUsageState } from "./childUsage.js";
|
|
@@ -35,6 +37,7 @@ import { registerGoCommand } from "./pipeline/goCommand.js";
|
|
|
35
37
|
import { registerGoCompareCommand } from "./pipeline/goCompareCommand.js";
|
|
36
38
|
import { DEFAULT_PERMISSION_POLICY, createModeHolder, registerPermissionGate } from "./permission/gate.js";
|
|
37
39
|
import { loadHooksConfig, makeHookRunner, registerHooks } from "./hooks.js";
|
|
40
|
+
import { loadPermissionRules } from "./permissionRules/loadConfig.js";
|
|
38
41
|
import { registerSubagents } from "./subagents.js";
|
|
39
42
|
import { createUltraHolder, registerUltraCommand } from "./ultra.js";
|
|
40
43
|
import { registerTodos } from "./todos.js";
|
|
@@ -334,6 +337,8 @@ export async function registerYagni(pi, deps = {}) {
|
|
|
334
337
|
// The static queue only carries non-connectivity notices (e.g. the
|
|
335
338
|
// `.mcp.json` approval prompt), which stay one-shot.
|
|
336
339
|
let connectivityNoticesFlushed = false;
|
|
340
|
+
// permission-rule notices are one-shot like connectivity notices.
|
|
341
|
+
let rulesNoticesFlushed = false;
|
|
337
342
|
pi.on("after_provider_response", (_event, ctx) => {
|
|
338
343
|
try {
|
|
339
344
|
if (!ctx.hasUI)
|
|
@@ -347,6 +352,21 @@ export async function registerYagni(pi, deps = {}) {
|
|
|
347
352
|
while (mcpApprovalNotices.length > 0) {
|
|
348
353
|
ctx.ui.notify(mcpApprovalNotices.shift(), "info");
|
|
349
354
|
}
|
|
355
|
+
// one-shot permission-rule config warnings (bad files,
|
|
356
|
+
// never-consulted tools). One consolidated line, never a wall.
|
|
357
|
+
if (!rulesNoticesFlushed) {
|
|
358
|
+
rulesNoticesFlushed = true;
|
|
359
|
+
const parts = [];
|
|
360
|
+
if (loadedRules.diagnostics.warnings.length > 0) {
|
|
361
|
+
parts.push(`${loadedRules.diagnostics.warnings.length} permission-rule config warning(s)`);
|
|
362
|
+
}
|
|
363
|
+
if (loadedRules.diagnostics.neverConsultedTools.length > 0) {
|
|
364
|
+
parts.push(`rules for unknown tools: ${loadedRules.diagnostics.neverConsultedTools.join(", ")} (ignored)`);
|
|
365
|
+
}
|
|
366
|
+
if (parts.length > 0) {
|
|
367
|
+
ctx.ui.notify(`Permission rules: ${parts.join(" · ")}`, "warning");
|
|
368
|
+
}
|
|
369
|
+
}
|
|
350
370
|
}
|
|
351
371
|
catch {
|
|
352
372
|
// A notice must never break a turn.
|
|
@@ -408,6 +428,25 @@ export async function registerYagni(pi, deps = {}) {
|
|
|
408
428
|
// the startup load is the trust boundary; live reload was reviewed and
|
|
409
429
|
// rejected as a same-session self-authorization path, PR #1698).
|
|
410
430
|
const sessionGrants = evalMode ? [] : loadGrants();
|
|
431
|
+
// settings-based permission rules, loaded once at startup (same
|
|
432
|
+
// trust-boundary posture as grants: no mid-session reload). User config
|
|
433
|
+
// (~/.yagni-code/config.json) + project config (.yagni-code/config.json);
|
|
434
|
+
// project allow rules are trust-gated at evaluation time, deny/ask always.
|
|
435
|
+
const loadedRules = evalMode
|
|
436
|
+
? { rules: [], diagnostics: { warnings: [], neverConsultedTools: [] } }
|
|
437
|
+
: loadPermissionRules({ cwd: process.cwd() });
|
|
438
|
+
for (const w of loadedRules.diagnostics.warnings) {
|
|
439
|
+
logEvent({ source: "permission-rules", level: "warn", event: "config_warning", fields: { warning: w } });
|
|
440
|
+
}
|
|
441
|
+
if (loadedRules.diagnostics.neverConsultedTools.length > 0) {
|
|
442
|
+
logEvent({
|
|
443
|
+
source: "permission-rules",
|
|
444
|
+
level: "warn",
|
|
445
|
+
event: "never_consulted_tools",
|
|
446
|
+
fields: { tools: loadedRules.diagnostics.neverConsultedTools.join(", ") },
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
const rulesStateHome = codeStateHome(null, env);
|
|
411
450
|
const GUARDIAN_EVENT_TIMEOUT_MS = 5_000;
|
|
412
451
|
// YAG-506: load user-configurable lifecycle hooks config and create the
|
|
413
452
|
// hook runner for the permission gate. Skipped in eval mode.
|
|
@@ -420,6 +459,27 @@ export async function registerYagni(pi, deps = {}) {
|
|
|
420
459
|
guardianState,
|
|
421
460
|
guardianLimits,
|
|
422
461
|
guardianTier,
|
|
462
|
+
...(loadedRules.rules.length > 0
|
|
463
|
+
? {
|
|
464
|
+
permissionRules: loadedRules.rules,
|
|
465
|
+
rulesUserStateHome: rulesStateHome,
|
|
466
|
+
rulesProjectRoot: process.cwd(),
|
|
467
|
+
onRuleVerdict: (ev) => {
|
|
468
|
+
logEvent({
|
|
469
|
+
source: "permission-rules",
|
|
470
|
+
level: "debug",
|
|
471
|
+
event: `rule_${ev.verdict}`,
|
|
472
|
+
sessionId: env.YAGNI_SESSION_ID ?? undefined,
|
|
473
|
+
fields: {
|
|
474
|
+
tool: ev.toolName,
|
|
475
|
+
via: ev.matchedVia,
|
|
476
|
+
source: ev.rule.source,
|
|
477
|
+
rule: ev.rule.raw,
|
|
478
|
+
},
|
|
479
|
+
});
|
|
480
|
+
},
|
|
481
|
+
}
|
|
482
|
+
: {}),
|
|
423
483
|
guardianDisabled,
|
|
424
484
|
childUsage,
|
|
425
485
|
guardianReview: (command, deps) => reviewCommand(command, { ...deps, modelTier: guardianTier }),
|
|
@@ -471,6 +531,45 @@ export async function registerYagni(pi, deps = {}) {
|
|
|
471
531
|
if (!evalMode)
|
|
472
532
|
appendGrant(grant);
|
|
473
533
|
},
|
|
534
|
+
// persist a user-level allow rule (Guardian ask dialog's third
|
|
535
|
+
// option). Atomic write, never overwrites other keys, fail-soft.
|
|
536
|
+
persistUserRule: (ruleString) => {
|
|
537
|
+
if (evalMode)
|
|
538
|
+
return;
|
|
539
|
+
try {
|
|
540
|
+
const userPath = join(rulesStateHome, "config.json");
|
|
541
|
+
let parsed = {};
|
|
542
|
+
if (existsSync(userPath)) {
|
|
543
|
+
parsed = JSON.parse(readFileSync(userPath, "utf-8"));
|
|
544
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
const perms = (parsed.permissions ?? {});
|
|
548
|
+
const allow = Array.isArray(perms.allow) ? perms.allow : [];
|
|
549
|
+
if (!allow.includes(ruleString))
|
|
550
|
+
allow.push(ruleString);
|
|
551
|
+
perms.allow = allow;
|
|
552
|
+
parsed.permissions = perms;
|
|
553
|
+
const tmp = join(rulesStateHome, `.config.json.yagni-${process.pid}-${Date.now()}.tmp`);
|
|
554
|
+
writeFileSync(tmp, `${JSON.stringify(parsed, null, 2)}\n`, { mode: 0o600 });
|
|
555
|
+
renameSync(tmp, userPath);
|
|
556
|
+
logEvent({
|
|
557
|
+
source: "permission-rules",
|
|
558
|
+
level: "info",
|
|
559
|
+
event: "user_rule_saved",
|
|
560
|
+
sessionId: env.YAGNI_SESSION_ID ?? undefined,
|
|
561
|
+
fields: { rule: ruleString },
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
catch (err) {
|
|
565
|
+
logEvent({
|
|
566
|
+
source: "permission-rules",
|
|
567
|
+
level: "warn",
|
|
568
|
+
event: "user_rule_save_failed",
|
|
569
|
+
fields: { message: err instanceof Error ? err.message : "unknown" },
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
},
|
|
474
573
|
// Opt-in storage stream (YAG-510). Tier decides what leaves the machine:
|
|
475
574
|
// "off" → nothing (not even sent); "hash" → sha256 + family prefix +
|
|
476
575
|
// metadata, no command content; "raw" → adds client-REDACTED command and
|
|
@@ -94,6 +94,33 @@ export interface GateDecision {
|
|
|
94
94
|
*/
|
|
95
95
|
classifyJustification?: string;
|
|
96
96
|
}
|
|
97
|
+
/**
|
|
98
|
+
* What the hard floors say about an ALLOW-rule verdict — the three invariants
|
|
99
|
+
* that outrank any user/project permission rule. Extracted from the inline
|
|
100
|
+
* allow path so the gate reads linearly and the floors are directly testable.
|
|
101
|
+
* PURE — no I/O, no session state:
|
|
102
|
+
*
|
|
103
|
+
* "allow" no floor applies — the rule's allow short-circuits everything
|
|
104
|
+
* "block" the exec-policy forbidden band holds despite the allow rule
|
|
105
|
+
* "confirm" alwaysConfirmTools keeps its fresh-consent contract
|
|
106
|
+
* "hold" plan mode's no-mutation contract outranks the allow rule —
|
|
107
|
+
* fall through to the normal plan-mode gate
|
|
108
|
+
*
|
|
109
|
+
* Order is deliberate: plan-mode first (its outcome routes to the EXISTING
|
|
110
|
+
* plan-mode machinery, not a bespoke block); then the terminal forbidden-band
|
|
111
|
+
* block; then the confirm deferral.
|
|
112
|
+
*/
|
|
113
|
+
export type AllowRuleFloorOutcome = {
|
|
114
|
+
kind: "allow";
|
|
115
|
+
} | {
|
|
116
|
+
kind: "block";
|
|
117
|
+
reason: string;
|
|
118
|
+
} | {
|
|
119
|
+
kind: "confirm";
|
|
120
|
+
} | {
|
|
121
|
+
kind: "hold";
|
|
122
|
+
};
|
|
123
|
+
export declare function allowRuleFloorVerdict(toolName: string, params: Record<string, unknown>, mode: PermissionMode, policy: PermissionPolicy): AllowRuleFloorOutcome;
|
|
97
124
|
/**
|
|
98
125
|
* Pure permission decision for one tool call under a mode + policy. Auto allows
|
|
99
126
|
* ordinary tools; plan blocks the write/exec set; review marks writes for confirmation
|
|
@@ -187,12 +214,38 @@ export interface RegisterPermissionDeps {
|
|
|
187
214
|
/** Persist a new grant (fire-and-forget; the in-memory list is updated
|
|
188
215
|
* either way). index.ts wires approvedPrefixes.appendGrant. */
|
|
189
216
|
persistGrant?: (grant: ApprovedPrefixGrant) => void;
|
|
217
|
+
/**
|
|
218
|
+
* persist a user-level permission rule string (e.g.
|
|
219
|
+
* `Bash(git push:*)`) into ~/.yagni-code/config.json permissions.allow.
|
|
220
|
+
* Wired by index.ts; offered as a third option on Guardian ask dialogs.
|
|
221
|
+
* Fail-soft: the in-session approval applies even if the write fails.
|
|
222
|
+
*/
|
|
223
|
+
persistUserRule?: (ruleString: string) => void;
|
|
190
224
|
/**
|
|
191
225
|
* Called (fire-and-forget) at every terminal prompt-band outcome with the
|
|
192
226
|
* rich storage event (raw command — the wiring layer redacts/hashes).
|
|
193
227
|
* Fail-soft; never blocks.
|
|
194
228
|
*/
|
|
195
229
|
onGuardianEvent?: (event: GuardianGateEvent) => void;
|
|
230
|
+
/**
|
|
231
|
+
* settings-based permission rules (user + project config.json).
|
|
232
|
+
* When present, evaluated at the TOP of the tool_call handler, before
|
|
233
|
+
* hooks: deny → ask → allow, first match wins. Deny/ask are final; allow
|
|
234
|
+
* short-circuits the Guardian but can never lift the exec-policy forbidden
|
|
235
|
+
* band or the alwaysConfirmTools contract (both re-checked below).
|
|
236
|
+
*/
|
|
237
|
+
permissionRules?: readonly import("../permissionRules/loadConfig.js").PermissionRule[];
|
|
238
|
+
/** ~/.yagni-code — the user `/`-anchor base for path rules. */
|
|
239
|
+
rulesUserStateHome?: string;
|
|
240
|
+
/** Project root for project-source `/`-anchored path rules; null outside a repo. */
|
|
241
|
+
rulesProjectRoot?: string | null;
|
|
242
|
+
/** Overrides ~ expansion for path rules (tests). */
|
|
243
|
+
rulesHomeDir?: string;
|
|
244
|
+
/** Called (fire-and-soft) after every rule verdict for debug logging. */
|
|
245
|
+
onRuleVerdict?: (event: import("../permissionRules/engine.js").RuleEvaluation & {
|
|
246
|
+
toolName: string;
|
|
247
|
+
cwd: string;
|
|
248
|
+
}) => void;
|
|
196
249
|
/**
|
|
197
250
|
* User-configurable lifecycle hooks (YAG-506). When present, PreToolUse
|
|
198
251
|
* hooks run before decideGate and can short-circuit (allow/deny/ask),
|
|
@@ -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
|
|
@@ -300,6 +348,8 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
300
348
|
deps.modeHolder?.onSet((m) => {
|
|
301
349
|
if (m !== mode)
|
|
302
350
|
approvedCommands.clear();
|
|
351
|
+
if (m !== mode)
|
|
352
|
+
ruleAskApprovals.clear();
|
|
303
353
|
mode = m;
|
|
304
354
|
});
|
|
305
355
|
// The session bless store is created lazily on the first tool_call (it needs
|
|
@@ -349,6 +399,23 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
349
399
|
// LRU-capped, cleared on every /mode transition.
|
|
350
400
|
const APPROVED_CACHE_MAX = 50;
|
|
351
401
|
const approvedCommands = new Map();
|
|
402
|
+
// Session cache for ASK-RULE approvals (the rules analog of the exact-command
|
|
403
|
+
// approval cache above): a user "yes" on a permission-rule ask covers an
|
|
404
|
+
// identical later (tool, rule, input) call for the rest of the session, so a
|
|
405
|
+
// retrying model cannot re-prompt the same question in a loop. Keyed by
|
|
406
|
+
// tool + rule raw + the primary input param; cleared with the other caches
|
|
407
|
+
// on every /mode transition (mode changes re-ask — the safe direction).
|
|
408
|
+
const ruleAskApprovals = new Map();
|
|
409
|
+
const ruleAskKey = (toolName, ruleRaw, params) => {
|
|
410
|
+
const primary = typeof params.command === "string"
|
|
411
|
+
? params.command
|
|
412
|
+
: typeof params.path === "string"
|
|
413
|
+
? params.path
|
|
414
|
+
: typeof params.url === "string"
|
|
415
|
+
? params.url
|
|
416
|
+
: JSON.stringify(params);
|
|
417
|
+
return `${toolName}\u0000${ruleRaw}\u0000${primary}`;
|
|
418
|
+
};
|
|
352
419
|
const cacheKey = (cwd, command) => `${cwd}\u0000${command}`;
|
|
353
420
|
const rememberApproved = (cwd, command) => {
|
|
354
421
|
const key = cacheKey(cwd, command);
|
|
@@ -419,6 +486,39 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
419
486
|
return "no";
|
|
420
487
|
return ctx.signal?.aborted ? "aborted" : "dismissed";
|
|
421
488
|
};
|
|
489
|
+
/**
|
|
490
|
+
* variant: the Guardian ask dialog with an optional third option
|
|
491
|
+
* (persist a user-level permission rule). Same semantics as askUser.
|
|
492
|
+
*/
|
|
493
|
+
const askUserWithOptions = async (ctx, title, rememberLabel, ruleLabel) => {
|
|
494
|
+
if (ctx.signal?.aborted)
|
|
495
|
+
return "aborted";
|
|
496
|
+
const options = [
|
|
497
|
+
ASK_YES,
|
|
498
|
+
...(rememberLabel ? [rememberLabel] : []),
|
|
499
|
+
...(ruleLabel ? [ruleLabel] : []),
|
|
500
|
+
ASK_NO,
|
|
501
|
+
];
|
|
502
|
+
let choice;
|
|
503
|
+
try {
|
|
504
|
+
choice = await ctx.ui.select(title, options, {
|
|
505
|
+
...(ctx.signal ? { signal: ctx.signal } : {}),
|
|
506
|
+
timeout: ASK_TIMEOUT_MS,
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
catch {
|
|
510
|
+
choice = undefined;
|
|
511
|
+
}
|
|
512
|
+
if (choice === ASK_YES)
|
|
513
|
+
return "yes";
|
|
514
|
+
if (rememberLabel !== null && choice === rememberLabel)
|
|
515
|
+
return "remember";
|
|
516
|
+
if (ruleLabel !== null && choice === ruleLabel)
|
|
517
|
+
return "rule";
|
|
518
|
+
if (choice === ASK_NO)
|
|
519
|
+
return "no";
|
|
520
|
+
return ctx.signal?.aborted ? "aborted" : "dismissed";
|
|
521
|
+
};
|
|
422
522
|
const buildAskTitle = (command, rationale, riskLevel) => {
|
|
423
523
|
const risk = riskLevel ? ` (risk: ${riskLevel})` : "";
|
|
424
524
|
return `Guardian asks${risk}\n${rationale}\n$ ${boundedCommand(command)}`;
|
|
@@ -427,8 +527,121 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
427
527
|
// Snapshot the mode ONCE: /mode can flip mid-await, and post-await reads
|
|
428
528
|
// of the closure variable would disagree with the decision already made.
|
|
429
529
|
const modeAtEntry = mode;
|
|
530
|
+
// set when an allow-rule verdict hits an alwaysConfirmTools tool
|
|
531
|
+
// (Floor 2) — forces the confirm flow below instead of short-circuiting.
|
|
532
|
+
let ruleAskConfirm = false;
|
|
430
533
|
try {
|
|
431
534
|
const input = event.input ?? {};
|
|
535
|
+
// settings permission rules run BEFORE hooks (Claude Code
|
|
536
|
+
// semantics: a deny rule blocks even when a hook would allow). Order
|
|
537
|
+
// deny → ask → allow; the verdict is final for deny/ask, and allow
|
|
538
|
+
// short-circuits everything below EXCEPT the two hard floors.
|
|
539
|
+
const rulesDeps = deps.permissionRules;
|
|
540
|
+
// The guard + evaluation share ONE try: a poisoned rules array can
|
|
541
|
+
// throw at the `.length` guard just as easily as inside evaluateRules,
|
|
542
|
+
// and both are engine errors — both must log engine_error and degrade
|
|
543
|
+
// to the normal gate, never escape to the outer fail-open catch.
|
|
544
|
+
let ruleVerdict = null;
|
|
545
|
+
try {
|
|
546
|
+
if (rulesDeps && rulesDeps.length > 0) {
|
|
547
|
+
const cwd = ctx?.cwd ?? ".";
|
|
548
|
+
ruleVerdict = evaluateRules(rulesDeps, {
|
|
549
|
+
toolName: event.toolName,
|
|
550
|
+
params: input,
|
|
551
|
+
cwd,
|
|
552
|
+
isProjectTrusted: (() => { try {
|
|
553
|
+
return ctx?.isProjectTrusted() ?? true;
|
|
554
|
+
}
|
|
555
|
+
catch {
|
|
556
|
+
return true;
|
|
557
|
+
} })(),
|
|
558
|
+
userStateHome: deps.rulesUserStateHome ?? cwd,
|
|
559
|
+
projectRoot: deps.rulesProjectRoot ?? null,
|
|
560
|
+
...(deps.rulesHomeDir ? { homeDir: deps.rulesHomeDir } : {}),
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
catch (err) {
|
|
565
|
+
// Fail-soft: a rule-engine error never blocks or allows — but it
|
|
566
|
+
// must not be SILENT: for a would-be deny this degrades to the
|
|
567
|
+
// normal gate (likely an allow), so the trail needs the failure.
|
|
568
|
+
// Error class only — never the thrown message (it can carry
|
|
569
|
+
// command content) or the user content.
|
|
570
|
+
logEvent({
|
|
571
|
+
source: "permission-rules",
|
|
572
|
+
level: "warn",
|
|
573
|
+
event: "engine_error",
|
|
574
|
+
fields: {
|
|
575
|
+
tool: event.toolName,
|
|
576
|
+
error: err instanceof Error ? err.constructor.name : typeof err,
|
|
577
|
+
},
|
|
578
|
+
});
|
|
579
|
+
}
|
|
580
|
+
if (ruleVerdict) {
|
|
581
|
+
try {
|
|
582
|
+
deps.onRuleVerdict?.({ ...ruleVerdict, toolName: event.toolName, cwd: ctx?.cwd ?? "." });
|
|
583
|
+
}
|
|
584
|
+
catch { /* logging must never affect the gate */ }
|
|
585
|
+
if (ruleVerdict.verdict === "deny") {
|
|
586
|
+
const origin = ruleVerdict.rule.source === "project" ? "the project's settings" : "your user settings";
|
|
587
|
+
return {
|
|
588
|
+
block: true,
|
|
589
|
+
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.`,
|
|
590
|
+
};
|
|
591
|
+
}
|
|
592
|
+
if (ruleVerdict.verdict === "ask") {
|
|
593
|
+
// Ask is final: nothing below may auto-allow it. With a UI, the
|
|
594
|
+
// user arbitrates; headless (incl. /go children) fails closed.
|
|
595
|
+
if (!ctx?.hasUI) {
|
|
596
|
+
return {
|
|
597
|
+
block: true,
|
|
598
|
+
reason: `${event.toolName} requires user approval (permission rule ${ruleVerdict.rule.raw}); no UI available — the call was held.`,
|
|
599
|
+
};
|
|
600
|
+
}
|
|
601
|
+
if (ctx.signal?.aborted)
|
|
602
|
+
return { block: true };
|
|
603
|
+
// Session approval cache: an identical (tool, rule, input) "yes"
|
|
604
|
+
// earlier this session covers this call — a retrying model must
|
|
605
|
+
// not re-prompt the same question (dialog-storm guard).
|
|
606
|
+
const askKey = ruleAskKey(event.toolName, ruleVerdict.rule.raw, input);
|
|
607
|
+
if (ruleAskApprovals.has(askKey))
|
|
608
|
+
return {};
|
|
609
|
+
const origin = ruleVerdict.rule.source === "project" ? "the project's settings" : "your user settings";
|
|
610
|
+
const choice = await askUser(ctx, `Permission rule (ask) in ${origin}:\n${ruleVerdict.rule.raw}\nAllow ${event.toolName}?`, null);
|
|
611
|
+
if (choice === "yes") {
|
|
612
|
+
if (ruleAskApprovals.size > APPROVED_CACHE_MAX)
|
|
613
|
+
ruleAskApprovals.clear();
|
|
614
|
+
ruleAskApprovals.set(askKey, true);
|
|
615
|
+
return {};
|
|
616
|
+
}
|
|
617
|
+
if (choice === "aborted")
|
|
618
|
+
return { block: true };
|
|
619
|
+
return {
|
|
620
|
+
block: true,
|
|
621
|
+
reason: `The user declined ${event.toolName} (permission rule ${ruleVerdict.rule.raw}). Ask what they would like to do differently, or take a different approach.`,
|
|
622
|
+
};
|
|
623
|
+
}
|
|
624
|
+
// verdict === "allow": the floors decide whether the allow
|
|
625
|
+
// short-circuits. Linear by construction — allowRuleFloorVerdict
|
|
626
|
+
// owns the three invariants (plan-mode hold, forbidden band,
|
|
627
|
+
// alwaysConfirmTools confirm) and is unit-tested directly.
|
|
628
|
+
const floor = allowRuleFloorVerdict(event.toolName, input, modeAtEntry, effectivePolicy);
|
|
629
|
+
if (floor.kind === "block") {
|
|
630
|
+
return { block: true, reason: floor.reason };
|
|
631
|
+
}
|
|
632
|
+
if (floor.kind === "confirm") {
|
|
633
|
+
// Defer to the existing confirm flow via the ruleAskConfirm flag
|
|
634
|
+
// (set below) — alwaysConfirmTools keeps its fresh-consent contract.
|
|
635
|
+
ruleAskConfirm = true;
|
|
636
|
+
}
|
|
637
|
+
else if (floor.kind === "hold") {
|
|
638
|
+
// Plan mode's no-mutation contract outranks the allow rule —
|
|
639
|
+
// fall through to the normal plan-mode gate below.
|
|
640
|
+
}
|
|
641
|
+
else {
|
|
642
|
+
return {};
|
|
643
|
+
}
|
|
644
|
+
}
|
|
432
645
|
// YAG-506: PreToolUse hooks run BEFORE decideGate. They can short-circuit
|
|
433
646
|
// (allow/deny/ask) or fall through to the normal gate logic. The result
|
|
434
647
|
// is cached in preToolUseResult so the "ask" check below does NOT
|
|
@@ -681,12 +894,22 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
681
894
|
}
|
|
682
895
|
}
|
|
683
896
|
// Offer "don't ask again" only when the grant would actually
|
|
684
|
-
// cover this command (grant-time validation).
|
|
897
|
+
// cover this command (grant-time validation). adds a
|
|
898
|
+
// third option: persist a user-level permission rule (survives
|
|
899
|
+
// across repos, unlike the repo-scoped grant) — offered only when
|
|
900
|
+
// a grantCandidate also exists (the same prefix discipline; the
|
|
901
|
+
// rule is the same pattern in settings form).
|
|
685
902
|
const grantCandidate = validateGrant(command, effectivePolicy.execPolicy ?? DEFAULT_EXEC_POLICY, resolveRepoKeyFor(cwd));
|
|
903
|
+
const ruleCandidate = grantCandidate
|
|
904
|
+
? `Bash(${grantCandidate.pattern.join(" ")}:*)`
|
|
905
|
+
: null;
|
|
686
906
|
const rememberLabel = grantCandidate
|
|
687
907
|
? `Yes, and don't ask again for \`${describePrefix(grantCandidate.pattern)}\` in this repo`
|
|
688
908
|
: null;
|
|
689
|
-
const
|
|
909
|
+
const ruleLabel = grantCandidate && deps.persistUserRule
|
|
910
|
+
? `Yes, and always allow \`${grantCandidate.pattern.join(" ")}\` in my user settings`
|
|
911
|
+
: null;
|
|
912
|
+
const resolution = await askUserWithOptions(ctx, buildAskTitle(command, verdict.rationale, verdict.riskLevel), rememberLabel, ruleLabel);
|
|
690
913
|
if (resolution === "yes") {
|
|
691
914
|
rememberApproved(cwd, command);
|
|
692
915
|
emitGateEvent({
|
|
@@ -699,6 +922,44 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
699
922
|
});
|
|
700
923
|
return {};
|
|
701
924
|
}
|
|
925
|
+
if (resolution === "rule" && ruleCandidate && deps.persistUserRule) {
|
|
926
|
+
let persisted = false;
|
|
927
|
+
try {
|
|
928
|
+
deps.persistUserRule(ruleCandidate);
|
|
929
|
+
persisted = true;
|
|
930
|
+
// Also covers this session like a grant would:
|
|
931
|
+
rememberApproved(cwd, command);
|
|
932
|
+
}
|
|
933
|
+
catch (err) {
|
|
934
|
+
// Fail-soft: the in-memory approval still applies THIS
|
|
935
|
+
// session. But the user just made an explicit durable choice
|
|
936
|
+
// in the dialog — a silent failure would leave them believing
|
|
937
|
+
// a rule exists that will not survive restart. Log the
|
|
938
|
+
// failure (rule string only, it is user-configured text, plus
|
|
939
|
+
// error class — never the thrown message) and tell the user.
|
|
940
|
+
logEvent({
|
|
941
|
+
source: "permission-rules",
|
|
942
|
+
level: "warn",
|
|
943
|
+
event: "user_rule_save_failed",
|
|
944
|
+
fields: {
|
|
945
|
+
rule: ruleCandidate,
|
|
946
|
+
error: err instanceof Error ? err.constructor.name : typeof err,
|
|
947
|
+
},
|
|
948
|
+
});
|
|
949
|
+
if (ctx?.hasUI) {
|
|
950
|
+
ctx.ui.notify(`Could not save the permission rule to your settings — it applies to this session only.`, "warning");
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
emitGateEvent({
|
|
954
|
+
...eventBase,
|
|
955
|
+
outcome: persisted ? "ask_approved_remembered" : "ask_approved",
|
|
956
|
+
riskLevel: verdict.riskLevel,
|
|
957
|
+
rationale: verdict.rationale,
|
|
958
|
+
durationMs,
|
|
959
|
+
consulted: true,
|
|
960
|
+
});
|
|
961
|
+
return {};
|
|
962
|
+
}
|
|
702
963
|
if (resolution === "remember" && grantCandidate) {
|
|
703
964
|
const grantRecord = {
|
|
704
965
|
...grantCandidate,
|
|
@@ -823,6 +1084,11 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
823
1084
|
if (preToolUseResult?.decision === "ask") {
|
|
824
1085
|
decision = { block: false, confirm: true };
|
|
825
1086
|
}
|
|
1087
|
+
// an allow-rule verdict on an alwaysConfirmTools tool forced
|
|
1088
|
+
// the confirm flow (Floor 2 above) — same treatment as a hook "ask".
|
|
1089
|
+
if (ruleAskConfirm) {
|
|
1090
|
+
decision = { block: false, confirm: true };
|
|
1091
|
+
}
|
|
826
1092
|
if (decision.confirm) {
|
|
827
1093
|
// YAG-506: PermissionRequest hooks run before the confirm dialog.
|
|
828
1094
|
if (hookRunner) {
|
|
@@ -937,6 +1203,7 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
937
1203
|
// A mode change is a trust-posture change: session ask-approvals do
|
|
938
1204
|
// not carry across it (grants persist but are suppressed in review).
|
|
939
1205
|
approvedCommands.clear();
|
|
1206
|
+
ruleAskApprovals.clear();
|
|
940
1207
|
}
|
|
941
1208
|
mode = arg;
|
|
942
1209
|
paintMode(ctx);
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Curated bash command → file-operation table.
|
|
3
|
+
*
|
|
4
|
+
* The bridge that makes Read/Edit deny rules bite on bash commands: for a
|
|
5
|
+
* curated set of commands, extract the file paths they touch, classify each
|
|
6
|
+
* touch as read or write, and run those paths through the same path-rule
|
|
7
|
+
* engine. `cat .env`, `sed -i .env`, `cp .env x`, `echo x > .env` are all
|
|
8
|
+
* caught by a `Read(.env)`/`Edit(.env)` deny.
|
|
9
|
+
*
|
|
10
|
+
* Ported from Claude Code's BashTool/pathValidation.ts (PATH_EXTRACTORS +
|
|
11
|
+
* COMMAND_OPERATION_TYPE), adapted to shellParse tokens. Guardrails (same
|
|
12
|
+
* posture as Claude Code):
|
|
13
|
+
* - unknown flags on commands where flags can change path meaning
|
|
14
|
+
* (mv/cp --target-directory) → the command is NOT auto-analyzed; it
|
|
15
|
+
* degrades to the caller's "ask" path, never silently allowed.
|
|
16
|
+
* - glob arguments in write position → not analyzed (write globs bypass
|
|
17
|
+
* checks in Claude Code; we degrade to ask).
|
|
18
|
+
* - anything unextractable → no opinion; caller decides.
|
|
19
|
+
*/
|
|
20
|
+
export type FileOperation = "read" | "write";
|
|
21
|
+
/** A command's opinion: extracted (op, paths) or needs-ask. */
|
|
22
|
+
export type BashFileArgsResult = {
|
|
23
|
+
kind: "analyzed";
|
|
24
|
+
operation: FileOperation;
|
|
25
|
+
paths: string[];
|
|
26
|
+
} | {
|
|
27
|
+
kind: "unknown";
|
|
28
|
+
};
|
|
29
|
+
/** Commands whose file paths we can extract confidently. */
|
|
30
|
+
export declare const COMMAND_OPERATION: Record<string, FileOperation>;
|
|
31
|
+
/**
|
|
32
|
+
* Analyze one bash SUBCOMMAND string. `kind: "unknown"` means no confident
|
|
33
|
+
* opinion — the caller degrades to ask for deny-evaluation purposes (never
|
|
34
|
+
* to silent allow).
|
|
35
|
+
*/
|
|
36
|
+
export declare function analyzeSubcommand(subcommand: string): BashFileArgsResult;
|
|
37
|
+
/** Split a full command into subcommand strings (reuses shellRules' splitter). */
|
|
38
|
+
export { splitSubcommands } from "./shellRules.js";
|
|
39
|
+
//# sourceMappingURL=bashFileArgs.d.ts.map
|