Files
my-pi/pi-permission-system/docs/plans/0352-access-intent-extractors-path-aware-tools.md
T

22 KiB

issue, issue_title
issue issue_title
352 Add access intent extractors for path-aware extension tools

Path-aware gating for extension and MCP tools

Problem Statement

Path-based permission gating only recognizes six hardcoded built-in tools. PATH_BEARING_TOOLS in path-utils.ts is { read, write, edit, find, grep, ls }, and getPathBearingToolPath returns a path only for those. Both cross-cutting path gates — describePathGate (the path surface) and describeExternalDirectoryGate (the external_directory boundary) — call getPathBearingToolPath, so for any other tool they receive null and skip the gate.

Consequently, a third-party extension tool or an MCP tool that reads or writes the filesystem bypasses the path and external_directory gates entirely. A user who configured "path": { "*.env": "deny" } or relies on the external-directory boundary is silently unprotected against tools outside the built-in six. This is a permission-bypass gap in a package whose whole purpose is deterministic least-privilege gating.

This work is derived from third-party PR #352 (moekyo, branch feature/path-aware-extension-tools). The operator's chosen direction (confirmed via the planning ask_user gate) is to adopt the capability with a simplified design, not to merge the PR as-is.

Attribution

The capability and the cross-extension API shape originate with moekyo's PR #352. Because we ship a re-implementation rather than merging the branch, credit must be explicit and durable:

  • Every implementation and docs commit for this issue carries this trailer (blank line before it, at the end of the commit body):

    Co-authored-by: moekyo <shigotods@outlook.com>
    
  • The PR #352 close comment (ship stage) thanks @moekyo by name for the original PR and design exploration, and links the implementing SHA(s).

Do not use Closes #352 in any commit (it pre-empts the curated close comment, per AGENTS.md); reference the PR as Refs #352 / (#352) instead.

Goals

  • Close the bypass: extension tools and MCP tools that operate on a filesystem path are subject to the cross-cutting path and external_directory gates.
  • Detect path tools by convention, without registration: any non-bash tool exposing input.path (and MCP via input.arguments.path) is path-gated automatically (default-on).
  • Provide registerToolAccessExtractor(toolName, extractor) on the cross-extension PermissionsService as the escape hatch for tools whose path lives under a non-standard key.
  • Keep the design lean: the extractor is (input) => string | undefined (a path value), with no ToolAccessIntent envelope.
  • This change is breaking: extension/MCP path tools that were previously ungated become gated on upgrade without a user edit. It is a security fix; use feat!: with a BREAKING CHANGE: footer on the behavior-changing commit.

