mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-10-27 03:05:22 +00:00
refactor(permission-system): simplify policy and autoreview flow
This commit is contained in:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user