Files
my-pi/pi-permission-system/docs/plans/0712-yolo-residual-synthetic-asks.md

25 KiB

issue, issue_title
issue issue_title
712 pi-permission-system: yolo mode prompts for wrapper-floored and unparseable bash asks

Honor yolo for residual synthetic bash asks

Release Recommendation

Release: ship independently

Issue #712 is not a numbered step in the docs/architecture/architecture.md roadmap and carries no Release: batch tag, so it ships on its own. It lands as a fix: commit pair, which cuts a release at the next release-please merge.

Problem Statement

yoloMode: true is meant to suppress every ask prompt. It is implemented as a composition-stage rewrite: rewriteAsksToYolo turns every ask rule in the composed ruleset into an allow tagged origin: "yolo" (#526), and GateRunner fast-paths state === "allow" && origin === "yolo" into a single auto_approved review entry without prompting.

Two bash asks are synthesized after the resolver returns — at the result level, outside the ruleset — so the rewrite never sees them:

  1. The wrapper floor in resolveBashCommandCheck (src/handlers/gates/bash-command.ts) clamps a resolved allow up to { ...base, state: "ask", matchedPattern: WRAPPER_SENTINEL[kind] } for an opaque-payload (#481) or indirection (#490, #575) wrapper.
  2. The fail-closed branch of the same function synthesizes { state: "ask", matchedPattern: "<unparseable-bash-command>" } for a non-empty command that parses to zero command units (#452).

Both reach GateRunner with state: "ask", miss the state === "allow" fast path, and prompt.

Measured at planning time against the real extension (composition-root harness, makeFakePi + the real factory, a UI ctx capturing ui.select titles):

Config Command Observed today
yoloMode: true, bash: {"*": "ask"} git status | xargs grep foo prompts — matched '<indirection-bash-wrapper>'
yoloMode: true, bash: {"*": "allow"} git status | xargs grep foo prompts — same sentinel
yoloMode: true, bash: {"*": "ask"} git status | grep foo no prompt (the rewrite works)
yoloMode: true, bash: {"*": "allow"} > out.txt / 2>&1 prompts — matched '<unparseable-bash-command>'
yoloMode: false, bash: {"*": "deny"} > out.txt prompts, and the prompt is approvable

The last row is a second defect found while tracing, independent of yolo: the unparseable branch synthesizes its ask without consulting the resolver at all, so an explicit bash deny is silently downgraded to a prompt the user can approve. It must be fixed before yolo may auto-approve a residual ask, or yolo would turn that masked deny into a silent grant.

Goals

  • Under yoloMode: true, a wrapper-floored or unparseable bash ask is auto-approved without prompting, recorded exactly as today's yolo grant (permission_request.auto_approved + a decision event with resolution: "auto_approved").
  • An explicit deny still blocks under yolo — including for an unparseable command, which today prompts instead of denying.
  • The reconciliation lives at the gate's single choke point, so the PermissionPrompter contract ("an ask never reaches this class under yolo") holds structurally for every synthesized ask, not only the two bash ones.
  • The docs that assert the (currently false) contract are corrected: docs/architecture/architecture.md § "yolo is recorded authority", docs/architecture/permission-prompter.md, docs/configuration.md § "Fail-closed behavior", and the package skill.

Not a breaking change: yoloMode defaults to false, and with yolo off every decision is byte-identical to today except that an unparseable command matching an explicit deny now blocks instead of prompting — a tightening of a deny-masking hole, not a loosened default.

Non-Goals

  • Configurable exemptions from the indirection-wrapper floor (#680) — unchanged; the floor still applies with yolo off.
  • Inspecting or unwrapping the inner command of a wrapper (#706, #713) — unchanged.
  • Yolo parity on the advisory path (resolveBashAdvisoryCheck, src/bash-advisory-check.ts, #309). It answers through the same resolveBashCommandCheck, so under yolo it will report ask for a wrapper the gate now allows. That direction is safe (the advisory answer stays stricter than the gate, never weaker), and the reconciliation is a gate concern; see Open Questions.
  • Any change to rewriteAsksToYolo, the fail-closed allowask floor (#646), or the composition-stage overlay order.
  • A --yolo launcher flag (#720) or the yolo override PR — orthogonal wiring of the same config field.

Background

Relevant modules:

  • src/rule.tsrewriteAsksToYolo(rules): the composition-stage overlay, origin: "yolo".
  • src/permission-manager.tscheck() applies the rewrite post-cache when the injected isYoloEnabled() reader reports true; index.ts supplies () => isYoloModeEnabled(configStore.current()), read per check so a mid-session toggle takes effect.
  • src/handlers/gates/bash-command.tsresolveBashCommandCheck: per-unit resolve, wrapper floor, unparseable fail-closed, pickMostRestrictive.
  • src/handlers/gates/runner.tsGateRunner.runDescriptor: resolve → session fast path → yolo fast path (step 2b) → applyPermissionGate (prompt) → decision event → session-approval record.
  • src/handlers/gates/helpers.tsbuildDecisionEvent, deriveResolution (already maps state: "ask" + autoApproved"auto_approved").
  • src/authority/authorizer-selection.tsAskEscalator.escalate, the seam past the gate; selectAuthorizer has no yolo knowledge and gains none here.

Constraints that apply:

  • The architecture doc's § "yolo is recorded authority" states "the decision path loses all yolo knowledge". The two floors are per-parse, not per-pattern, so they cannot be expressed as rules — some yolo read outside the ruleset is unavoidable. The doc claim must be amended rather than worked around; the amendment is bounded ("the ruleset overlay is the whole story except for post-resolution floors, which the gate reconciles at one place").
  • AGENTS.md § Clarification gates and the third-party-issue rule: #712 was filed by maertayn, not the operator, and re-files #570 (closed NOT_PLANNED because a rogue agent opened it). The direction and placement were confirmed via ask_user before planning: fix it, at the GateRunner choke point, with the unparseable deny consult folded into this plan.
  • Package skill: least privilege — the change adds auto-approval only behind an explicit yoloMode: true opt-in, and never over an explicit deny.

Blast radius of a runner-level catch-all beyond bash, enumerated from every check source in runDescriptor:

  • descriptor.preCheckpath, external-directory, bash-path, bash-external-directory, and the tool gate all derive it from ScopedPermissionResolver.resolve, i.e. already yolo-rewritten; a residual ask there is impossible.
  • this.resolver.resolve(...) — same.
  • descriptor.preResolved — only describeSkillReadGate, whose state comes from a SkillPromptEntry resolved (and cached) at prompt-sanitization time. Under yolo it is already allow; it can be a stale ask only if yolo is switched on mid-session after sanitization, in which case auto-approving it is the correct yolo behavior.
  • The synthesized evaluate() fallback (origin: "builtin", ask) cannot surface: synthesizeDefaults always contributes a */* rule, so a lookup always matches a real rule.

So the catch-all changes behavior today for exactly the two bash sentinels, and structurally covers whatever floor is added next.

Design Overview

1. Deny-preserving unparseable branch

resolveBashCommandCheck's zero-units, non-empty branch consults the resolver on the whole command before synthesizing:

const whole = resolver.resolve({
  kind: "tool",
  surface: "bash",
  input: { command },
  agentName,
});
if (whole.state === "deny") {
  return whole;
}
return {
  state: "ask",
  toolName: "bash",
  source: "bash",
  origin: "builtin",
  command,
  matchedPattern: "<unparseable-bash-command>",
};

An explicit deny (whole-string match) wins; everything else still fails closed to the sentinel ask, so #452's invariant — a permissive top-level * never silently allows an unparseable command — is untouched. The resolver already returns command in the result extras for the bash surface, so the deny result carries the offending command for the prompt/log without extra shaping.

2. Residual-ask yolo grant at the gate

A pure helper next to the other gate-result derivations in src/handlers/gates/helpers.ts:

export function resolveYoloGrant(
  check: PermissionCheckResult,
  yoloEnabled: boolean,
): PermissionCheckResult | null {
  if (check.state === "allow" && check.origin === "yolo") return check;
  if (check.state === "ask" && yoloEnabled)
    return { ...check, state: "allow", origin: "yolo" };
  return null;
}

The first arm is today's fast path, verbatim, so the #526 review-log parity holds byte-for-byte when the ruleset already granted. The second arm re-permits a post-resolution floor, preserving matchedPattern (the sentinel — the review log still shows why it was floored) and stamping origin: "yolo" (why it was auto-approved). A deny never matches either arm, so an explicit deny is structurally out of reach.

GateRunner consumes it at the existing step 2b:

const grant = resolveYoloGrant(check, this.isYoloEnabled());
if (grant) {
  this.reporter.writeReviewLog("permission_request.auto_approved", {
    ...descriptor.logContext, agentName, resolution: "auto_approved",
  });
  this.reporter.emitDecision(
    buildDecisionEvent(descriptor.decision, grant, agentName, "allow",
      deriveResolution(grant.state, "allow", false, false, true)),
  );
  return { action: "allow" };
}

deriveResolution("allow", "allow", …, true) returns "auto_approved" for both arms, so the emitted resolution is unchanged for the existing case.

3. Wiring

GateRunner gains a fifth constructor dependency, isYoloEnabled: () => boolean, read per run so a mid-session /permission-system toggle takes effect — the same closure shape PermissionManager already receives. index.ts hoists the expression it already builds inline for the manager and passes the one reader to both:

const isYoloEnabled = (): boolean => isYoloModeEnabled(configStore.current());
const permissionManager = new PermissionManager({ agentDir, flavor: hostFlavor, isYoloEnabled });
// …
const gateRunner = new GateRunner(resolver, sessionRules, authorizerSelection, reporter, isYoloEnabled);

Design-review notes (checklist run before finalizing):

  • Dependency widthGateRunner goes from four to five constructor parameters. Four are role collaborators; the fifth is a live config read, not an object to reach through, and there is no intermediary relaying it (index.ts constructs the runner directly). The alternative — a YoloOverlay collaborator wrapping one predicate — buys an interface and no behavior; declined, with the width noted as track-and-watch if a sixth arrives.
  • Parameter relay — none: no function between index.ts and GateRunner passes the reader through. The rejected bash-local placement would have relayed it through ToolCallGatePipelinedescribeBashCommandGateresolveBashCommandCheck, three layers that only forward it.
  • Law of Demeter — the reader is a closure over configStore, so the runner never reaches config.yoloMode through an injected object.
  • Repeated discriminatorsorigin === "yolo" currently appears at one production site; after the change it is still one site (inside resolveYoloGrant), and state === "ask" reconciliation is decided once rather than per gate.

Rejected alternatives

  • Reconcile inside resolveBashCommandCheck (skip the floor when yolo is on). Narrower, but it leaves the PermissionPrompter contract unenforced — the next post-resolution floor re-opens the same bug — and it relays the reader through three layers (above).
  • Select an auto-approving TerminalAuthorizer under yolo. Puts yolo back in the prompt path the #526 design removed it from, and produces waiting/approved review entries instead of the single auto_approved entry, breaking log parity.
  • Express the floors as rules so the existing rewrite covers them. The floor is a property of a parsed command unit (wrapperKind), not of a pattern, so there is no rule to write.

Module-Level Changes

  • src/handlers/gates/bash-command.ts — resolve the whole command in the zero-units/non-empty branch, return an explicit deny, otherwise keep the sentinel ask; update the function JSDoc paragraph describing that branch.
  • src/handlers/gates/helpers.ts — add resolveYoloGrant.
  • src/handlers/gates/runner.ts — add the isYoloEnabled constructor dependency; replace the step-2b condition with the resolveYoloGrant call; update the class/step comment to describe the two arms (ruleset grant, residual-ask grant).
  • src/index.ts — hoist isYoloEnabled to a named local, pass it to both PermissionManager and GateRunner.
  • src/authority/permission-prompter.ts — amend the class docstring: yolo is resolved upstream at composition and reconciled at the gate for post-resolution floors, so this class still has no yolo knowledge.
  • test/helpers/gate-fixtures.tsmakeGateRunner gains a yolo?: boolean override (default false) and passes a reader to the fifth argument.
  • test/helpers/handler-fixtures.tsmakeHandler gains a yolo?: boolean override; the new GateRunner(...) call at line 319 passes the reader.
  • test/helpers/external-directory-fixtures.ts — the new GateRunner(...) call passes a () => false reader.
  • test/handlers/gates/bash-command.test.ts — new deny case; the existing "fails closed to ask when a non-empty command parses to zero command units" test's expect(resolver.resolve).not.toHaveBeenCalled() becomes "called once, sentinel ask still returned".
  • test/bash-advisory-check.test.ts — the same assertion at "fails closed for a non-empty command that parses to zero units" (> out.txt) becomes a single-consult assertion.
  • test/handlers/gates/helpers.test.ts — unit tests for resolveYoloGrant.
  • test/handlers/gates/runner.test.ts — yolo-on residual-ask, yolo-on deny, yolo-off ask, per-call reader tests.
  • test/composition-root.test.ts — an end-to-end describe pinning the issue's literal repro through the real factory.
  • docs/architecture/architecture.md — § "yolo is recorded authority": amend "the decision path loses all yolo knowledge" to name the one gate-level reconciliation for post-resolution floors, keeping the deny-preserving claim; update the runner.ts module-tree entry (line 766) to mention the live yolo reader and the two grant arms, and the bash-command.ts entry (line 779) to say the unparseable branch consults the ruleset for an explicit deny first.
  • docs/architecture/permission-prompter.md — mirror the amended docstring sentence.
  • docs/configuration.md — § "Fail-closed behavior": add that under yoloMode: true both floors are re-permitted at the gate (an explicit deny still denies), and qualify the now-absolute line "there is no way to auto-allow a wrapper"; extend the unparseable bullet with the explicit-deny consult; check the yoloMode row in the config table reads correctly against the new behavior.
  • .pi/skills/package-pi-permission-system/SKILL.md — in the fail-closed/wrapper paragraph under Debugging, add that yolo re-permits both synthetic asks at the gate and that the unparseable branch consults the ruleset for an explicit deny first.

Greps run to build this list: <indirection-bash-wrapper> / <unparseable-bash-command> across src/, test/, docs/, README.md, and .pi/skills/ (matches outside docs/plans/ are the four files listed above); new GateRunner across src/ and test/ (four sites); yolo across docs/ and README.md (README's only mention is project-trust scoping — no stale text).

Test Impact Analysis

  1. Newly enabled tests. resolveYoloGrant is a pure function, so the residual-ask policy gets direct unit coverage that was previously reachable only through a full descriptor run. The composition-root repro test is newly meaningful because the reader is wired end to end — it fails today and passes after step 2.
  2. Redundant tests. None are removed. Two assertions invert from "the resolver is not consulted" to "consulted exactly once" — they were pinning an implementation detail that the deny fix deliberately changes; the surrounding fail-closed assertions stay.
  3. Tests that must stay as-is. Every wrapper-floor case in bash-command.test.ts and bash-command-metamorphic.test.ts (yolo off, floors unchanged), the #526 "yolo-origin allow" runner test (log/event parity), the yolo rewrite tests in permission-manager-yolo.test.ts and rule.test.ts (composition stage untouched), and shell-tool-alias.test.ts (aliased shell tools inherit the same path).

Invariants at risk

Invariant Source Pinned by
A yolo grant produces exactly one auto_approved review entry + decision event #526 test/handlers/gates/runner.test.ts "returns allow and emits auto_approved on a yolo-origin allow without prompting" — must stay green unmodified
A non-empty command parsing to zero units never rides a permissive top-level * #452 bash-command.test.ts zero-units test (assertion updated, fail-closed claim retained) and bash-advisory-check.test.ts
A wrapper's allow is floored to ask; an explicit deny still denies #481, #490, #575 bash-command.test.ts floor cases (yolo off)
yolo preserves hard denies #526 new runner test (yolo on + deny → block, escalator not called) and the composition-root deny case
The fail-closed allowask floor is re-permitted by yolo at composition #646 permission-manager-yolo.test.ts — composition stage untouched by this change
The advisory answer is never weaker than the gate #309 unchanged; under yolo the advisory becomes stricter than the gate, which is the safe direction

Quantitative baseline (measured, composition-root harness): git status | xargs grep foo under yoloMode: true produces 1 prompt today and must produce 0 after; git status | grep foo produces 0 before and after.

TDD Order

  1. Deny-preserving unparseable branch. Red: test/handlers/gates/bash-command.test.ts — "returns the explicit deny when an unparseable command matches a deny rule" (resolver returns deny for the whole string → result is that deny, not the sentinel); update the existing zero-units test to expect one resolver consult and the sentinel ask when the whole-string rule is not deny; mirror the consult-count update in test/bash-advisory-check.test.ts. Green: the whole-command consult in resolveBashCommandCheck plus the JSDoc update. Commit: fix(pi-permission-system): honor an explicit bash deny for an unparseable command (#712).
  2. Residual-ask yolo grant at the gate. Red: test/handlers/gates/helpers.test.tsresolveYoloGrant returns the check unchanged for a yolo-origin allow, a { state: "allow", origin: "yolo" } copy preserving matchedPattern for an ask under yolo, and null for an ask with yolo off, an allow with another origin, and any deny. test/handlers/gates/runner.test.ts — with yolo: true, an ask check (sentinel matchedPattern) returns allow, never calls escalate, writes permission_request.auto_approved, and emits a decision with resolution: "auto_approved", origin: "yolo", and the sentinel preserved; a deny check still blocks without escalating; with yolo: false an ask still prompts; a reader flipped between two run calls changes the outcome (read per call). Green: resolveYoloGrant in helpers.ts, the fifth GateRunner dependency and the generalized step 2b, the three fixture updates, and the index.ts wiring — all in one commit, since the new required parameter breaks the type check otherwise. Commit: fix(pi-permission-system): auto-approve residual synthetic asks under yolo (#712).
  3. End-to-end repro pin. Red-then-green is inverted here by design (it passes as soon as step 2 lands), so it is written last to keep every commit green: test/composition-root.test.ts — a describe running the real factory with yoloMode: true and bash: {"*": "allow"} over git status | xargs grep foo (no ui.select call, not blocked), > out.txt (no prompt), bash: {"*": "allow", "xargs*": "deny"} (blocked), and bash: {"*": "deny"} + > out.txt (blocked). Commit: test(pi-permission-system): pin the yolo wrapper and unparseable repro at the composition root (#712).
  4. Documentation. The five doc/comment targets in Module-Level Changes. Commit: docs(pi-permission-system): describe the gate-level yolo grant for post-resolution floors (#712).

Risks and Mitigations

  • Auto-approval broadens beyond the reported bug. A runner-level catch-all also covers a skill-read preResolved ask and any future synthetic ask. Mitigated by the enumeration in Background (only the stale-skill-entry case exists today, and auto-approving it is correct under yolo) and by the deny arm being structurally unreachable.
  • A masked deny becomes a silent grant. This is the reason step 1 precedes step 2; without it, an unparseable command under an explicit bash deny would go from "prompted" to "auto-approved". Pinned by the composition-root deny case.
  • Review-log regression for the existing yolo path. The first arm of resolveYoloGrant is the current condition verbatim and the #526 test is not modified.
  • Fewer forwarded permission requests from subagents. A child under yolo now resolves the floored ask locally instead of forwarding it to the parent. That is the intended contract (no ask reaches the escalator under yolo); no forwarding test asserts the old behavior.
  • Doc drift. Three docs and one docstring currently assert a contract the code does not keep; step 4 corrects them together, and the pre-completion reviewer checks doc staleness.

Open Questions

  • Should the advisory path (resolveBashAdvisoryCheck) report the gate's yolo-adjusted answer instead of the floored ask? Deferred, not filed: the discrepancy is in the safe direction (advisory stricter than the gate), and no consumer is known to depend on gate parity under yolo. File an issue if an Authorizer link or sibling extension turns out to read it while yolo is on.
  • Whether #680's per-wrapper exemption config should subsume the yolo case once it lands — no; that config governs the floor itself, this change governs what yolo does with a floor that fired.