@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.
@@ -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 { block: true, reason: classification.justification };
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 { block: false, confirm: true, classify: "prompt" };
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, mode, effectivePolicy);
376
+ const decision = decideGate(event.toolName, input, modeAtEntry, effectivePolicy);
270
377
  if (decision.block)
271
378
  return { block: true, reason: decision.reason };
272
- // Guardian: when the exec policy classified a bash command as "prompt",
273
- // run the Guardian LLM review instead of interrupting the user (if enabled).
274
- if (decision.classify === "prompt" && guardianState && !guardianDisabled && guardianReview) {
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
- // Session cap check — prevents unlimited Guardian consults.
279
- const limits = guardianLimits ?? DEFAULT_GUARDIAN_LIMITS;
280
- if (guardianState.read().reviews >= limits.maxReviews) {
281
- if (ctx?.hasUI)
282
- ctx.ui.notify(`Guardian review cap reached (${limits.maxReviews} this session).`, "warning");
283
- return { block: true, reason: `Guardian review cap reached (${limits.maxReviews} this session). Switch to /mode review to approve manually.` };
284
- }
285
- // Circuit breaker check.
286
- const breaker = checkCircuitBreaker(guardianState.read(), limits);
287
- if (breaker.tripped) {
288
- if (ctx?.hasUI)
289
- ctx.ui.notify(breaker.reason ?? "Guardian circuit breaker tripped.", "warning");
290
- return { block: true, reason: breaker.reason };
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
- // Show the reviewing chip.
293
- if (ctx?.hasUI)
294
- ctx.ui.setStatus?.("yagni-guardian", "🛡 reviewing");
295
- const startMs = Date.now();
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
- catch {
305
- reviewResult = { verdict: null, error: "network", cost: 0 };
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
- finally {
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", undefined);
310
- }
311
- const durationMs = Date.now() - startMs;
312
- const state = guardianState.read();
313
- if (reviewResult.verdict?.outcome === "allow") {
314
- guardianState.recordReview("allow");
315
- if (deps.onGuardianReview) {
316
- void Promise.resolve(deps.onGuardianReview(buildDiagnosticEvent("allow", {
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: reviewResult.verdict.rationale,
476
+ ...(rationale ? { rationale } : {}),
320
477
  debug: isDebug(),
321
478
  }))).catch(() => { });
322
- }
323
- return {};
324
- }
325
- if (reviewResult.verdict?.outcome === "deny") {
326
- guardianState.recordReview("deny");
327
- const rationale = reviewResult.verdict.rationale;
328
- if (deps.onGuardianReview) {
329
- void Promise.resolve(deps.onGuardianReview(buildDiagnosticEvent("deny", {
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
- tier: guardianTier,
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
- debug: isDebug(),
334
- }))).catch(() => { });
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
- // Check circuit breaker after recording.
337
- const breaker2 = checkCircuitBreaker(guardianState.read(), guardianLimits ?? DEFAULT_GUARDIAN_LIMITS);
338
- if (breaker2.tripped) {
339
- if (ctx?.hasUI)
340
- ctx.ui.notify(breaker2.reason ?? "Guardian circuit breaker tripped.", "warning");
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
- else if (ctx?.hasUI) {
343
- ctx.ui.notify(`Guardian denied: ${rationale}`, "warning");
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
- // Auto mode (or review without confirm): fail closed.
364
- const errorMsg = guardianErrorMessage(reviewResult.error);
365
- if (ctx?.hasUI)
366
- ctx.ui.notify(`Guardian unavailable: ${errorMsg}`, "warning");
367
- return {
368
- block: true,
369
- reason: `Guardian unavailable (${errorMsg}). Switch to /mode review to approve manually.`,
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 (mode !== "auto" && sideEffects.has(event.toolName)) {
709
+ if (modeAtEntry !== "auto" && sideEffects.has(event.toolName)) {
423
710
  return {
424
711
  block: true,
425
- reason: `permission gate failed while ${mode} mode was active; held ${event.toolName}. Switch to /mode auto to apply.`,
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
- // Reset the Guardian circuit breaker at the start of each turn so
437
- // consecutive denials don't accumulate across turns.
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 whether to allow or deny them. You output only a JSON verdict — no tools, no exploration, no prose.
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: sandbox retries, benign local filesystem reads, user-requested operations on specific paths. Low risk.
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
- - Deny any action that exposes sensitive data without explicit user authorization for that specific destination.
109
- - Deny credential probing.
110
- - Deny broad or persistent security weakening unless the user explicitly authorized the exact change.
111
- - Deny broad destructive actions with significant risk of irreversible damage.
112
- - Allow low-risk actions and actions the user clearly requested as part of their stated task.
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":"one sentence explaining the verdict"}
118
+ {"outcome":"allow"|"ask"|"deny","riskLevel":"low"|"medium"|"high"|"critical","rationale":"see rationale rules"}
116
119
 
117
- Do not output anything else. No markdown, no prose, only the JSON object.`;
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