20 KiB
issue, issue_title
| issue | issue_title |
|---|---|
| 526 | pi-permission-system: move yolo into recorded authority (composition-stage ask→allow rewrite) |
Move yolo into recorded authority (composition-stage ask→allow rewrite)
Release Recommendation
Release: mid-batch — defer (batch "yolo-recorded-authority"); confirm at ship time
This is Phase 8 Step 2, the first member of the yolo-recorded-authority release batch (Steps 2, 3; tail = Step 3 / [#527]).
Step 2 relocates the yolo decision with observable review-log/decision-event field changes; Step 3 is its cleanup.
The batch ships together when Step 3 lands, so this issue leaves the release-please PR open.
Problem Statement
yolo mode is smeared across the prompt path.
shouldAutoApprovePermissionState is checked in PermissionPrompter.prompt and again in the forwarded-inbox serve arm, and canResolveAskPermissionRequest's yolo arm sits in PromptingGateway.canConfirm() — three modules know about yolo on the decision path.
The architecture "yolo is recorded authority" section names yolo as a standing authorization that belongs in the ruleset, not the prompt path.
This step delivers that framing: yolo becomes a composition-stage rewrite over the composed ruleset — every ask action becomes allow, tagged origin: "yolo" — so evaluate() is the single yolo decision point.
It is a preparatory step ahead of the full authority spine (Phase 9); it does not build the spine.
Goals
- Apply the yolo rewrite over the composed ruleset at check time in
PermissionManager.check, keyed off an injected yolo reader. Every matchedaskrule (including the synthesized universal default) resolves toallowtaggedorigin: "yolo"; explicitdenypasses through untouched (yolo suppresses prompts but preserves hard denies). - Add
"yolo"toRuleOrigin(additive) and keep the architecture doc's inlineRule/RuleOriginlisting in sync. - Preserve review-log and decision-event parity: a yolo-origin
allowderives resolutionauto_approved, and the gate runner writes thepermission_request.auto_approvedreview-log entry. - Keep display unchanged:
getComposedConfigRules//permission-system showandgetToolPermissionkeep showing the configured actions, not the rewrite. - Leave the prompter and gateway yolo arms physically present but unreachable (their removal is [#527]).
This change is not breaking in the semver sense — behavior parity holds (a yolo ask still auto-approves).
The origin: "yolo" value is additive.
Observable review-log/decision-event field values change (a yolo grant now carries origin: "yolo" and resolves via the composed ruleset), which is why the batch ships as a real (non-hidden) release rather than a test-only change.
Suggested commit type: feat(pi-permission-system):.
Non-Goals
- Deleting the dead prompter/gateway yolo arms and dissolving
yolo-mode.ts— that is Phase 8 Step 3 ([#527]). After this step those arms are unreachable but still compiled; their yolo tests stay green until [#527] removes them. - The forwarded-inbox serve-arm yolo check (
PermissionForwarder.processInbox) — it survives Phase 8 and dissolves when serving becomes resolution (Phase 9). - Threading
origin: "yolo"through the skill sanitizer →SkillPromptEntry→ skill-readpreResolvedchain. Under yolo the skill sanitizer already resolves a skill's state toallowvia the yolo-awarecheck, so a skill-read auto-allows withorigin: "builtin"and logspolicy_allowrather thanauto_approved. This is an accepted parity nuance (confirmed with the operator): no prompt, no regression, just a diagnostic-label difference for skill-reads. - The authority spine itself (the
Authorizerinterface,canConfirm()dissolution, serving-as-resolution) — Phase 9.
Background
Relevant modules and how they relate:
src/rule.ts—RuleOriginunion,Rule/Rulesettypes, and the pureevaluate*functions. Home for a new purerewriteAsksToYolo(rules)helper.src/permission-manager.ts—PermissionManager implements ScopedPermissionManager.check(intent, sessionRules?)is the single resolution entry point ([#478]): it callsresolvePermissions(agentName)(cached, keyed byagentName+ loader stamp), composesfullRules = [...composedRules, ...sessionRules], and delegates tobuildCheckResult.getComposedConfigRulesandgetToolPermissionread the cache directly and are the display/injection surfaces that must stay yolo-free. Constraint (ADR-0002, [#506]): the manager stays string-based and must not importAccessPath— ano-restricted-importslint rule guardspermission-manager.ts.rewriteAsksToYolooperates on aRuleset(strings only), so it does not breach the boundary.src/permission-resolver.ts—PermissionResolver.resolve/checkPermissionboth delegate tomanager.check, so every gate, the skill sanitizer, and the cross-extension service/RPC route through the manager (and thus the yolo rewrite).src/handlers/gates/runner.ts—GateRunner.runDescriptoris the single choke point every tool-call and skill-input gate passes through (ToolCallGatePipelineandSkillInputGatePipelineboth callrunner.run). It already has a session-hit fast-path; the yolo fast-path mirrors it.src/handlers/gates/helpers.ts—deriveResolutionmaps(state, action, hasSession, canConfirm, autoApproved)to aPermissionDecisionResolution. Itsstate === "allow"branch currently returnspolicy_allowunconditionally.src/index.ts— composition root;configStoreandsessionare forward-declaredlets so lazy thunks can close over them.PermissionManageris constructed beforeconfigStoreis assigned (the store depends on the manager forpolicyPaths).src/yolo-mode.ts—isYoloModeEnabled(config)readsconfig.yoloMode. Stays here for this step ([#527] moves it intoextension-config.ts).
AGENTS.md / skill constraints that apply:
- The architecture doc inline-copies
Rule/RuleOrigin; adding a field toRuleOriginmust update that listing (a module-move check misses it). - Mark the completed roadmap step ✅ (heading + Mermaid node) in the implementation doc-update commit, not a deferred ship commit.
- The manager stays string-based (ADR-0002); no
AccessPathimport.
Design Overview
1. yolo rewrite in the manager (single decision point)
Add a pure helper to rule.ts:
/** Rewrite every `ask` rule to `allow` tagged `origin: "yolo"`; deny/allow pass through. */
export function rewriteAsksToYolo(rules: Ruleset): Ruleset {
return rules.map((rule) =>
rule.action === "ask" ? { ...rule, action: "allow", origin: "yolo" } : rule,
);
}
Inject an optional yolo reader into the manager and apply the rewrite post-cache, inside check() only:
// PermissionManagerOptions gains:
isYoloEnabled?: () => boolean; // defaults to () => false
// inside check(), after composing fullRules and before buildCheckResult:
const rules = this.isYoloEnabled() ? rewriteAsksToYolo(fullRules) : fullRules;
Why post-cache (in check), not inside resolvePermissions:
- The
resolvedPermissionsCacheis keyed byagentName+ loader stamp, not yolo state. Applying the rewrite to the cachedcomposedRuleswould pollutegetComposedConfigRulesandgetToolPermission, breaking the display-unchanged goal. RewritingfullRulesper-call keeps the cache yolo-free and touches only the resolution path. - The synthesized universal default (
{ surface: "*", pattern: "*", action: "ask", layer: "default" }fromsynthesizeDefaults) is part ofcomposedRules, so an unmatched surface under yolo is rewritten too — no separate default-fallback handling needed. - The rewrite preserves each rule's
layerandpattern, sobuildCheckResult'smatchedPatternderivation (config/sessionlayers only) andderiveSourceare unaffected; onlyoriginchanges to"yolo"andactionto"allow".
Consumer call-site sketch (index.ts), following the existing getConfig: () => configStore.current() forward-declared-closure pattern:
const permissionManager = new PermissionManager({
agentDir,
platform: hostPlatform,
isYoloEnabled: () => isYoloModeEnabled(configStore.current()),
});
The closure captures the forward-declared configStore and is only invoked at check time (after assignment), so no TDZ read occurs.
To keep the reference textually after the let configStore declaration, relocate the new PermissionManager(...) call to just below the forward declarations (it has no earlier consumer); configStore = new ConfigStore({ policyPaths: permissionManager, ... }) still follows it.
The manager gains no dependency on config types — it holds only a () => boolean, keeping yolo knowledge out of the string-based manager (DIP).
2. Resolution + review-log parity in the runner
Extend deriveResolution so a yolo-origin allow maps to auto_approved (the mapping stays centralized in helpers.ts):
if (state === "allow") return autoApproved ? "auto_approved" : "policy_allow";
Existing callers pass autoApproved defaulting to false, so policy_allow is unchanged for a normal allow.
Add a yolo fast-path to GateRunner.runDescriptor, placed right after the session-hit fast-path (mirroring its shape):
// Composition-stage ask→allow rewrite records origin "yolo" on the matched
// rule. Auto-approve without prompting; preserve today's single
// auto_approved review entry + decision event.
if (check.state === "allow" && check.origin === "yolo") {
this.reporter.writeReviewLog("permission_request.auto_approved", {
...descriptor.logContext,
agentName,
resolution: "auto_approved",
});
this.reporter.emitDecision(
buildDecisionEvent(
descriptor.decision,
check,
agentName,
"allow",
deriveResolution(check.state, "allow", false, false, true),
),
);
return { action: "allow" };
}
Review-log entry shape (confirmed with operator): the runner writes { ...descriptor.logContext, agentName, resolution: "auto_approved" }, consistent with the runner's session_approved/blocked entries.
This carries toolCallId (not the prompter's requestId); the same event name (permission_request.auto_approved) is still emitted.
The decision-event channel keeps exact field parity via buildDecisionEvent, with origin now "yolo" for a yolo grant (the intended, batch-acknowledged field change).
Because both ToolCallGatePipeline and SkillInputGatePipeline funnel through runner.run, this one fast-path covers tool, bash, mcp, path, external_directory, and skill-input surfaces uniformly.
The prompter's yolo arm becomes unreachable: under yolo no manager-resolved check returns ask, and the skill-read preResolved state is already allow (resolved by the yolo-aware sanitizer), so no ask reaches applyPermissionGate's prompt branch.
Edge cases
- Explicit deny under yolo —
denyis notask, so it passes through the rewrite; the runner's normal deny path emitspolicy_deny. Hard denies survive yolo. - Session rules — the rewrite runs over
fullRules(composed ∪ session), so a hypothetical sessionaskis also rewritten; sessionallowapprovals are unaffected. - Cross-extension service/RPC queries —
LocalPermissionsServiceand the event-bus RPC route throughPermissionResolver→manager.check, so under yolo they answerallow(originyolo) instead ofask. This is consistent with "yolo is recorded authority" (under yolo the effective policy is allow) and is called out in Risks. - Skill-read
preResolved— resolves toallow(originbuiltin) via the yolo-aware sanitizer and logspolicy_allow; see Non-Goals.
Module-Level Changes
src/rule.ts— add"yolo"to theRuleOriginunion (update the doc comment's provenance grouping to note yolo as a composition-stage rewrite origin); add exportedrewriteAsksToYolo(rules: Ruleset): Ruleset.src/permission-manager.ts— addisYoloEnabled?: () => booleantoPermissionManagerOptions; store it (default() => false); applyrewriteAsksToYolo(fullRules)incheck()when enabled; importrewriteAsksToYolofrom./rule. No change toresolvePermissions,getComposedConfigRules, orgetToolPermission.src/index.ts— passisYoloEnabled: () => isYoloModeEnabled(configStore.current())toPermissionManager; relocate thenew PermissionManager(...)call below thelet configStoreforward declaration; importisYoloModeEnabledfrom./yolo-mode.src/handlers/gates/helpers.ts—deriveResolution:state === "allow"returnsauto_approvedwhenautoApproved, elsepolicy_allow.src/handlers/gates/runner.ts— add the yolo fast-path inrunDescriptorafter the session-hit fast-path.test/helpers/manager-harness.ts— thread an optionalisYoloEnabled(oryolo: boolean) through the relevant factory (createManagerWithConfig/createManager) so manager tests can build a yolo-enabled manager. Default off — existing callers unaffected.test/rule.test.ts— unit tests forrewriteAsksToYolo.test/permission-manager-*.test.ts(new or existing manager suite) — yolo rewrite behavior + display-unchanged assertions.test/handlers/gates/helpers.test.ts—deriveResolutionyolo case.test/handlers/gates/runner.test.ts— yolo fast-path behavior.docs/architecture/architecture.md— update the inlineRuleOriginlisting (add| "yolo"and the comment note); mark Step 2 (#526) ✅ on the step heading and theS2Mermaid node.
Docs verified as not needing change in this step:
docs/cross-extension-api.md— already documentsauto_approved("Yolo mode — approved automatically without dialog") and that it does not emit aui_promptevent. Still accurate.docs/architecture/permission-prompter.md— describes the prompter's yolo arm, which still physically exists after this step (unreachable). Its update rides with [#527] when the arm is deleted.README.md— no/permission-systemcommand surface change; grepped foryolo/auto_approved— no stale command docs.
The "yolo checks on the ask path" health-metric row (Phase 7 close = 3 → target = 1) is not flipped in this step: the composition-stage rewrite lands (the 1), but the prompter/gateway arms are removed only in [#527], so the count is not yet reducible. Leave the metric row for the batch tail.
Test Impact Analysis
-
New unit tests enabled by the change:
rewriteAsksToYoloas a pure ruleset transform (ask→allow+yolo, deny/allow pass-through, layer/pattern preserved) — previously the yolo decision was an inline boolean in the prompter with no pure seam.PermissionManager.checkunder a yolo reader: anask-resolving intent returnsallow+origin: "yolo"; adenyintent staysdeny;getComposedConfigRules/getToolPermissionstill report the configuredask(display-unchanged).GateRunneryolo fast-path:allow+origin: "yolo"writes onepermission_request.auto_approvedreview entry, emitsauto_approvedwithorigin: "yolo", and never calls the prompter.deriveResolution("allow", "allow", false, _, true) === "auto_approved".
-
Tests that become redundant: none in this step. The prompter's yolo tests (
test/permission-prompter.test.ts) and the handlerauto_approved-via-prompt tests (tool-call-events.test.ts,input-events.test.ts) still exercise code that physically exists. They are removed/retargeted in [#527] when the prompter arm is deleted. -
Tests that must stay as-is:
test/permission-prompter.test.tsyolo-mode block — the arm is present (unreachable) until [#527].test/handlers/{tool-call-events,input-events}.test.tsauto_approvedtests — they mock the prompt to returnautoApproved: true, exercising the runner'sdecision.autoApprovedhandling, which persists.test/permission-manager-unified.test.ts— the shared manager fixtures ([#525]) must stay green after themanager-harnessextension.
Invariants at risk
This change touches surfaces earlier Phase 6–8 steps refactored:
- [#478] single resolution entry point —
ScopedPermissionManager.checkis the one method. The yolo rewrite lives insidecheck, adding no second method. Pinned by the existing manager-unified suite andmakeFakePermissionManager's singlecheckstub. - [#506] ADR-0002 string boundary —
permission-manager.tsmust not importAccessPath.rewriteAsksToYolois aRulesettransform (strings only). Pinned by theno-restricted-importslint rule on the file (pnpm run lint). - [#525] manager-harness fixtures — extending the harness with an optional yolo reader must not regress the extracted factories.
Pinned by
test/permission-manager-unified.test.ts. - Display-unchanged —
getComposedConfigRules/getToolPermissionreport configured actions. Add an explicit test asserting these returnaskeven when the injected yolo reader istrue(the invariant lives only in prose otherwise).
TDD Order
-
rewriteAsksToYolo+RuleOrigin(rule.ts). Red:test/rule.test.ts—rewriteAsksToYolomapsask→allowwithorigin: "yolo", passesdeny/allowthrough unchanged, and preserveslayer/pattern/surface. Green: add"yolo"toRuleOrigin; implementrewriteAsksToYolo. Commit:feat(pi-permission-system): add yolo rule origin and ask→allow rewrite helper. -
yolo rewrite in
PermissionManager.check+index.tswiring. Red: manager suite — withisYoloEnabled: () => true, a would-be-askcheckreturnsallow+origin: "yolo"; adenystaysdeny;getComposedConfigRulesandgetToolPermissionstill reportask. Extendmanager-harnessto build a yolo-enabled manager. Green: addisYoloEnabledtoPermissionManagerOptions; applyrewriteAsksToYolo(fullRules)incheck(); wire the reader inindex.ts(relocate the manager construction below theconfigStoreforward declaration). Runpnpm run check(options-interface change with a single call site inindex.ts). Commit:feat(pi-permission-system): rewrite ask rules to yolo-origin allow at check time. -
deriveResolutionyolo mapping +GateRunnerfast-path. Red:helpers.test.ts—deriveResolution("allow","allow",false,false,true)→"auto_approved"(existingpolicy_allowcase unchanged).runner.test.ts—resolveResult: allow+origin: "yolo"writespermission_request.auto_approved, emitsauto_approvedwithorigin: "yolo", returnsallow, and does not prompt. Green: extendderiveResolution's allow branch; add the runner yolo fast-path. Commit:feat(pi-permission-system): auto-approve yolo-origin allow in the gate runner. -
Docs sync + roadmap completion. Update
architecture.md's inlineRuleOriginlisting (add| "yolo"and the comment note); mark Step 2 (#526) ✅ on the heading and theS2Mermaid node. No red/green (docs-only). Commit:docs(pi-permission-system): record yolo origin and mark Phase 8 Step 2 complete.
Risks and Mitigations
- Cross-extension policy queries change under yolo.
PermissionsService/ RPC now answerallow(originyolo) for a would-be-asksurface when yolo is on. Mitigation: this is the intended "recorded authority" semantics (the effective policy under yolo is allow). Documented here; no consumer in-repo asserts anaskanswer under yolo. - Skill-read logs
policy_allowunder yolo, notauto_approved. Accepted parity nuance (operator-confirmed). No prompt and no regression — only a diagnostic label difference. Documented in Non-Goals. - Intermediate-commit review-log gap.
Between Step 2 and Step 3, a yolo grant would resolve to
allowbut logpolicy_allow(Step 3 restoresauto_approved). Mitigation: no existing test asserts the production-yolo→auto_approved path at the manager level (the handler auto_approved tests mock the prompt and do not wire the manager's yolo reader), so each commit stays green; Step 3 immediately follows. - Forgetting the inline
RuleOrigindoc update. Mitigation: Step 4 is an explicit, required step; the pre-completion reviewer backstops it.
Open Questions
None.
The two observable-output forks (review-log entry shape; skill-read reporting) were resolved with the operator during planning — runner logContext convention and accept policy_allow for skill-reads, respectively.