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
@@ -27,7 +27,7 @@ export interface ToolAccessIntent {
*/
export interface PathValuesAccessIntent {
kind: "path-values";
/** `"path"` or `"external_directory"`. */
/** Permission surface associated with these path match values. */
surface: string;
values: readonly string[];
agentName?: string;
@@ -36,9 +36,9 @@ export interface PathValuesAccessIntent {
/**
* An `AccessPath` value object for a path-shaped surface.
*
* Built for every path-shaped surface: the cross-cutting `path` and
* `external_directory` gates, the per-tool path-bearing surfaces
* (`read`/`write`/`edit`/`grep`/`find`/`ls`, #502), and the service/RPC policy
* Built for every path-shaped surface: the cross-cutting `path` gate, the
* per-tool path-bearing surfaces (`read`/`write`/`edit`/`grep`/`find`/`ls`,
* #502), and the service/RPC policy
* queries for those surfaces (#503). Lets `AccessPath` flow into the resolver
* as a first-class variant so the resolver — not the producer — asks it for
* `matchValues()` (Tell-Don't-Ask).
@@ -9,16 +9,12 @@ import {
/**
* A path's two representations held behind type-distinct accessors.
*
* A single `string` carrying both meanings was the root cause of [#418]:
* both external-directory gates matched config patterns against the
* symlink-resolved (canonical) path instead of the typed (lexical) path,
* defeating a configured `/tmp/*` allow.
* The object keeps the typed (lexical) path and its symlink-resolved
* (canonical) alias separate so matching and display do not accidentally use
* the wrong representation.
*
* `AccessPath` makes the misuse a compile error:
* - {@link matchValues} returns `string[]` — the lexical alias union ∪ canonical,
* for `external_directory` pattern matching.
* - {@link boundaryValue} returns `string` — the canonical form, for
* outside-CWD containment and infra-read checks.
* - {@link matchValues} returns the lexical aliases plus the canonical alias.
* - {@link boundaryValue} returns the canonical form for forwarding metadata.
* - {@link value} returns `string` — the lexical absolute form, for display,
* approval patterns, decision values, and logs.
* - {@link resolvedAlias} returns `string | undefined` — the canonical form
@@ -37,10 +33,9 @@ export class AccessPath {
) {}
/**
* Pattern-match values for the `external_directory` surface: the lexical
* alias union plus the canonical alias, so a config pattern on either the
* typed form (`/tmp/*`) or the symlink-resolved form (`/private/tmp/*`)
* matches (#418).
* Pattern-match values for path policy: the lexical alias union plus the
* canonical alias, so a pattern on either the typed form (`/tmp/*`) or the
* symlink-resolved form (`/private/tmp/*`) matches.
*
* Collapses to the lexical aliases when the canonical equals one of them
* (e.g. when the path is not a symlink).
@@ -136,9 +131,8 @@ export class AccessPath {
* preserved verbatim across all three representations — `value()`,
* `boundaryValue()`, and `matchValues()` are the device path itself, never
* `win32.resolve`-mangled into `c:\dev\null`. The identical lexical and
* canonical forms let the boundary check reach `isSafeSystemPath` (so the
* device never triggers `external_directory`) while a config rule still
* matches the path as typed.
* canonical forms remain identical so policy matches the device path exactly
* as typed.
*/
static forDevice(devicePath: string): AccessPath {
return new AccessPath(devicePath, [devicePath], devicePath);
@@ -6,7 +6,6 @@ import {
import type { TSNode } from "#src/access-intent/bash/parser";
import {
classifyBareTokenCandidate,
classifyTokenAsPathCandidate,
classifyTokenAsRuleCandidate,
} from "#src/access-intent/bash/token-classification";
import {
@@ -17,7 +16,6 @@ import {
} from "#src/access-intent/bash/token-collection";
import { normalizePathPolicyLiteral } from "#src/access-intent/path-normalization";
import type { PathNormalizer } from "#src/path-normalizer";
import { isSafeSystemPath } from "#src/safe-system-paths";
// ── Internal types ───────────────────────────────────────────────────────────
@@ -53,14 +51,8 @@ export interface BashPathRuleCandidate {
readonly path: AccessPath;
}
/**
* The filesystem paths a bash program references, resolved against the working
* directory and platform — the two typed slices {@link BashProgram} exposes.
*/
/** Cd-aware path-rule candidates extracted from a bash program. */
export interface ResolvedBashPaths {
/** Deduplicated paths resolving outside the working directory (#418). */
readonly externalPaths: readonly AccessPath[];
/** Every path-rule token paired with its cd-aware policy values (#393). */
readonly ruleCandidates: readonly BashPathRuleCandidate[];
}
@@ -99,15 +91,9 @@ export class BashPathResolver {
) {}
/**
* Resolve a parsed bash program's path references into its external-path and
* rule-candidate slices, walking the AST exactly once.
*
* When a `workdir` is set (an aliased shell tool's working directory, #574),
* it seeds the initial effective base — as if the program were prefixed with
* `cd <workdir>` — so relative tokens resolve against it, and the `workdir`
* itself is added to the external paths when it resolves outside the cwd.
* Containment is always measured against the session cwd baked into the
* normalizer, so a `workdir` outside the cwd does not widen the sandbox.
* Resolve a parsed bash program's path references into cd-aware policy
* candidates while walking the AST exactly once. An aliased shell tool's
* `workdir` seeds the initial effective base for relative tokens.
*/
resolve(rootNode: TSNode): ResolvedBashPaths {
const initialBase =
@@ -116,36 +102,10 @@ export class BashPathResolver {
: this.deriveBaseFromCdTarget(CWD_BASE, this.workdir);
const candidates = this.collectPathCandidates(rootNode, initialBase);
return {
externalPaths: this.withWorkdirExternal(
this.projectExternalPaths(candidates),
),
ruleCandidates: this.projectRuleCandidates(candidates),
};
}
/**
* Prepend the `workdir`'s own {@link AccessPath} to the external paths when it
* resolves outside the cwd. A real `cd /etc` flags `/etc` via its argument
* token; the seeded base carries no such token, so it is added explicitly and
* deduplicated against the command's own external tokens (#574).
*/
private withWorkdirExternal(
tokenExternals: readonly AccessPath[],
): AccessPath[] {
if (this.workdir === undefined) return [...tokenExternals];
const wdPath = this.normalizer.forBashToken(this.workdir);
const canonical = wdPath.boundaryValue();
const isExternal = canonical
? this.normalizer.isBoundaryOutsideWorkingDirectory(canonical)
: true;
if (!isExternal) return [...tokenExternals];
const key = canonical || wdPath.value();
const alreadyPresent = tokenExternals.some(
(p) => (p.boundaryValue() || p.value()) === key,
);
return alreadyPresent ? [...tokenExternals] : [wdPath, ...tokenExternals];
}
// ── AST walk — collect PathCandidates ──────────────────────────────────
/**
@@ -401,112 +361,8 @@ export class BashPathResolver {
// ── Projection ─────────────────────────────────────────────────────────
/**
* Project the collected candidates into deduplicated external paths.
*
* Filters candidates through the strict path classifier
* (`classifyTokenAsPathCandidate`), resolves each against its effective working
* directory base, and returns only paths that resolve outside the baked cwd in
* their lexical (as-typed, normalized but not symlink-resolved) form.
*
* The outside-cwd decision and the dedup identity use the canonical
* (symlink-resolved) form so `external_directory` config patterns match the
* path as the user typed it (#418).
*/
private projectExternalPaths(
candidates: readonly PathCandidate[],
): AccessPath[] {
const seen = new Set<string>();
const externalPaths: AccessPath[] = [];
for (const { token, base } of candidates) {
const candidate = classifyTokenAsPathCandidate(token);
if (!candidate) {
// A bare token the strict shape gate rejects can still escape the tree
// through a symlink, so probe it and apply the ordinary boundary
// decision to whatever it resolves to (#645).
const probed = this.probeBareToken(token, base);
if (probed) this.collectIfExternal(probed.path, seen, externalPaths);
continue;
}
// Unknown effective directory: a relative candidate could resolve
// anywhere, so flag it conservatively (resolved against the baked cwd
// only for a display path). Absolute / `~` candidates are base-independent
// below.
if (base.kind === "unknown" && this.isRelativeCandidate(candidate)) {
const accessPath = this.normalizer.forPath(candidate);
const canonical = accessPath.boundaryValue();
if (canonical && !isSafeSystemPath(canonical) && !seen.has(canonical)) {
seen.add(canonical);
externalPaths.push(accessPath);
}
continue;
}
const resolveBase =
base.kind === "known"
? this.normalizer.resolveBase(base.offset)
: undefined;
this.collectIfExternal(
this.normalizer.forBashToken(candidate, { resolveBase }),
seen,
externalPaths,
);
}
return externalPaths;
}
/**
* Record `accessPath` when it resolves outside the working directory and has
* not already been collected.
*
* The boundary decision and dedup identity use the canonical
* (symlink-resolved) form the {@link AccessPath} already derived, while the
* stored value keeps the lexical form so config patterns match the path as
* the user typed it (#418). A win32 device path preserves `/dev/null` as its
* boundary value, so `isBoundaryOutsideWorkingDirectory` reaches the
* safe-path exclusion (#533).
*
* A literal-only bash token (a win32 non-mount POSIX absolute like `/tmp`)
* has no canonical form; it is foreign to the win32 cwd, so it is always
* external. Its lexical value is the dedup identity so two distinct
* literal-only paths do not collapse (#533).
*/
private collectIfExternal(
accessPath: AccessPath,
seen: Set<string>,
out: AccessPath[],
): void {
const lexical = accessPath.value();
if (!lexical) return;
const canonical = accessPath.boundaryValue();
const isExternal = canonical
? this.normalizer.isBoundaryOutsideWorkingDirectory(canonical)
: true;
const dedupKey = canonical || lexical;
if (isExternal && !seen.has(dedupKey)) {
seen.add(dedupKey);
out.push(accessPath);
}
}
/**
* Project the collected candidates into rule candidates with their cd-aware
* policy lookup values.
*
* Filters candidates through the broad path classifier
* (`classifyTokenAsRuleCandidate`), falling back to {@link probeBareToken}
* for a bare token the broad classifier rejects for shape — admitted only
* when it names an existing filesystem entry (#645).
* On win32 the broad classifier is told to treat a backslash as a path
* separator, so a backslash-relative token (`dir\file`) is recognized as a
* rule candidate the same as its forward-slash equivalent (#520); on POSIX
* `\` is a legal filename character, so the token stays bare there.
* Pairs each qualifying token with its set of policy values (absolute +
* project-relative + raw).
* A token after a non-literal `cd` keeps only its literal value so no
* spurious absolute rule can match (#393).
* Project candidates into policy lookup values while preserving literal
* values after a non-literal `cd`, where no reliable absolute base exists.
*/
private projectRuleCandidates(
candidates: readonly PathCandidate[],
@@ -1,4 +1,3 @@
import type { AccessPath } from "#src/access-intent/access-path";
import {
BashPathResolver,
type BashPathRuleCandidate,
@@ -26,7 +25,6 @@ export class BashProgram {
private constructor(
private readonly sourceCommand: string,
private readonly commandUnits: readonly BashCommand[],
private readonly resolvedExternalPaths: readonly AccessPath[],
private readonly resolvedRuleCandidates: readonly BashPathRuleCandidate[],
) {}
@@ -44,10 +42,8 @@ export class BashProgram {
* (ADR 0009, #645). No policy is consulted, so every caller gets identical
* slices for a given command and working directory.
*
* `options.workdir`, when supplied (an aliased shell tool's working directory,
* #574), seeds the initial effective base — as if the command were prefixed
* with `cd <workdir>` — so relative tokens resolve against it, and the workdir
* itself is flagged as external when it resolves outside the cwd.
* `options.workdir`, when supplied for an aliased shell tool, seeds the
* initial effective base so relative path-rule tokens resolve against it.
*/
static async parse(
command: string,
@@ -56,17 +52,16 @@ export class BashProgram {
): Promise<BashProgram> {
const parser = await getParser();
const tree = parser.parse(command);
if (!tree) return new BashProgram(command, [], [], []);
if (!tree) return new BashProgram(command, [], []);
try {
const { externalPaths, ruleCandidates } = new BashPathResolver(
const { ruleCandidates } = new BashPathResolver(
normalizer,
options?.workdir,
).resolve(tree.rootNode);
return new BashProgram(
command,
collectCommands(tree.rootNode),
externalPaths,
ruleCandidates,
);
} finally {
@@ -103,20 +98,6 @@ export class BashProgram {
return [...this.commandUnits];
}
/**
* Deduplicated paths that resolve outside `cwd`, as {@link AccessPath} value
* objects holding both the lexical (as-typed) and canonical (symlink-resolved)
* forms behind distinct accessors.
*
* Resolved eagerly at parse time through the `PathNormalizer` supplied to
* `parse()` (platform + cwd baked in).
* Use `.matchValues()` for `external_directory` pattern matching and
* `.boundaryValue()` for containment checks; `.value()` for display and logs.
*/
externalPaths(): AccessPath[] {
return [...this.resolvedExternalPaths];
}
/**
* Path-rule candidates paired with their policy lookup values.
*
@@ -11,7 +11,7 @@ import { classifyToolKind } from "./tool-kind";
* service and the event-bus RPC) feeds to the resolver from a `(surface, value)`
* pair.
*
* For a path-shaped surface (`path`, `external_directory`, or a path-bearing
* For a path-shaped surface (`path` or a path-bearing
* tool) carrying a non-empty value, it builds an `AccessPath` and emits an
* `access-path` intent, so the resolver matches the lexical aliases ∪ canonical
* (symlink-resolved) set — at parity with the gates (#486, #502). Every other
@@ -95,7 +95,6 @@ function buildInputForSurface(
const v = value ?? "";
if (surface === "bash") return { command: v };
if (surface === "skill") return { name: v };
if (surface === "external_directory") return { path: v };
// MCP and tool surfaces: normalizeInput handles them from the surface alone.
return {};
}
@@ -125,7 +124,7 @@ export interface NormalizedInput {
* `checkPermission()`.
*
* Handles bash, skill, mcp, and extension surfaces. Path-bearing tool surfaces
* (`path`, `external_directory`, `read`, `write`, `edit`, `grep`, `find`,
* (`path`, `read`, `write`, `edit`, `grep`, `find`,
* `ls`) now route through the access-path gate (#502) and service/RPC builder
* (#503) before reaching the manager, so they never arrive here with a real
* path value — all fall through to the extension catch-all `["*"]`.
@@ -1,14 +1,4 @@
/**
* File tools that only read — never write — the filesystem.
* Only these tools are eligible for the Pi infrastructure auto-allow.
*/
export const READ_ONLY_PATH_BEARING_TOOLS: ReadonlySet<string> = new Set([
"read",
"find",
"grep",
"ls",
]);
/** File tools whose primary input carries a local filesystem path. */
export const PATH_BEARING_TOOLS = new Set([
"read",
"write",
@@ -18,13 +8,8 @@ export const PATH_BEARING_TOOLS = new Set([
"ls",
]);
/**
* Surfaces whose patterns are matched against filesystem paths and therefore
* fold case (and separators) on Windows: the path-bearing tools plus the
* cross-cutting `path` gate and the `external_directory` boundary gate.
*/
/** Surfaces whose patterns use filesystem path matching semantics. */
export const PATH_SURFACES: ReadonlySet<string> = new Set([
...PATH_BEARING_TOOLS,
"external_directory",
"path",
]);
@@ -14,8 +14,8 @@ export function getPathBearingToolPath(
}
/**
* Extract the filesystem path a tool will access, for the cross-cutting `path`
* and `external_directory` gates.
* Extract the filesystem path a tool will access for the cross-cutting `path`
* gate.
*
* Unlike {@link getPathBearingToolPath} (built-in tools only), this recognizes
* extension and MCP tools so they are no longer exempt from path gating:
@@ -26,8 +26,8 @@ import { PATH_BEARING_TOOLS } from "./path-surfaces";
* - `skill` — a distinct surface `normalizeInput`/`deriveSource` treat specially.
* - `path` — a path-bearing built-in (`read`/`write`/`edit`/`grep`/`find`/`ls`);
* extraction product is `input.path`.
* - `extension` — every other tool, plus the `external_directory`/`path` special
* surfaces that reach `deriveSource` as normalized names.
* - `extension` — every other tool, plus the `path` special surface that
* reaches `deriveSource` as a normalized name.
*/
export type ToolKind = "bash" | "mcp" | "skill" | "path" | "extension";
@@ -9,7 +9,6 @@ import {
} from "./authorizer";
import { composeAuthorizerChain } from "./authorizer-chain";
import type { AuthorizerLookup } from "./authorizer-registry";
import { encloseInDelegationEnvelope } from "./delegation-envelope";
import type {
PermissionPrompterApi,
PromptPermissionDetails,
@@ -112,8 +111,8 @@ export class AuthorizerSelection
* Resolve the operator's `authorizerChain` names to registered links, in
* config order (ADR 0007 invariant 1). An unregistered name is skipped with a
* warning (invariant 2 — more prompting, never less); each resolved link is
* wrapped in the bounded-delegation envelope so an `allow` on an excluded
* surface cannot exceed the operator's policy.
* Authorizer verdicts are used directly. A configured authorizer may approve
* any ordinary-policy `ask`, including the `path` surface.
*
* The resolved names are recorded against the ask before any link runs — a
* link that defers decides nothing and would otherwise leave no evidence it
@@ -136,7 +135,7 @@ export class AuthorizerSelection
continue;
}
resolved.push(name);
links.push({ name, authorize: encloseInDelegationEnvelope(authorize) });
links.push({ name, authorize });
}
if (resolved.length > 0) {
this.deps.logger.review("authorizer_chain_resolved", {
@@ -1,64 +0,0 @@
/**
* The bounded-delegation enforcement checkpoint (ADR 0007 §5).
*
* The chain owner caps every registered link's verdict so a buggy or over-eager
* external judge can never exceed the operator's policy: a link's `allow` on an
* excluded surface is downgraded to `defer`, letting the `ask` fall through to
* the terminal (a prompt) instead. The checkpoint only ever *tightens* a
* verdict — it never turns a `defer`/`deny` into an `allow`.
*
* The excluded set is the whole `path` surface plus `external_directory`, with
* one bundle-maintained exception: built-in read-only path tools may accept a
* link's `allow` for an external-directory ask. Mutating tools, bash, extension
* tools, and unknown tools remain capped to the terminal human authority. A
* finer secret-shaped-`path` exclusion remains deferred; `path` stays fully
* excluded.
*/
import { READ_ONLY_PATH_BEARING_TOOLS } from "#src/access-intent/path-surfaces";
import type { Authorizer } from "./authorizer";
import type { PromptPermissionDetails } from "./permission-prompter";
/** Surfaces on which a link may never grant an `allow` (ADR 0007 §5). */
export const DELEGATION_EXCLUDED_SURFACES: ReadonlySet<string> = new Set([
"external_directory",
"path",
]);
/**
* Wrap a link's `authorize` so an `allow` on an excluded surface is capped to
* `defer`. All other verdicts, and `allow`s on non-excluded surfaces, pass
* through unchanged. `details`, the injected `query`, and the review-log `log`
* are forwarded as-is.
*/
export function encloseInDelegationEnvelope(
authorize: Authorizer["authorize"],
): Authorizer["authorize"] {
return async (details, query, log) => {
const verdict = await authorize(details, query, log);
if (verdict.kind === "allow" && isExcludedSurface(details)) {
return { kind: "defer" };
}
return verdict;
};
}
/**
* Whether an allow verdict exceeds the delegation envelope. The gate-computed
* surface is authoritative. `path` remains fully excluded; external-directory
* access is excluded unless it comes from a built-in read-only path tool.
* Unknown surfaces fail safe to the terminal authority.
*/
function isExcludedSurface(details: PromptPermissionDetails): boolean {
const surface = details.accessIntent?.surface ?? details.surface ?? undefined;
if (surface === undefined) {
return true;
}
if (surface === "external_directory") {
return !(
details.toolName !== undefined &&
READ_ONLY_PATH_BEARING_TOOLS.has(details.toolName)
);
}
return DELEGATION_EXCLUDED_SURFACES.has(surface);
}
@@ -89,9 +89,8 @@ export interface ForwardedRequestServerDeps {
* judges a forwarded ask on the same evidence as a local one (ADR 0008; #635).
*
* The display `surface` and the fact `surface` are distinct and both belong
* here: the former is the child's tool name (what the UI shows), the latter the
* gate surface the rule fired on (what the bounded-delegation checkpoint
* excludes on).
* here: the former is the child's tool name (what the UI shows), while the
* latter preserves the gate surface as authorizer evidence.
*/
function buildForwardedAskDetails(
request: ForwardedPermissionRequest,
@@ -113,9 +112,8 @@ function buildForwardedAskDetails(
...(request.sessionApproval
? { sessionApproval: request.sessionApproval }
: {}),
// Absent for a version-skew request that carried no intent — which the
// delegation envelope reads as "surface undetermined" and fail-safes to
// excluded, so absence must stay absence rather than become `undefined`.
// Absent for a version-skew request that carried no access intent; preserve
// that absence rather than manufacturing authorizer evidence.
...(request.accessIntent
? { accessIntent: toAccessFacts(request.accessIntent) }
: {}),
@@ -88,7 +88,7 @@ export interface ForwardedSessionApproval {
* (`docs/decisions/0002-path-values-string-boundary.md`).
*/
export interface ForwardedAccessFacts {
/** Gate surface: `"path"`, `"external_directory"`, `"bash"`, a tool name, or a skill name. */
/** Gate surface: `"path"`, `"bash"`, a tool name, or a skill name. */
surface: string;
/**
* The child-fixed match set. Path surface: `AccessPath.matchValues()`
+8 -69
View File
@@ -5,8 +5,6 @@ import {
getGlobalConfigPath,
getLegacyExtensionConfigPath,
getLegacyGlobalPolicyPath,
getLegacyProjectPolicyPath,
getProjectConfigPath,
} from "./config-paths";
import {
type ShellToolsConfig,
@@ -194,8 +192,6 @@ function formatConfigIssues(error: ZodError): string[] {
* - `permission` is deep-shallow merged (surface-level object maps are shallow-merged).
* - Scalar fields (debugLog, permissionReviewLog, yoloMode) are replaced when
* present in the override.
* - Array fields (piInfrastructureReadPaths) replace the base when present in
* the override (override-wins, same as scalars).
*/
// Scalar knobs merged by override-replaces-base; keep in sync with
// PermissionSystemExtensionConfig booleans (debugLog, permissionReviewLog,
@@ -235,11 +231,9 @@ export function mergeUnifiedConfigs(
}
// Array fields: override replaces base when defined
for (const key of ["piInfrastructureReadPaths", "authorizerChain"] as const) {
const value = override[key] ?? base[key];
if (value !== undefined) {
merged[key] = value;
}
const authorizerChain = override.authorizerChain ?? base.authorizerChain;
if (authorizerChain !== undefined) {
merged.authorizerChain = authorizerChain;
}
// shellTools: merge by tool name, then by descriptor field. A project can
@@ -286,51 +280,25 @@ export function mergeUnifiedConfigs(
export interface MergedConfigResult {
global: UnifiedPermissionConfig;
project: UnifiedPermissionConfig;
merged: UnifiedPermissionConfig;
issues: string[];
}
/**
* Load global and project configs from the new layout, detect legacy files,
* merge everything, and collect issues.
*
* Merge order:
* 1. Legacy global policy (if present) — lowest precedence
* 2. Legacy extension runtime config (if present and path differs from new global)
* 3. New global config
* 4. Legacy project policy (if present)
* 5. New project config — highest precedence
*
* Legacy files are detected and warned about. Their content is parsed with the
* flat-format parser — legacy-format keys (defaultPolicy, tools, bash, etc.)
* are not translated and contribute no permission rules.
*
* When `options.includeProjectScope` is `false`, the project-scope steps (4 and
* 5) are skipped entirely — neither the legacy project policy nor the new
* project config is read or merged. This gates project-local config on project
* trust: an untrusted repository cannot loosen the operator's global policy
* (#644). It defaults to `true`, preserving the trusted / caller-agnostic path.
* Load the user-global config, detect legacy global files, and collect issues.
* Project-level `policy.jsonc` files are intentionally not loaded.
*/
export function loadAndMergeConfigs(
agentDir: string,
cwd: string,
_cwd: string,
extensionRoot: string,
options: { includeProjectScope?: boolean } = {},
): MergedConfigResult {
const includeProjectScope = options.includeProjectScope !== false;
const allIssues: string[] = [];
const newGlobalPath = getGlobalConfigPath(agentDir);
const newProjectPath = getProjectConfigPath(cwd);
const legacyGlobalPolicyPath = getLegacyGlobalPolicyPath(agentDir);
const legacyProjectPolicyPath = getLegacyProjectPolicyPath(cwd);
const legacyExtConfigPath = getLegacyExtensionConfigPath(extensionRoot);
// Start with empty
let merged: UnifiedPermissionConfig = {};
// 1. Legacy global policy
if (existsSync(legacyGlobalPolicyPath)) {
const legacy = loadUnifiedConfig(legacyGlobalPolicyPath);
allIssues.push(
@@ -338,55 +306,27 @@ export function loadAndMergeConfigs(
`Move it to '${newGlobalPath}':\n` +
` mv '${legacyGlobalPolicyPath}' '${newGlobalPath}'`,
);
// Legacy files are migrated away; the move-it guidance above is the
// actionable signal, so strict-validation issues for them are suppressed.
merged = mergeUnifiedConfigs(merged, legacy.config);
}
// 2. Legacy extension runtime config (only if different from new global path)
const normalizedLegacyExt = normalize(legacyExtConfigPath);
const normalizedNewGlobal = normalize(newGlobalPath);
if (
normalizedLegacyExt !== normalizedNewGlobal &&
normalize(legacyExtConfigPath) !== normalize(newGlobalPath) &&
existsSync(legacyExtConfigPath)
) {
const legacy = loadUnifiedConfig(legacyExtConfigPath);
allIssues.push(
`Legacy extension config found at '${legacyExtConfigPath}'. ` +
`Move runtime settings to '${newGlobalPath}':\n` +
`Move it to '${newGlobalPath}':\n` +
` mv '${legacyExtConfigPath}' '${newGlobalPath}'`,
);
// See above: legacy-file validation issues are suppressed.
merged = mergeUnifiedConfigs(merged, legacy.config);
}
// 3. New global config
const globalResult = loadUnifiedConfig(newGlobalPath);
allIssues.push(...globalResult.issues);
const globalConfig = globalResult.config;
merged = mergeUnifiedConfigs(merged, globalConfig);
// 4. Legacy project policy — skipped when the project scope is withheld.
if (includeProjectScope && existsSync(legacyProjectPolicyPath)) {
const legacy = loadUnifiedConfig(legacyProjectPolicyPath);
allIssues.push(
`Legacy project policy found at '${legacyProjectPolicyPath}'. ` +
`Move it to '${newProjectPath}':\n` +
` mv '${legacyProjectPolicyPath}' '${newProjectPath}'`,
);
// See above: legacy-file validation issues are suppressed.
merged = mergeUnifiedConfigs(merged, legacy.config);
}
// 5. New project config — skipped when the project scope is withheld, so an
// untrusted project contributes nothing and `project` reports empty.
const projectResult = includeProjectScope
? loadUnifiedConfig(newProjectPath)
: { config: {}, issues: [] };
allIssues.push(...projectResult.issues);
const projectConfig = projectResult.config;
merged = mergeUnifiedConfigs(merged, projectConfig);
const bashFallbackIssue = detectPermissiveBashFallback(merged.permission);
if (bashFallbackIssue) allIssues.push(bashFallbackIssue);
@@ -395,7 +335,6 @@ export function loadAndMergeConfigs(
return {
global: globalConfig,
project: projectConfig,
merged,
issues: allIssues,
};
-6
View File
@@ -26,9 +26,6 @@ export function getGlobalLogsDir(agentDir: string): string {
return join(getGlobalConfigDir(agentDir), "logs");
}
export function getProjectConfigPath(cwd: string): string {
return join(cwd, ".pi", "extensions", EXTENSION_ID, "policy.jsonc");
}
/**
* Directory holding project-scoped custom agent definition files.
@@ -47,9 +44,6 @@ export function getLegacyGlobalPolicyPath(agentDir: string): string {
return join(agentDir, "pi-permissions.jsonc");
}
export function getLegacyProjectPolicyPath(cwd: string): string {
return join(cwd, ".pi", "agent", "pi-permissions.jsonc");
}
export function getLegacyExtensionConfigPath(extensionRoot: string): string {
return join(extensionRoot, "config.json");
@@ -3,21 +3,17 @@ import type { ResolvedPolicyPaths } from "./permission-manager";
export interface ResolvedConfigLogEntry {
globalConfigPath: string;
globalConfigExists: boolean;
projectConfigPath: string | null;
projectConfigExists: boolean;
agentsDir: string;
agentsDirExists: boolean;
projectAgentsDir: string | null;
projectAgentsDirExists: boolean;
legacyGlobalPolicyDetected: boolean;
legacyProjectPolicyDetected: boolean;
legacyExtensionConfigDetected: boolean;
}
export interface BuildResolvedConfigLogEntryOptions {
policyPaths: ResolvedPolicyPaths;
legacyGlobalPolicyDetected?: boolean;
legacyProjectPolicyDetected?: boolean;
legacyExtensionConfigDetected?: boolean;
}
@@ -27,7 +23,6 @@ export function buildResolvedConfigLogEntry(
return {
...options.policyPaths,
legacyGlobalPolicyDetected: options.legacyGlobalPolicyDetected ?? false,
legacyProjectPolicyDetected: options.legacyProjectPolicyDetected ?? false,
legacyExtensionConfigDetected:
options.legacyExtensionConfigDetected ?? false,
};
+5 -13
View File
@@ -72,9 +72,9 @@ const permissionMapSchema = z
.meta({
id: "permissionMap",
description:
"A map of wildcard patterns to permission states. Last matching pattern wins.",
"A map of wildcard patterns to permission states. The most specific matching pattern wins.",
markdownDescription:
"A map of wildcard patterns to permission states.\n\nUse `*` for wildcard matching. When multiple patterns match, the **last matching rule wins** — put broad catch-alls first and specific overrides after them.\n\nPattern keys support home directory expansion:\n- `~/path` or `$HOME/path` — expanded to the OS home directory at match time.\n- `~` or `$HOME` alone — expands to the home directory itself.\n\nThe stored pattern is always shown in logs and approval dialogs as written (e.g. `~/dev/*`).",
"A map of wildcard patterns to permission states.\n\nUse `*` for wildcard matching. When several patterns match, the most specific pattern wins: exact patterns first, then more literal characters, fewer wildcards, and longer patterns. Declaration order is only the final tie-breaker, where the later rule wins.\n\nPattern keys support home directory expansion: `~/path` and `$HOME/path` expand at match time. The stored pattern is shown in logs and approval dialogs as written.",
});
const permissionSchema = z
@@ -88,7 +88,7 @@ const permissionSchema = z
description:
"Flat permission policy. Each key is a surface name; values are a PermissionState string (catch-all) or a pattern→action map.",
markdownDescription:
'Flat permission policy.\n\nEach top-level key is a surface name:\n- `"*"` — universal fallback (replaces `defaultPolicy.tools` from the legacy format)\n- Tool names (`read`, `write`, `bash`, `mcp`, `skill`, `external_directory`, `path`, etc.)\n\nA **string** value is shorthand for `{ "*": action }` (surface-level catch-all).\nAn **object** value maps wildcard patterns to actions — last matching pattern wins.\n\nFor built-in file tools (`read`, `write`, `edit`, `find`, `grep`, `ls`), patterns are matched against the file path from `input.path`. For example, `"read": { "*": "allow", "*.env": "deny" }` allows reads but denies `.env` files.\n\nWhen Pi\'s current working directory is known, relative path inputs also match their cwd-normalized absolute form, so `src/App.jsx` can match both `src/*` and `/workspace/project/*`. Bash path tokens use the effective directory after literal `cd` commands for this matching; non-literal `cd "$DIR"` style commands remain conservative.\n\nThe `path` surface is a cross-cutting gate that applies to **all** file access: Pi tools, bash commands, MCP calls (via `input.arguments.path`), and extension tools (via `input.path` or a registered access extractor). A `path` deny cannot be overridden by a per-tool allow. Use it to protect sensitive files (`.env`, `~/.ssh/*`) from all path-aware tools at once.\n\nThe `external_directory` surface gates access **outside** the working directory. Give it a pattern map to allow specific outside-CWD directories without opening all external access — e.g. `"external_directory": { "*": "ask", "~/.cargo/registry/*": "allow" }` to silence repeated prompts on a local cache. The trailing `*` is greedy and crosses subdirectory boundaries; a bare `~/.cargo/registry` matches only the directory entry itself. Because layers compose with most-restrictive-wins, a `path` allow cannot loosen an `external_directory: ask` boundary — allow outside-CWD directories here, not on `path`.\n\n**Merge order (lowest → highest precedence):** global → project → per-agent frontmatter.',
'Flat permission policy.\n\nEach top-level key is a surface name:\n- `"*"` — universal fallback\n- Tool names (`read`, `write`, `bash`, `mcp`, `skill`, `path`, etc.)\n\nA string value is shorthand for `{ "*": action }`. An object maps wildcard patterns to actions, and the most specific matching pattern wins.\n\nFor built-in file tools, patterns match `input.path`. The cross-cutting `path` surface checks path-bearing tools and bash path tokens.\n\n**Merge order (lowest → highest precedence):** global → per-agent frontmatter → project-agent frontmatter.',
examples: [
{
"*": "ask",
@@ -109,7 +109,6 @@ const permissionSchema = z
},
mcp: { "*": "ask", mcp_status: "allow", "exa:*": "allow" },
skill: { "*": "ask", librarian: "allow" },
external_directory: { "*": "ask", "~/.cargo/registry/*": "allow" },
},
],
});
@@ -240,18 +239,11 @@ export const unifiedConfigSchema = z
markdownDescription:
"**Deprecated and ignored.** Superseded by `promptMaxRows` and `promptFieldMaxWidth`, which bound the whole permission prompt rather than one summary inside it.\n\nStill accepted so an existing config is not rejected fail-closed, but the value no longer takes effect. Remove it.",
}),
piInfrastructureReadPaths: z.array(z.string().min(1)).optional().meta({
description:
"Additional directories to auto-allow for reads as Pi infrastructure, bypassing the external_directory gate. Supports ~ expansion and wildcard patterns (* and ?).",
markdownDescription:
"Additional directories to auto-allow for reads as Pi infrastructure, bypassing the `external_directory` gate.\n\nThe extension auto-discovers the global node_modules root (walks up from the extension's install path; falls back to `npm root -g` from a dev checkout), Pi's own install directory (via the coding-agent `getPackageDir()` API), `agentDir`, `agentDir/git`, and project-local `.pi/npm/` and `.pi/git/`. Add entries here for edge cases where auto-discovery is insufficient (e.g. custom `npmCommand` pointing to pnpm).\n\nSupports `~`/`$HOME` expansion. Entries may be plain directory prefixes or wildcard patterns using `*` (matches any characters, including `/`) and `?` (matches exactly one character). `**` and `*` are equivalent — both cross directory boundaries.\n\nOn Windows, matching is case-insensitive and tolerant of either path separator.",
default: [],
}),
authorizerChain: z.array(z.string().min(1)).optional().meta({
description:
"Ordered names of registered live-authority chain links to consult before the terminal authorizer. Config order (not registration order) fixes the chain order; an unregistered name is skipped fail-safe (more prompting, never less); a link decides nothing until it is named here.",
markdownDescription:
"Ordered names of registered **live-authority chain links** (e.g. a model judge) to consult before the terminal authorizer (the human, or the subagent-forwarding / headless-deny fallback).\n\nA link reviews an `ask` and returns `allow` / `deny` (with an optional teaching reason) / `defer` to the next link. Three invariants govern the chain:\n\n- **Config order wins.** The order here \u2014 not the order extensions register in \u2014 fixes the security-relevant chain order.\n- **Fail-safe skip.** A name with no registered link is skipped with a warning; the `ask` still reaches the terminal (more prompting, never less).\n- **Opt-in activation.** Installing a judge extension grants it no authority; a link decides nothing until you name it here.\n\nThe chain owner caps every verdict with a bounded-delegation checkpoint: a link's `allow` on an excluded surface (`external_directory` or `path`) is downgraded to `defer`, so a link cannot exceed your policy.\n\nDefaults to an empty list (no links).",
"Ordered names of registered **live-authority chain links** (for example AutoReview) to consult before the terminal human authorizer. A named link may approve or deny any ordinary-policy `ask`, including `path`; `defer` advances to the next link or the human terminal. Defaults to an empty list.",
default: [],
}),
permission: permissionSchema.optional(),
@@ -270,7 +262,7 @@ export const unifiedConfigSchema = z
description:
"Ordinary JSONC policy combining runtime knobs, AutoReview settings, and flat permission rules for pi-permission-system.",
markdownDescription:
"Ordinary JSONC policy for [pi-permission-system](https://github.com/gotgenes/pi-packages/tree/main/packages/pi-permission-system).\n\nPlace at `~/.pi/agent/extensions/pi-permission-system/policy.jsonc` (global) or `<project>/.pi/extensions/pi-permission-system/policy.jsonc` (project). Global-only hard guards use the separate `hard-guards.jsonc` schema.",
"Global ordinary JSONC policy for [pi-permission-system](https://github.com/gotgenes/pi-packages/tree/main/packages/pi-permission-system).\n\nPlace at `~/.pi/agent/extensions/pi-permission-system/policy.jsonc`. Project-level policy files are not loaded. Global-only hard guards use the separate `hard-guards.jsonc` schema.",
});
/** A permission decision. */
+3 -11
View File
@@ -16,7 +16,6 @@ import {
getGlobalConfigPath,
getLegacyExtensionConfigPath,
getLegacyGlobalPolicyPath,
getLegacyProjectPolicyPath,
} from "./config-paths";
import { buildResolvedConfigLogEntry } from "./config-reporter";
import {
@@ -95,10 +94,8 @@ export class ConfigStore implements SessionConfigStore, CommandConfigStore {
/**
* Reload merged config from disk.
*
* If `ctx` is provided, uses it to derive the cwd and sync UI status.
* When `projectTrusted` is `false`, the project scope is withheld so an
* untrusted repository's runtime config (`yoloMode`, `permissionReviewLog`,
* …) cannot loosen the operator's global config (#644).
* Runtime configuration is global-only. `projectTrusted` is retained here
* for lifecycle logging and active-agent trust decisions owned elsewhere.
*/
refresh(ctx: ExtensionContext | undefined, projectTrusted: boolean): void {
const cwd = ctx?.cwd ?? null;
@@ -106,7 +103,6 @@ export class ConfigStore implements SessionConfigStore, CommandConfigStore {
this.deps.agentDir,
cwd ?? "",
EXTENSION_ROOT,
{ includeProjectScope: projectTrusted },
);
const runtimeConfig = normalizePermissionSystemConfig(mergeResult.merged);
this.config = runtimeConfig;
@@ -194,15 +190,12 @@ export class ConfigStore implements SessionConfigStore, CommandConfigStore {
*
* Equivalent to `logResolvedConfigPaths(runtime)`.
*/
logResolvedPaths(cwd?: string): void {
logResolvedPaths(_cwd?: string): void {
const policyPaths = this.deps.policyPaths.getResolvedPolicyPaths();
const { agentDir } = this.deps;
const legacyGlobalPolicyDetected = existsSync(
getLegacyGlobalPolicyPath(agentDir),
);
const legacyProjectPolicyDetected = cwd
? existsSync(getLegacyProjectPolicyPath(cwd))
: false;
const legacyExtConfigPath = getLegacyExtensionConfigPath(EXTENSION_ROOT);
const newGlobalPath = getGlobalConfigPath(agentDir);
const legacyExtensionConfigDetected =
@@ -211,7 +204,6 @@ export class ConfigStore implements SessionConfigStore, CommandConfigStore {
const entry = buildResolvedConfigLogEntry({
policyPaths,
legacyGlobalPolicyDetected,
legacyProjectPolicyDetected,
legacyExtensionConfigDetected,
});
this.deps.logger.review(
@@ -24,8 +24,6 @@ export interface PermissionSystemExtensionConfig {
yoloMode: boolean;
/** Require a confirming second press of a decision hotkey in the inline TUI dialog. Defaults to true. */
doublePressToConfirm: boolean;
/** Additional directories to auto-allow for reads as Pi infrastructure. */
piInfrastructureReadPaths?: string[];
/** How long a subagent waits for the parent's answer to a forwarded ask, in ms. Defaults to 600000. */
forwardingTimeoutMs?: number;
/** Max rows a permission prompt renders before eliding its evidence. Defaults to 24. */
@@ -64,7 +62,6 @@ const PERMISSION_POLICY_KEYS: ReadonlySet<string> = new Set([
"mcp",
"skills",
"special",
"external_directory",
]);
export function detectMisplacedPermissionKeys(
@@ -88,9 +85,6 @@ export function normalizePermissionSystemConfig(
if (raw.analysis !== undefined) {
result.analysis = normalizeAutoReviewAnalysisConfig(raw.analysis);
}
if (raw.piInfrastructureReadPaths !== undefined) {
result.piInfrastructureReadPaths = raw.piInfrastructureReadPaths;
}
if (raw.forwardingTimeoutMs !== undefined) {
result.forwardingTimeoutMs = raw.forwardingTimeoutMs;
}
+3 -43
View File
@@ -1,66 +1,26 @@
import { join } from "node:path";
import { getGlobalLogsDir } from "./config-paths";
import { discoverGlobalNodeModulesRoot } from "./node-modules-discovery";
/**
* Immutable path constants derived from `agentDir` at construction time.
*
* Computed once at startup in `computeExtensionPaths()` and embedded into
* `ExtensionRuntime`. Later refactorings (#129 PermissionSession, #130
* handler classes) consume this as a single dep instead of individual fields.
*/
/** Immutable path constants derived from `agentDir` at construction time. */
export interface ExtensionPaths {
readonly agentDir: string;
readonly sessionsDir: string;
readonly subagentSessionsDir: string;
readonly forwardingDir: string;
readonly globalLogsDir: string;
/**
* Static Pi infrastructure directories used for external-directory
* read auto-allow. Computed once from `agentDir`,
* `discoverGlobalNodeModulesRoot()`, and (when provided) Pi's own
* install directory (`getPackageDir()`). Config-based extras
* (`piInfrastructureReadPaths`) are read from `runtime.config` at
* call time in the handler so they pick up config reloads.
*/
readonly piInfrastructureDirs: readonly string[];
}
/**
* Compute all immutable path constants from `agentDir`.
*
* Calls `discoverGlobalNodeModulesRoot()` internally so the result is
* self-contained. Call this once at extension startup, not at module scope.
*
* `piPackageDir` is Pi's own install directory (from the coding-agent
* `getPackageDir()` API, resolved at the composition root). When provided it is
* auto-allowed for read-only tools so the agent can read Pi's bundled docs and
* examples regardless of install layout. It is strictly narrower than the
* discovered global `node_modules` root already included here.
*/
export function computeExtensionPaths(
agentDir: string,
piPackageDir?: string,
): ExtensionPaths {
export function computeExtensionPaths(agentDir: string): ExtensionPaths {
const sessionsDir = join(agentDir, "sessions");
const subagentSessionsDir = join(agentDir, "subagent-sessions");
const subagentSessionsDir = join(sessionsDir, "subagents");
const forwardingDir = join(sessionsDir, "permission-forwarding");
const globalLogsDir = getGlobalLogsDir(agentDir);
const globalNodeModulesRoot = discoverGlobalNodeModulesRoot();
const piInfrastructureDirs: string[] = [
agentDir,
join(agentDir, "git"),
...(globalNodeModulesRoot ? [globalNodeModulesRoot] : []),
...(piPackageDir ? [piPackageDir] : []),
];
return {
agentDir,
sessionsDir,
subagentSessionsDir,
forwardingDir,
globalLogsDir,
piInfrastructureDirs,
};
}
@@ -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(
@@ -90,7 +90,6 @@ export class HardGuardPolicyLoader implements PolicyLoader {
return DENY_ALL;
}
loadProjectConfig(): ScopeConfig { return EMPTY_SCOPE; }
loadAgentConfig(): ScopeConfig { return EMPTY_SCOPE; }
loadProjectAgentConfig(): ScopeConfig { return EMPTY_SCOPE; }
getConfiguredMcpServerNames(): readonly string[] { return []; }
@@ -114,8 +113,6 @@ export class HardGuardPolicyLoader implements PolicyLoader {
return {
globalConfigPath: this.userPath,
globalConfigExists: existsSync(this.userPath),
projectConfigPath: null,
projectConfigExists: false,
agentsDir: dirname(this.userPath),
agentsDirExists: existsSync(dirname(this.userPath)),
projectAgentsDir: null,
+5 -14
View File
@@ -1,5 +1,5 @@
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { getAgentDir, getPackageDir } from "@earendil-works/pi-coding-agent";
import { getAgentDir } from "@earendil-works/pi-coding-agent";
import { warmBashParser } from "./access-intent/bash/parser";
import { buildResolvedIntentFromMatchValues } from "./access-intent/input-normalizer";
import { AuthorizerRegistry } from "./authority/authorizer-registry";
@@ -57,9 +57,7 @@ import { ToolInputFormatterRegistry } from "./tool-input-formatter-registry";
export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
const agentDir = getAgentDir();
// getPackageDir() is Pi's own install dir; auto-allow it for read-only tools
// so the agent can read Pi's bundled docs/examples regardless of layout.
const paths = computeExtensionPaths(agentDir, getPackageDir());
const paths = computeExtensionPaths(agentDir);
// The single process.platform read for the whole extension, resolved once
// into the path-language flavor that every consumer shares (the session's
// PathNormalizer, rule evaluation, and subagent detection). Interior modules
@@ -273,8 +271,6 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
const toolRegistry = {
getAll: () => pi.getAllTools(),
getActive: () => pi.getActiveTools(),
setActive: (names: string[]) => pi.setActiveTools(names),
};
const audit = new DecisionAudit();
@@ -285,14 +281,9 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
logger,
audit,
);
const agentPrep = new AgentPrepHandler(
session,
resolver,
toolRegistry,
() => {
void warmBashParser();
},
);
const agentPrep = new AgentPrepHandler(session, resolver, () => {
void warmBashParser();
});
const reporter = new GateDecisionReporter(logger, pi.events);
const gateRunner = new GateRunner(
+3 -62
View File
@@ -4,12 +4,9 @@ import type { PathFlavor } from "#src/path/path-flavor";
import { AccessPath } from "./access-intent/access-path";
import {
canonicalNormalizePathForComparison,
normalizePathForComparison,
normalizePathPolicyLiteral,
} from "./access-intent/path-normalization";
import { isPathOutsideWorkingDirectory } from "./path/path-containment";
import { isPiInfrastructureRead } from "./path/pi-infrastructure-read";
/**
* The interpreted effect of a literal `cd` target on the effective base, under
@@ -42,15 +39,10 @@ export type BashCdTarget =
* `path-containment` / `path-normalization` / `AccessPath` primitives.
*/
export class PathNormalizer {
/** Canonical form of the baked cwd, resolved once (the symlink target is stable per session). */
private readonly canonicalCwd: string;
constructor(
readonly flavor: PathFlavor,
private readonly cwd: string,
) {
this.canonicalCwd = canonicalNormalizePathForComparison(cwd, cwd, flavor);
}
) {}
/** Build an AccessPath for a token, resolved against `resolveBase` (default cwd). */
forPath(pathValue: string, options?: { resolveBase?: string }): AccessPath {
@@ -144,65 +136,14 @@ export class PathNormalizer {
return this.flavor.isWithin(pathValue, directory);
}
/** Canonical (symlink-resolved) outside-cwd test against the baked cwd. */
isOutsideWorkingDirectory(pathValue: string): boolean {
const canonicalPath = canonicalNormalizePathForComparison(
pathValue,
this.cwd,
this.flavor,
);
return isPathOutsideWorkingDirectory(
canonicalPath,
this.canonicalCwd,
this.flavor,
);
}
/**
* Outside-cwd test for an already-canonical boundary value (from
* {@link AccessPath.boundaryValue}), against the baked cwd.
*
* Unlike {@link isOutsideWorkingDirectory}, it does not re-derive the
* canonical form — the caller passes a value the {@link AccessPath} already
* canonicalized, so a device's preserved `/dev/null` reaches the pure check's
* `isSafeSystemPath` exclusion intact.
*/
isBoundaryOutsideWorkingDirectory(canonicalPath: string): boolean {
return isPathOutsideWorkingDirectory(
canonicalPath,
this.canonicalCwd,
this.flavor,
);
}
/**
* Lexical (not symlink-resolved) comparison value, resolved against the baked
* cwd. Mirrors the as-typed absolute form used for skill-prompt matching;
* touches no filesystem, unlike {@link forPath}'s canonical alias.
* Lexical comparison value resolved against the configured cwd.
* This does not classify project boundaries or touch the filesystem.
*/
comparableValue(pathValue: string): string {
return normalizePathForComparison(pathValue, this.cwd, this.flavor);
}
/**
* Pi infrastructure-read containment for a read-only tool, decided against
* the canonical (symlink-resolved) path and the baked cwd/flavor. Takes the
* already-built {@link AccessPath} so the caller does not re-resolve it.
*/
isInfrastructureRead(
toolName: string,
accessPath: AccessPath,
infraDirs: readonly string[],
): boolean {
return isPiInfrastructureRead(
toolName,
accessPath.boundaryValue(),
infraDirs,
this.cwd,
this.flavor,
);
}
/**
* True when `absolutePath` names an existing filesystem entry.
*
@@ -1,59 +0,0 @@
import { join } from "node:path";
import { READ_ONLY_PATH_BEARING_TOOLS } from "#src/access-intent/path-surfaces";
import { expandHomePath } from "#src/expand-home";
import type { PathFlavor } from "#src/path/path-flavor";
import { wildcardMatch } from "#src/wildcard-matcher";
function containsGlobChars(value: string): boolean {
return value.includes("*") || value.includes("?");
}
/**
* Returns true if the given tool + normalized path combination qualifies for
* automatic allow as a Pi infrastructure read.
*
* A path qualifies when:
* 1. The tool is read-only (in READ_ONLY_PATH_BEARING_TOOLS).
* 2. The normalized path is within one of the provided `infrastructureDirs`
* OR within the project-local Pi package directories
* (`<cwd>/.pi/npm/` or `<cwd>/.pi/git/`).
*
* `infrastructureDirs` entries may be absolute paths or patterns containing
* `~`/`$HOME` (expanded at call time) or glob characters (`*`, `?`).
* Project-local paths are computed fresh from `cwd` on each call so they
* follow working-directory changes without a runtime rebuild.
*/
export function isPiInfrastructureRead(
toolName: string,
normalizedPath: string,
infrastructureDirs: readonly string[],
cwd: string,
flavor: PathFlavor,
): boolean {
if (!READ_ONLY_PATH_BEARING_TOOLS.has(toolName)) {
return false;
}
// On Windows the path value is canonicalized + lowercased; the flavor's match
// options fold case (and separators) so mixed-case infra dirs and glob
// patterns still match.
for (const dir of infrastructureDirs) {
if (containsGlobChars(dir)) {
if (wildcardMatch(dir, normalizedPath, flavor.matchOptions)) return true;
} else {
if (flavor.isWithin(normalizedPath, expandHomePath(dir))) return true;
}
}
// Project-local Pi packages — checked fresh every call so CWD changes work.
const projectNpmDir = join(cwd, ".pi", "npm");
const projectGitDir = join(cwd, ".pi", "git");
if (flavor.isWithin(normalizedPath, projectNpmDir)) {
return true;
}
if (flavor.isWithin(normalizedPath, projectGitDir)) {
return true;
}
return false;
}
@@ -100,8 +100,6 @@ function buildLabel(pattern: string, surface: string): string {
return `Yes, allow mcp tool "${pattern}" for this session`;
case "skill":
return `Yes, allow skill "${pattern}" for this session`;
case "external_directory":
return `Yes, allow access to external directory "${pattern}" for this session`;
case "path":
return `Yes, allow path "${pattern}" for this session`;
default:
@@ -140,9 +138,6 @@ export function suggestSessionPattern(
case "skill":
pattern = value;
break;
case "external_directory":
pattern = deriveApprovalPattern(value);
break;
case "path":
pattern = deriveApprovalPattern(value);
break;
@@ -105,7 +105,6 @@ export type PermissionDecisionResolution =
| "policy_allow"
| "policy_deny"
| "session_approved"
| "infrastructure_auto_allowed"
| "user_approved"
| "user_approved_for_session"
| "user_denied"
@@ -120,7 +119,7 @@ export interface PermissionDecisionEvent {
* runs several gates and so raises several requests.
*/
requestId: string;
/** Permission surface: "bash", "read", "mcp", "skill", "external_directory", etc. */
/** Permission surface: "bash", "read", "mcp", "skill", "path", etc. */
surface: string;
/** The value that was evaluated (command, tool name, skill name, path). */
value: string;
+4 -12
View File
@@ -3,11 +3,7 @@ import type { ResolvedAccessIntent } from "./access-intent/access-intent";
import { normalizeInput } from "./access-intent/input-normalizer";
import { PATH_SURFACES } from "./access-intent/path-surfaces";
import { classifyToolKind } from "./access-intent/tool-kind";
import {
getGlobalConfigPath,
getProjectAgentsDir,
getProjectConfigPath,
} from "./config-paths";
import { getGlobalConfigPath, getProjectAgentsDir } from "./config-paths";
import { normalizeFlatConfig } from "./normalize";
import { type PathFlavor, posixPathFlavor } from "./path/path-flavor";
import {
@@ -37,7 +33,7 @@ import type {
} from "./types";
import { isPermissionState } from "./types";
const SPECIAL_PERMISSION_KEYS = new Set(["external_directory", "path"]);
const SPECIAL_PERMISSION_KEYS = new Set(["path"]);
/** Universal fallback when permission["*"] is absent from all scopes. */
const DEFAULT_UNIVERSAL_FALLBACK: PermissionState = "ask";
@@ -176,7 +172,6 @@ export class PermissionManager implements ScopedPermissionManager {
}
const globalConfig = this.loader.loadGlobalConfig();
const projectConfig = this.loader.loadProjectConfig();
const agentConfig = this.loader.loadAgentConfig(agentName);
const projectAgentConfig = this.loader.loadProjectAgentConfig(agentName);
@@ -185,7 +180,6 @@ export class PermissionManager implements ScopedPermissionManager {
// (surface, pattern) entry.
const { mergedPermission, origins } = mergeScopesWithOrigins([
["global", globalConfig],
["project", projectConfig],
["agent", agentConfig],
["project-agent", projectAgentConfig],
]);
@@ -227,7 +221,6 @@ export class PermissionManager implements ScopedPermissionManager {
// higher scope meant to tighten policy cannot silently fail open (#646).
// Global is excluded — nothing more permissive is inherited when it fails.
const failClosedScopes: RuleOrigin[] = [];
if (projectConfig.invalid === true) failClosedScopes.push("project");
if (agentConfig.invalid === true) failClosedScopes.push("agent");
if (projectAgentConfig.invalid === true)
failClosedScopes.push("project-agent");
@@ -333,8 +326,8 @@ export class PermissionManager implements ScopedPermissionManager {
/**
* Evaluate a normalized surface/values triple and shape the result.
*
* Path surfaces use {@link evaluateAnyValue} (last-match-wins across equivalent
* aliases); every other surface keeps {@link evaluateFirst}. Shared by the
* Path surfaces use {@link evaluateAnyValue} (most-specific match across
* equivalent aliases); every other surface keeps {@link evaluateFirst}. Shared by the
* `"tool"` and `"path-values"` branches of {@link PermissionManager.check}.
*/
function buildCheckResult(
@@ -383,7 +376,6 @@ function derivePolicyLoaderOptions(
return {
globalConfigPath: getGlobalConfigPath(agentDir),
agentsDir: join(agentDir, "agents"),
projectGlobalConfigPath: cwd ? getProjectConfigPath(cwd) : undefined,
projectAgentsDir: cwd ? getProjectAgentsDir(cwd) : undefined,
};
}
+1 -14
View File
@@ -42,7 +42,7 @@ export class PermissionSession implements ToolCallGateInputs {
private pathNormalizer: PathNormalizer;
constructor(
private readonly paths: ExtensionPaths,
_paths: ExtensionPaths,
private readonly forwarding: ForwardingController,
private readonly permissionManager: ScopedPermissionManager,
private readonly sessionRules: SessionRules,
@@ -199,19 +199,6 @@ export class PermissionSession implements ToolCallGateInputs {
return this.configStore.current();
}
// ── Infrastructure paths ───────────────────────────────────────────────
/**
* Combined infrastructure read directories: static paths from
* `ExtensionPaths` plus config-derived paths.
*/
getInfrastructureReadDirs(): string[] {
return [
...this.paths.piInfrastructureDirs,
...(this.config.piInfrastructureReadPaths ?? []),
];
}
/**
* Resolved tool-preview formatter options from the current config.
*
+1 -38
View File
@@ -67,8 +67,6 @@ function getConfiguredMcpServerNamesFromPaths(
export interface ResolvedPolicyPaths {
globalConfigPath: string;
globalConfigExists: boolean;
projectConfigPath: string | null;
projectConfigExists: boolean;
agentsDir: string;
agentsDirExists: boolean;
projectAgentsDir: string | null;
@@ -87,7 +85,6 @@ export interface ResolvedPolicyPaths {
*/
export interface PolicyLoader {
loadGlobalConfig(): ScopeConfig;
loadProjectConfig(): ScopeConfig;
loadAgentConfig(agentName?: string): ScopeConfig;
loadProjectAgentConfig(agentName?: string): ScopeConfig;
getConfiguredMcpServerNames(): readonly string[];
@@ -130,7 +127,6 @@ type FileCacheEntry<TValue> = {
export interface PolicyLoaderOptions {
globalConfigPath?: string;
agentsDir?: string;
projectGlobalConfigPath?: string;
projectAgentsDir?: string;
globalMcpConfigPath?: string;
mcpServerNames?: readonly string[];
@@ -147,13 +143,11 @@ export interface PolicyLoaderOptions {
export class FilePolicyLoader implements PolicyLoader {
private readonly globalConfigPath: string;
private readonly agentsDir: string;
private readonly projectGlobalConfigPath: string | null;
private readonly projectAgentsDir: string | null;
private readonly globalMcpConfigPath: string;
private readonly configuredMcpServerNamesOverride: readonly string[] | null;
private globalConfigCache: FileCacheEntry<ScopeConfig> | null = null;
private projectGlobalConfigCache: FileCacheEntry<ScopeConfig> | null = null;
private readonly agentConfigCache = new Map<
string,
FileCacheEntry<ScopeConfig>
@@ -171,7 +165,6 @@ export class FilePolicyLoader implements PolicyLoader {
this.globalConfigPath =
options.globalConfigPath ?? defaultGlobalConfigPath();
this.agentsDir = options.agentsDir ?? defaultAgentsDir();
this.projectGlobalConfigPath = options.projectGlobalConfigPath ?? null;
this.projectAgentsDir = options.projectAgentsDir ?? null;
this.globalMcpConfigPath =
options.globalMcpConfigPath ?? defaultGlobalMcpConfigPath();
@@ -219,29 +212,6 @@ export class FilePolicyLoader implements PolicyLoader {
return value;
}
loadProjectConfig(): ScopeConfig {
if (!this.projectGlobalConfigPath) {
return {};
}
const stamp = getFileStamp(this.projectGlobalConfigPath);
if (this.projectGlobalConfigCache?.stamp === stamp) {
return this.projectGlobalConfigCache.value;
}
const { config, issues } = loadUnifiedConfig(this.projectGlobalConfigPath);
this.accumulateConfigIssues(issues);
// A present-but-rejected file yields issues (parse error or schema
// rejection); an absent file yields none. Fail closed on the former.
const value: ScopeConfig = {
permission: config.permission,
...(issues.length > 0 ? { invalid: true } : {}),
};
this.projectGlobalConfigCache = { stamp, value };
return value;
}
private loadScopeConfigFrom(
dir: string | null,
@@ -335,15 +305,12 @@ export class FilePolicyLoader implements PolicyLoader {
const agentStamp = agentName
? getFileStamp(join(this.agentsDir, `${agentName}.md`))
: "missing";
const projectStamp = this.projectGlobalConfigPath
? getFileStamp(this.projectGlobalConfigPath)
: "none";
const projectAgentStamp =
this.projectAgentsDir && agentName
? getFileStamp(join(this.projectAgentsDir, `${agentName}.md`))
: "none";
return `${getFileStamp(this.globalConfigPath)}|${projectStamp}|${agentStamp}|${projectAgentStamp}`;
return `${getFileStamp(this.globalConfigPath)}|${agentStamp}|${projectAgentStamp}`;
}
// ── Resolved paths ────────────────────────────────────────────────────
@@ -352,10 +319,6 @@ export class FilePolicyLoader implements PolicyLoader {
return {
globalConfigPath: this.globalConfigPath,
globalConfigExists: existsSync(this.globalConfigPath),
projectConfigPath: this.projectGlobalConfigPath,
projectConfigExists: this.projectGlobalConfigPath
? existsSync(this.projectGlobalConfigPath)
: false,
agentsDir: this.agentsDir,
agentsDirExists: existsSync(this.agentsDir),
projectAgentsDir: this.projectAgentsDir,
@@ -1,7 +1,4 @@
import {
allEvidence,
type PromptPayload,
} from "#src/presentation/prompt-payload";
import type { PromptPayload } from "#src/presentation/prompt-payload";
import type { BashCommandContext } from "#src/types";
/**
@@ -24,37 +21,24 @@ import type { BashCommandContext } from "#src/types";
* operator is ruling on.
*/
export function flaggedElements(payload: PromptPayload): readonly string[] {
if (payload.kind === "bash_external_directory") {
return allEvidence(payload, "external path").map((entry) => entry.text);
}
return payload.request.value === "" ? [] : [payload.request.value];
}
/**
* What {@link flaggedElements} returns is called.
*
* Differs from {@link valueLabel} for exactly one shape: a bash ask that
* escaped the working directory flags paths while its value is the command,
* so the two nouns are for two different things.
*/
/** What {@link flaggedElements} returns is called. */
export function flaggedElementLabel(payload: PromptPayload): string {
return payload.kind === "bash_external_directory"
? "path"
: valueLabel(payload);
return valueLabel(payload);
}
/** What the decision-relevant value is called, per ask shape. */
export function valueLabel(payload: PromptPayload): string {
switch (payload.kind) {
case "bash":
case "bash_external_directory":
return "command";
case "mcp":
return "target";
case "tool":
return "tool";
case "path":
case "external_directory":
return "path";
case "skill":
case "skill_read":
@@ -1,101 +1,20 @@
import type {
PromptEvidence,
PromptPayload,
} from "#src/presentation/prompt-payload";
import type { PromptPayload } from "#src/presentation/prompt-payload";
import { localRequester } from "#src/presentation/prompt-payload";
/** A displayed external path paired with its resolved target, when distinct. */
export interface ExternalPathDisclosure {
/** The path as displayed (typed for tools, lexical-absolute for bash). */
path: string;
/** The canonical symlink-resolved target; present only when it differs. */
resolvedPath?: string;
}
/** The facts a path-shaped gate holds when it raises an ask. */
interface PathAskFacts {
toolName: string;
/** The path as the caller typed it — what the user recognizes. */
pathValue: string;
agentName: string | null;
matchedPattern?: string;
}
/** A tool ask gated by an explicit `path` rule. */
/** Build the prompt payload for an explicit cross-cutting `path` ask. */
export function buildPathAskPayload(facts: PathAskFacts): PromptPayload {
return pathPayload("path", "path", facts, []);
}
/** The facts the external-directory gate adds: the boundary and the alias. */
interface ExternalDirectoryAskFacts extends PathAskFacts {
/** The canonical location, when it names somewhere other than the typed path. */
resolvedPath?: string;
/** The working directory the path escapes. */
cwd: string;
}
/** A tool ask for a path outside the working directory. */
export function buildExternalDirectoryAskPayload(
facts: ExternalDirectoryAskFacts,
): PromptPayload {
return pathPayload("external_directory", "external_directory", facts, [
...resolvedAliasEvidence(facts.resolvedPath),
workingDirectoryEvidence(facts.cwd),
]);
}
/** The facts the bash external-directory gate holds: one command, many paths. */
interface BashExternalDirectoryAskFacts {
command: string;
/** Every uncovered path the command references, with its canonical alias. */
externalPaths: readonly ExternalPathDisclosure[];
cwd: string;
agentName: string | null;
toolName: string;
matchedPattern?: string;
}
/** A bash ask whose command references paths outside the working directory. */
export function buildBashExternalDirectoryAskPayload(
facts: BashExternalDirectoryAskFacts,
): PromptPayload {
return {
kind: "bash_external_directory",
kind: "path",
request: {
requester: localRequester(facts.agentName),
surface: "external_directory",
toolName: facts.toolName,
invokedToolName: null,
value: facts.command,
matchedPattern: facts.matchedPattern ?? null,
commandContext: null,
executedUnit: null,
},
evidence: [
workingDirectoryEvidence(facts.cwd),
...facts.externalPaths.map(externalPathEvidence),
],
annotations: [],
};
}
// ── Shared shape ────────────────────────────────────────────────────────────
/**
* The payload common to the single-path asks: the typed path is the
* decision-relevant value, and the gate surface distinguishes them.
*/
function pathPayload(
kind: "path" | "external_directory",
surface: string,
facts: PathAskFacts,
evidence: PromptEvidence[],
): PromptPayload {
return {
kind,
request: {
requester: localRequester(facts.agentName),
surface,
surface: "path",
toolName: facts.toolName,
invokedToolName: null,
value: facts.pathValue,
@@ -103,33 +22,7 @@ function pathPayload(
commandContext: null,
executedUnit: null,
},
evidence,
evidence: [],
annotations: [],
};
}
/**
* The canonical location, as its own entry rather than folded into the value:
* the user decides on the path they typed, and the alias is what that path
* turns out to name.
*/
function resolvedAliasEvidence(resolvedPath?: string): PromptEvidence[] {
return resolvedPath === undefined
? []
: [{ label: "resolves to", text: resolvedPath, detail: null }];
}
function workingDirectoryEvidence(cwd: string): PromptEvidence {
return { label: "working directory", text: cwd, detail: null };
}
/**
* One escaping path. The canonical alias rides as the entry's `detail` so a
* render cannot separate a path from what it resolves to.
*/
function externalPathEvidence({
path,
resolvedPath,
}: ExternalPathDisclosure): PromptEvidence {
return { label: "external path", text: path, detail: resolvedPath ?? null };
}
@@ -20,9 +20,8 @@ export interface PromptPayload {
/**
* Which ask this payload describes — the renderers' dispatch discriminant.
*
* Present because the ask shapes are not separable by surface alone: a tool
* external-directory ask and a bash one share the `external_directory` surface,
* and the `path` gate and the per-tool gate differ only in wording. It gives
* Present because the `path` gate and the per-tool gate differ only in
* wording. It gives
* every renderer an exhaustive switch rather than a set of string comparisons a
* new variant sails past — which is what let the parallel denial-context union
* ADR 0011 §7 described dissolve into this one (#746).
@@ -32,8 +31,6 @@ export type PromptPayloadKind =
| "mcp"
| "tool"
| "path"
| "external_directory"
| "bash_external_directory"
| "skill"
| "skill_read"
| "forwarded";
@@ -126,8 +123,6 @@ const PROMPT_PAYLOAD_KINDS = [
"mcp",
"tool",
"path",
"external_directory",
"bash_external_directory",
"skill",
"skill_read",
"forwarded",
+70 -14
View File
@@ -28,7 +28,7 @@ export type RuleOrigin =
/** A single permission rule — the atomic unit of policy. */
export interface Rule {
/** The permission surface: "bash", "read", "mcp", "skill", "external_directory", etc. */
/** The permission surface: "bash", "read", "mcp", "skill", "path", etc. */
surface: string;
/** The match pattern: a command glob, tool name, skill name, or "*". */
pattern: string;
@@ -45,7 +45,7 @@ export interface Rule {
origin: RuleOrigin;
}
/** An ordered list of rules. Later rules take priority (last-match-wins). */
/** Rules evaluated by pattern specificity; declaration order breaks exact ties. */
export type Ruleset = Rule[];
/**
@@ -91,8 +91,10 @@ export function floorAllowsToAsk(rules: Ruleset): Ruleset {
/**
* Pure permission evaluation.
*
* Returns the last rule in `rules` whose surface and pattern both
* wildcard-match the supplied values (last-match-wins).
* Among matching rules, the most specific pattern wins. Specificity is ordered
* by exact patterns first, then more literal characters, then fewer wildcards,
* then longer patterns. Declaration order is only the final tie-breaker, where
* the later rule wins.
*
* When no rule matches, returns a synthetic rule with `defaultAction`
* (defaults to "ask" — least privilege).
@@ -104,7 +106,12 @@ export function evaluate(
flavor: PathFlavor,
defaultAction?: PermissionState,
): Rule {
const rule = rules.findLast((r) => ruleMatches(r, surface, pattern, flavor));
const rule = findMostSpecificMatchingRule(
surface,
[pattern],
rules,
flavor,
);
if (rule !== undefined) return rule;
return {
surface,
@@ -139,10 +146,62 @@ function ruleMatches(
);
}
interface PatternSpecificity {
exact: number;
literalCharacters: number;
wildcardCount: number;
length: number;
}
function patternSpecificity(pattern: string): PatternSpecificity {
const wildcardCount = [...pattern].filter((character) => character === "*").length;
return {
exact: wildcardCount === 0 ? 1 : 0,
literalCharacters: pattern.length - wildcardCount,
wildcardCount,
length: pattern.length,
};
}
/** Positive means `candidate` is more specific than `current`. */
function comparePatternSpecificity(candidate: string, current: string): number {
const left = patternSpecificity(candidate);
const right = patternSpecificity(current);
if (left.exact !== right.exact) return left.exact - right.exact;
if (left.literalCharacters !== right.literalCharacters) {
return left.literalCharacters - right.literalCharacters;
}
if (left.wildcardCount !== right.wildcardCount) {
return right.wildcardCount - left.wildcardCount;
}
return left.length - right.length;
}
function findMostSpecificMatchingRule(
surface: string,
values: readonly string[],
rules: Ruleset,
flavor: PathFlavor,
): Rule | undefined {
let best: Rule | undefined;
for (const rule of rules) {
if (!values.some((value) => ruleMatches(rule, surface, value, flavor))) {
continue;
}
if (
best === undefined ||
comparePatternSpecificity(rule.pattern, best.pattern) >= 0
) {
best = rule;
}
}
return best;
}
/**
* Evaluate a surface against an ordered list of candidate values, stopping at
* the first candidate that matches a non-default rule (last-match-wins within
* each candidate, first-non-default-wins across candidates).
* the first candidate that matches a non-default rule. Pattern specificity is
* resolved independently for each candidate.
*
* Used by MCP (multi-candidate target list) and, uniformly, by all other
* surfaces (single-element candidate list).
@@ -203,10 +262,9 @@ export function evaluateFirst(
/**
* Evaluate equivalent lookup values as aliases of the same path.
*
* Unlike `evaluateFirst()`, this preserves rule ordering across aliases: the
* last rule that matches any alias wins. This lets absolute allowlists and
* legacy relative rules coexist without a catch-all match on the first alias
* masking a later, more specific rule on another alias.
* Pattern specificity is resolved across every alias together, so a broad
* catch-all on one alias cannot mask a more specific rule on another alias.
* Declaration order remains the final tie-breaker.
*/
export function evaluateAnyValue(
surface: string,
@@ -215,9 +273,7 @@ export function evaluateAnyValue(
flavor: PathFlavor,
): { rule: Rule; value: string } {
const fallbackValue = values[0] ?? "*";
const rule = rules.findLast((r) =>
values.some((value) => ruleMatches(r, surface, value, flavor)),
);
const rule = findMostSpecificMatchingRule(surface, values, rules, flavor);
if (rule !== undefined) {
return {
rule,
+8 -8
View File
@@ -75,8 +75,8 @@ export interface PermissionQuery {
/**
* Query the permission policy for a surface and value.
*
* @param surface - Permission surface: "bash", "read", "mcp", "skill",
* "external_directory", etc.
* @param surface - Permission surface such as "bash", "read", "mcp",
* "skill", or "path".
* @param value - The value to evaluate: command string, tool name, skill
* name, or path. Omit or pass `undefined` for a
* surface-level query.
@@ -134,7 +134,7 @@ export interface PermissionsService extends PermissionQuery {
* Register a custom access-intent extractor for a specific tool name.
*
* The extractor declares the filesystem path a tool will access so the
* cross-cutting `path` and `external_directory` gates can see it. Use it for
* cross-cutting `path` gate can see it. Use it for
* tools whose path lives under a non-standard key — built-in file tools and
* any tool exposing `input.path` (plus MCP via `input.arguments.path`) are
* already covered by convention without registration.
@@ -163,11 +163,11 @@ export interface PermissionsService extends PermissionQuery {
* handler so registration is robust to load order and survives `/reload`.
*
* Registration alone grants **no authority**: the link decides nothing until
* the operator names it in the `authorizerChain` config (opt-in activation),
* and the chain owner caps every verdict with the bounded-delegation
* checkpoint (an `allow` on an excluded surface downgrades to `defer`). Only
* one link may be registered per name — a second call for the same name
* throws. The returned disposer unregisters the link.
* the operator names it in `authorizerChain` (opt-in activation). Once named,
* its `allow` or `deny` verdict is final for any ordinary-policy `ask`, including
* `path`; `defer` advances to the next link or terminal. Only one link may be
* registered per name — a second call throws. The returned disposer unregisters
* the link.
*
* @param name - Operator-facing link name referenced from `authorizerChain`.
* @param authorize - The link's decision callback
+3 -3
View File
@@ -7,9 +7,9 @@ import type { PermissionState } from "./types";
* Produces one rule:
* `{ surface: "*", pattern: "*", action: universalDefault, layer: "default" }`
*
* Per-surface catch-alls (`bash["*"]`, `mcp["*"]`, etc.) are expressed as
* regular config rules from `normalizeFlatConfig()` and sit at higher indices
* in the composed array, so they override this default via last-match-wins.
* Per-surface catch-alls (`bash["*"]`, `mcp["*"]`, etc.) are regular config
* rules. Their exact surface is selected before the universal fallback when the
* value-pattern specificity is tied.
*/
export function synthesizeDefaults(
universalDefault: PermissionState,
@@ -1,274 +0,0 @@
export interface SanitizeSystemPromptResult {
prompt: string;
removed: boolean;
}
type LineSection = {
start: number;
end: number;
};
type GuidelineRule = {
matches: (guideline: string) => boolean;
shouldKeep: (allowedTools: ReadonlySet<string>) => boolean;
};
const AVAILABLE_TOOLS_SECTION_HEADER = "Available tools:";
const GUIDELINES_SECTION_HEADER = "Guidelines:";
const TOOL_GUIDELINE_RULES: readonly GuidelineRule[] = [
{
matches: (guideline) =>
guideline === "use bash for file operations like ls, rg, find",
shouldKeep: (allowedTools) => allowedTools.has("bash"),
},
{
matches: (guideline) =>
guideline ===
"prefer grep/find/ls tools over bash for file exploration (faster, respects .gitignore)",
shouldKeep: (allowedTools) =>
allowedTools.has("bash") &&
(allowedTools.has("grep") ||
allowedTools.has("find") ||
allowedTools.has("ls")),
},
{
matches: (guideline) =>
guideline ===
"use read to examine files before editing. you must use this tool instead of cat or sed." ||
guideline === "use read to examine files instead of cat or sed.",
shouldKeep: (allowedTools) => allowedTools.has("read"),
},
{
matches: (guideline) =>
guideline ===
"use edit for precise changes (old text must match exactly)",
shouldKeep: (allowedTools) => allowedTools.has("edit"),
},
{
matches: (guideline) =>
guideline === "use write only for new files or complete rewrites",
shouldKeep: (allowedTools) => allowedTools.has("write"),
},
{
matches: (guideline) =>
guideline ===
"when summarizing your actions, output plain text directly - do not use cat or bash to display what you did",
shouldKeep: (allowedTools) =>
allowedTools.has("edit") || allowedTools.has("write"),
},
{
matches: (guideline) =>
guideline ===
"use task when work should be delegated to one or more specialized agents instead of handled entirely in the current session.",
shouldKeep: (allowedTools) => allowedTools.has("task"),
},
{
matches: (guideline) =>
guideline ===
"use mcp for mcp discovery first: search by capability, describe one exact tool name, then call it.",
shouldKeep: (allowedTools) => allowedTools.has("mcp"),
},
];
function normalizePrompt(prompt: string): string {
return (prompt || "").replace(/\r\n/g, "\n");
}
function collapseExtraBlankLines(text: string): string {
return text.replace(/\n{3,}/g, "\n\n").trimEnd();
}
function normalizeGuidelineText(line: string): string {
return line
.trim()
.replace(/^[-*]\s+/, "")
.replace(/\s+/g, " ")
.toLowerCase();
}
function isTopLevelSectionHeader(line: string): boolean {
const trimmed = line.trim();
return (
trimmed.length > 0 && trimmed.endsWith(":") && !trimmed.startsWith("-")
);
}
function isSectionBodyLine(line: string): boolean {
const trimmed = line.trim();
if (trimmed.length === 0) return true; // blank line
if (trimmed.startsWith("- ")) return true; // bullet
if (line !== line.trimStart()) return true; // indented
return false;
}
function findSection(
lines: readonly string[],
header: string,
): LineSection | null {
const start = lines.findIndex((line) => line.trim() === header);
if (start === -1) {
return null;
}
// If a subsequent recognised section header exists, use it as the boundary.
// This preserves the original behaviour for the common case where sections
// are adjacent (e.g. "Available tools:" followed by "Guidelines:") and
// ensures any prose continuation between the two headers is also removed.
for (let index = start + 1; index < lines.length; index += 1) {
if (isTopLevelSectionHeader(lines[index])) {
return { start, end: index };
}
}
// No subsequent section header — stop at the first non-body line so that
// content after the section (e.g. custom user notes) is not silently deleted.
let end = start + 1;
for (let index = start + 1; index < lines.length; index += 1) {
if (!isSectionBodyLine(lines[index])) {
end = index;
break;
}
end = index + 1;
}
return { start, end };
}
/**
* Tool name from an `Available tools:` bullet (`- read: …` -> `read`), or
* `null` for non-tool lines (blank lines, boilerplate prose). Matches the
* first token after the bullet marker, with or without a trailing colon.
*/
function extractToolBulletName(line: string): string | null {
const match = /^\s*-\s+([A-Za-z0-9_-]+)/.exec(line);
return match ? match[1] : null;
}
/**
* Narrow the `Available tools:` section to the allowed tools: keep allowed-tool
* bullet lines and any non-tool prose, drop denied/inactive bullet lines. When
* no tool bullet survives, remove the section header too. This mirrors what Pi
* itself renders for the active tool set, so the result is byte-stable across
* turns regardless of whether the input still carries the full default listing.
*/
function narrowAvailableToolsSection(
lines: readonly string[],
allowedTools: ReadonlySet<string>,
): { lines: string[]; removed: boolean } {
const section = findSection(lines, AVAILABLE_TOOLS_SECTION_HEADER);
if (!section) {
return { lines: [...lines], removed: false };
}
const before = lines.slice(0, section.start);
const header = lines[section.start];
const body = lines.slice(section.start + 1, section.end);
const after = lines.slice(section.end);
const filteredBody = body.filter((line) => {
const toolName = extractToolBulletName(line);
if (toolName === null) {
return true; // keep blank lines and non-tool boilerplate
}
return allowedTools.has(toolName);
});
const removed = filteredBody.length !== body.length;
if (!removed) {
return { lines: [...lines], removed: false };
}
const hasToolBullet = filteredBody.some(
(line) => extractToolBulletName(line) !== null,
);
if (!hasToolBullet) {
return { lines: [...before, ...after], removed: true };
}
return {
lines: [...before, header, ...filteredBody, ...after],
removed: true,
};
}
function shouldKeepGuideline(
line: string,
allowedTools: ReadonlySet<string>,
): boolean {
const normalized = normalizeGuidelineText(line);
for (const rule of TOOL_GUIDELINE_RULES) {
if (rule.matches(normalized)) {
return rule.shouldKeep(allowedTools);
}
}
return true;
}
function sanitizeGuidelinesSection(
lines: readonly string[],
allowedTools: ReadonlySet<string>,
): { lines: string[]; removed: boolean } {
const section = findSection(lines, GUIDELINES_SECTION_HEADER);
if (!section) {
return { lines: [...lines], removed: false };
}
const before = lines.slice(0, section.start + 1);
const after = lines.slice(section.end);
const body = lines.slice(section.start + 1, section.end);
const filteredBody = body.filter((line) => {
const trimmed = line.trim();
if (!trimmed.startsWith("- ")) {
return true;
}
return shouldKeepGuideline(line, allowedTools);
});
const removed = filteredBody.length !== body.length;
if (!removed) {
return { lines: [...lines], removed: false };
}
const hasBullet = filteredBody.some((line) => line.trim().startsWith("- "));
if (!hasBullet) {
return {
lines: [...lines.slice(0, section.start), ...after],
removed: true,
};
}
return {
lines: [...before, ...filteredBody, ...after],
removed: true,
};
}
export function sanitizeAvailableToolsSection(
systemPrompt: string,
allowedToolNames: readonly string[],
): SanitizeSystemPromptResult {
const allowedTools = new Set(
allowedToolNames.map((toolName) => toolName.trim()).filter(Boolean),
);
const normalizedLines = normalizePrompt(systemPrompt).split("\n");
const narrowedToolsSection = narrowAvailableToolsSection(
normalizedLines,
allowedTools,
);
const sanitizedGuidelines = sanitizeGuidelinesSection(
narrowedToolsSection.lines,
allowedTools,
);
const removed = narrowedToolsSection.removed || sanitizedGuidelines.removed;
return {
prompt: removed
? collapseExtraBlankLines(sanitizedGuidelines.lines.join("\n"))
: systemPrompt,
removed,
};
}
@@ -3,7 +3,7 @@
*
* Lets sibling extensions declare the filesystem path a tool will access when
* the tool's input shape is not the default `input.path` convention, so the
* cross-cutting `path` and `external_directory` gates can see it.
* cross-cutting `path` gate can see it.
* One extractor per tool name; duplicate registration throws.
*/
+1 -4
View File
@@ -1,12 +1,9 @@
import { getNonEmptyString, toRecord } from "./value-guards";
/** Narrow interface for the Pi tool API subset used by handler classes. */
/** Narrow interface for the read-only Pi tool registry used by the gate. */
export interface ToolRegistry {
/** All registered tools (`pi.getAllTools()` — `ToolInfo[]`); kept defensively wide. */
getAll(): unknown[];
/** Currently active tool names (`pi.getActiveTools()`). */
getActive(): string[];
setActive(names: string[]): void;
}
export type ToolRegistrationCheckResult =