refactor(permission-system): simplify policy and autoreview flow

This commit is contained in:
云服务部-叶林立
2026-09-03 02:01:06 +08:00
parent 180da95ab7
commit 6e7effa792
112 changed files with 911 additions and 10346 deletions
@@ -5,9 +5,6 @@ import type {
import type { PermissionResolver } from "#src/permission-resolver";
import type { PermissionSession } from "#src/permission-session";
import { resolveSkillPromptEntries } from "#src/skill-prompt-sanitizer";
import { sanitizeAvailableToolsSection } from "#src/system-prompt-sanitizer";
import { getToolNameFromValue, type ToolRegistry } from "#src/tool-registry";
import type { PermissionState } from "#src/types";
/** Minimal subset of BeforeAgentStartEvent used by this handler. */
interface BeforeAgentStartPayload {
@@ -15,40 +12,14 @@ interface BeforeAgentStartPayload {
}
/**
* Pure helper: returns true when the tool should be exposed to the agent.
* Checks the tool-level permission (not command-level) so that a blanket
* `bash: deny` hides the tool entirely before any invocation is attempted.
*/
export function shouldExposeTool(
toolName: string,
agentName: string | null,
getToolPermission: (toolName: string, agentName?: string) => PermissionState,
): boolean {
const toolPermission = getToolPermission(toolName, agentName ?? undefined);
return toolPermission !== "deny";
}
/**
* Handles the `before_agent_start` event: tool filtering + prompt sanitization.
*
* Recomputes the active tool set and the returned system-prompt override on
* every fire (no memoization): the override must be returned each turn so that
* skill filtering is reapplied and the wire prompt stays byte-stable, rather
* than letting Pi reset to its skill-unfiltered base prompt on a cache hit.
*
* Constructor deps:
* - `session` — encapsulates all mutable session state and lifecycle operations
* - `resolver` — owns permission-query surface: `getToolPermission`, skill check
* - `toolRegistry` — Pi tool API subset (getActive + setActive)
* - `warmParser` — warms the tree-sitter parser so the synchronous advisory
* bash path can decompose at gate parity; `before_agent_start` precedes any
* tool call, so triggering it here closes the pre-warm window (#309)
* Prepares permission state before an agent turn without changing Pi's active
* tool set. Tool Search is the bundle's sole active-tool controller; this
* handler only refreshes permission state and sanitizes denied skill entries.
*/
export class AgentPrepHandler {
constructor(
private readonly session: PermissionSession,
private readonly resolver: PermissionResolver,
private readonly toolRegistry: ToolRegistry,
private readonly warmParser: () => void,
) {}
@@ -62,38 +33,11 @@ export class AgentPrepHandler {
// to whole-string matching.
this.warmParser();
this.session.activate(ctx);
// Gate the mid-session runtime-config refresh on project trust too, so an
// untrusted project cannot slip its runtime config (e.g. `yoloMode`) in
// right before agent start after session_start withheld it (#644). The
// session_start handler already warned; do not re-warn on every start.
this.session.refreshConfig(ctx, ctx.isProjectTrusted());
const agentName = this.session.resolveAgentName(ctx, event.systemPrompt);
const activeTools = this.toolRegistry.getActive();
const allowedTools: string[] = [];
for (const tool of activeTools) {
const toolName = getToolNameFromValue(tool);
if (!toolName) {
continue;
}
if (
shouldExposeTool(toolName, agentName, (t, a) =>
this.resolver.getToolPermission(t, a),
)
) {
allowedTools.push(toolName);
}
}
this.toolRegistry.setActive(allowedTools);
const toolPromptResult = sanitizeAvailableToolsSection(
event.systemPrompt,
allowedTools,
);
const skillPromptResult = resolveSkillPromptEntries(
toolPromptResult.prompt,
event.systemPrompt,
this.resolver,
agentName,
this.session.getPathNormalizer(),
@@ -1,126 +0,0 @@
import type { BashProgram } from "#src/access-intent/bash/program";
import type { ScopedPermissionResolver } from "#src/permission-resolver";
import { buildBashExternalDirectoryAskPayload } from "#src/presentation/path-ask-payload";
import { SessionApproval } from "#src/session-approval";
import { deriveApprovalPattern } from "#src/session-rules";
import type { GateResult } from "./descriptor";
import { selectUncoveredExternalPaths } from "./external-directory-policy";
import { accessFactsFromPath } from "./helpers";
import type { ToolCallContext } from "./types";
/**
* Build a pure descriptor for the bash external-directory permission gate.
*
* Reads the external paths from the injected `BashProgram` and checks whether
* any reference directories outside the working directory. Returns `null` when the gate
* does not apply (not a shell invocation, no command, or no external paths found).
* Returns a `GateBypass` when all paths are allowed (by config or session rule).
* Returns a `GateDescriptor` with multi-pattern sessionApproval for uncovered paths.
*
* The shell command (native `bash` or an aliased shell tool) is read from the
* injected `BashProgram`, which owns the source text it was parsed from, so
* this gate does not re-derive the input field name (#574).
*/
export function describeBashExternalDirectoryGate(
tcc: ToolCallContext,
bashProgram: BashProgram | null,
resolver: ScopedPermissionResolver,
): GateResult {
if (!bashProgram) return null;
const command = bashProgram.commandText();
const externalPaths = bashProgram.externalPaths();
if (externalPaths.length === 0) return null;
// Resolve every external path on the external_directory surface and keep the
// ones not already allowed (config-level allows suppress the prompt just as
// session-level allows do); the shared helper single-sources the #418 alias
// matching and the worst-uncovered selection.
const { uncovered: uncoveredEntries, worstCheck } =
selectUncoveredExternalPaths(
externalPaths,
resolver,
tcc.agentName ?? undefined,
);
const uncoveredPaths = uncoveredEntries.map(({ path }) => path.value());
if (uncoveredPaths.length === 0) {
return {
action: "allow",
// A whole-command bypass covers every external path at once, and each
// may have matched a different session pattern -- so the surface is one
// value and the pattern is not. The entry's `externalPaths` lists what
// was covered.
decidedBy: {
kind: "session_approval",
surface: "external_directory",
pattern: null,
},
log: {
event: "permission_request.session_approved",
details: {
source: "tool_call",
toolCallId: tcc.toolCallId,
toolName: tcc.toolName,
agentName: tcc.agentName,
command,
externalPaths: externalPaths.map((p) => p.value()),
resolution: "session_approved",
},
},
};
}
// After the early bypass, at least one path is uncovered, so worstCheck is
// defined; the fallback keeps TypeScript happy across the early return. A
// config-level "deny" is preserved (not downgraded to the catch-all "ask").
const preCheck = worstCheck ?? uncoveredEntries[0].check;
// The AccessPath the decision was made against — its facts ride the wire.
const worstEntry =
uncoveredEntries.find(({ check }) => check === preCheck) ??
uncoveredEntries[0];
const disclosures = uncoveredEntries.map(({ path }) => ({
path: path.value(),
resolvedPath: path.resolvedAlias(),
}));
const payload = buildBashExternalDirectoryAskPayload({
command,
externalPaths: disclosures,
cwd: tcc.cwd,
agentName: tcc.agentName,
toolName: tcc.toolName,
matchedPattern: preCheck.matchedPattern,
});
const patterns = uncoveredPaths.map((p) => deriveApprovalPattern(p));
return {
surface: "external_directory",
input: {},
payload,
sessionApproval: SessionApproval.multiple("external_directory", patterns),
promptDetails: {
source: "tool_call",
agentName: tcc.agentName,
toolCallId: tcc.toolCallId,
toolName: tcc.toolName,
command,
accessIntent: accessFactsFromPath("external_directory", worstEntry.path),
},
logContext: {
source: "tool_call",
toolCallId: tcc.toolCallId,
toolName: tcc.toolName,
agentName: tcc.agentName,
command,
externalPaths: uncoveredPaths,
},
decision: {
surface: "external_directory",
value: command,
},
preCheck,
};
}
@@ -1,23 +0,0 @@
import { BashProgram } from "#src/access-intent/bash/program";
import type { PathNormalizer } from "#src/path-normalizer";
/**
* Extract paths from a bash command that resolve outside CWD.
*
* Thin facade over {@link BashProgram.externalPaths}; parses the command
* through the injected {@link PathNormalizer} (platform + cwd baked in) and
* returns the cd-aware external paths in their lexical (as-typed) string form.
* See `BashProgram` for the parsing and resolution semantics.
*
* Returns `string[]` (not `AccessPath[]`) so the large projection-correctness
* test suite in `bash-external-directory.test.ts` can assert path values
* without migrating to the `AccessPath` accessors.
*/
export async function extractExternalPathsFromBashCommand(
command: string,
normalizer: PathNormalizer,
): Promise<string[]> {
return (await BashProgram.parse(command, normalizer))
.externalPaths()
.map((p) => p.value());
}
@@ -15,7 +15,7 @@ import type { PermissionCheckResult, PermissionState } from "#src/types";
* about logging, event emission, or session-rule recording.
*/
export interface GateDescriptor {
/** Permission surface to check (e.g. "bash", "external_directory", "skill"). */
/** Permission surface to check (e.g. "bash", "path", "skill"). */
surface: string;
/** Input passed to checkPermission. */
input: unknown;
@@ -1,66 +0,0 @@
import type { AccessPath } from "#src/access-intent/access-path";
import type { ScopedPermissionResolver } from "#src/permission-resolver";
import type { PermissionCheckResult } from "#src/types";
import { pickMostRestrictive } from "./candidate-check";
/** An external path whose resolved `external_directory` state is not "allow". */
export interface UncoveredExternalPath {
path: AccessPath;
check: PermissionCheckResult;
}
/** The uncovered external paths plus the most restrictive check among them. */
export interface UncoveredExternalPaths {
uncovered: UncoveredExternalPath[];
/** Worst check among uncovered paths; `undefined` only when none are uncovered. */
worstCheck: PermissionCheckResult | undefined;
}
/**
* Resolve one external path's policy on the `external_directory` surface.
*
* Emits an `access-path` {@link AccessIntent}; the resolver unwraps it via
* {@link AccessPath.matchValues} so a config pattern on either the typed or
* symlink-resolved alias applies (#418). This is the single source for the
* external-directory resolve that the two external-directory gates previously
* duplicated.
*/
export function resolveExternalDirectoryPolicy(
path: AccessPath,
resolver: ScopedPermissionResolver,
agentName: string | undefined,
): PermissionCheckResult {
return resolver.resolve({
kind: "access-path",
surface: "external_directory",
path,
agentName,
});
}
/**
* Resolve a set of external paths and select those not already allowed.
*
* Each path is resolved via {@link resolveExternalDirectoryPolicy}; entries
* whose state is not "allow" are collected (filtering on state, not source, so
* config-level allow rules suppress the prompt just as session-level allow
* rules do), and the most restrictive uncovered check is returned so a config
* "deny" is not downgraded to the catch-all "ask".
*/
export function selectUncoveredExternalPaths(
paths: readonly AccessPath[],
resolver: ScopedPermissionResolver,
agentName: string | undefined,
): UncoveredExternalPaths {
const uncovered: UncoveredExternalPath[] = [];
for (const path of paths) {
const check = resolveExternalDirectoryPolicy(path, resolver, agentName);
if (check.state !== "allow") {
uncovered.push({ path, check });
}
}
return {
uncovered,
worstCheck: pickMostRestrictive(uncovered.map(({ check }) => check)),
};
}
@@ -1,118 +0,0 @@
import { getToolInputPath } from "#src/access-intent/tool-input-path";
import type { PathNormalizer } from "#src/path-normalizer";
import type { ScopedPermissionResolver } from "#src/permission-resolver";
import { buildExternalDirectoryAskPayload } from "#src/presentation/path-ask-payload";
import { SessionApproval } from "#src/session-approval";
import { deriveApprovalPattern } from "#src/session-rules";
import type { ToolAccessExtractorLookup } from "#src/tool-access-extractor-registry";
import type { GateResult } from "./descriptor";
import { resolveExternalDirectoryPolicy } from "./external-directory-policy";
import { accessFactsFromPath } from "./helpers";
import type { ToolCallContext } from "./types";
/**
* Build a pure descriptor for the external-directory permission gate.
*
* Returns `null` when the gate does not apply (no CWD, tool is not
* path-bearing, or path is inside the working directory).
* Returns a `GateBypass` for Pi infrastructure reads.
* Returns a `GateDescriptor` for external paths needing a permission check.
*/
export function describeExternalDirectoryGate(
tcc: ToolCallContext,
infraDirs: string[],
resolver: ScopedPermissionResolver,
normalizer: PathNormalizer,
extractors?: ToolAccessExtractorLookup,
): GateResult {
const externalDirectoryPath = getToolInputPath(
tcc.toolName,
tcc.input,
extractors,
);
if (!externalDirectoryPath) return null;
if (!normalizer.isOutsideWorkingDirectory(externalDirectoryPath)) {
return null;
}
// The boundary decision (above) and the infrastructure-read containment
// check (below) use the canonical, symlink-resolved path; pattern matching
// uses the typed and resolved aliases (#418).
const accessPath = normalizer.forPath(externalDirectoryPath);
// ── Pi infrastructure read bypass ──────────────────────────────────────
if (normalizer.isInfrastructureRead(tcc.toolName, accessPath, infraDirs)) {
return {
action: "allow",
// Containment allowed this, not a rule the operator wrote.
decidedBy: { kind: "infrastructure_read" },
log: {
event: "permission_request.infrastructure_auto_allowed",
details: {
source: "tool_call",
toolCallId: tcc.toolCallId,
toolName: tcc.toolName,
agentName: tcc.agentName,
path: externalDirectoryPath,
},
},
decision: {
surface: tcc.toolName,
value: externalDirectoryPath,
result: "allow",
resolution: "infrastructure_auto_allowed",
origin: null,
agentName: tcc.agentName ?? null,
matchedPattern: null,
},
};
}
// ── Build descriptor for permission check ───────────────────────────────
const resolvedAlias = accessPath.resolvedAlias();
// The runner consumes this preCheck and skips its own resolve.
const preCheck = resolveExternalDirectoryPolicy(
accessPath,
resolver,
tcc.agentName ?? undefined,
);
const pattern = deriveApprovalPattern(accessPath.value());
const payload = buildExternalDirectoryAskPayload({
toolName: tcc.toolName,
pathValue: externalDirectoryPath,
resolvedPath: resolvedAlias,
cwd: tcc.cwd,
agentName: tcc.agentName,
matchedPattern: preCheck.matchedPattern,
});
return {
surface: "external_directory",
input: {},
preCheck,
payload,
sessionApproval: SessionApproval.single("external_directory", pattern),
promptDetails: {
source: "tool_call",
agentName: tcc.agentName,
toolCallId: tcc.toolCallId,
toolName: tcc.toolName,
path: externalDirectoryPath,
accessIntent: accessFactsFromPath("external_directory", accessPath),
},
logContext: {
source: "tool_call",
toolCallId: tcc.toolCallId,
toolName: tcc.toolName,
agentName: tcc.agentName,
path: externalDirectoryPath,
},
decision: {
surface: "external_directory",
value: externalDirectoryPath,
},
};
}
@@ -26,9 +26,8 @@ export function describePathGate(
const filePath = getToolInputPath(tcc.toolName, tcc.input, extractors);
if (!filePath) return null;
// Emit an access-path intent so the resolver matches the lexical aliases
// *and* the canonical (symlink-resolved) form, the same set
// `external_directory` matches (#418, #486).
// Emit an access-path intent so path policy matches both lexical aliases and
// the canonical (symlink-resolved) form.
const accessPath = normalizer.forPath(filePath);
const check = resolver.resolve({
kind: "access-path",
@@ -17,10 +17,8 @@ import {
} from "#src/tool-preview-formatter";
import type { PermissionCheckResult } from "#src/types";
import { resolveBashCommandCheck } from "./bash-command";
import { describeBashExternalDirectoryGate } from "./bash-external-directory";
import { describeBashPathGate } from "./bash-path";
import type { GateResult } from "./descriptor";
import { describeExternalDirectoryGate } from "./external-directory";
import { describePathGate } from "./path";
import type { GateRunner } from "./runner";
import { describeSkillReadGate } from "./skill-read";
@@ -41,8 +39,6 @@ import type { GateOutcome, ToolCallContext } from "./types";
export interface ToolCallGateInputs {
/** Active skill prompt entries for the skill-read gate. */
getActiveSkillEntries(): SkillPromptEntry[];
/** Combined infrastructure read directories (static + config-derived). */
getInfrastructureReadDirs(): string[];
/** Resolved tool-preview formatter options from the current config. */
getToolPreviewLimits(): ToolPreviewFormatterOptions;
/** The session's path normalizer (platform + cwd baked in). */
@@ -62,8 +58,7 @@ export interface ToolCallGateInputs {
* `PermissionGateHandler`. `evaluate(tcc, runner)` encapsulates:
* - bash-command extraction and single `BashProgram.parse` (#308)
* - `ToolPreviewFormatter` construction from `getToolPreviewLimits()`
* - infrastructure-dir list from `getInfrastructureReadDirs()`
* - all six gate producers in their prescribed order
* - the configured gate producers in their prescribed order
* - the run loop that returns the first block outcome, or allow
*/
export class ToolCallGatePipeline {
@@ -99,8 +94,6 @@ export class ToolCallGatePipeline {
this.customFormatters,
);
const infraDirs = this.inputs.getInfrastructureReadDirs();
const gateProducers: Array<() => GateResult | Promise<GateResult>> = [
() =>
describeSkillReadGate(tcc, normalizer, () =>
@@ -108,15 +101,6 @@ export class ToolCallGatePipeline {
),
() =>
describePathGate(tcc, this.resolver, normalizer, this.customExtractors),
() =>
describeExternalDirectoryGate(
tcc,
infraDirs,
this.resolver,
normalizer,
this.customExtractors,
),
() => describeBashExternalDirectoryGate(tcc, bashProgram, this.resolver),
() => describeBashPathGate(tcc, bashProgram, this.resolver),
() => {
const { toolCheck, accessPath } = this.resolvePerToolCheck(