Non-Goals

  • Per-tool path maps for extension tools (e.g. "ffgrep": { "*.env": "deny" }). Deferred as an additive follow-up (see Open Questions). In this change, extension/MCP tools are gated via the cross-cutting path and external_directory surfaces; whole-tool policy ("ffgrep": "deny") still works via the per-tool surface.
  • Threading the extractor through normalizeInput / PermissionManager — not needed for the cross-cutting gates and reserved for the per-tool-path-map follow-up.
  • The ToolAccessIntent envelope from PR #352 (resource / operation / confidence / source / toolName). Only .value was ever consumed by a gate; the remaining fields are speculative (resource and confidence each have one inhabitant) and a maintenance trap.
  • Multi-path tools: one path per tool (the default input.path convention and PR #352's own ToolAccessIntentDeclaration.value are both single-path).
  • Extending the read-only infrastructure auto-allow (READ_ONLY_PATH_BEARING_TOOLS) to extension tools — out of scope.
  • Wiring into the Phase 5 roadmap: this is a new feature, not a roadmap step.

Background

Current extraction and consumers

src/path-utils.ts:

export const PATH_BEARING_TOOLS = new Set(["read", "write", "edit", "find", "grep", "ls"]);

export function getPathBearingToolPath(toolName: string, input: unknown): string | null {
  if (!PATH_BEARING_TOOLS.has(toolName)) return null;
  return getNonEmptyString(toRecord(input).path);
}

getPathBearingToolPath has four callers:

  1. handlers/gates/path.ts — the path gate (decision-bearing).
  2. handlers/gates/external-directory.ts — the external_directory gate (decision-bearing).
  3. handlers/gates/tool.ts — the per-tool gate, but only for the session-approval suggestion value and the decision log value (cosmetic, not the decision).

Only the two cross-cutting gates need to become path-aware for extension tools. tool.ts stays on getPathBearingToolPath (built-in semantics are correct there — the per-tool surface for extension tools stays "*", consistent with normalizeInput).

The proven registrar pattern to mirror

src/tool-input-formatter-registry.ts already establishes the exact shape this change needs:

  • A read-only ToolInputFormatterLookup (get(toolName)) and a write-only ToolInputFormatterRegistrar (register(toolName, fn): () => void), split for ISP.
  • A ToolInputFormatterRegistry class implementing both; duplicate registration throws; the disposer is identity-guarded.
  • Created once in index.ts; the same instance is passed as a Registrar to LocalPermissionsService (line 131) and as a Lookup to ToolCallGatePipeline (line 174).
  • Exposed cross-extension via PermissionsService.registerToolInputFormatter (service.ts).

The new extractor registry mirrors this one-for-one.

Pipeline wiring

ToolCallGatePipeline is constructed once (index.ts:171) with (resolver, session, formatterRegistry) and holds customFormatters? as a constructor field, handing it to ToolPreviewFormatter. Adding customExtractors?: ToolAccessExtractorLookup as a fourth constructor parameter and forwarding it to the two gate producers mirrors the existing customFormatters relay exactly.

AGENTS.md / skill constraints

  • Keep schema, example config, docs/configuration.md, README.md, and loaders aligned (package skill). Here only schemas/permissions.schema.json markdownDescription, docs/configuration.md, docs/cross-extension-api.md, and README.md need touching — there is no new config field (registration is a runtime API), so the loader/PermissionSystemExtensionConfig are untouched.
  • docs/architecture/architecture.md carries a "Path-bearing tool normalization" section and a "Module structure" listing — both need updates.
  • Default to least privilege — gating previously-ungated path tools is the safe direction.

Design Overview

Lean extractor + registry (src/tool-access-extractor-registry.ts)

Adapted from PR #352's registry (which is already lean and correct), with the value-only extractor signature:

/** Returns the filesystem path this tool will access, or undefined to decline. */
export type ToolAccessExtractor = (
  input: Record<string, unknown>,
) => string | undefined;

export interface ToolAccessExtractorLookup {
  get(toolName: string): ToolAccessExtractor | undefined;
}

export interface ToolAccessExtractorRegistrar {
  register(toolName: string, extractor: ToolAccessExtractor): () => void;
}

export class ToolAccessExtractorRegistry
  implements ToolAccessExtractorLookup, ToolAccessExtractorRegistrar {
  private readonly extractors = new Map<string, ToolAccessExtractor>();
  register(toolName, extractor) { /* throw on dup; identity-guarded disposer */ }
  get(toolName) { return this.extractors.get(toolName); }
}

Extensible extraction (src/path-utils.ts)

A new function alongside the unchanged getPathBearingToolPath:

export function getToolInputPath(
  toolName: string,
  input: unknown,
  extractors?: ToolAccessExtractorLookup,
): string | null {
  if (toolName === "bash") return null; // bash has its own token-based path gates
  const record = toRecord(input);

  if (PATH_BEARING_TOOLS.has(toolName)) return getNonEmptyString(record.path);
  if (toolName === "mcp") return getNonEmptyString(toRecord(record.arguments).path);

  const custom = extractors?.get(toolName);
  if (custom) return getNonEmptyString(custom(record));

  return getNonEmptyString(record.path); // default convention for extension tools
}

toRecord coerces non-objects to {}, so MCP/extension extraction is null-safe without an isPlainRecord guard.

Gate consumption

describePathGate(tcc, resolver, extractors?) and describeExternalDirectoryGate(tcc, infraDirs, extractors?) swap getPathBearingToolPath(tcc.toolName, tcc.input) for getToolInputPath(tcc.toolName, tcc.input, extractors). Everything downstream is unchanged: the path gate still calls resolver.resolve("path", { path }, …) and still short-circuits when no explicit path rule matched (matchedPattern === undefined, preserving #58); external_directory still applies the isPathOutsideWorkingDirectory + infra-read checks. No PermissionManager/normalizeInput change is needed, because the path and external_directory surfaces are special keys that normalizeInput already resolves from { path }.

Service call-site sketch (consumer)

const permissions = getPermissionsService();
const dispose = permissions?.registerToolAccessExtractor(
  "ffgrep",
  (input) => (typeof input.target === "string" ? input.target : undefined),
);
// ...later
dispose?.();

Tell-Don't-Ask: the consumer hands a pure function to the registrar and gets a disposer; it never inspects registry internals.

Composition-root wiring (src/index.ts)

const accessExtractorRegistry = new ToolAccessExtractorRegistry();

const permissionsService = new LocalPermissionsService(
  permissionManager, sessionRules, formatterRegistry, accessExtractorRegistry, // +1
);

const toolCallGatePipeline = new ToolCallGatePipeline(
  resolver, session, formatterRegistry, accessExtractorRegistry, // +1 (lookup)
);

One registry instance; the service holds the Registrar side, the pipeline the Lookup side — exactly as formatterRegistry is shared today.

Design-review checklist result

  • Dependency width: ToolAccessExtractorLookup is one method; pipeline gains one optional field. Narrow.
  • Law of Demeter: gates call getToolInputPath(tcc.toolName, tcc.input, this.customExtractors) — no reach-through.
  • Output arguments: none; the extractor is a pure (input) => string | undefined.
  • Parameter relay: customExtractors flows index → pipeline → gate fns and is consumed at the gates, mirroring the existing customFormatters relay — established pattern, not a new smell.
  • Missing abstraction: the registry is the cohesive abstraction.

No structural smells; the change mirrors the ToolInputFormatterRegistry precedent.

Module-Level Changes

New files

  1. src/tool-access-extractor-registry.tsToolAccessExtractor (value-only), ToolAccessExtractorLookup, ToolAccessExtractorRegistrar, ToolAccessExtractorRegistry.
  2. test/tool-access-extractor-registry.test.ts — register/dispose/duplicate-throw/identity-guard/get, mirroring tool-input-formatter-registry.test.ts.

Modified — source

  1. src/path-utils.ts — add getToolInputPath; getPathBearingToolPath unchanged.
  2. src/handlers/gates/path.tsdescribePathGate accepts extractors?: ToolAccessExtractorLookup; use getToolInputPath.
  3. src/handlers/gates/external-directory.ts — same for describeExternalDirectoryGate.
  4. src/handlers/gates/tool-call-gate-pipeline.ts — add customExtractors? constructor param; pass to the two gate producers.
  5. src/service.ts — add registerToolAccessExtractor(toolName, extractor): () => void to the PermissionsService interface (mirror the registerToolInputFormatter doc block).
  6. src/permissions-service.ts — inject ToolAccessExtractorRegistrar (4th ctor param); implement registerToolAccessExtractor delegating to registry.register.
  7. src/index.ts — construct ToolAccessExtractorRegistry; pass to LocalPermissionsService and ToolCallGatePipeline.

Modified — tests

  1. test/path-utils.test.tsgetToolInputPath cases: built-ins → input.path; bash → null; mcparguments.path; extension default → input.path; registered extractor overrides; missing/empty → null.
  2. test/handlers/gates/path.test.ts — extension/MCP tool with input.path now produces a path descriptor under a matching rule; registered extractor's path is used.
  3. test/handlers/gates/external-directory.test.ts — extension/MCP external path now gated.
  4. test/handlers/gates/tool-call-gate-pipeline.test.ts — pipeline forwards customExtractors to the gates (end-to-end: an extension tool with an external input.path blocks).
  5. test/permissions-service.test.tsregisterToolAccessExtractor delegates to the registrar and returns its disposer.
  6. test/service.test.ts — adapter delegation + accessor includes the new method.
  7. test/composition-root.test.ts — a registered extractor reaches the live pipeline (parallel to the existing registerToolInputFormatter wiring assertion at line 321).

Modified — docs / schema

  1. docs/cross-extension-api.md — add a registerToolAccessExtractor section mirroring registerToolInputFormatter; note default-on convention.
  2. docs/configuration.md — document that path / external_directory now cover extension + MCP path tools; show the path-surface form for protecting files across all tools.
  3. README.md — brief mention in the permissions/behavior section.
  4. schemas/permissions.schema.json — update the permission markdownDescription to state extension tools (via input.path or a registered extractor) and MCP (input.arguments.path) participate in path gating.
  5. docs/architecture/architecture.md — "Path-bearing tool normalization" gets a note that the cross-cutting gates extract paths for extension/MCP tools via the extractor registry; add tool-access-extractor-registry.ts to the "Module structure" listing.
  6. config/config.example.jsonreview only; if adding an illustrative entry, use the cross-cutting path surface (e.g. "path": { "*.env": "deny" }), not PR #352's per-tool ffgrep path map (that requires the deferred per-tool feature).

Test Impact Analysis

  1. New lower-level tests enabled:
    • tool-access-extractor-registry.test.ts — register/dispose/dup semantics in isolation.
    • getToolInputPath table tests covering built-in / bash / mcp / extension-default / registered-extractor / empty branches purely (mocked lookup).
  2. Existing tests that change (behavior change, not redundant):
    • path.test.ts / external-directory.test.ts gain cases asserting extension/MCP tools are now gated. Existing built-in cases stay green (the extractors parameter is optional and built-in extraction is unchanged).
    • getPathBearingToolPath tests in path-utils.test.ts stay as-is (function unchanged); add a sibling block for getToolInputPath.
  3. Tests that must stay as-is:
    • tool.test.ts — the per-tool gate is unchanged (still uses getPathBearingToolPath for suggestion/log values).
    • All normalizeInput / permission-manager tests — untouched in this scope.

TDD Order

Every commit below includes the Co-authored-by: moekyo <shigotods@outlook.com> trailer (see Attribution).

Cycle 1 — extractor registry

  1. RED: test/tool-access-extractor-registry.test.tsregister returns a disposer; duplicate registration throws; disposer is identity-guarded (a stale disposer cannot evict a later registration); get returns the extractor or undefined.
  2. GREEN: src/tool-access-extractor-registry.ts.
  • Commit: feat: add tool access extractor registry (#352)

Cycle 2 — extensible path extraction

  1. RED: test/path-utils.test.tsgetToolInputPath table: built-ins → input.path; bash → null; mcparguments.path; extension default → input.path; a registered extractor (via a fake Lookup) overrides; missing/empty → null.
  2. GREEN: add getToolInputPath to src/path-utils.ts.
  • Commit: feat: add extensible tool input path extraction (#352)

Cycle 3 — gates gate extension/MCP path tools (default-on, breaking)

  1. RED: update test/handlers/gates/path.test.ts, external-directory.test.ts, and tool-call-gate-pipeline.test.ts — an extension tool with input.path (and an MCP tool with arguments.path) under a matching path rule / outside the cwd now produces a gate descriptor; a registered extractor's path is honored; bash and pathless tools still skip.
  2. GREEN: thread customExtractors?: ToolAccessExtractorLookup into ToolCallGatePipeline and both gate signatures; switch the two gates to getToolInputPath; construct + pass the registry lookup in src/index.ts. Run pnpm --filter @gotgenes/pi-permission-system run check (pipeline constructor signature change has a single call site in index.ts).
  • Commit: feat!: gate extension and MCP path tools by default (#352)
  • Footer: BREAKING CHANGE: extension and MCP tools that expose a filesystem path (input.path, or input.arguments.path for MCP) are now subject to the path and external_directory permission gates. Tools previously ungated may now prompt or be denied under existing path rules.

Cycle 4 — expose registerToolAccessExtractor on the service

  1. RED: test/permissions-service.test.ts (delegates to the registrar, returns its disposer), test/service.test.ts (adapter delegation + accessor surface), test/composition-root.test.ts (a registered extractor reaches the live pipeline and gates the matching tool end-to-end).
  2. GREEN: add the method to the PermissionsService interface (src/service.ts); inject ToolAccessExtractorRegistrar into LocalPermissionsService and implement it; pass accessExtractorRegistry as the registrar in src/index.ts. Run pnpm --filter @gotgenes/pi-permission-system run check.
  • Commit: feat: expose registerToolAccessExtractor via permissions service (#352)

Cycle 5 — docs + schema

  1. GREEN (docs): update docs/cross-extension-api.md, docs/configuration.md, README.md, schemas/permissions.schema.json (markdownDescription), and docs/architecture/architecture.md; review config/config.example.json (path-surface example only).
  • Commit: docs: document path-aware extension/MCP gating and registerToolAccessExtractor (#352)

Risks and Mitigations

Risk Mitigation
input.path false positives — a tool whose input.path is not a filesystem path gets spuriously gated The path gate is a no-op unless an explicit path rule matched (matchedPattern === undefined → skip, #58); external_directory only fires for paths resolving outside the cwd. Friction only arises with configured path rules and an external-looking value. Documented under default-on.
Behavior change surprises users on upgrade Shipped as feat!: with a BREAKING CHANGE: footer; documented in docs/configuration.md and the schema description; it is a security improvement (closing a bypass).
Pipeline constructor signature change breaks the single call site index.ts is the sole construction site; updated in the same commit (Cycle 3); pnpm run check after.
MCP arguments.path does not match every MCP tool's shape This is best-effort default coverage; non-conforming MCP tools use a registered extractor. Matches PR #352's MCP handling.
Divergence from the source PR causes confusion when the PR is closed The close comment credits @moekyo by name, states the simplified design adopted (lean extractor, no envelope), links the implementing SHA(s), and notes the deferred per-tool follow-up.

Open Questions

  • Per-tool path maps for extension tools ("ffgrep": { "*.env": "deny" }) — deferred. Additive later: thread the same extractor through PermissionManager.checkPermissionnormalizeInput, and change normalizeInput's extension-tool branch from values: ["*"] to getToolInputPath(...). No API or registry change required. File a follow-up issue and reference it in the PR #352 close comment.
  • Should built-in MCP path extraction eventually consider arguments beyond path (e.g. paths, file)? Out of scope; revisit if MCP tooling conventions broaden.