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
@moekyoby 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
pathandexternal_directorygates. - Detect path tools by convention, without registration: any non-bash tool exposing
input.path(and MCP viainput.arguments.path) is path-gated automatically (default-on). - Provide
registerToolAccessExtractor(toolName, extractor)on the cross-extensionPermissionsServiceas 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 noToolAccessIntentenvelope. - 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 aBREAKING 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-cuttingpathandexternal_directorysurfaces; 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
ToolAccessIntentenvelope from PR #352 (resource/operation/confidence/source/toolName). Only.valuewas ever consumed by a gate; the remaining fields are speculative (resourceandconfidenceeach have one inhabitant) and a maintenance trap. - Multi-path tools: one path per tool (the default
input.pathconvention and PR #352's ownToolAccessIntentDeclaration.valueare 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:
handlers/gates/path.ts— thepathgate (decision-bearing).handlers/gates/external-directory.ts— theexternal_directorygate (decision-bearing).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-onlyToolInputFormatterRegistrar(register(toolName, fn): () => void), split for ISP. - A
ToolInputFormatterRegistryclass implementing both; duplicate registration throws; the disposer is identity-guarded. - Created once in
index.ts; the same instance is passed as aRegistrartoLocalPermissionsService(line 131) and as aLookuptoToolCallGatePipeline(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 onlyschemas/permissions.schema.jsonmarkdownDescription,docs/configuration.md,docs/cross-extension-api.md, andREADME.mdneed touching — there is no new config field (registration is a runtime API), so the loader/PermissionSystemExtensionConfigare untouched. docs/architecture/architecture.mdcarries 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:
ToolAccessExtractorLookupis 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:
customExtractorsflowsindex → pipeline → gate fnsand is consumed at the gates, mirroring the existingcustomFormattersrelay — 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
src/tool-access-extractor-registry.ts—ToolAccessExtractor(value-only),ToolAccessExtractorLookup,ToolAccessExtractorRegistrar,ToolAccessExtractorRegistry.test/tool-access-extractor-registry.test.ts— register/dispose/duplicate-throw/identity-guard/get, mirroringtool-input-formatter-registry.test.ts.
Modified — source
src/path-utils.ts— addgetToolInputPath;getPathBearingToolPathunchanged.src/handlers/gates/path.ts—describePathGateacceptsextractors?: ToolAccessExtractorLookup; usegetToolInputPath.src/handlers/gates/external-directory.ts— same fordescribeExternalDirectoryGate.src/handlers/gates/tool-call-gate-pipeline.ts— addcustomExtractors?constructor param; pass to the two gate producers.src/service.ts— addregisterToolAccessExtractor(toolName, extractor): () => voidto thePermissionsServiceinterface (mirror theregisterToolInputFormatterdoc block).src/permissions-service.ts— injectToolAccessExtractorRegistrar(4th ctor param); implementregisterToolAccessExtractordelegating toregistry.register.src/index.ts— constructToolAccessExtractorRegistry; pass toLocalPermissionsServiceandToolCallGatePipeline.
Modified — tests
test/path-utils.test.ts—getToolInputPathcases: built-ins →input.path;bash→ null;mcp→arguments.path; extension default →input.path; registered extractor overrides; missing/empty → null.test/handlers/gates/path.test.ts— extension/MCP tool withinput.pathnow produces apathdescriptor under a matching rule; registered extractor's path is used.test/handlers/gates/external-directory.test.ts— extension/MCP external path now gated.test/handlers/gates/tool-call-gate-pipeline.test.ts— pipeline forwardscustomExtractorsto the gates (end-to-end: an extension tool with an externalinput.pathblocks).test/permissions-service.test.ts—registerToolAccessExtractordelegates to the registrar and returns its disposer.test/service.test.ts— adapter delegation + accessor includes the new method.test/composition-root.test.ts— a registered extractor reaches the live pipeline (parallel to the existingregisterToolInputFormatterwiring assertion at line 321).
Modified — docs / schema
docs/cross-extension-api.md— add aregisterToolAccessExtractorsection mirroringregisterToolInputFormatter; note default-on convention.docs/configuration.md— document thatpath/external_directorynow cover extension + MCP path tools; show thepath-surface form for protecting files across all tools.README.md— brief mention in the permissions/behavior section.schemas/permissions.schema.json— update thepermissionmarkdownDescriptionto state extension tools (viainput.pathor a registered extractor) and MCP (input.arguments.path) participate inpathgating.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; addtool-access-extractor-registry.tsto the "Module structure" listing.config/config.example.json— review only; if adding an illustrative entry, use the cross-cuttingpathsurface (e.g."path": { "*.env": "deny" }), not PR #352's per-toolffgreppath map (that requires the deferred per-tool feature).
Test Impact Analysis
- New lower-level tests enabled:
tool-access-extractor-registry.test.ts— register/dispose/dup semantics in isolation.getToolInputPathtable tests covering built-in / bash / mcp / extension-default / registered-extractor / empty branches purely (mocked lookup).
- Existing tests that change (behavior change, not redundant):
path.test.ts/external-directory.test.tsgain cases asserting extension/MCP tools are now gated. Existing built-in cases stay green (theextractorsparameter is optional and built-in extraction is unchanged).getPathBearingToolPathtests inpath-utils.test.tsstay as-is (function unchanged); add a sibling block forgetToolInputPath.
- Tests that must stay as-is:
tool.test.ts— the per-tool gate is unchanged (still usesgetPathBearingToolPathfor suggestion/log values).- All
normalizeInput/permission-managertests — 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
- RED:
test/tool-access-extractor-registry.test.ts—registerreturns a disposer; duplicate registration throws; disposer is identity-guarded (a stale disposer cannot evict a later registration);getreturns the extractor orundefined. - GREEN:
src/tool-access-extractor-registry.ts.
- Commit:
feat: add tool access extractor registry (#352)
Cycle 2 — extensible path extraction
- RED:
test/path-utils.test.ts—getToolInputPathtable: built-ins →input.path;bash→ null;mcp→arguments.path; extension default →input.path; a registered extractor (via a fakeLookup) overrides; missing/empty → null. - GREEN: add
getToolInputPathtosrc/path-utils.ts.
- Commit:
feat: add extensible tool input path extraction (#352)
Cycle 3 — gates gate extension/MCP path tools (default-on, breaking)
- RED: update
test/handlers/gates/path.test.ts,external-directory.test.ts, andtool-call-gate-pipeline.test.ts— an extension tool withinput.path(and an MCP tool witharguments.path) under a matchingpathrule / outside the cwd now produces a gate descriptor; a registered extractor's path is honored; bash and pathless tools still skip. - GREEN: thread
customExtractors?: ToolAccessExtractorLookupintoToolCallGatePipelineand both gate signatures; switch the two gates togetToolInputPath; construct + pass the registry lookup insrc/index.ts. Runpnpm --filter @gotgenes/pi-permission-system run check(pipeline constructor signature change has a single call site inindex.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
- 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). - GREEN: add the method to the
PermissionsServiceinterface (src/service.ts); injectToolAccessExtractorRegistrarintoLocalPermissionsServiceand implement it; passaccessExtractorRegistryas the registrar insrc/index.ts. Runpnpm --filter @gotgenes/pi-permission-system run check.
- Commit:
feat: expose registerToolAccessExtractor via permissions service (#352)
Cycle 5 — docs + schema
- GREEN (docs): update
docs/cross-extension-api.md,docs/configuration.md,README.md,schemas/permissions.schema.json(markdownDescription), anddocs/architecture/architecture.md; reviewconfig/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 throughPermissionManager.checkPermission→normalizeInput, and changenormalizeInput's extension-tool branch fromvalues: ["*"]togetToolInputPath(...). 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.