@gotgenes/pi-permission-system 10.10.1 → 12.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +40 -0
- package/README.md +2 -2
- package/package.json +1 -1
- package/schemas/permissions.schema.json +1 -1
- package/src/handlers/before-agent-start.ts +3 -3
- package/src/handlers/gates/external-directory.ts +8 -2
- package/src/handlers/gates/path.ts +4 -2
- package/src/handlers/gates/tool-call-gate-pipeline.ts +5 -2
- package/src/index.ts +5 -0
- package/src/path-utils.ts +41 -0
- package/src/permissions-service.ts +12 -0
- package/src/service.ts +24 -0
- package/src/tool-access-extractor-registry.ts +68 -0
- package/src/tool-registry.ts +3 -0
- package/test/composition-root.test.ts +36 -0
- package/test/handlers/before-agent-start.test.ts +31 -3
- package/test/handlers/gates/external-directory.test.ts +54 -0
- package/test/handlers/gates/path.test.ts +72 -0
- package/test/handlers/gates/tool-call-gate-pipeline.test.ts +63 -0
- package/test/helpers/handler-fixtures.ts +1 -0
- package/test/helpers/make-fake-pi.ts +5 -0
- package/test/path-utils.test.ts +63 -0
- package/test/permission-events.test.ts +1 -0
- package/test/permissions-service.test.ts +35 -1
- package/test/service-lifecycle.test.ts +1 -0
- package/test/service.test.ts +53 -0
- package/test/session-start.test.ts +2 -0
- package/test/tool-access-extractor-registry.test.ts +77 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,46 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [12.0.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v11.0.0...pi-permission-system-v12.0.0) (2026-06-12)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### ⚠ BREAKING CHANGES
|
|
12
|
+
|
|
13
|
+
* 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.
|
|
14
|
+
|
|
15
|
+
### Features
|
|
16
|
+
|
|
17
|
+
* add extensible tool input path extraction ([#352](https://github.com/gotgenes/pi-packages/issues/352)) ([3a54ea1](https://github.com/gotgenes/pi-packages/commit/3a54ea16be4d621bd7474f7a728d97ce9781a994))
|
|
18
|
+
* add tool access extractor registry ([#352](https://github.com/gotgenes/pi-packages/issues/352)) ([7a34f01](https://github.com/gotgenes/pi-packages/commit/7a34f0187f6b3fbb75e056082f04d3b805a37c8a))
|
|
19
|
+
* expose registerToolAccessExtractor via permissions service ([#352](https://github.com/gotgenes/pi-packages/issues/352)) ([5e02c16](https://github.com/gotgenes/pi-packages/commit/5e02c163b212adf9648a2631e4788f030539a36a))
|
|
20
|
+
* gate extension and MCP path tools by default ([#352](https://github.com/gotgenes/pi-packages/issues/352)) ([1d53f4f](https://github.com/gotgenes/pi-packages/commit/1d53f4ffa1a08e953b96437e9adf0214c6ca7465))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
### Documentation
|
|
24
|
+
|
|
25
|
+
* document path-aware extension/MCP gating and registerToolAccessExtractor ([#352](https://github.com/gotgenes/pi-packages/issues/352)) ([a2f825f](https://github.com/gotgenes/pi-packages/commit/a2f825f031ec26f1c47dbf13d056e724fda87021))
|
|
26
|
+
|
|
27
|
+
## [11.0.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v10.10.1...pi-permission-system-v11.0.0) (2026-06-11)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
### ⚠ BREAKING CHANGES
|
|
31
|
+
|
|
32
|
+
* The permission system no longer auto-activates pi's off-by-default tools (`find`, `grep`, `ls`) in the main session. Users who want them active should enable them via pi's own `activeTools` configuration rather than relying on the permission system to expose every non-denied tool.
|
|
33
|
+
|
|
34
|
+
### Features
|
|
35
|
+
|
|
36
|
+
* add getActive to ToolRegistry wired to pi.getActiveTools ([#385](https://github.com/gotgenes/pi-packages/issues/385)) ([79c4594](https://github.com/gotgenes/pi-packages/commit/79c459443294c1b58643b746e3511fc17c9f8961))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
### Bug Fixes
|
|
40
|
+
|
|
41
|
+
* respect pi's default active tool set in before_agent_start ([#385](https://github.com/gotgenes/pi-packages/issues/385)) ([bf5be48](https://github.com/gotgenes/pi-packages/commit/bf5be48ca8b06e8cb08f66d08eccb85af0673987))
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
### Documentation
|
|
45
|
+
|
|
46
|
+
* clarify before_agent_start filters pi's active tool set ([#385](https://github.com/gotgenes/pi-packages/issues/385)) ([bdb5a6a](https://github.com/gotgenes/pi-packages/commit/bdb5a6a08e3c1bb611c8b1795c4d46856104b3b0))
|
|
47
|
+
|
|
8
48
|
## [10.10.1](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v10.10.0...pi-permission-system-v10.10.1) (2026-06-11)
|
|
9
49
|
|
|
10
50
|
|
package/README.md
CHANGED
|
@@ -65,8 +65,8 @@ All permissions use one of three states:
|
|
|
65
65
|
When the dialog prompts, you can approve once or approve a pattern for the rest of the session.
|
|
66
66
|
See [docs/session-approvals.md](docs/session-approvals.md) for details on session-scoped rules and pattern suggestions.
|
|
67
67
|
|
|
68
|
-
The `path` surface is a cross-cutting gate that applies to **all** file access —
|
|
69
|
-
|
|
68
|
+
The `path` surface is a cross-cutting gate that applies to **all** file access — Pi tools, bash commands, MCP calls, and extension tools alike.
|
|
69
|
+
Extension and MCP tools that operate on paths (via `input.path`, MCP's `input.arguments.path`, or a registered access extractor) are gated by default, so a `path` deny cannot be overridden by a per-tool allow — making it the right place to protect sensitive files like `.env` or `~/.ssh/*` from every tool at once.
|
|
70
70
|
|
|
71
71
|
For per-tool path patterns (`read`, `write`, `edit`, `find`, `grep`, `ls`), patterns are matched against the file path from `input.path`.
|
|
72
72
|
This lets you express rules like "allow reads but deny `.env` files" at the individual tool level.
|
package/package.json
CHANGED
|
@@ -53,7 +53,7 @@
|
|
|
53
53
|
},
|
|
54
54
|
"permission": {
|
|
55
55
|
"description": "Flat permission policy. Each key is a surface name; values are a PermissionState string (catch-all) or a pattern→action map.",
|
|
56
|
-
"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
|
|
56
|
+
"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\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\n**Merge order (lowest → highest precedence):** global → project → per-agent frontmatter.",
|
|
57
57
|
"type": "object",
|
|
58
58
|
"propertyNames": {
|
|
59
59
|
"description": "A surface name or the universal fallback key '*'.",
|
|
@@ -38,7 +38,7 @@ export function shouldExposeTool(
|
|
|
38
38
|
* Constructor deps:
|
|
39
39
|
* - `session` — encapsulates all mutable session state and lifecycle operations
|
|
40
40
|
* - `resolver` — owns permission-query surface: `getToolPermission`, `getPolicyCacheStamp`, skill check
|
|
41
|
-
* - `toolRegistry` — Pi tool API subset (
|
|
41
|
+
* - `toolRegistry` — Pi tool API subset (getActive + setActive)
|
|
42
42
|
*/
|
|
43
43
|
export class AgentPrepHandler {
|
|
44
44
|
constructor(
|
|
@@ -56,10 +56,10 @@ export class AgentPrepHandler {
|
|
|
56
56
|
this.session.refreshConfig(ctx);
|
|
57
57
|
|
|
58
58
|
const agentName = this.session.resolveAgentName(ctx, event.systemPrompt);
|
|
59
|
-
const
|
|
59
|
+
const activeTools = this.toolRegistry.getActive();
|
|
60
60
|
const allowedTools: string[] = [];
|
|
61
61
|
|
|
62
|
-
for (const tool of
|
|
62
|
+
for (const tool of activeTools) {
|
|
63
63
|
const toolName = getToolNameFromValue(tool);
|
|
64
64
|
if (!toolName) {
|
|
65
65
|
continue;
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import {
|
|
2
2
|
canonicalNormalizePathForComparison,
|
|
3
|
-
|
|
3
|
+
getToolInputPath,
|
|
4
4
|
isPathOutsideWorkingDirectory,
|
|
5
5
|
isPiInfrastructureRead,
|
|
6
6
|
} from "#src/path-utils";
|
|
7
7
|
import { SessionApproval } from "#src/session-approval";
|
|
8
8
|
import { deriveApprovalPattern } from "#src/session-rules";
|
|
9
|
+
import type { ToolAccessExtractorLookup } from "#src/tool-access-extractor-registry";
|
|
9
10
|
import type { GateResult } from "./descriptor";
|
|
10
11
|
import { formatExternalDirectoryAskPrompt } from "./external-directory-messages";
|
|
11
12
|
import type { ToolCallContext } from "./types";
|
|
@@ -21,10 +22,15 @@ import type { ToolCallContext } from "./types";
|
|
|
21
22
|
export function describeExternalDirectoryGate(
|
|
22
23
|
tcc: ToolCallContext,
|
|
23
24
|
infraDirs: string[],
|
|
25
|
+
extractors?: ToolAccessExtractorLookup,
|
|
24
26
|
): GateResult {
|
|
25
27
|
if (!tcc.cwd) return null;
|
|
26
28
|
|
|
27
|
-
const externalDirectoryPath =
|
|
29
|
+
const externalDirectoryPath = getToolInputPath(
|
|
30
|
+
tcc.toolName,
|
|
31
|
+
tcc.input,
|
|
32
|
+
extractors,
|
|
33
|
+
);
|
|
28
34
|
if (!externalDirectoryPath) return null;
|
|
29
35
|
|
|
30
36
|
if (!isPathOutsideWorkingDirectory(externalDirectoryPath, tcc.cwd)) {
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { getToolInputPath } from "#src/path-utils";
|
|
2
2
|
import type { ScopedPermissionResolver } from "#src/permission-resolver";
|
|
3
3
|
import { SessionApproval } from "#src/session-approval";
|
|
4
4
|
import { deriveApprovalPattern } from "#src/session-rules";
|
|
5
|
+
import type { ToolAccessExtractorLookup } from "#src/tool-access-extractor-registry";
|
|
5
6
|
import type { GateDescriptor, GateResult } from "./descriptor";
|
|
6
7
|
import type { ToolCallContext } from "./types";
|
|
7
8
|
|
|
@@ -16,8 +17,9 @@ import type { ToolCallContext } from "./types";
|
|
|
16
17
|
export function describePathGate(
|
|
17
18
|
tcc: ToolCallContext,
|
|
18
19
|
resolver: ScopedPermissionResolver,
|
|
20
|
+
extractors?: ToolAccessExtractorLookup,
|
|
19
21
|
): GateResult {
|
|
20
|
-
const filePath =
|
|
22
|
+
const filePath = getToolInputPath(tcc.toolName, tcc.input, extractors);
|
|
21
23
|
if (!filePath) return null;
|
|
22
24
|
|
|
23
25
|
const check = resolver.resolve(
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { getNonEmptyString, toRecord } from "#src/common";
|
|
2
2
|
import type { ScopedPermissionResolver } from "#src/permission-resolver";
|
|
3
3
|
import type { SkillPromptEntry } from "#src/skill-prompt-sanitizer";
|
|
4
|
+
import type { ToolAccessExtractorLookup } from "#src/tool-access-extractor-registry";
|
|
4
5
|
import type { ToolInputFormatterLookup } from "#src/tool-input-formatter-registry";
|
|
5
6
|
import {
|
|
6
7
|
ToolPreviewFormatter,
|
|
@@ -53,6 +54,7 @@ export class ToolCallGatePipeline {
|
|
|
53
54
|
private readonly resolver: ScopedPermissionResolver,
|
|
54
55
|
private readonly inputs: ToolCallGateInputs,
|
|
55
56
|
private readonly customFormatters?: ToolInputFormatterLookup,
|
|
57
|
+
private readonly customExtractors?: ToolAccessExtractorLookup,
|
|
56
58
|
) {}
|
|
57
59
|
|
|
58
60
|
async evaluate(
|
|
@@ -77,8 +79,9 @@ export class ToolCallGatePipeline {
|
|
|
77
79
|
const gateProducers: Array<() => GateResult | Promise<GateResult>> = [
|
|
78
80
|
() =>
|
|
79
81
|
describeSkillReadGate(tcc, () => this.inputs.getActiveSkillEntries()),
|
|
80
|
-
() => describePathGate(tcc, this.resolver),
|
|
81
|
-
() =>
|
|
82
|
+
() => describePathGate(tcc, this.resolver, this.customExtractors),
|
|
83
|
+
() =>
|
|
84
|
+
describeExternalDirectoryGate(tcc, infraDirs, this.customExtractors),
|
|
82
85
|
() => describeBashExternalDirectoryGate(tcc, bashProgram, this.resolver),
|
|
83
86
|
() => describeBashPathGate(tcc, bashProgram, this.resolver),
|
|
84
87
|
() => {
|
package/src/index.ts
CHANGED
|
@@ -32,6 +32,7 @@ import { PermissionSessionLogger } from "./session-logger";
|
|
|
32
32
|
import { SessionRules } from "./session-rules";
|
|
33
33
|
import { subscribeSubagentLifecycle } from "./subagent-lifecycle-events";
|
|
34
34
|
import { getSubagentSessionRegistry } from "./subagent-registry";
|
|
35
|
+
import { ToolAccessExtractorRegistry } from "./tool-access-extractor-registry";
|
|
35
36
|
import { ToolInputFormatterRegistry } from "./tool-input-formatter-registry";
|
|
36
37
|
|
|
37
38
|
export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
@@ -44,6 +45,7 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
44
45
|
const subagentRegistry = getSubagentSessionRegistry();
|
|
45
46
|
const formatterRegistry = new ToolInputFormatterRegistry();
|
|
46
47
|
registerBuiltinToolInputFormatters(formatterRegistry);
|
|
48
|
+
const accessExtractorRegistry = new ToolAccessExtractorRegistry();
|
|
47
49
|
|
|
48
50
|
// Both `configStore` and `session` are forward-declared so the logger's
|
|
49
51
|
// lazy thunks can close over them without a cast or null-init holder.
|
|
@@ -129,6 +131,7 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
129
131
|
permissionManager,
|
|
130
132
|
sessionRules,
|
|
131
133
|
formatterRegistry,
|
|
134
|
+
accessExtractorRegistry,
|
|
132
135
|
);
|
|
133
136
|
|
|
134
137
|
// Subscribe to @gotgenes/pi-subagents' child lifecycle events so child
|
|
@@ -152,6 +155,7 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
152
155
|
|
|
153
156
|
const toolRegistry = {
|
|
154
157
|
getAll: () => pi.getAllTools(),
|
|
158
|
+
getActive: () => pi.getActiveTools(),
|
|
155
159
|
setActive: (names: string[]) => pi.setActiveTools(names),
|
|
156
160
|
};
|
|
157
161
|
|
|
@@ -171,6 +175,7 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
171
175
|
resolver,
|
|
172
176
|
session,
|
|
173
177
|
formatterRegistry,
|
|
178
|
+
accessExtractorRegistry,
|
|
174
179
|
);
|
|
175
180
|
const skillInputGatePipeline = new SkillInputGatePipeline(resolver);
|
|
176
181
|
const gates = new PermissionGateHandler(
|
package/src/path-utils.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
import { canonicalizePath } from "./canonicalize-path";
|
|
10
10
|
import { getNonEmptyString, toRecord } from "./common";
|
|
11
11
|
import { expandHomePath } from "./expand-home";
|
|
12
|
+
import type { ToolAccessExtractorLookup } from "./tool-access-extractor-registry";
|
|
12
13
|
import { wildcardMatch } from "./wildcard-matcher";
|
|
13
14
|
|
|
14
15
|
export function normalizePathForComparison(
|
|
@@ -123,6 +124,46 @@ export function getPathBearingToolPath(
|
|
|
123
124
|
return getNonEmptyString(toRecord(input).path);
|
|
124
125
|
}
|
|
125
126
|
|
|
127
|
+
/**
|
|
128
|
+
* Extract the filesystem path a tool will access, for the cross-cutting `path`
|
|
129
|
+
* and `external_directory` gates.
|
|
130
|
+
*
|
|
131
|
+
* Unlike {@link getPathBearingToolPath} (built-in tools only), this recognizes
|
|
132
|
+
* extension and MCP tools so they are no longer exempt from path gating:
|
|
133
|
+
*
|
|
134
|
+
* - `bash` → `null` (bash has its own token-based path gates).
|
|
135
|
+
* - Built-in path-bearing tools → `input.path`.
|
|
136
|
+
* - `mcp` → `input.arguments.path`.
|
|
137
|
+
* - Any other tool → a registered {@link ToolAccessExtractor}'s path, else the
|
|
138
|
+
* default `input.path` convention.
|
|
139
|
+
*/
|
|
140
|
+
export function getToolInputPath(
|
|
141
|
+
toolName: string,
|
|
142
|
+
input: unknown,
|
|
143
|
+
extractors?: ToolAccessExtractorLookup,
|
|
144
|
+
): string | null {
|
|
145
|
+
if (toolName === "bash") {
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const record = toRecord(input);
|
|
150
|
+
|
|
151
|
+
if (PATH_BEARING_TOOLS.has(toolName)) {
|
|
152
|
+
return getNonEmptyString(record.path);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (toolName === "mcp") {
|
|
156
|
+
return getNonEmptyString(toRecord(record.arguments).path);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const custom = extractors?.get(toolName);
|
|
160
|
+
if (custom) {
|
|
161
|
+
return getNonEmptyString(custom(record));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return getNonEmptyString(record.path);
|
|
165
|
+
}
|
|
166
|
+
|
|
126
167
|
/**
|
|
127
168
|
* Like {@link normalizePathForComparison} but also resolves symlinks via
|
|
128
169
|
* `realpathSync` (best-effort). Use this for containment decisions where the
|
|
@@ -2,6 +2,10 @@ import { buildInputForSurface } from "./input-normalizer";
|
|
|
2
2
|
import type { ScopedPermissionManager } from "./permission-manager";
|
|
3
3
|
import type { PermissionsService } from "./service";
|
|
4
4
|
import type { SessionRules } from "./session-rules";
|
|
5
|
+
import type {
|
|
6
|
+
ToolAccessExtractor,
|
|
7
|
+
ToolAccessExtractorRegistrar,
|
|
8
|
+
} from "./tool-access-extractor-registry";
|
|
5
9
|
import type {
|
|
6
10
|
ToolInputFormatter,
|
|
7
11
|
ToolInputFormatterRegistrar,
|
|
@@ -19,6 +23,7 @@ export class LocalPermissionsService implements PermissionsService {
|
|
|
19
23
|
private readonly permissionManager: ScopedPermissionManager,
|
|
20
24
|
private readonly sessionRules: Pick<SessionRules, "getRuleset">,
|
|
21
25
|
private readonly formatterRegistry: ToolInputFormatterRegistrar,
|
|
26
|
+
private readonly accessExtractorRegistry: ToolAccessExtractorRegistrar,
|
|
22
27
|
) {}
|
|
23
28
|
|
|
24
29
|
checkPermission(
|
|
@@ -48,4 +53,11 @@ export class LocalPermissionsService implements PermissionsService {
|
|
|
48
53
|
): ReturnType<PermissionsService["registerToolInputFormatter"]> {
|
|
49
54
|
return this.formatterRegistry.register(toolName, formatter);
|
|
50
55
|
}
|
|
56
|
+
|
|
57
|
+
registerToolAccessExtractor(
|
|
58
|
+
toolName: string,
|
|
59
|
+
extractor: ToolAccessExtractor,
|
|
60
|
+
): ReturnType<PermissionsService["registerToolAccessExtractor"]> {
|
|
61
|
+
return this.accessExtractorRegistry.register(toolName, extractor);
|
|
62
|
+
}
|
|
51
63
|
}
|
package/src/service.ts
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* reference — this ensures resilience across `/reload` and load-order edge cases.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
+
import type { ToolAccessExtractor } from "./tool-access-extractor-registry";
|
|
14
15
|
import type { ToolInputFormatter } from "./tool-input-formatter-registry";
|
|
15
16
|
import type { PermissionCheckResult, PermissionState } from "./types";
|
|
16
17
|
|
|
@@ -80,6 +81,29 @@ export interface PermissionsService {
|
|
|
80
81
|
formatter: ToolInputFormatter,
|
|
81
82
|
): () => void;
|
|
82
83
|
|
|
84
|
+
/**
|
|
85
|
+
* Register a custom access-intent extractor for a specific tool name.
|
|
86
|
+
*
|
|
87
|
+
* The extractor declares the filesystem path a tool will access so the
|
|
88
|
+
* cross-cutting `path` and `external_directory` gates can see it. Use it for
|
|
89
|
+
* tools whose path lives under a non-standard key — built-in file tools and
|
|
90
|
+
* any tool exposing `input.path` (plus MCP via `input.arguments.path`) are
|
|
91
|
+
* already covered by convention without registration.
|
|
92
|
+
*
|
|
93
|
+
* The extractor receives the raw `input` record and returns the path string,
|
|
94
|
+
* or `undefined` to decline. Only one extractor may be registered per tool
|
|
95
|
+
* name — a second call for the same name throws. The returned disposer
|
|
96
|
+
* unregisters the extractor.
|
|
97
|
+
*
|
|
98
|
+
* @param toolName - Exact tool name to register for (e.g. `"ffgrep"`).
|
|
99
|
+
* @param extractor - Receives the raw `input` record; return the path string,
|
|
100
|
+
* or `undefined` to decline.
|
|
101
|
+
*/
|
|
102
|
+
registerToolAccessExtractor(
|
|
103
|
+
toolName: string,
|
|
104
|
+
extractor: ToolAccessExtractor,
|
|
105
|
+
): () => void;
|
|
106
|
+
|
|
83
107
|
/**
|
|
84
108
|
* Query the tool-level permission state for pre-filtering tools before
|
|
85
109
|
* creating a child session.
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Registry for custom tool access-intent extractors.
|
|
3
|
+
*
|
|
4
|
+
* Lets sibling extensions declare the filesystem path a tool will access when
|
|
5
|
+
* the tool's input shape is not the default `input.path` convention, so the
|
|
6
|
+
* cross-cutting `path` and `external_directory` gates can see it.
|
|
7
|
+
* One extractor per tool name; duplicate registration throws.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** Returns the filesystem path this tool will access, or `undefined` to decline. */
|
|
11
|
+
export type ToolAccessExtractor = (
|
|
12
|
+
input: Record<string, unknown>,
|
|
13
|
+
) => string | undefined;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Read-only lookup used by the gate pipeline (ISP — exposes only the read
|
|
17
|
+
* side, not the registration surface).
|
|
18
|
+
*/
|
|
19
|
+
export interface ToolAccessExtractorLookup {
|
|
20
|
+
get(toolName: string): ToolAccessExtractor | undefined;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Registration side of the extractor registry (ISP — exposes only the write
|
|
25
|
+
* surface, mirroring the read-only {@link ToolAccessExtractorLookup}).
|
|
26
|
+
*/
|
|
27
|
+
export interface ToolAccessExtractorRegistrar {
|
|
28
|
+
register(toolName: string, extractor: ToolAccessExtractor): () => void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Persistent registry mapping tool names to custom access-intent extractors.
|
|
33
|
+
*
|
|
34
|
+
* Owned by the extension factory (`index.ts`) so it survives across the
|
|
35
|
+
* per-tool-call gate evaluation cycle.
|
|
36
|
+
* Exposed to sibling extensions via `PermissionsService.registerToolAccessExtractor`.
|
|
37
|
+
*/
|
|
38
|
+
export class ToolAccessExtractorRegistry
|
|
39
|
+
implements ToolAccessExtractorLookup, ToolAccessExtractorRegistrar
|
|
40
|
+
{
|
|
41
|
+
private readonly extractors = new Map<string, ToolAccessExtractor>();
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Register an extractor for `toolName`.
|
|
45
|
+
*
|
|
46
|
+
* Throws if an extractor is already registered for that name — keeps
|
|
47
|
+
* resolution deterministic (a pi-permission-system package priority).
|
|
48
|
+
* Returns a disposer that removes the extractor; the disposer is
|
|
49
|
+
* identity-guarded so a stale call cannot evict a later registration.
|
|
50
|
+
*/
|
|
51
|
+
register(toolName: string, extractor: ToolAccessExtractor): () => void {
|
|
52
|
+
if (this.extractors.has(toolName)) {
|
|
53
|
+
throw new Error(
|
|
54
|
+
`A tool access extractor is already registered for '${toolName}'.`,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
this.extractors.set(toolName, extractor);
|
|
58
|
+
return () => {
|
|
59
|
+
if (this.extractors.get(toolName) === extractor) {
|
|
60
|
+
this.extractors.delete(toolName);
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
get(toolName: string): ToolAccessExtractor | undefined {
|
|
66
|
+
return this.extractors.get(toolName);
|
|
67
|
+
}
|
|
68
|
+
}
|
package/src/tool-registry.ts
CHANGED
|
@@ -2,7 +2,10 @@ import { getNonEmptyString, toRecord } from "./common";
|
|
|
2
2
|
|
|
3
3
|
/** Narrow interface for the Pi tool API subset used by handler classes. */
|
|
4
4
|
export interface ToolRegistry {
|
|
5
|
+
/** All registered tools (`pi.getAllTools()` — `ToolInfo[]`); kept defensively wide. */
|
|
5
6
|
getAll(): unknown[];
|
|
7
|
+
/** Currently active tool names (`pi.getActiveTools()`). */
|
|
8
|
+
getActive(): string[];
|
|
6
9
|
setActive(names: string[]): void;
|
|
7
10
|
}
|
|
8
11
|
|
|
@@ -337,6 +337,42 @@ describe("service and gate share one formatter registry", () => {
|
|
|
337
337
|
});
|
|
338
338
|
});
|
|
339
339
|
|
|
340
|
+
describe("service and gate share one access extractor registry", () => {
|
|
341
|
+
// An extractor registered through the published service must be consulted by
|
|
342
|
+
// the live gate handler — proving both reference the same
|
|
343
|
+
// ToolAccessExtractorRegistry instance the factory created once (#352).
|
|
344
|
+
it("path-gates a custom-shaped tool via a service-registered extractor", async () => {
|
|
345
|
+
writeGlobalConfig({
|
|
346
|
+
permission: { "*": "allow", path: { "*.env": "deny" } },
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
const cwd = mkdtempSync(join(tmpdir(), "pi-perm-ext-cwd-"));
|
|
350
|
+
const pi = makeFakePi({ toolNames: ["ffgrep"] });
|
|
351
|
+
piPermissionSystemExtension(pi as unknown as ExtensionAPI);
|
|
352
|
+
|
|
353
|
+
const { ctx } = makeUiCtx(cwd, []);
|
|
354
|
+
await fireSessionStart(pi, ctx);
|
|
355
|
+
|
|
356
|
+
// ffgrep carries its path under a non-standard key; without the extractor
|
|
357
|
+
// the default input.path convention would miss it.
|
|
358
|
+
getPermissionsService()!.registerToolAccessExtractor("ffgrep", (input) =>
|
|
359
|
+
typeof input.target === "string" ? input.target : undefined,
|
|
360
|
+
);
|
|
361
|
+
|
|
362
|
+
const result = (await pi.fire(
|
|
363
|
+
"tool_call",
|
|
364
|
+
{ toolName: "ffgrep", toolCallId: "ff-1", input: { target: ".env" } },
|
|
365
|
+
ctx,
|
|
366
|
+
)) as { block?: true };
|
|
367
|
+
|
|
368
|
+
// The path deny fired — so the gate extracted ffgrep's path through the
|
|
369
|
+
// same registry the service wrote to.
|
|
370
|
+
expect(result.block).toBe(true);
|
|
371
|
+
|
|
372
|
+
rmSync(cwd, { recursive: true, force: true });
|
|
373
|
+
});
|
|
374
|
+
});
|
|
375
|
+
|
|
340
376
|
describe("ready emitted after service publication", () => {
|
|
341
377
|
// Ordering contracts exist only at the composition root: a consumer reacting
|
|
342
378
|
// to permissions:ready must be able to resolve the service immediately. The
|
|
@@ -31,6 +31,7 @@ function makeEvent(systemPrompt = "You are an assistant.") {
|
|
|
31
31
|
function makeToolRegistry(overrides: Partial<ToolRegistry> = {}): ToolRegistry {
|
|
32
32
|
return {
|
|
33
33
|
getAll: vi.fn().mockReturnValue([]),
|
|
34
|
+
getActive: vi.fn().mockReturnValue([]),
|
|
34
35
|
setActive: vi.fn(),
|
|
35
36
|
...overrides,
|
|
36
37
|
};
|
|
@@ -126,7 +127,7 @@ describe("AgentPrepHandler.handle", () => {
|
|
|
126
127
|
const { handler, toolRegistry } = makeSetup({
|
|
127
128
|
toolPermission: "deny",
|
|
128
129
|
toolRegistry: {
|
|
129
|
-
|
|
130
|
+
getActive: vi.fn().mockReturnValue(["write", "read"]),
|
|
130
131
|
},
|
|
131
132
|
});
|
|
132
133
|
await handler.handle(makeEvent(), makeCtx());
|
|
@@ -136,17 +137,44 @@ describe("AgentPrepHandler.handle", () => {
|
|
|
136
137
|
it("includes allowed and ask tools in the active list", async () => {
|
|
137
138
|
const { handler, toolRegistry } = makeSetup({
|
|
138
139
|
toolRegistry: {
|
|
139
|
-
|
|
140
|
+
getActive: vi.fn().mockReturnValue(["read", "write"]),
|
|
140
141
|
},
|
|
141
142
|
});
|
|
142
143
|
await handler.handle(makeEvent(), makeCtx());
|
|
143
144
|
expect(toolRegistry.setActive).toHaveBeenCalledWith(["read", "write"]);
|
|
144
145
|
});
|
|
145
146
|
|
|
147
|
+
it("does not activate registered tools pi left inactive (find/grep/ls)", async () => {
|
|
148
|
+
// Regression for #385: the active set is the base, not the full registry.
|
|
149
|
+
const { handler, toolRegistry } = makeSetup({
|
|
150
|
+
toolRegistry: {
|
|
151
|
+
getActive: vi.fn().mockReturnValue(["read", "bash", "edit", "write"]),
|
|
152
|
+
getAll: vi
|
|
153
|
+
.fn()
|
|
154
|
+
.mockReturnValue([
|
|
155
|
+
{ name: "read" },
|
|
156
|
+
{ name: "bash" },
|
|
157
|
+
{ name: "edit" },
|
|
158
|
+
{ name: "write" },
|
|
159
|
+
{ name: "find" },
|
|
160
|
+
{ name: "grep" },
|
|
161
|
+
{ name: "ls" },
|
|
162
|
+
]),
|
|
163
|
+
},
|
|
164
|
+
});
|
|
165
|
+
await handler.handle(makeEvent(), makeCtx());
|
|
166
|
+
expect(toolRegistry.setActive).toHaveBeenCalledWith([
|
|
167
|
+
"read",
|
|
168
|
+
"bash",
|
|
169
|
+
"edit",
|
|
170
|
+
"write",
|
|
171
|
+
]);
|
|
172
|
+
});
|
|
173
|
+
|
|
146
174
|
it("calls setActive once across repeated calls with the same allowed tools", async () => {
|
|
147
175
|
const { handler, toolRegistry } = makeSetup({
|
|
148
176
|
toolRegistry: {
|
|
149
|
-
|
|
177
|
+
getActive: vi.fn().mockReturnValue(["read"]),
|
|
150
178
|
},
|
|
151
179
|
});
|
|
152
180
|
await handler.handle(makeEvent(), makeCtx());
|
|
@@ -168,3 +168,57 @@ describe("describeExternalDirectoryGate", () => {
|
|
|
168
168
|
expect(result.logContext.message).toBeDefined();
|
|
169
169
|
});
|
|
170
170
|
});
|
|
171
|
+
|
|
172
|
+
// Extension and MCP tools are now external-directory gated (#352) ───────────
|
|
173
|
+
|
|
174
|
+
describe("describeExternalDirectoryGate — extension and MCP tools (#352)", () => {
|
|
175
|
+
it("gates an extension tool with an external input.path", () => {
|
|
176
|
+
const result = describeExternalDirectoryGate(
|
|
177
|
+
makeTcc({
|
|
178
|
+
toolName: "my-ext",
|
|
179
|
+
input: { path: "/outside/project/file.ts" },
|
|
180
|
+
}),
|
|
181
|
+
["/test/agent"],
|
|
182
|
+
);
|
|
183
|
+
expect(isGateDescriptor(result)).toBe(true);
|
|
184
|
+
expect((result as GateDescriptor).surface).toBe("external_directory");
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("gates an MCP tool with an external arguments.path", () => {
|
|
188
|
+
const result = describeExternalDirectoryGate(
|
|
189
|
+
makeTcc({
|
|
190
|
+
toolName: "mcp",
|
|
191
|
+
input: { arguments: { path: "/outside/project/file.ts" } },
|
|
192
|
+
}),
|
|
193
|
+
["/test/agent"],
|
|
194
|
+
);
|
|
195
|
+
expect(isGateDescriptor(result)).toBe(true);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("uses a registered extractor's external path for a custom-shaped tool", () => {
|
|
199
|
+
const extractors = {
|
|
200
|
+
get: (name: string) =>
|
|
201
|
+
name === "ffgrep"
|
|
202
|
+
? (input: Record<string, unknown>) =>
|
|
203
|
+
typeof input.target === "string" ? input.target : undefined
|
|
204
|
+
: undefined,
|
|
205
|
+
};
|
|
206
|
+
const result = describeExternalDirectoryGate(
|
|
207
|
+
makeTcc({ toolName: "ffgrep", input: { target: "/outside/project/x" } }),
|
|
208
|
+
["/test/agent"],
|
|
209
|
+
extractors,
|
|
210
|
+
);
|
|
211
|
+
expect(isGateDescriptor(result)).toBe(true);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("returns null for an extension tool whose path is inside cwd", () => {
|
|
215
|
+
const result = describeExternalDirectoryGate(
|
|
216
|
+
makeTcc({
|
|
217
|
+
toolName: "my-ext",
|
|
218
|
+
input: { path: "/test/project/src/x.ts" },
|
|
219
|
+
}),
|
|
220
|
+
["/test/agent"],
|
|
221
|
+
);
|
|
222
|
+
expect(result).toBeNull();
|
|
223
|
+
});
|
|
224
|
+
});
|
|
@@ -206,3 +206,75 @@ describe("describePathGate — home-relative paths", () => {
|
|
|
206
206
|
expect(result).toBeNull();
|
|
207
207
|
});
|
|
208
208
|
});
|
|
209
|
+
|
|
210
|
+
// Extension and MCP tools are now path-gated (#352) ──────────────────────────
|
|
211
|
+
|
|
212
|
+
describe("describePathGate — extension and MCP tools (#352)", () => {
|
|
213
|
+
function extractorLookup(toolName: string, key: string) {
|
|
214
|
+
return {
|
|
215
|
+
get: (name: string) =>
|
|
216
|
+
name === toolName
|
|
217
|
+
? (input: Record<string, unknown>) =>
|
|
218
|
+
typeof input[key] === "string" ? input[key] : undefined
|
|
219
|
+
: undefined,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
it("gates an extension tool that exposes input.path", () => {
|
|
224
|
+
const resolver = makeResolver(
|
|
225
|
+
makeCheckResult({ state: "deny", matchedPattern: "*.env" }),
|
|
226
|
+
);
|
|
227
|
+
const result = describePathGate(
|
|
228
|
+
makeTcc({ toolName: "my-ext", input: { path: ".env" } }),
|
|
229
|
+
resolver,
|
|
230
|
+
);
|
|
231
|
+
expect(isGateDescriptor(result)).toBe(true);
|
|
232
|
+
expect(resolver.resolve).toHaveBeenCalledWith(
|
|
233
|
+
"path",
|
|
234
|
+
{ path: ".env" },
|
|
235
|
+
undefined,
|
|
236
|
+
);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it("gates an MCP tool via arguments.path", () => {
|
|
240
|
+
const resolver = makeResolver(
|
|
241
|
+
makeCheckResult({ state: "deny", matchedPattern: "*.env" }),
|
|
242
|
+
);
|
|
243
|
+
const result = describePathGate(
|
|
244
|
+
makeTcc({ toolName: "mcp", input: { arguments: { path: ".env" } } }),
|
|
245
|
+
resolver,
|
|
246
|
+
);
|
|
247
|
+
expect(isGateDescriptor(result)).toBe(true);
|
|
248
|
+
expect(resolver.resolve).toHaveBeenCalledWith(
|
|
249
|
+
"path",
|
|
250
|
+
{ path: ".env" },
|
|
251
|
+
undefined,
|
|
252
|
+
);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("uses a registered extractor's path for a custom-shaped tool", () => {
|
|
256
|
+
const resolver = makeResolver(
|
|
257
|
+
makeCheckResult({ state: "deny", matchedPattern: "*" }),
|
|
258
|
+
);
|
|
259
|
+
describePathGate(
|
|
260
|
+
makeTcc({ toolName: "ffgrep", input: { target: "/etc/passwd" } }),
|
|
261
|
+
resolver,
|
|
262
|
+
extractorLookup("ffgrep", "target"),
|
|
263
|
+
);
|
|
264
|
+
expect(resolver.resolve).toHaveBeenCalledWith(
|
|
265
|
+
"path",
|
|
266
|
+
{ path: "/etc/passwd" },
|
|
267
|
+
undefined,
|
|
268
|
+
);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it("returns null for an extension tool without a path", () => {
|
|
272
|
+
const resolver = makeResolver();
|
|
273
|
+
const result = describePathGate(
|
|
274
|
+
makeTcc({ toolName: "my-ext", input: { other: true } }),
|
|
275
|
+
resolver,
|
|
276
|
+
);
|
|
277
|
+
expect(result).toBeNull();
|
|
278
|
+
expect(resolver.resolve).not.toHaveBeenCalled();
|
|
279
|
+
});
|
|
280
|
+
});
|
|
@@ -186,4 +186,67 @@ describe("ToolCallGatePipeline", () => {
|
|
|
186
186
|
expect(mockBashProgramParse).not.toHaveBeenCalled();
|
|
187
187
|
});
|
|
188
188
|
});
|
|
189
|
+
|
|
190
|
+
// ── customExtractors threading (#352) ────────────────────────────────────
|
|
191
|
+
|
|
192
|
+
describe("evaluate — customExtractors threading (#352)", () => {
|
|
193
|
+
// Deny only the cross-cutting `path` surface; allow everything else, so a
|
|
194
|
+
// block can only come from the path gate seeing the extracted path.
|
|
195
|
+
function pathDenyingResolver() {
|
|
196
|
+
const resolver = makeResolver();
|
|
197
|
+
resolver.resolve.mockImplementation((surface) =>
|
|
198
|
+
surface === "path"
|
|
199
|
+
? makeCheckResult({ state: "deny", matchedPattern: "*" })
|
|
200
|
+
: makeCheckResult(),
|
|
201
|
+
);
|
|
202
|
+
return resolver;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const extractors = {
|
|
206
|
+
get: (name: string) =>
|
|
207
|
+
name === "ffgrep"
|
|
208
|
+
? (input: Record<string, unknown>) =>
|
|
209
|
+
typeof input.target === "string" ? input.target : undefined
|
|
210
|
+
: undefined,
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
it("forwards extractors so a custom-shaped tool is path-gated", async () => {
|
|
214
|
+
const resolver = pathDenyingResolver();
|
|
215
|
+
const inputs = makeGateInputs();
|
|
216
|
+
const { runner } = makeGateRunner();
|
|
217
|
+
const pipeline = new ToolCallGatePipeline(
|
|
218
|
+
resolver,
|
|
219
|
+
inputs,
|
|
220
|
+
undefined,
|
|
221
|
+
extractors,
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
const result = await pipeline.evaluate(
|
|
225
|
+
makeTcc({
|
|
226
|
+
toolName: "ffgrep",
|
|
227
|
+
input: { target: "/test/project/secret.env" },
|
|
228
|
+
}),
|
|
229
|
+
runner,
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
expect(result).toMatchObject({ action: "block" });
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("without extractors the custom-shaped tool is not path-gated", async () => {
|
|
236
|
+
const resolver = pathDenyingResolver();
|
|
237
|
+
const inputs = makeGateInputs();
|
|
238
|
+
const { runner } = makeGateRunner();
|
|
239
|
+
const pipeline = new ToolCallGatePipeline(resolver, inputs);
|
|
240
|
+
|
|
241
|
+
const result = await pipeline.evaluate(
|
|
242
|
+
makeTcc({
|
|
243
|
+
toolName: "ffgrep",
|
|
244
|
+
input: { target: "/test/project/secret.env" },
|
|
245
|
+
}),
|
|
246
|
+
runner,
|
|
247
|
+
);
|
|
248
|
+
|
|
249
|
+
expect(result).toEqual({ action: "allow" });
|
|
250
|
+
});
|
|
251
|
+
});
|
|
189
252
|
});
|
|
@@ -42,6 +42,8 @@ export interface FakePi {
|
|
|
42
42
|
fire(event: string, input?: unknown, ctx?: unknown): Promise<unknown>;
|
|
43
43
|
/** Minimal tool registry — returns the configured tool names. */
|
|
44
44
|
getAllTools(): { name: string }[];
|
|
45
|
+
/** Active tool names (`pi.getActiveTools()` shape — bare strings). */
|
|
46
|
+
getActiveTools(): string[];
|
|
45
47
|
setActiveTools(names: string[]): void;
|
|
46
48
|
}
|
|
47
49
|
|
|
@@ -80,6 +82,9 @@ export function makeFakePi(options: MakeFakePiOptions = {}): FakePi {
|
|
|
80
82
|
getAllTools(): { name: string }[] {
|
|
81
83
|
return toolNames.map((name) => ({ name }));
|
|
82
84
|
},
|
|
85
|
+
getActiveTools(): string[] {
|
|
86
|
+
return [...toolNames];
|
|
87
|
+
},
|
|
83
88
|
setActiveTools: vi.fn(),
|
|
84
89
|
// ── ExtensionAPI methods the factory touches (recorded) ────────────────
|
|
85
90
|
on(event: string, handler: RecordedHandler): void {
|
package/test/path-utils.test.ts
CHANGED
|
@@ -23,6 +23,7 @@ vi.mock("node:fs", () => ({
|
|
|
23
23
|
import {
|
|
24
24
|
canonicalNormalizePathForComparison,
|
|
25
25
|
getPathBearingToolPath,
|
|
26
|
+
getToolInputPath,
|
|
26
27
|
isPathOutsideWorkingDirectory,
|
|
27
28
|
isPathWithinDirectory,
|
|
28
29
|
isPiInfrastructureRead,
|
|
@@ -32,6 +33,7 @@ import {
|
|
|
32
33
|
READ_ONLY_PATH_BEARING_TOOLS,
|
|
33
34
|
SAFE_SYSTEM_PATHS,
|
|
34
35
|
} from "#src/path-utils";
|
|
36
|
+
import type { ToolAccessExtractorLookup } from "#src/tool-access-extractor-registry";
|
|
35
37
|
|
|
36
38
|
describe("normalizePathForComparison", () => {
|
|
37
39
|
const cwd = "/projects/my-app";
|
|
@@ -244,6 +246,67 @@ describe("getPathBearingToolPath", () => {
|
|
|
244
246
|
});
|
|
245
247
|
});
|
|
246
248
|
|
|
249
|
+
describe("getToolInputPath", () => {
|
|
250
|
+
function lookupOf(
|
|
251
|
+
toolName: string,
|
|
252
|
+
extractor: (input: Record<string, unknown>) => string | undefined,
|
|
253
|
+
): ToolAccessExtractorLookup {
|
|
254
|
+
return {
|
|
255
|
+
get: (name) => (name === toolName ? extractor : undefined),
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
test("returns input.path for a built-in path-bearing tool", () => {
|
|
260
|
+
expect(getToolInputPath("read", { path: "/src/foo.ts" })).toBe(
|
|
261
|
+
"/src/foo.ts",
|
|
262
|
+
);
|
|
263
|
+
expect(getToolInputPath("write", { path: "/src/bar.ts" })).toBe(
|
|
264
|
+
"/src/bar.ts",
|
|
265
|
+
);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
test("returns null for bash", () => {
|
|
269
|
+
expect(getToolInputPath("bash", { path: "/src/foo.ts" })).toBeNull();
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test("returns the MCP arguments.path for an mcp call", () => {
|
|
273
|
+
expect(getToolInputPath("mcp", { arguments: { path: "/etc/hosts" } })).toBe(
|
|
274
|
+
"/etc/hosts",
|
|
275
|
+
);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test("returns null for an mcp call without an arguments.path", () => {
|
|
279
|
+
expect(getToolInputPath("mcp", { arguments: { query: "x" } })).toBeNull();
|
|
280
|
+
expect(getToolInputPath("mcp", {})).toBeNull();
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
test("defaults to input.path for an unregistered extension tool", () => {
|
|
284
|
+
expect(getToolInputPath("my-ext", { path: "/work/file.txt" })).toBe(
|
|
285
|
+
"/work/file.txt",
|
|
286
|
+
);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
test("returns null for an extension tool without a path", () => {
|
|
290
|
+
expect(getToolInputPath("my-ext", { other: true })).toBeNull();
|
|
291
|
+
expect(getToolInputPath("my-ext", { path: "" })).toBeNull();
|
|
292
|
+
expect(getToolInputPath("my-ext", null)).toBeNull();
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
test("uses a registered extractor's path over the default convention", () => {
|
|
296
|
+
const extractors = lookupOf("ffgrep", (input) =>
|
|
297
|
+
typeof input.target === "string" ? input.target : undefined,
|
|
298
|
+
);
|
|
299
|
+
expect(
|
|
300
|
+
getToolInputPath("ffgrep", { target: "/etc/passwd" }, extractors),
|
|
301
|
+
).toBe("/etc/passwd");
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
test("returns null when a registered extractor declines", () => {
|
|
305
|
+
const extractors = lookupOf("ffgrep", () => undefined);
|
|
306
|
+
expect(getToolInputPath("ffgrep", { target: "x" }, extractors)).toBeNull();
|
|
307
|
+
});
|
|
308
|
+
});
|
|
309
|
+
|
|
247
310
|
describe("isPathOutsideWorkingDirectory", () => {
|
|
248
311
|
const cwd = "/projects/my-app";
|
|
249
312
|
|
|
@@ -363,6 +363,7 @@ describe("piPermissionSystemExtension ready event wiring", () => {
|
|
|
363
363
|
),
|
|
364
364
|
registerCommand: vi.fn(),
|
|
365
365
|
getAllTools: vi.fn().mockReturnValue([]),
|
|
366
|
+
getActiveTools: vi.fn().mockReturnValue([]),
|
|
366
367
|
setActiveTools: vi.fn(),
|
|
367
368
|
registerProvider: vi.fn(),
|
|
368
369
|
events: { emit: emitSpy, on: vi.fn().mockReturnValue(() => undefined) },
|
|
@@ -3,6 +3,7 @@ import type { ScopedPermissionManager } from "#src/permission-manager";
|
|
|
3
3
|
import { LocalPermissionsService } from "#src/permissions-service";
|
|
4
4
|
import type { Ruleset } from "#src/rule";
|
|
5
5
|
import type { SessionRules } from "#src/session-rules";
|
|
6
|
+
import type { ToolAccessExtractorRegistrar } from "#src/tool-access-extractor-registry";
|
|
6
7
|
import type {
|
|
7
8
|
ToolInputFormatter,
|
|
8
9
|
ToolInputFormatterRegistrar,
|
|
@@ -39,22 +40,40 @@ function makeFormatterRegistry(): ToolInputFormatterRegistrar {
|
|
|
39
40
|
};
|
|
40
41
|
}
|
|
41
42
|
|
|
43
|
+
function makeAccessExtractorRegistry(): ToolAccessExtractorRegistrar {
|
|
44
|
+
return {
|
|
45
|
+
register: vi
|
|
46
|
+
.fn<ToolAccessExtractorRegistrar["register"]>()
|
|
47
|
+
.mockReturnValue(vi.fn()),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
42
51
|
function makeService(overrides?: {
|
|
43
52
|
permissionManager?: ScopedPermissionManager;
|
|
44
53
|
sessionRules?: Pick<SessionRules, "getRuleset">;
|
|
45
54
|
formatterRegistry?: ToolInputFormatterRegistrar;
|
|
55
|
+
accessExtractorRegistry?: ToolAccessExtractorRegistrar;
|
|
46
56
|
}) {
|
|
47
57
|
const permissionManager =
|
|
48
58
|
overrides?.permissionManager ?? makeFakePermissionManager();
|
|
49
59
|
const sessionRules = overrides?.sessionRules ?? makeSessionRules();
|
|
50
60
|
const formatterRegistry =
|
|
51
61
|
overrides?.formatterRegistry ?? makeFormatterRegistry();
|
|
62
|
+
const accessExtractorRegistry =
|
|
63
|
+
overrides?.accessExtractorRegistry ?? makeAccessExtractorRegistry();
|
|
52
64
|
const service = new LocalPermissionsService(
|
|
53
65
|
permissionManager,
|
|
54
66
|
sessionRules,
|
|
55
67
|
formatterRegistry,
|
|
68
|
+
accessExtractorRegistry,
|
|
56
69
|
);
|
|
57
|
-
return {
|
|
70
|
+
return {
|
|
71
|
+
service,
|
|
72
|
+
permissionManager,
|
|
73
|
+
sessionRules,
|
|
74
|
+
formatterRegistry,
|
|
75
|
+
accessExtractorRegistry,
|
|
76
|
+
};
|
|
58
77
|
}
|
|
59
78
|
|
|
60
79
|
// ── tests ──────────────────────────────────────────────────────────────────
|
|
@@ -141,3 +160,18 @@ describe("registerToolInputFormatter", () => {
|
|
|
141
160
|
expect(result).toBe(unsub);
|
|
142
161
|
});
|
|
143
162
|
});
|
|
163
|
+
|
|
164
|
+
describe("registerToolAccessExtractor", () => {
|
|
165
|
+
it("delegates to accessExtractorRegistry.register and returns the unsubscribe function", () => {
|
|
166
|
+
const unsub = vi.fn();
|
|
167
|
+
const { service, accessExtractorRegistry } = makeService();
|
|
168
|
+
vi.mocked(accessExtractorRegistry.register).mockReturnValue(unsub);
|
|
169
|
+
const extractor = vi.fn();
|
|
170
|
+
const result = service.registerToolAccessExtractor("ffgrep", extractor);
|
|
171
|
+
expect(accessExtractorRegistry.register).toHaveBeenCalledWith(
|
|
172
|
+
"ffgrep",
|
|
173
|
+
extractor,
|
|
174
|
+
);
|
|
175
|
+
expect(result).toBe(unsub);
|
|
176
|
+
});
|
|
177
|
+
});
|
package/test/service.test.ts
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
publishPermissionsService,
|
|
7
7
|
unpublishPermissionsService,
|
|
8
8
|
} from "#src/service";
|
|
9
|
+
import { ToolAccessExtractorRegistry } from "#src/tool-access-extractor-registry";
|
|
9
10
|
import { ToolInputFormatterRegistry } from "#src/tool-input-formatter-registry";
|
|
10
11
|
import type { PermissionCheckResult } from "#src/types";
|
|
11
12
|
|
|
@@ -18,6 +19,7 @@ function makeService(
|
|
|
18
19
|
checkPermission: vi.fn(),
|
|
19
20
|
getToolPermission: vi.fn(),
|
|
20
21
|
registerToolInputFormatter: vi.fn(),
|
|
22
|
+
registerToolAccessExtractor: vi.fn(),
|
|
21
23
|
...overrides,
|
|
22
24
|
};
|
|
23
25
|
}
|
|
@@ -155,6 +157,7 @@ describe("service adapter delegation", () => {
|
|
|
155
157
|
return getToolPermissionFn(toolName, agentName);
|
|
156
158
|
},
|
|
157
159
|
registerToolInputFormatter: vi.fn(),
|
|
160
|
+
registerToolAccessExtractor: vi.fn(),
|
|
158
161
|
};
|
|
159
162
|
|
|
160
163
|
publishPermissionsService(service);
|
|
@@ -177,6 +180,7 @@ describe("service adapter delegation", () => {
|
|
|
177
180
|
return getToolPermissionFn(toolName, agentName);
|
|
178
181
|
},
|
|
179
182
|
registerToolInputFormatter: vi.fn(),
|
|
183
|
+
registerToolAccessExtractor: vi.fn(),
|
|
180
184
|
};
|
|
181
185
|
|
|
182
186
|
publishPermissionsService(service);
|
|
@@ -253,3 +257,52 @@ describe("registerToolInputFormatter delegation", () => {
|
|
|
253
257
|
).toThrow("my-tool");
|
|
254
258
|
});
|
|
255
259
|
});
|
|
260
|
+
|
|
261
|
+
// ── registerToolAccessExtractor delegation (#352) ────────────────────────
|
|
262
|
+
|
|
263
|
+
describe("registerToolAccessExtractor delegation", () => {
|
|
264
|
+
afterEach(() => {
|
|
265
|
+
const current = getPermissionsService();
|
|
266
|
+
if (current) {
|
|
267
|
+
unpublishPermissionsService(current);
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it("delegates to the registry and returns its disposer", () => {
|
|
272
|
+
const registry = new ToolAccessExtractorRegistry();
|
|
273
|
+
const extractor = () => "/etc/hosts";
|
|
274
|
+
|
|
275
|
+
const service = makeService({
|
|
276
|
+
registerToolAccessExtractor(toolName, ext) {
|
|
277
|
+
return registry.register(toolName, ext);
|
|
278
|
+
},
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
publishPermissionsService(service);
|
|
282
|
+
const dispose = getPermissionsService()!.registerToolAccessExtractor(
|
|
283
|
+
"ffgrep",
|
|
284
|
+
extractor,
|
|
285
|
+
);
|
|
286
|
+
|
|
287
|
+
expect(registry.get("ffgrep")).toBe(extractor);
|
|
288
|
+
|
|
289
|
+
dispose();
|
|
290
|
+
expect(registry.get("ffgrep")).toBeUndefined();
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it("throws when an extractor is already registered for the tool name", () => {
|
|
294
|
+
const registry = new ToolAccessExtractorRegistry();
|
|
295
|
+
registry.register("ffgrep", () => undefined);
|
|
296
|
+
|
|
297
|
+
const service = makeService({
|
|
298
|
+
registerToolAccessExtractor(toolName, ext) {
|
|
299
|
+
return registry.register(toolName, ext);
|
|
300
|
+
},
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
publishPermissionsService(service);
|
|
304
|
+
expect(() =>
|
|
305
|
+
getPermissionsService()!.registerToolAccessExtractor("ffgrep", () => ""),
|
|
306
|
+
).toThrow("ffgrep");
|
|
307
|
+
});
|
|
308
|
+
});
|
|
@@ -56,6 +56,7 @@ describe("session_start handler consolidation", () => {
|
|
|
56
56
|
},
|
|
57
57
|
registerCommand: (): void => {},
|
|
58
58
|
getAllTools: (): Array<{ name: string }> => [],
|
|
59
|
+
getActiveTools: (): string[] => [],
|
|
59
60
|
setActiveTools: (): void => {},
|
|
60
61
|
registerProvider: (): void => {},
|
|
61
62
|
events: {
|
|
@@ -79,6 +80,7 @@ describe("session_start handler consolidation", () => {
|
|
|
79
80
|
},
|
|
80
81
|
registerCommand: (): void => {},
|
|
81
82
|
getAllTools: (): Array<{ name: string }> => [],
|
|
83
|
+
getActiveTools: (): string[] => [],
|
|
82
84
|
setActiveTools: (): void => {},
|
|
83
85
|
registerProvider: (): void => {},
|
|
84
86
|
events: {
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
type ToolAccessExtractor,
|
|
5
|
+
ToolAccessExtractorRegistry,
|
|
6
|
+
} from "#src/tool-access-extractor-registry";
|
|
7
|
+
|
|
8
|
+
const noopExtractor: ToolAccessExtractor = () => "/tmp/x";
|
|
9
|
+
|
|
10
|
+
describe("ToolAccessExtractorRegistry", () => {
|
|
11
|
+
describe("register", () => {
|
|
12
|
+
test("stores an extractor so get() returns it", () => {
|
|
13
|
+
const registry = new ToolAccessExtractorRegistry();
|
|
14
|
+
registry.register("my-tool", noopExtractor);
|
|
15
|
+
expect(registry.get("my-tool")).toBe(noopExtractor);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("returns a disposer that removes the extractor", () => {
|
|
19
|
+
const registry = new ToolAccessExtractorRegistry();
|
|
20
|
+
const dispose = registry.register("my-tool", noopExtractor);
|
|
21
|
+
dispose();
|
|
22
|
+
expect(registry.get("my-tool")).toBeUndefined();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("throws when an extractor is already registered for the same tool name", () => {
|
|
26
|
+
const registry = new ToolAccessExtractorRegistry();
|
|
27
|
+
registry.register("my-tool", noopExtractor);
|
|
28
|
+
expect(() => registry.register("my-tool", () => undefined)).toThrow(
|
|
29
|
+
"my-tool",
|
|
30
|
+
);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("allows registering different tool names independently", () => {
|
|
34
|
+
const registry = new ToolAccessExtractorRegistry();
|
|
35
|
+
const extractorA: ToolAccessExtractor = () => "/a";
|
|
36
|
+
const extractorB: ToolAccessExtractor = () => "/b";
|
|
37
|
+
registry.register("tool-a", extractorA);
|
|
38
|
+
registry.register("tool-b", extractorB);
|
|
39
|
+
expect(registry.get("tool-a")).toBe(extractorA);
|
|
40
|
+
expect(registry.get("tool-b")).toBe(extractorB);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe("disposer identity guard", () => {
|
|
45
|
+
test("stale disposer does not evict a later registration", () => {
|
|
46
|
+
const registry = new ToolAccessExtractorRegistry();
|
|
47
|
+
const first: ToolAccessExtractor = () => "/first";
|
|
48
|
+
const second: ToolAccessExtractor = () => "/second";
|
|
49
|
+
|
|
50
|
+
const disposeFirst = registry.register("my-tool", first);
|
|
51
|
+
disposeFirst(); // removes first
|
|
52
|
+
|
|
53
|
+
registry.register("my-tool", second); // second registration is now valid
|
|
54
|
+
disposeFirst(); // calling stale disposer again — must not remove second
|
|
55
|
+
|
|
56
|
+
expect(registry.get("my-tool")).toBe(second);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe("get", () => {
|
|
61
|
+
test("returns undefined for an unregistered tool name", () => {
|
|
62
|
+
const registry = new ToolAccessExtractorRegistry();
|
|
63
|
+
expect(registry.get("unknown")).toBeUndefined();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("the registered extractor is callable and returns its path", () => {
|
|
67
|
+
const registry = new ToolAccessExtractorRegistry();
|
|
68
|
+
const extractor: ToolAccessExtractor = (input) =>
|
|
69
|
+
typeof input.target === "string" ? input.target : undefined;
|
|
70
|
+
registry.register("ffgrep", extractor);
|
|
71
|
+
expect(registry.get("ffgrep")?.({ target: "/etc/hosts" })).toBe(
|
|
72
|
+
"/etc/hosts",
|
|
73
|
+
);
|
|
74
|
+
expect(registry.get("ffgrep")?.({ other: true })).toBeUndefined();
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
});
|