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