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:
- The wrapper floor in
resolveBashCommandCheck(src/handlers/gates/bash-command.ts) clamps a resolvedallowup to{ ...base, state: "ask", matchedPattern: WRAPPER_SENTINEL[kind] }for an opaque-payload (#481) or indirection (#490, #575) wrapper. - 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 bashaskis auto-approved without prompting, recorded exactly as today's yolo grant (permission_request.auto_approved+ a decision event withresolution: "auto_approved"). - An explicit
denystill 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
PermissionPromptercontract ("anasknever 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 sameresolveBashCommandCheck, so under yolo it will reportaskfor 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-closedallow→askfloor (#646), or the composition-stage overlay order. - A
--yololauncher flag (#720) or the yolo override PR — orthogonal wiring of the same config field.
Background
Relevant modules:
src/rule.ts—rewriteAsksToYolo(rules): the composition-stage overlay,origin: "yolo".src/permission-manager.ts—check()applies the rewrite post-cache when the injectedisYoloEnabled()reader reports true;index.tssupplies() => isYoloModeEnabled(configStore.current()), read per check so a mid-session toggle takes effect.src/handlers/gates/bash-command.ts—resolveBashCommandCheck: per-unit resolve, wrapper floor, unparseable fail-closed,pickMostRestrictive.src/handlers/gates/runner.ts—GateRunner.runDescriptor: resolve → session fast path → yolo fast path (step 2b) →applyPermissionGate(prompt) → decision event → session-approval record.src/handlers/gates/helpers.ts—buildDecisionEvent,deriveResolution(already mapsstate: "ask"+autoApproved→"auto_approved").src/authority/authorizer-selection.ts—AskEscalator.escalate, the seam past the gate;selectAuthorizerhas 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 bymaertayn, not the operator, and re-files #570 (closed NOT_PLANNED because a rogue agent opened it). The direction and placement were confirmed viaask_userbefore planning: fix it, at theGateRunnerchoke point, with the unparseable deny consult folded into this plan.- Package skill: least privilege — the change adds auto-approval only behind an explicit
yoloMode: trueopt-in, and never over an explicitdeny.
Blast radius of a runner-level catch-all beyond bash, enumerated from every check source in runDescriptor:
descriptor.preCheck—path,external-directory,bash-path,bash-external-directory, and the tool gate all derive it fromScopedPermissionResolver.resolve, i.e. already yolo-rewritten; a residualaskthere is impossible.this.resolver.resolve(...)— same.descriptor.preResolved— onlydescribeSkillReadGate, whose state comes from aSkillPromptEntryresolved (and cached) at prompt-sanitization time. Under yolo it is alreadyallow; it can be a staleaskonly 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:synthesizeDefaultsalways 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 width —
GateRunnergoes 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.tsconstructs the runner directly). The alternative — aYoloOverlaycollaborator 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.tsandGateRunnerpasses the reader through. The rejected bash-local placement would have relayed it throughToolCallGatePipeline→describeBashCommandGate→resolveBashCommandCheck, three layers that only forward it. - Law of Demeter — the reader is a closure over
configStore, so the runner never reachesconfig.yoloModethrough an injected object. - Repeated discriminators —
origin === "yolo"currently appears at one production site; after the change it is still one site (insideresolveYoloGrant), andstate === "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 thePermissionPromptercontract unenforced — the next post-resolution floor re-opens the same bug — and it relays the reader through three layers (above). - Select an auto-approving
TerminalAuthorizerunder yolo. Puts yolo back in the prompt path the #526 design removed it from, and produceswaiting/approvedreview entries instead of the singleauto_approvedentry, 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 explicitdeny, otherwise keep the sentinelask; update the function JSDoc paragraph describing that branch.src/handlers/gates/helpers.ts— addresolveYoloGrant.src/handlers/gates/runner.ts— add theisYoloEnabledconstructor dependency; replace the step-2b condition with theresolveYoloGrantcall; update the class/step comment to describe the two arms (ruleset grant, residual-ask grant).src/index.ts— hoistisYoloEnabledto a named local, pass it to bothPermissionManagerandGateRunner.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.ts—makeGateRunnergains ayolo?: booleanoverride (defaultfalse) and passes a reader to the fifth argument.test/helpers/handler-fixtures.ts—makeHandlergains ayolo?: booleanoverride; thenew GateRunner(...)call at line 319 passes the reader.test/helpers/external-directory-fixtures.ts— thenew GateRunner(...)call passes a() => falsereader.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'sexpect(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 forresolveYoloGrant.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-enddescribepinning 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 therunner.tsmodule-tree entry (line 766) to mention the live yolo reader and the two grant arms, and thebash-command.tsentry (line 779) to say the unparseable branch consults the ruleset for an explicitdenyfirst.docs/architecture/permission-prompter.md— mirror the amended docstring sentence.docs/configuration.md— § "Fail-closed behavior": add that underyoloMode: trueboth floors are re-permitted at the gate (an explicitdenystill 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 theyoloModerow 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 explicitdenyfirst.
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
- Newly enabled tests.
resolveYoloGrantis 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. - 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.
- Tests that must stay as-is.
Every wrapper-floor case in
bash-command.test.tsandbash-command-metamorphic.test.ts(yolo off, floors unchanged), the #526 "yolo-origin allow" runner test (log/event parity), the yolo rewrite tests inpermission-manager-yolo.test.tsandrule.test.ts(composition stage untouched), andshell-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 allow→ask 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
- 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 returnsdenyfor the whole string → result is that deny, not the sentinel); update the existing zero-units test to expect one resolver consult and the sentinelaskwhen the whole-string rule is notdeny; mirror the consult-count update intest/bash-advisory-check.test.ts. Green: the whole-command consult inresolveBashCommandCheckplus the JSDoc update. Commit:fix(pi-permission-system): honor an explicit bash deny for an unparseable command (#712). - Residual-ask yolo grant at the gate.
Red:
test/handlers/gates/helpers.test.ts—resolveYoloGrantreturns the check unchanged for a yolo-origin allow, a{ state: "allow", origin: "yolo" }copy preservingmatchedPatternfor an ask under yolo, andnullfor an ask with yolo off, an allow with another origin, and any deny.test/handlers/gates/runner.test.ts— withyolo: true, anaskcheck (sentinelmatchedPattern) returns allow, never callsescalate, writespermission_request.auto_approved, and emits a decision withresolution: "auto_approved",origin: "yolo", and the sentinel preserved; adenycheck still blocks without escalating; withyolo: falseanaskstill prompts; a reader flipped between tworuncalls changes the outcome (read per call). Green:resolveYoloGrantinhelpers.ts, the fifthGateRunnerdependency and the generalized step 2b, the three fixture updates, and theindex.tswiring — 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). - 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— adescriberunning the real factory withyoloMode: trueandbash: {"*": "allow"}overgit status | xargs grep foo(noui.selectcall, not blocked),> out.txt(no prompt),bash: {"*": "allow", "xargs*": "deny"}(blocked), andbash: {"*": "deny"}+> out.txt(blocked). Commit:test(pi-permission-system): pin the yolo wrapper and unparseable repro at the composition root (#712). - 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
preResolvedask 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
denybecomes a silent grant. This is the reason step 1 precedes step 2; without it, an unparseable command under an explicitbashdenywould 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
resolveYoloGrantis 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 flooredask? 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 anAuthorizerlink 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.