@gotgenes/pi-permission-system 11.0.0 → 13.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 +43 -0
- package/README.md +9 -11
- package/package.json +1 -1
- package/schemas/permissions.schema.json +1 -1
- package/src/config-modal.ts +3 -7
- package/src/extension-config.ts +11 -25
- package/src/handlers/gates/bash-path-extractor.ts +0 -12
- package/src/handlers/gates/bash-path.ts +12 -10
- package/src/handlers/gates/bash-program.ts +52 -11
- package/src/handlers/gates/bash-token-classification.ts +1 -1
- 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 +8 -2
- package/src/input-normalizer.ts +17 -11
- package/src/path-utils.ts +122 -0
- package/src/permission-manager.ts +81 -17
- package/src/permission-resolver.ts +24 -0
- package/src/permission-session.ts +2 -4
- package/src/permissions-service.ts +12 -0
- package/src/rule.ts +61 -11
- package/src/service.ts +24 -0
- package/src/tool-access-extractor-registry.ts +68 -0
- package/test/bash-external-directory.test.ts +1 -81
- package/test/composition-root.test.ts +36 -0
- package/test/config-modal.test.ts +7 -10
- package/test/config-pipeline.test.ts +90 -0
- package/test/extension-config.test.ts +0 -58
- package/test/handlers/gates/bash-path.test.ts +45 -2
- package/test/handlers/gates/bash-program.test.ts +44 -11
- 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 +64 -1
- package/test/helpers/gate-fixtures.ts +23 -3
- package/test/helpers/handler-fixtures.ts +14 -2
- package/test/helpers/session-fixtures.ts +14 -0
- package/test/input-normalizer.test.ts +52 -0
- package/test/path-utils.test.ts +135 -0
- package/test/permission-manager-unified.test.ts +134 -0
- package/test/permission-resolver.test.ts +69 -0
- package/test/permissions-service.test.ts +35 -1
- package/test/rule.test.ts +74 -1
- package/test/service-lifecycle.test.ts +1 -0
- package/test/service.test.ts +53 -0
- package/test/tool-access-extractor-registry.test.ts +77 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,49 @@ 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
|
+
## [13.0.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v12.0.0...pi-permission-system-v13.0.0) (2026-06-12)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### ⚠ BREAKING CHANGES
|
|
12
|
+
|
|
13
|
+
* A relative bash path token now also matches absolute allowlist rules naming the same file, resolved against the effective directory after literal cd commands. A token under a config like `path: { "*": "ask", "/workspace/project/*": "allow" }` moves from `ask` to `allow`. Tokens after a non-literal cd (e.g. cd "$DIR") stay conservative and match only their literal form.
|
|
14
|
+
* When Pi's working directory is known, a relative path input now also matches absolute allowlist rules naming the same file. A config like `path: { "*": "ask", "/workspace/project/*": "allow" }` moves a relative `src/App.jsx` from `ask` to `allow`. To keep tighter control, narrow the allowlist patterns or add an explicit `path` deny for the sensitive paths.
|
|
15
|
+
|
|
16
|
+
### Features
|
|
17
|
+
|
|
18
|
+
* add alias-aware evaluateAnyValue ([#393](https://github.com/gotgenes/pi-packages/issues/393)) ([2b7d240](https://github.com/gotgenes/pi-packages/commit/2b7d24091fbeb078bcfbc363bc0062199ee1de24))
|
|
19
|
+
* add cd-aware pathRuleCandidates to BashProgram ([#393](https://github.com/gotgenes/pi-packages/issues/393)) ([102a491](https://github.com/gotgenes/pi-packages/commit/102a491ef73e225a6a93008195ff958e4c5bd315))
|
|
20
|
+
* add path-policy value derivation ([#393](https://github.com/gotgenes/pi-packages/issues/393)) ([d34e57f](https://github.com/gotgenes/pi-packages/commit/d34e57fe96c74b2ba87e9d0ebe9be5055db0855f))
|
|
21
|
+
* add resolvePathPolicy resolver method ([#393](https://github.com/gotgenes/pi-packages/issues/393)) ([8ec81da](https://github.com/gotgenes/pi-packages/commit/8ec81da65e7f994beb5f65bead8b11174277fa53))
|
|
22
|
+
* match relative path inputs against absolute allowlists ([#393](https://github.com/gotgenes/pi-packages/issues/393)) ([6d0c564](https://github.com/gotgenes/pi-packages/commit/6d0c564d1d7b48227898d0be5fbbf5c91cc7ca89))
|
|
23
|
+
* normalize path inputs to cwd-aware policy values ([#393](https://github.com/gotgenes/pi-packages/issues/393)) ([3c2784f](https://github.com/gotgenes/pi-packages/commit/3c2784fc2199b4903a0aaa8a22a971c1bfb4969d))
|
|
24
|
+
* resolve bash path tokens with cd-aware policy values ([#393](https://github.com/gotgenes/pi-packages/issues/393)) ([7bcdbe7](https://github.com/gotgenes/pi-packages/commit/7bcdbe708a29448cbfda4a76b7e8917795fbb741))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
### Documentation
|
|
28
|
+
|
|
29
|
+
* document cwd-aware path policy matching ([#393](https://github.com/gotgenes/pi-packages/issues/393)) ([8ab53a2](https://github.com/gotgenes/pi-packages/commit/8ab53a2de6c4deea5bbcd9c72a36ec0943e1a69a))
|
|
30
|
+
* **pi-permission-system:** update Development section to current scripts and tooling ([ebda301](https://github.com/gotgenes/pi-packages/commit/ebda301798290f528f930917b6792a5c21379a5d))
|
|
31
|
+
|
|
32
|
+
## [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)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
### ⚠ BREAKING CHANGES
|
|
36
|
+
|
|
37
|
+
* 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.
|
|
38
|
+
|
|
39
|
+
### Features
|
|
40
|
+
|
|
41
|
+
* add extensible tool input path extraction ([#352](https://github.com/gotgenes/pi-packages/issues/352)) ([3a54ea1](https://github.com/gotgenes/pi-packages/commit/3a54ea16be4d621bd7474f7a728d97ce9781a994))
|
|
42
|
+
* add tool access extractor registry ([#352](https://github.com/gotgenes/pi-packages/issues/352)) ([7a34f01](https://github.com/gotgenes/pi-packages/commit/7a34f0187f6b3fbb75e056082f04d3b805a37c8a))
|
|
43
|
+
* expose registerToolAccessExtractor via permissions service ([#352](https://github.com/gotgenes/pi-packages/issues/352)) ([5e02c16](https://github.com/gotgenes/pi-packages/commit/5e02c163b212adf9648a2631e4788f030539a36a))
|
|
44
|
+
* 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))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
### Documentation
|
|
48
|
+
|
|
49
|
+
* 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))
|
|
50
|
+
|
|
8
51
|
## [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)
|
|
9
52
|
|
|
10
53
|
|
package/README.md
CHANGED
|
@@ -65,11 +65,12 @@ 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.
|
|
73
|
+
When Pi's current working directory is known, relative path inputs also match their cwd-normalized absolute form, so `src/App.jsx` can match both `src/*` and `/workspace/project/*`.
|
|
73
74
|
|
|
74
75
|
Four layers compose with most-restrictive-wins: `path` (cross-cutting) → `external_directory` (CWD boundary) → per-tool patterns → `bash` command patterns.
|
|
75
76
|
|
|
@@ -104,19 +105,16 @@ For the full reference — all surfaces, runtime knobs, per-agent overrides, mer
|
|
|
104
105
|
## Development
|
|
105
106
|
|
|
106
107
|
```bash
|
|
107
|
-
pnpm run
|
|
108
|
-
pnpm run lint # Biome
|
|
109
|
-
pnpm run lint:
|
|
110
|
-
pnpm run
|
|
111
|
-
pnpm run
|
|
112
|
-
pnpm run format # Biome format --write
|
|
113
|
-
pnpm run test # Run tests from ./tests
|
|
114
|
-
pnpm run check # build + lint:all + test
|
|
108
|
+
pnpm run check # Type-check TypeScript (no emit)
|
|
109
|
+
pnpm run lint # Biome + ESLint + lint:md
|
|
110
|
+
pnpm run lint:md # rumdl on README and docs
|
|
111
|
+
pnpm run test # Run tests from ./test
|
|
112
|
+
pnpm run test:watch # Run tests in watch mode
|
|
115
113
|
```
|
|
116
114
|
|
|
117
115
|
### Pre-commit hooks
|
|
118
116
|
|
|
119
|
-
This project uses [prek](https://prek.j178.dev/) to run Biome and
|
|
117
|
+
This project uses [prek](https://prek.j178.dev/) to run Biome, ESLint, and rumdl on staged files before each commit.
|
|
120
118
|
Run `pnpm install` to set up hooks automatically.
|
|
121
119
|
|
|
122
120
|
## Acknowledgments
|
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\nWhen Pi's current working directory is known, relative path inputs also match their cwd-normalized absolute form, so `src/App.jsx` can match both `src/*` and `/workspace/project/*`. Bash path tokens use the effective directory after literal `cd` commands for this matching; non-literal `cd \"$DIR\"` style commands remain conservative.\n\nThe `path` surface is a cross-cutting gate that applies to **all** file access: Pi tools, bash commands, MCP calls (via `input.arguments.path`), and extension tools (via `input.path` or a registered access extractor). A `path` deny cannot be overridden by a per-tool allow. Use it to protect sensitive files (`.env`, `~/.ssh/*`) from all path-aware tools at once.\n\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 '*'.",
|
package/src/config-modal.ts
CHANGED
|
@@ -16,10 +16,8 @@ interface PermissionSystemConfigController {
|
|
|
16
16
|
config: CommandConfigStore;
|
|
17
17
|
/** Precomputed global config file path. */
|
|
18
18
|
configPath: string;
|
|
19
|
-
/** Returns the composed config-layer ruleset for
|
|
20
|
-
|
|
21
|
-
/** Provides the active agent name for scoped rule lookup. */
|
|
22
|
-
session: { readonly lastKnownActiveAgentName: string | null };
|
|
19
|
+
/** Returns the composed config-layer ruleset for the active agent scope. */
|
|
20
|
+
getActiveAgentConfigRules(): Ruleset;
|
|
23
21
|
}
|
|
24
22
|
|
|
25
23
|
const ON_OFF = ["on", "off"];
|
|
@@ -206,9 +204,7 @@ function handleArgs(
|
|
|
206
204
|
}
|
|
207
205
|
|
|
208
206
|
if (normalized === "show") {
|
|
209
|
-
const rules = controller.
|
|
210
|
-
controller.session.lastKnownActiveAgentName ?? undefined,
|
|
211
|
-
);
|
|
207
|
+
const rules = controller.getActiveAgentConfigRules();
|
|
212
208
|
ctx.ui.notify(
|
|
213
209
|
`permission-system: ${summarizeConfig(controller.config.current(), rules)}`,
|
|
214
210
|
"info",
|
package/src/extension-config.ts
CHANGED
|
@@ -2,11 +2,7 @@ import { mkdirSync } from "node:fs";
|
|
|
2
2
|
import { dirname, join } from "node:path";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
4
|
|
|
5
|
-
import {
|
|
6
|
-
normalizeOptionalPositiveInt,
|
|
7
|
-
normalizeOptionalStringArray,
|
|
8
|
-
toRecord,
|
|
9
|
-
} from "./common";
|
|
5
|
+
import type { UnifiedPermissionConfig } from "./config-loader";
|
|
10
6
|
|
|
11
7
|
export const EXTENSION_ID = "pi-permission-system";
|
|
12
8
|
|
|
@@ -51,31 +47,21 @@ export function detectMisplacedPermissionKeys(
|
|
|
51
47
|
}
|
|
52
48
|
|
|
53
49
|
export function normalizePermissionSystemConfig(
|
|
54
|
-
raw:
|
|
50
|
+
raw: UnifiedPermissionConfig,
|
|
55
51
|
): PermissionSystemExtensionConfig {
|
|
56
|
-
const record = toRecord(raw);
|
|
57
|
-
const piInfrastructureReadPaths = normalizeOptionalStringArray(
|
|
58
|
-
record.piInfrastructureReadPaths,
|
|
59
|
-
);
|
|
60
52
|
const result: PermissionSystemExtensionConfig = {
|
|
61
|
-
debugLog:
|
|
62
|
-
permissionReviewLog:
|
|
63
|
-
yoloMode:
|
|
53
|
+
debugLog: raw.debugLog === true,
|
|
54
|
+
permissionReviewLog: raw.permissionReviewLog !== false,
|
|
55
|
+
yoloMode: raw.yoloMode === true,
|
|
64
56
|
};
|
|
65
|
-
if (piInfrastructureReadPaths !== undefined) {
|
|
66
|
-
result.piInfrastructureReadPaths = piInfrastructureReadPaths;
|
|
57
|
+
if (raw.piInfrastructureReadPaths !== undefined) {
|
|
58
|
+
result.piInfrastructureReadPaths = raw.piInfrastructureReadPaths;
|
|
67
59
|
}
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
);
|
|
71
|
-
if (toolInputPreviewMaxLength !== undefined) {
|
|
72
|
-
result.toolInputPreviewMaxLength = toolInputPreviewMaxLength;
|
|
60
|
+
if (raw.toolInputPreviewMaxLength !== undefined) {
|
|
61
|
+
result.toolInputPreviewMaxLength = raw.toolInputPreviewMaxLength;
|
|
73
62
|
}
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
);
|
|
77
|
-
if (toolTextSummaryMaxLength !== undefined) {
|
|
78
|
-
result.toolTextSummaryMaxLength = toolTextSummaryMaxLength;
|
|
63
|
+
if (raw.toolTextSummaryMaxLength !== undefined) {
|
|
64
|
+
result.toolTextSummaryMaxLength = raw.toolTextSummaryMaxLength;
|
|
79
65
|
}
|
|
80
66
|
return result;
|
|
81
67
|
}
|
|
@@ -13,15 +13,3 @@ export async function extractExternalPathsFromBashCommand(
|
|
|
13
13
|
): Promise<string[]> {
|
|
14
14
|
return (await BashProgram.parse(command)).externalPaths(cwd);
|
|
15
15
|
}
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* Extract tokens from a bash command that may be file paths, using the broader
|
|
19
|
-
* filter suitable for cross-cutting `path` permission rules.
|
|
20
|
-
*
|
|
21
|
-
* Thin facade over {@link BashProgram.pathTokens}.
|
|
22
|
-
*/
|
|
23
|
-
export async function extractTokensForPathRules(
|
|
24
|
-
command: string,
|
|
25
|
-
): Promise<string[]> {
|
|
26
|
-
return (await BashProgram.parse(command)).pathTokens();
|
|
27
|
-
}
|
|
@@ -12,10 +12,12 @@ import type { ToolCallContext } from "./types";
|
|
|
12
12
|
/**
|
|
13
13
|
* Build a pure descriptor for the cross-cutting path permission gate (bash).
|
|
14
14
|
*
|
|
15
|
-
* Reads path-
|
|
16
|
-
* `path`-rule filter, accepting dot-files and relative paths).
|
|
17
|
-
*
|
|
18
|
-
*
|
|
15
|
+
* Reads path-rule candidates from the injected `BashProgram` (the broader
|
|
16
|
+
* `path`-rule filter, accepting dot-files and relative paths). Each candidate
|
|
17
|
+
* pairs the raw token with cd-aware policy values; the gate evaluates those
|
|
18
|
+
* values against the `path` permission surface and returns the most
|
|
19
|
+
* restrictive result, while prompts, logs, and session approvals use the raw
|
|
20
|
+
* token.
|
|
19
21
|
*
|
|
20
22
|
* Returns `null` when the gate does not apply (tool is not bash, no command,
|
|
21
23
|
* no tokens extracted, or all tokens evaluate to `allow`).
|
|
@@ -34,18 +36,18 @@ export function describeBashPathGate(
|
|
|
34
36
|
|
|
35
37
|
if (!bashProgram) return null;
|
|
36
38
|
|
|
37
|
-
const
|
|
38
|
-
if (
|
|
39
|
+
const candidates = bashProgram.pathRuleCandidates(tcc.cwd);
|
|
40
|
+
if (candidates.length === 0) return null;
|
|
41
|
+
const tokens = candidates.map(({ token }) => token);
|
|
39
42
|
|
|
40
43
|
// Tokens whose resolved state needs a check (deny/ask), paired with the
|
|
41
44
|
// token that produced them so the descriptor can derive its pattern.
|
|
42
45
|
const uncovered: Array<{ token: string; check: PermissionCheckResult }> = [];
|
|
43
46
|
let allSessionCovered = true;
|
|
44
47
|
|
|
45
|
-
for (const token of
|
|
46
|
-
const check = resolver.
|
|
47
|
-
|
|
48
|
-
{ path: token },
|
|
48
|
+
for (const { token, policyValues } of candidates) {
|
|
49
|
+
const check = resolver.resolvePathPolicy(
|
|
50
|
+
policyValues,
|
|
49
51
|
tcc.agentName ?? undefined,
|
|
50
52
|
);
|
|
51
53
|
|
|
@@ -6,9 +6,11 @@ import {
|
|
|
6
6
|
classifyTokenAsRuleCandidate,
|
|
7
7
|
} from "#src/handlers/gates/bash-token-classification";
|
|
8
8
|
import {
|
|
9
|
+
getPathPolicyValues,
|
|
9
10
|
isPathWithinDirectory,
|
|
10
11
|
isSafeSystemPath,
|
|
11
12
|
normalizePathForComparison,
|
|
13
|
+
normalizePathPolicyLiteral,
|
|
12
14
|
} from "#src/path-utils";
|
|
13
15
|
import type { BashCommandContext } from "#src/types";
|
|
14
16
|
|
|
@@ -98,6 +100,13 @@ interface PathCandidate {
|
|
|
98
100
|
readonly base: EffectiveBase;
|
|
99
101
|
}
|
|
100
102
|
|
|
103
|
+
export interface BashPathRuleCandidate {
|
|
104
|
+
/** Raw path-like token shown in prompts, logs, and session approvals. */
|
|
105
|
+
readonly token: string;
|
|
106
|
+
/** Equivalent values used for permission policy matching. */
|
|
107
|
+
readonly policyValues: readonly string[];
|
|
108
|
+
}
|
|
109
|
+
|
|
101
110
|
/**
|
|
102
111
|
* A bash command parsed once into a reusable representation.
|
|
103
112
|
*
|
|
@@ -139,23 +148,36 @@ export class BashProgram {
|
|
|
139
148
|
}
|
|
140
149
|
|
|
141
150
|
/**
|
|
142
|
-
*
|
|
151
|
+
* Path-rule candidates paired with their policy lookup values.
|
|
143
152
|
*
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
*
|
|
153
|
+
* When `cwd` is available, each relative token is resolved against the
|
|
154
|
+
* effective working directory in force at the token's position (folding
|
|
155
|
+
* literal current-shell `cd` commands), while raw and project-relative
|
|
156
|
+
* aliases are retained for backward-compatible relative rules. A token after
|
|
157
|
+
* a non-literal `cd` keeps only its literal value so no spurious absolute
|
|
158
|
+
* rule can match.
|
|
147
159
|
*/
|
|
148
|
-
|
|
160
|
+
pathRuleCandidates(cwd?: string): BashPathRuleCandidate[] {
|
|
149
161
|
const seen = new Set<string>();
|
|
150
|
-
const result:
|
|
151
|
-
|
|
162
|
+
const result: BashPathRuleCandidate[] = [];
|
|
163
|
+
|
|
164
|
+
for (const { token, base } of this.rawCandidates) {
|
|
152
165
|
const candidate = classifyTokenAsRuleCandidate(token);
|
|
153
166
|
if (!candidate) continue;
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
167
|
+
|
|
168
|
+
const policyValues = getPolicyValuesForRuleCandidate(
|
|
169
|
+
candidate,
|
|
170
|
+
base,
|
|
171
|
+
cwd,
|
|
172
|
+
);
|
|
173
|
+
if (policyValues.length === 0) continue;
|
|
174
|
+
|
|
175
|
+
const key = policyValues.join("\0");
|
|
176
|
+
if (seen.has(key)) continue;
|
|
177
|
+
seen.add(key);
|
|
178
|
+
result.push({ token: candidate, policyValues });
|
|
158
179
|
}
|
|
180
|
+
|
|
159
181
|
return result;
|
|
160
182
|
}
|
|
161
183
|
|
|
@@ -894,6 +916,25 @@ function isRelativeCandidate(candidate: string): boolean {
|
|
|
894
916
|
return !candidate.startsWith("/") && !candidate.startsWith("~");
|
|
895
917
|
}
|
|
896
918
|
|
|
919
|
+
function getPolicyValuesForRuleCandidate(
|
|
920
|
+
candidate: string,
|
|
921
|
+
base: EffectiveBase,
|
|
922
|
+
cwd: string | undefined,
|
|
923
|
+
): string[] {
|
|
924
|
+
if (!cwd) {
|
|
925
|
+
const literal = normalizePathPolicyLiteral(candidate);
|
|
926
|
+
return literal ? [literal] : [];
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
if (base.kind === "unknown" && isRelativeCandidate(candidate)) {
|
|
930
|
+
const literal = normalizePathPolicyLiteral(candidate);
|
|
931
|
+
return literal ? [literal] : [];
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
const resolveBase = base.kind === "known" ? resolve(cwd, base.offset) : cwd;
|
|
935
|
+
return getPathPolicyValues(candidate, { cwd, resolveBase });
|
|
936
|
+
}
|
|
937
|
+
|
|
897
938
|
/**
|
|
898
939
|
* Compute the effective base after a command runs. Returns `base` unchanged
|
|
899
940
|
* unless the command is `cd`:
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Pure, synchronous token-classification helpers for bash path extraction.
|
|
3
3
|
*
|
|
4
|
-
* Exports two classifiers consumed by `bash-
|
|
4
|
+
* Exports two classifiers consumed by `bash-program.ts`:
|
|
5
5
|
* - `classifyTokenAsPathCandidate` — strict gate for the external-directory guard.
|
|
6
6
|
* - `classifyTokenAsRuleCandidate` — broader gate for cross-cutting `path` rules.
|
|
7
7
|
*
|
|
@@ -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.
|
|
@@ -113,8 +115,10 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
113
115
|
registerPermissionSystemCommand(pi, {
|
|
114
116
|
config: configStore,
|
|
115
117
|
configPath,
|
|
116
|
-
|
|
117
|
-
|
|
118
|
+
getActiveAgentConfigRules: () =>
|
|
119
|
+
permissionManager.getComposedConfigRules(
|
|
120
|
+
session.lastKnownActiveAgentName ?? undefined,
|
|
121
|
+
),
|
|
118
122
|
});
|
|
119
123
|
|
|
120
124
|
const rpcHandles = registerPermissionRpcHandlers(pi.events, {
|
|
@@ -129,6 +133,7 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
129
133
|
permissionManager,
|
|
130
134
|
sessionRules,
|
|
131
135
|
formatterRegistry,
|
|
136
|
+
accessExtractorRegistry,
|
|
132
137
|
);
|
|
133
138
|
|
|
134
139
|
// Subscribe to @gotgenes/pi-subagents' child lifecycle events so child
|
|
@@ -172,6 +177,7 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
172
177
|
resolver,
|
|
173
178
|
session,
|
|
174
179
|
formatterRegistry,
|
|
180
|
+
accessExtractorRegistry,
|
|
175
181
|
);
|
|
176
182
|
const skillInputGatePipeline = new SkillInputGatePipeline(resolver);
|
|
177
183
|
const gates = new PermissionGateHandler(
|
package/src/input-normalizer.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { getNonEmptyString, toRecord } from "./common";
|
|
2
|
-
import { expandHomePath } from "./expand-home";
|
|
3
2
|
import { createMcpPermissionTargets } from "./mcp-targets";
|
|
4
|
-
import { PATH_BEARING_TOOLS } from "./path-utils";
|
|
3
|
+
import { getPathPolicyValues, PATH_BEARING_TOOLS } from "./path-utils";
|
|
5
4
|
|
|
6
5
|
/**
|
|
7
6
|
* Construct a surface-appropriate input object from a raw value string.
|
|
@@ -66,12 +65,13 @@ export function normalizeInput(
|
|
|
66
65
|
toolName: string,
|
|
67
66
|
input: unknown,
|
|
68
67
|
configuredMcpServerNames: readonly string[],
|
|
68
|
+
cwd?: string,
|
|
69
69
|
): NormalizedInput {
|
|
70
70
|
// --- Special surfaces (path, external_directory) ---
|
|
71
71
|
if (SPECIAL_PERMISSION_KEYS.has(toolName)) {
|
|
72
72
|
return {
|
|
73
73
|
surface: toolName,
|
|
74
|
-
values:
|
|
74
|
+
values: normalizePathSurfaceValues(input, cwd),
|
|
75
75
|
resultExtras: {},
|
|
76
76
|
};
|
|
77
77
|
}
|
|
@@ -117,7 +117,7 @@ export function normalizeInput(
|
|
|
117
117
|
if (PATH_BEARING_TOOLS.has(toolName)) {
|
|
118
118
|
return {
|
|
119
119
|
surface: toolName,
|
|
120
|
-
values:
|
|
120
|
+
values: normalizePathSurfaceValues(input, cwd),
|
|
121
121
|
resultExtras: {},
|
|
122
122
|
};
|
|
123
123
|
}
|
|
@@ -131,15 +131,21 @@ export function normalizeInput(
|
|
|
131
131
|
}
|
|
132
132
|
|
|
133
133
|
/**
|
|
134
|
-
* Extract and
|
|
135
|
-
*
|
|
134
|
+
* Extract and normalize the path lookup values shared by every path surface
|
|
135
|
+
* (`path`, `external_directory`, and the path-bearing tools).
|
|
136
136
|
*
|
|
137
137
|
* Missing, empty, or whitespace-only paths collapse to the surface catch-all
|
|
138
|
-
* `"*"
|
|
139
|
-
*
|
|
140
|
-
*
|
|
138
|
+
* `"*"`. When CWD is known, a relative path also produces a normalized
|
|
139
|
+
* absolute policy value and a project-relative alias while keeping its legacy
|
|
140
|
+
* relative value, so values match home- and cwd-anchored patterns
|
|
141
|
+
* symmetrically with how the patterns themselves are expanded (#350).
|
|
142
|
+
*
|
|
143
|
+
* Only `input.path` is read — policy values are never sourced from any other
|
|
144
|
+
* (potentially attacker-controlled) field on the raw tool input.
|
|
141
145
|
*/
|
|
142
|
-
function
|
|
146
|
+
function normalizePathSurfaceValues(input: unknown, cwd?: string): string[] {
|
|
143
147
|
const path = getNonEmptyString(toRecord(input).path);
|
|
144
|
-
|
|
148
|
+
if (path === null) return ["*"];
|
|
149
|
+
const values = getPathPolicyValues(path, cwd ? { cwd } : {});
|
|
150
|
+
return values.length > 0 ? values : ["*"];
|
|
145
151
|
}
|