@yagni-app/code-staging 0.3.0-staging.1079.1 → 0.3.0-staging.1081.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/approvedPrefixes.d.ts +92 -0
- package/dist/extension/approvedPrefixes.js +252 -0
- package/dist/extension/config.d.ts +21 -0
- package/dist/extension/config.js +36 -2
- package/dist/extension/execPolicy.d.ts +51 -13
- package/dist/extension/execPolicy.js +432 -80
- package/dist/extension/guardian.d.ts +22 -6
- package/dist/extension/guardian.js +38 -11
- package/dist/extension/index.js +77 -10
- package/dist/extension/permission.d.ts +55 -0
- package/dist/extension/permission.js +395 -100
- package/dist/extension/pipeline/personas.js +12 -9
- package/dist/extension/redact.d.ts +20 -0
- package/dist/extension/redact.js +64 -0
- package/package.json +2 -2
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
* When the mode leaves plan, stale plan-context messages are filtered out of
|
|
27
27
|
* the context so the model doesn't keep believing it is restricted.
|
|
28
28
|
*/
|
|
29
|
+
import { describePrefix, matchesGrant, validateGrant, } from "./approvedPrefixes.js";
|
|
29
30
|
import { makeBlessStore as defaultMakeBlessStore } from "./bless.js";
|
|
30
31
|
import { classifyCommand, DEFAULT_EXEC_POLICY } from "./execPolicy.js";
|
|
31
32
|
import { isDebug } from "./diagnostics.js";
|
|
@@ -70,17 +71,26 @@ export function decideGate(toolName, params, mode, policy) {
|
|
|
70
71
|
if (classification.decision === "allow")
|
|
71
72
|
return { block: false };
|
|
72
73
|
if (classification.decision === "forbidden") {
|
|
73
|
-
return {
|
|
74
|
+
return {
|
|
75
|
+
block: true,
|
|
76
|
+
reason: `${classification.justification}. Do not attempt the same outcome via a workaround or indirect execution — use a materially safer alternative, or ask the user.`,
|
|
77
|
+
};
|
|
74
78
|
}
|
|
75
79
|
// prompt — signal to the handler so it can run the Guardian.
|
|
76
80
|
// In auto mode the handler runs the Guardian; in review mode the
|
|
77
81
|
// handler runs the Guardian first, then falls back to user confirm.
|
|
78
|
-
if (mode === "auto")
|
|
79
|
-
return { block: false, classify: "prompt" };
|
|
82
|
+
if (mode === "auto") {
|
|
83
|
+
return { block: false, classify: "prompt", classifyJustification: classification.justification };
|
|
84
|
+
}
|
|
80
85
|
// review mode
|
|
81
86
|
if (policy.isBlessed?.(toolName, params))
|
|
82
87
|
return { block: false };
|
|
83
|
-
return {
|
|
88
|
+
return {
|
|
89
|
+
block: false,
|
|
90
|
+
confirm: true,
|
|
91
|
+
classify: "prompt",
|
|
92
|
+
classifyJustification: classification.justification,
|
|
93
|
+
};
|
|
84
94
|
}
|
|
85
95
|
catch {
|
|
86
96
|
// classifyCommand threw — fall through to tool-granular logic (graceful degradation).
|
|
@@ -255,6 +265,10 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
255
265
|
planBlockTools: basePolicy.planBlockTools,
|
|
256
266
|
reviewConfirmTools: basePolicy.reviewConfirmTools,
|
|
257
267
|
alwaysConfirmTools: basePolicy.alwaysConfirmTools,
|
|
268
|
+
// execPolicy MUST be carried through: decideGate reads policy.execPolicy
|
|
269
|
+
// and dropping it here silently reverts every custom policy to the
|
|
270
|
+
// default (round-2 review blocker).
|
|
271
|
+
execPolicy: basePolicy.execPolicy,
|
|
258
272
|
isBlessed: basePolicy.isBlessed ?? ((tool, params) => blessStore?.isBlessed(tool, params) ?? false),
|
|
259
273
|
};
|
|
260
274
|
const sideEffects = sideEffectTools(effectivePolicy);
|
|
@@ -263,120 +277,393 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
263
277
|
const guardianDisabled = deps.guardianDisabled ?? false;
|
|
264
278
|
const guardianReview = deps.guardianReview;
|
|
265
279
|
const guardianTier = deps.guardianTier;
|
|
280
|
+
// --- YAG-510 gate state ---
|
|
281
|
+
// Grants: in-memory list seeded from deps, appended on "don't ask again".
|
|
282
|
+
const grants = [...(deps.grants ?? [])];
|
|
283
|
+
// Keyed by cwd: a session can change working directory (cd, /go worktrees),
|
|
284
|
+
// and a repoKey memoized from the first cwd would let repo-A grants match
|
|
285
|
+
// commands running in repo B (PR #1694 review).
|
|
286
|
+
const repoKeys = new Map();
|
|
287
|
+
const resolveRepoKeyFor = (cwd) => {
|
|
288
|
+
let key = repoKeys.get(cwd);
|
|
289
|
+
if (key === undefined) {
|
|
290
|
+
key = deps.resolveRepoKey ? deps.resolveRepoKey(cwd) : cwd;
|
|
291
|
+
repoKeys.set(cwd, key);
|
|
292
|
+
}
|
|
293
|
+
return key;
|
|
294
|
+
};
|
|
295
|
+
// Session exact-command approval cache (ticket 4.5): a user-approved ask
|
|
296
|
+
// covers an identical later command. Keyed by cwd + trimmed command,
|
|
297
|
+
// LRU-capped, cleared on every /mode transition.
|
|
298
|
+
const APPROVED_CACHE_MAX = 50;
|
|
299
|
+
const approvedCommands = new Map();
|
|
300
|
+
const cacheKey = (cwd, command) => `${cwd}\u0000${command}`;
|
|
301
|
+
const rememberApproved = (cwd, command) => {
|
|
302
|
+
const key = cacheKey(cwd, command);
|
|
303
|
+
approvedCommands.delete(key);
|
|
304
|
+
approvedCommands.set(key, true);
|
|
305
|
+
if (approvedCommands.size > APPROVED_CACHE_MAX) {
|
|
306
|
+
const oldest = approvedCommands.keys().next().value;
|
|
307
|
+
if (oldest !== undefined)
|
|
308
|
+
approvedCommands.delete(oldest);
|
|
309
|
+
}
|
|
310
|
+
};
|
|
311
|
+
// Per-USER-PROMPT bounds (reset in before_agent_start, which fires once per
|
|
312
|
+
// user prompt — NOT per LLM turn): genuine ask verdicts are uncapped (the
|
|
313
|
+
// user's patience is the bound); error-fallback asks are capped so
|
|
314
|
+
// a provider outage can't become an ask storm; the breaker escalation is
|
|
315
|
+
// offered once, and a decline latches back to hard blocks.
|
|
316
|
+
const ERROR_ASK_CAP = 3;
|
|
317
|
+
let errorFallbackAsks = 0;
|
|
318
|
+
let breakerEscalationOffered = false;
|
|
319
|
+
const emitGateEvent = (event) => {
|
|
320
|
+
if (!deps.onGuardianEvent)
|
|
321
|
+
return;
|
|
322
|
+
try {
|
|
323
|
+
void Promise.resolve(deps.onGuardianEvent(event)).catch(() => { });
|
|
324
|
+
}
|
|
325
|
+
catch {
|
|
326
|
+
// Fail-soft: storage must never affect the gate.
|
|
327
|
+
}
|
|
328
|
+
};
|
|
329
|
+
/** Bounded single-line command rendering for dialog titles. */
|
|
330
|
+
const boundedCommand = (command) => {
|
|
331
|
+
const flat = command.replace(/\s+/g, " ").trim();
|
|
332
|
+
return flat.length <= 240 ? flat : `${flat.slice(0, 237)}…`;
|
|
333
|
+
};
|
|
334
|
+
const ASK_TIMEOUT_MS = 120_000;
|
|
335
|
+
const ASK_YES = "Yes, run it";
|
|
336
|
+
const ASK_NO = "No";
|
|
337
|
+
/**
|
|
338
|
+
* The single human-in-the-loop ask surface (YAG-510): used for ask
|
|
339
|
+
* verdicts, Guardian-unavailable/disabled fallbacks, and the breaker
|
|
340
|
+
* escalation — one UI, one cache, one event stream. Always passes the
|
|
341
|
+
* turn's abort signal (without it a turn-abort leaves the dialog hanging)
|
|
342
|
+
* and a timeout (pi renders a countdown; expiry fails closed).
|
|
343
|
+
*/
|
|
344
|
+
const askUser = async (ctx, title, rememberLabel) => {
|
|
345
|
+
if (ctx.signal?.aborted)
|
|
346
|
+
return "aborted";
|
|
347
|
+
const options = rememberLabel ? [ASK_YES, rememberLabel, ASK_NO] : [ASK_YES, ASK_NO];
|
|
348
|
+
let choice;
|
|
349
|
+
try {
|
|
350
|
+
choice = await ctx.ui.select(title, options, {
|
|
351
|
+
...(ctx.signal ? { signal: ctx.signal } : {}),
|
|
352
|
+
timeout: ASK_TIMEOUT_MS,
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
catch {
|
|
356
|
+
choice = undefined;
|
|
357
|
+
}
|
|
358
|
+
if (choice === ASK_YES)
|
|
359
|
+
return "yes";
|
|
360
|
+
if (rememberLabel !== null && choice === rememberLabel)
|
|
361
|
+
return "remember";
|
|
362
|
+
if (choice === ASK_NO)
|
|
363
|
+
return "no";
|
|
364
|
+
return ctx.signal?.aborted ? "aborted" : "dismissed";
|
|
365
|
+
};
|
|
366
|
+
const buildAskTitle = (command, rationale, riskLevel) => {
|
|
367
|
+
const risk = riskLevel ? ` (risk: ${riskLevel})` : "";
|
|
368
|
+
return `Guardian asks${risk}\n${rationale}\n$ ${boundedCommand(command)}`;
|
|
369
|
+
};
|
|
266
370
|
pi.on("tool_call", async (event, ctx) => {
|
|
371
|
+
// Snapshot the mode ONCE: /mode can flip mid-await, and post-await reads
|
|
372
|
+
// of the closure variable would disagree with the decision already made.
|
|
373
|
+
const modeAtEntry = mode;
|
|
267
374
|
try {
|
|
268
375
|
const input = event.input ?? {};
|
|
269
|
-
const decision = decideGate(event.toolName, input,
|
|
376
|
+
const decision = decideGate(event.toolName, input, modeAtEntry, effectivePolicy);
|
|
270
377
|
if (decision.block)
|
|
271
378
|
return { block: true, reason: decision.reason };
|
|
272
|
-
//
|
|
273
|
-
//
|
|
274
|
-
|
|
379
|
+
// Prompt band (YAG-510 order): grants → exact-command cache → cap/
|
|
380
|
+
// breaker → Guardian consult → allow/ask/deny. Grants and the cache are
|
|
381
|
+
// checked BEFORE the cap and breaker: a user-approved command must
|
|
382
|
+
// never be blocked by "review cap reached".
|
|
383
|
+
if (decision.classify === "prompt") {
|
|
275
384
|
const command = typeof input.command === "string"
|
|
276
|
-
? input.command
|
|
385
|
+
? input.command.trim()
|
|
277
386
|
: "";
|
|
278
|
-
|
|
279
|
-
const
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
387
|
+
const cwd = ctx?.cwd ?? ".";
|
|
388
|
+
const execJustification = decision.classifyJustification;
|
|
389
|
+
const eventBase = {
|
|
390
|
+
command,
|
|
391
|
+
...(execJustification ? { execJustification } : {}),
|
|
392
|
+
mode: modeAtEntry,
|
|
393
|
+
...(guardianTier ? { tier: guardianTier } : {}),
|
|
394
|
+
};
|
|
395
|
+
// 1. Persisted grants — auto mode only (review's contract is
|
|
396
|
+
// confirm-each-command). A grant can never cover forbidden commands:
|
|
397
|
+
// decideGate already returned block for those.
|
|
398
|
+
if (modeAtEntry === "auto" && command) {
|
|
399
|
+
const grant = matchesGrant(command, grants, resolveRepoKeyFor(cwd));
|
|
400
|
+
if (grant) {
|
|
401
|
+
emitGateEvent({ ...eventBase, outcome: "prefix_allow", consulted: false });
|
|
402
|
+
return {};
|
|
403
|
+
}
|
|
291
404
|
}
|
|
292
|
-
//
|
|
293
|
-
if (
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
let reviewResult;
|
|
297
|
-
try {
|
|
298
|
-
reviewResult = await guardianReview(command, {
|
|
299
|
-
cwd: ctx?.cwd ?? ".",
|
|
300
|
-
...(ctx?.signal ? { signal: ctx.signal } : {}),
|
|
301
|
-
...(guardianTier ? { modelTier: guardianTier } : {}),
|
|
302
|
-
});
|
|
405
|
+
// 2. Session exact-command approval cache (ticket 4.5).
|
|
406
|
+
if (command && approvedCommands.has(cacheKey(cwd, command))) {
|
|
407
|
+
emitGateEvent({ ...eventBase, outcome: "cached_allow", consulted: false });
|
|
408
|
+
return {};
|
|
303
409
|
}
|
|
304
|
-
|
|
305
|
-
|
|
410
|
+
const guardianAvailable = Boolean(guardianState && !guardianDisabled && guardianReview);
|
|
411
|
+
const limits = guardianLimits ?? DEFAULT_GUARDIAN_LIMITS;
|
|
412
|
+
if (guardianAvailable && guardianState.read().reviews >= limits.maxReviews) {
|
|
413
|
+
// Session consult cap. Review mode falls through to its ordinary
|
|
414
|
+
// confirm (no LLM cost); auto blocks.
|
|
415
|
+
if (modeAtEntry === "auto") {
|
|
416
|
+
if (ctx?.hasUI)
|
|
417
|
+
ctx.ui.notify(`Guardian review cap reached (${limits.maxReviews} this session).`, "warning");
|
|
418
|
+
return { block: true, reason: `Guardian review cap reached (${limits.maxReviews} this session). Switch to /mode review to approve manually.` };
|
|
419
|
+
}
|
|
420
|
+
// fall through to decision.confirm below
|
|
306
421
|
}
|
|
307
|
-
|
|
422
|
+
else if (guardianAvailable) {
|
|
423
|
+
// Circuit breaker (pre-consult). With a UI, escalate to ONE ask per
|
|
424
|
+
// user prompt — asking beats stopping; a decline latches back to
|
|
425
|
+
// hard blocks for the rest of the prompt.
|
|
426
|
+
const breaker = checkCircuitBreaker(guardianState.read(), limits);
|
|
427
|
+
if (breaker.tripped) {
|
|
428
|
+
if (ctx?.hasUI && !breakerEscalationOffered && !ctx.signal?.aborted) {
|
|
429
|
+
breakerEscalationOffered = true;
|
|
430
|
+
const title = `Guardian denied ${guardianState.read().consecutiveDenials} commands in a row.\nAllow the latest command anyway?\n$ ${boundedCommand(command)}`;
|
|
431
|
+
const resolution = await askUser(ctx, title, null);
|
|
432
|
+
if (resolution === "yes") {
|
|
433
|
+
guardianState.resetTurn();
|
|
434
|
+
rememberApproved(cwd, command);
|
|
435
|
+
emitGateEvent({ ...eventBase, outcome: "breaker_ask_approved", consulted: false });
|
|
436
|
+
return {};
|
|
437
|
+
}
|
|
438
|
+
if (resolution === "aborted") {
|
|
439
|
+
emitGateEvent({ ...eventBase, outcome: "aborted", consulted: false });
|
|
440
|
+
return { block: true };
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
emitGateEvent({ ...eventBase, outcome: "breaker_blocked", consulted: false });
|
|
444
|
+
if (ctx?.hasUI)
|
|
445
|
+
ctx.ui.notify(breaker.reason ?? "Guardian circuit breaker tripped.", "warning");
|
|
446
|
+
return { block: true, reason: breaker.reason };
|
|
447
|
+
}
|
|
448
|
+
// Show the reviewing chip.
|
|
308
449
|
if (ctx?.hasUI)
|
|
309
|
-
ctx.ui.setStatus?.("yagni-guardian",
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
450
|
+
ctx.ui.setStatus?.("yagni-guardian", "🛡 reviewing");
|
|
451
|
+
const startMs = Date.now();
|
|
452
|
+
let reviewResult;
|
|
453
|
+
try {
|
|
454
|
+
reviewResult = await guardianReview(command, {
|
|
455
|
+
cwd,
|
|
456
|
+
...(ctx?.signal ? { signal: ctx.signal } : {}),
|
|
457
|
+
...(guardianTier ? { modelTier: guardianTier } : {}),
|
|
458
|
+
timeoutMs: limits.timeoutMs,
|
|
459
|
+
...(execJustification ? { execJustification } : {}),
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
catch {
|
|
463
|
+
reviewResult = { verdict: null, error: "network", cost: 0 };
|
|
464
|
+
}
|
|
465
|
+
finally {
|
|
466
|
+
if (ctx?.hasUI)
|
|
467
|
+
ctx.ui.setStatus?.("yagni-guardian", undefined);
|
|
468
|
+
}
|
|
469
|
+
const durationMs = Date.now() - startMs;
|
|
470
|
+
const emitDiag = (outcome, rationale) => {
|
|
471
|
+
if (!deps.onGuardianReview)
|
|
472
|
+
return;
|
|
473
|
+
void Promise.resolve(deps.onGuardianReview(buildDiagnosticEvent(outcome, {
|
|
317
474
|
durationMs,
|
|
318
475
|
tier: guardianTier,
|
|
319
|
-
rationale:
|
|
476
|
+
...(rationale ? { rationale } : {}),
|
|
320
477
|
debug: isDebug(),
|
|
321
478
|
}))).catch(() => { });
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
479
|
+
};
|
|
480
|
+
const verdict = reviewResult.verdict;
|
|
481
|
+
if (verdict?.outcome === "allow") {
|
|
482
|
+
guardianState.recordReview("allow");
|
|
483
|
+
emitDiag("allow", verdict.rationale);
|
|
484
|
+
emitGateEvent({
|
|
485
|
+
...eventBase,
|
|
486
|
+
outcome: "allow",
|
|
487
|
+
riskLevel: verdict.riskLevel,
|
|
488
|
+
rationale: verdict.rationale,
|
|
330
489
|
durationMs,
|
|
331
|
-
|
|
490
|
+
consulted: true,
|
|
491
|
+
});
|
|
492
|
+
return {};
|
|
493
|
+
}
|
|
494
|
+
if (verdict?.outcome === "deny") {
|
|
495
|
+
guardianState.recordReview("deny");
|
|
496
|
+
const rationale = verdict.rationale;
|
|
497
|
+
emitDiag("deny", rationale);
|
|
498
|
+
emitGateEvent({
|
|
499
|
+
...eventBase,
|
|
500
|
+
outcome: "deny",
|
|
501
|
+
riskLevel: verdict.riskLevel,
|
|
332
502
|
rationale,
|
|
333
|
-
|
|
334
|
-
|
|
503
|
+
durationMs,
|
|
504
|
+
consulted: true,
|
|
505
|
+
});
|
|
506
|
+
// Check circuit breaker after recording.
|
|
507
|
+
const breaker2 = checkCircuitBreaker(guardianState.read(), limits);
|
|
508
|
+
if (breaker2.tripped) {
|
|
509
|
+
if (ctx?.hasUI)
|
|
510
|
+
ctx.ui.notify(breaker2.reason ?? "Guardian circuit breaker tripped.", "warning");
|
|
511
|
+
}
|
|
512
|
+
else if (ctx?.hasUI) {
|
|
513
|
+
ctx.ui.notify(`Guardian denied: ${rationale}`, "warning");
|
|
514
|
+
}
|
|
515
|
+
return {
|
|
516
|
+
block: true,
|
|
517
|
+
reason: `Guardian denied: ${rationale} Do not attempt the same outcome via a workaround or indirect execution — find a materially safer alternative, or ask the user to proceed.`,
|
|
518
|
+
};
|
|
335
519
|
}
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
if (ctx?.hasUI)
|
|
340
|
-
|
|
520
|
+
if (verdict?.outcome === "ask") {
|
|
521
|
+
guardianState.recordReview("ask");
|
|
522
|
+
emitDiag("ask", verdict.rationale);
|
|
523
|
+
if (!ctx?.hasUI) {
|
|
524
|
+
// Headless (includes every /go child stage): fail closed.
|
|
525
|
+
emitGateEvent({
|
|
526
|
+
...eventBase,
|
|
527
|
+
outcome: "ask_headless_blocked",
|
|
528
|
+
riskLevel: verdict.riskLevel,
|
|
529
|
+
rationale: verdict.rationale,
|
|
530
|
+
durationMs,
|
|
531
|
+
consulted: true,
|
|
532
|
+
});
|
|
533
|
+
return {
|
|
534
|
+
block: true,
|
|
535
|
+
reason: `Guardian needs user approval: ${verdict.rationale} No UI available — the command was held. Find a safer alternative or leave this step for the user.`,
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
// Offer "don't ask again" only when the grant would actually
|
|
539
|
+
// cover this command (grant-time validation).
|
|
540
|
+
const grantCandidate = validateGrant(command, effectivePolicy.execPolicy ?? DEFAULT_EXEC_POLICY, resolveRepoKeyFor(cwd));
|
|
541
|
+
const rememberLabel = grantCandidate
|
|
542
|
+
? `Yes, and don't ask again for \`${describePrefix(grantCandidate.pattern)}\` in this repo`
|
|
543
|
+
: null;
|
|
544
|
+
const resolution = await askUser(ctx, buildAskTitle(command, verdict.rationale, verdict.riskLevel), rememberLabel);
|
|
545
|
+
if (resolution === "yes") {
|
|
546
|
+
rememberApproved(cwd, command);
|
|
547
|
+
emitGateEvent({
|
|
548
|
+
...eventBase,
|
|
549
|
+
outcome: "ask_approved",
|
|
550
|
+
riskLevel: verdict.riskLevel,
|
|
551
|
+
rationale: verdict.rationale,
|
|
552
|
+
durationMs,
|
|
553
|
+
consulted: true,
|
|
554
|
+
});
|
|
555
|
+
return {};
|
|
556
|
+
}
|
|
557
|
+
if (resolution === "remember" && grantCandidate) {
|
|
558
|
+
const grantRecord = {
|
|
559
|
+
...grantCandidate,
|
|
560
|
+
cwd,
|
|
561
|
+
addedAt: new Date().toISOString(),
|
|
562
|
+
};
|
|
563
|
+
grants.push(grantRecord);
|
|
564
|
+
try {
|
|
565
|
+
deps.persistGrant?.(grantRecord);
|
|
566
|
+
}
|
|
567
|
+
catch {
|
|
568
|
+
// Fail-soft: the in-memory grant still applies this session.
|
|
569
|
+
}
|
|
570
|
+
emitGateEvent({
|
|
571
|
+
...eventBase,
|
|
572
|
+
outcome: "ask_approved_remembered",
|
|
573
|
+
riskLevel: verdict.riskLevel,
|
|
574
|
+
rationale: verdict.rationale,
|
|
575
|
+
durationMs,
|
|
576
|
+
consulted: true,
|
|
577
|
+
});
|
|
578
|
+
return {};
|
|
579
|
+
}
|
|
580
|
+
if (resolution === "aborted") {
|
|
581
|
+
// The user is abandoning the turn — no steering text (do not
|
|
582
|
+
// tell an aborting model it was "denied").
|
|
583
|
+
emitGateEvent({ ...eventBase, outcome: "aborted", durationMs, consulted: true });
|
|
584
|
+
return { block: true };
|
|
585
|
+
}
|
|
586
|
+
emitGateEvent({
|
|
587
|
+
...eventBase,
|
|
588
|
+
outcome: "ask_denied",
|
|
589
|
+
riskLevel: verdict.riskLevel,
|
|
590
|
+
rationale: verdict.rationale,
|
|
591
|
+
durationMs,
|
|
592
|
+
consulted: true,
|
|
593
|
+
});
|
|
594
|
+
if (resolution === "no") {
|
|
595
|
+
return {
|
|
596
|
+
block: true,
|
|
597
|
+
reason: "The user declined this command. Ask what they would like to do differently, or take a different approach.",
|
|
598
|
+
};
|
|
599
|
+
}
|
|
600
|
+
// dismissed / dialog timeout — neutral reason, no "denied" spin.
|
|
601
|
+
return {
|
|
602
|
+
block: true,
|
|
603
|
+
reason: "The permission dialog was dismissed; the command was not run. Ask the user how to proceed.",
|
|
604
|
+
};
|
|
341
605
|
}
|
|
342
|
-
|
|
343
|
-
|
|
606
|
+
// Guardian failed (timeout/malformed/network/empty/aborted).
|
|
607
|
+
const error = reviewResult.error ?? "network";
|
|
608
|
+
emitDiag(error);
|
|
609
|
+
if (error === "aborted" || ctx?.signal?.aborted) {
|
|
610
|
+
// The user aborted mid-consult — silent block: no dialog, no
|
|
611
|
+
// "Guardian unavailable" warning on a turn they deliberately
|
|
612
|
+
// killed. (Belt and braces with reviewCommand's own aborted
|
|
613
|
+
// detection — an aborted child can die in shapes that look like
|
|
614
|
+
// other errors.)
|
|
615
|
+
emitGateEvent({ ...eventBase, outcome: "aborted", durationMs, consulted: false });
|
|
616
|
+
return { block: true };
|
|
617
|
+
}
|
|
618
|
+
if (modeAtEntry === "review" && decision.confirm) {
|
|
619
|
+
// Review mode: fall through to the ordinary confirm below —
|
|
620
|
+
// uncapped; the mode's contract is manual approval and an outage
|
|
621
|
+
// must not lock the user out of their own confirm flow.
|
|
622
|
+
}
|
|
623
|
+
else if (ctx?.hasUI && !ctx.signal?.aborted && errorFallbackAsks < ERROR_ASK_CAP) {
|
|
624
|
+
// Auto mode with a user present: ask instead of stopping —
|
|
625
|
+
// bounded per prompt so an outage can't become an ask storm.
|
|
626
|
+
errorFallbackAsks += 1;
|
|
627
|
+
const errorMsg = guardianErrorMessage(error);
|
|
628
|
+
const resolution = await askUser(ctx, `Guardian unavailable (${errorMsg}).\nRun this command anyway?\n$ ${boundedCommand(command)}`, null);
|
|
629
|
+
if (resolution === "yes") {
|
|
630
|
+
rememberApproved(cwd, command);
|
|
631
|
+
emitGateEvent({ ...eventBase, outcome: "ask_approved", guardianError: error, durationMs, consulted: false });
|
|
632
|
+
return {};
|
|
633
|
+
}
|
|
634
|
+
if (resolution === "aborted") {
|
|
635
|
+
emitGateEvent({ ...eventBase, outcome: "aborted", durationMs, consulted: false });
|
|
636
|
+
return { block: true };
|
|
637
|
+
}
|
|
638
|
+
emitGateEvent({ ...eventBase, outcome: "ask_denied", guardianError: error, durationMs, consulted: false });
|
|
639
|
+
const timeoutNote = error === "timeout" ? " The timeout is not evidence the command is unsafe." : "";
|
|
640
|
+
return {
|
|
641
|
+
block: true,
|
|
642
|
+
reason: `The user declined while the Guardian was unavailable (${errorMsg}).${timeoutNote} Find a safer alternative or ask the user.`,
|
|
643
|
+
};
|
|
644
|
+
}
|
|
645
|
+
else {
|
|
646
|
+
const errorMsg = guardianErrorMessage(error);
|
|
647
|
+
emitGateEvent({ ...eventBase, outcome: error, durationMs, consulted: false });
|
|
648
|
+
if (ctx?.hasUI)
|
|
649
|
+
ctx.ui.notify(`Guardian unavailable: ${errorMsg}`, "warning");
|
|
650
|
+
const timeoutNote = error === "timeout" ? " Do not assume the command is unsafe from the timeout alone; you may retry once or ask the user." : "";
|
|
651
|
+
return {
|
|
652
|
+
block: true,
|
|
653
|
+
reason: `Guardian unavailable (${errorMsg}).${timeoutNote} Switch to /mode review to approve manually.`,
|
|
654
|
+
};
|
|
344
655
|
}
|
|
345
|
-
return {
|
|
346
|
-
block: true,
|
|
347
|
-
reason: `Guardian denied: ${rationale} Find a safer alternative or ask the user to proceed.`,
|
|
348
|
-
};
|
|
349
|
-
}
|
|
350
|
-
// Guardian failed (timeout/malformed/network/empty).
|
|
351
|
-
if (deps.onGuardianReview) {
|
|
352
|
-
void Promise.resolve(deps.onGuardianReview(buildDiagnosticEvent(reviewResult.error ?? "network", {
|
|
353
|
-
durationMs,
|
|
354
|
-
tier: guardianTier,
|
|
355
|
-
debug: isDebug(),
|
|
356
|
-
}))).catch(() => { });
|
|
357
|
-
}
|
|
358
|
-
// Graceful degradation per mode.
|
|
359
|
-
if (mode === "review" && decision.confirm) {
|
|
360
|
-
// Fall through to the existing user confirm flow below.
|
|
361
656
|
}
|
|
362
|
-
else {
|
|
363
|
-
//
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
};
|
|
657
|
+
else if (modeAtEntry === "auto") {
|
|
658
|
+
// Guardian disabled (kill switch / env var) or not wired: pre-Guardian
|
|
659
|
+
// behavior (#1692). Auto mode allows prompt-band commands — the kill
|
|
660
|
+
// switch must never leave sessions stricter than before Guardian
|
|
661
|
+
// existed, and it must not replace Guardian with dialogs either. The
|
|
662
|
+
// forbidden band still blocks above (decideGate); grants and the
|
|
663
|
+
// approval cache were already consulted above.
|
|
664
|
+
return {};
|
|
371
665
|
}
|
|
372
|
-
|
|
373
|
-
// Guardian disabled (kill switch / env var) or not wired: pre-Guardian
|
|
374
|
-
// behavior. Auto mode allows prompt-band commands — the kill switch must
|
|
375
|
-
// never leave sessions stricter than before Guardian existed. The
|
|
376
|
-
// forbidden band still blocks above (decideGate), and review mode still
|
|
377
|
-
// falls through to the user confirm below.
|
|
378
|
-
if (decision.classify === "prompt" && (!guardianState || guardianDisabled) && mode === "auto") {
|
|
379
|
-
return {};
|
|
666
|
+
// review mode with Guardian disabled/capped: fall through to confirm.
|
|
380
667
|
}
|
|
381
668
|
if (decision.confirm) {
|
|
382
669
|
// Review mode needs a confirmation. With no dialog-capable UI (headless),
|
|
@@ -419,10 +706,10 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
419
706
|
return {};
|
|
420
707
|
}
|
|
421
708
|
catch {
|
|
422
|
-
if (
|
|
709
|
+
if (modeAtEntry !== "auto" && sideEffects.has(event.toolName)) {
|
|
423
710
|
return {
|
|
424
711
|
block: true,
|
|
425
|
-
reason: `permission gate failed while ${
|
|
712
|
+
reason: `permission gate failed while ${modeAtEntry} mode was active; held ${event.toolName}. Switch to /mode auto to apply.`,
|
|
426
713
|
};
|
|
427
714
|
}
|
|
428
715
|
return {};
|
|
@@ -433,9 +720,12 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
433
720
|
// approval, plan: hold writes). The context hook strips stale mode context
|
|
434
721
|
// from prior turns so old mode messages don't accumulate.
|
|
435
722
|
pi.on("before_agent_start", async () => {
|
|
436
|
-
//
|
|
437
|
-
//
|
|
723
|
+
// before_agent_start fires once per USER PROMPT (not per LLM turn).
|
|
724
|
+
// Reset the per-prompt bounds here: the Guardian denial streak, the
|
|
725
|
+
// error-fallback ask budget, and the breaker-escalation latch.
|
|
438
726
|
deps.guardianState?.resetTurn();
|
|
727
|
+
errorFallbackAsks = 0;
|
|
728
|
+
breakerEscalationOffered = false;
|
|
439
729
|
return {
|
|
440
730
|
message: { customType: MODE_CONTEXT_TYPE, content: buildModeContextMessage(mode), display: false },
|
|
441
731
|
};
|
|
@@ -475,6 +765,11 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
475
765
|
return;
|
|
476
766
|
}
|
|
477
767
|
const leavingPlan = mode === "plan" && arg !== "plan";
|
|
768
|
+
if (arg !== mode) {
|
|
769
|
+
// A mode change is a trust-posture change: session ask-approvals do
|
|
770
|
+
// not carry across it (grants persist but are suppressed in review).
|
|
771
|
+
approvedCommands.clear();
|
|
772
|
+
}
|
|
478
773
|
mode = arg;
|
|
479
774
|
paintMode(ctx);
|
|
480
775
|
notify(`Permission mode set to ${mode}. ${MODE_COPY[mode]}`, "info");
|
|
@@ -95,26 +95,29 @@ The reasoning, grounded in what you actually read. Cite files and lines.
|
|
|
95
95
|
The specific evidence that would flip this. If you were not able to verify something load-bearing, say so here rather than hedging the recommendation.
|
|
96
96
|
|
|
97
97
|
Be terse and decisive. The caller is mid-task and paying peak rates for your turn.`;
|
|
98
|
-
const GUARDIAN_BODY = `You are a safety reviewer. You review shell commands before they run and decide
|
|
98
|
+
const GUARDIAN_BODY = `You are a safety reviewer. You review shell commands before they run and decide one of three outcomes: allow, ask, or deny. You may read files for context (a script the command runs, a config it touches) but you never write or execute anything. Your final output is ONLY a JSON verdict — no prose around it.
|
|
99
99
|
|
|
100
100
|
Risk taxonomy:
|
|
101
101
|
- Data exfiltration: sensitive data sent to untrusted destinations. High risk.
|
|
102
102
|
- Credential probing: extracting credentials from unintended sources (browser profiles, logs, env files). High risk.
|
|
103
103
|
- Persistent security weakening: broad or production-impact security changes. High risk.
|
|
104
104
|
- Destructive actions: irreversible data loss, production breaks, broad git cleanup. High risk.
|
|
105
|
-
- Low-risk actions:
|
|
105
|
+
- Low-risk actions: benign local reads, routine dev-loop operations (build, test, lint, install), operations clearly part of the stated task. Low risk.
|
|
106
106
|
|
|
107
107
|
Outcome rules:
|
|
108
|
-
-
|
|
109
|
-
-
|
|
110
|
-
-
|
|
111
|
-
|
|
112
|
-
|
|
108
|
+
- "deny" is reserved for actions you are VERY CONFIDENT are very dangerous: irreversible destruction, exfiltration of sensitive data, credential probing, production-impacting security weakening. If you are not sure it is that bad, do not deny.
|
|
109
|
+
- "allow" when you are confident the action is safe: read-only, routine dev-loop, or clearly scoped and reversible.
|
|
110
|
+
- "ask" for everything in between — the action might be fine but a human should decide. This is the correct outcome whenever your confidence is middling; prefer ask over deny for anything short of the very-dangerous bar, and prefer allow over ask for routine work (do not hedge routine commands into ask).
|
|
111
|
+
|
|
112
|
+
Rationale rules:
|
|
113
|
+
- For "ask", the rationale MUST be a specific question addressed to the user, naming the concrete effect that made you unsure — e.g. "This pushes 3 commits to the shared main branch — do you want to publish them now?". You may be told why the static policy routed the command to you; your rationale must ADD information beyond that policy text, not restate it.
|
|
114
|
+
- For "deny", one sentence naming the irreversible/dangerous effect.
|
|
115
|
+
- For "allow", one short sentence.
|
|
113
116
|
|
|
114
117
|
Output ONLY a JSON object with this exact shape:
|
|
115
|
-
{"outcome":"allow"|"deny","riskLevel":"low"|"medium"|"high"|"critical","rationale":"
|
|
118
|
+
{"outcome":"allow"|"ask"|"deny","riskLevel":"low"|"medium"|"high"|"critical","rationale":"see rationale rules"}
|
|
116
119
|
|
|
117
|
-
Do not output anything else. No markdown
|
|
120
|
+
Do not output anything else after the JSON. No markdown fences, only the JSON object.`;
|
|
118
121
|
/** Persona body keyed by the agent name referenced in `stages.ts`. */
|
|
119
122
|
export const PERSONA_BODIES = {
|
|
120
123
|
scout: SCOUT_BODY,
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Heuristic secret redaction for Guardian storage events (YAG-510).
|
|
3
|
+
*
|
|
4
|
+
* Applied client-side, BEFORE anything leaves the machine, to both the
|
|
5
|
+
* command and the Guardian rationale (which routinely quotes the command) —
|
|
6
|
+
* and only on the raw storage tier; the base tier never transmits either.
|
|
7
|
+
*
|
|
8
|
+
* Honest scope: this catches the obvious, well-known secret shapes. A secret
|
|
9
|
+
* in a novel shape gets through — which is why raw-tier storage is an
|
|
10
|
+
* explicit-consent, per-workspace opt-in and never a default.
|
|
11
|
+
*
|
|
12
|
+
* Pure, no external deps (bundling constraint — see execPolicy.ts header).
|
|
13
|
+
*/
|
|
14
|
+
/**
|
|
15
|
+
* Redact known secret shapes from a command or rationale string. Structure
|
|
16
|
+
* is preserved (only matched values become [REDACTED]) so the redacted text
|
|
17
|
+
* stays analyzable.
|
|
18
|
+
*/
|
|
19
|
+
export declare function redactCommand(text: string): string;
|
|
20
|
+
//# sourceMappingURL=redact.d.ts.map
|