@gotgenes/pi-permission-system 12.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 +24 -0
- package/README.md +7 -9
- 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/index.ts +4 -2
- package/src/input-normalizer.ts +17 -11
- package/src/path-utils.ts +81 -0
- package/src/permission-manager.ts +81 -17
- package/src/permission-resolver.ts +24 -0
- package/src/permission-session.ts +2 -4
- package/src/rule.ts +61 -11
- package/test/bash-external-directory.test.ts +1 -81
- 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/tool-call-gate-pipeline.test.ts +1 -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 +72 -0
- package/test/permission-manager-unified.test.ts +134 -0
- package/test/permission-resolver.test.ts +69 -0
- package/test/rule.test.ts +74 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,30 @@ 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
|
+
|
|
8
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)
|
|
9
33
|
|
|
10
34
|
|
package/README.md
CHANGED
|
@@ -70,6 +70,7 @@ Extension and MCP tools that operate on paths (via `input.path`, MCP's `input.ar
|
|
|
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 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.",
|
|
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
|
*
|
package/src/index.ts
CHANGED
|
@@ -115,8 +115,10 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
115
115
|
registerPermissionSystemCommand(pi, {
|
|
116
116
|
config: configStore,
|
|
117
117
|
configPath,
|
|
118
|
-
|
|
119
|
-
|
|
118
|
+
getActiveAgentConfigRules: () =>
|
|
119
|
+
permissionManager.getComposedConfigRules(
|
|
120
|
+
session.lastKnownActiveAgentName ?? undefined,
|
|
121
|
+
),
|
|
120
122
|
});
|
|
121
123
|
|
|
122
124
|
const rpcHandles = registerPermissionRpcHandlers(pi.events, {
|
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
|
}
|
package/src/path-utils.ts
CHANGED
|
@@ -2,6 +2,7 @@ import {
|
|
|
2
2
|
join,
|
|
3
3
|
normalize,
|
|
4
4
|
posix as posixPath,
|
|
5
|
+
relative,
|
|
5
6
|
resolve,
|
|
6
7
|
win32 as winPath,
|
|
7
8
|
} from "node:path";
|
|
@@ -63,6 +64,86 @@ export function isPathWithinDirectory(
|
|
|
63
64
|
);
|
|
64
65
|
}
|
|
65
66
|
|
|
67
|
+
export interface PathPolicyValueOptions {
|
|
68
|
+
/**
|
|
69
|
+
* Current Pi working directory. When provided, returned values include a
|
|
70
|
+
* project-relative alias for paths that resolve inside this directory.
|
|
71
|
+
*/
|
|
72
|
+
cwd?: string;
|
|
73
|
+
/**
|
|
74
|
+
* Directory used to resolve `pathValue` into an absolute policy value.
|
|
75
|
+
* Defaults to `cwd`. Bash uses this for tokens seen after a literal `cd`.
|
|
76
|
+
*/
|
|
77
|
+
resolveBase?: string;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Normalize a single path-like lookup value without resolving it against CWD.
|
|
82
|
+
*
|
|
83
|
+
* Preserves compatibility with existing relative path rules (`src/*`, `*.env`)
|
|
84
|
+
* while applying the same lexical cleanup as
|
|
85
|
+
* {@link normalizePathForComparison}: trim, strip simple wrapping quotes,
|
|
86
|
+
* strip the OpenCode-style leading `@`, and expand `~` / `$HOME`.
|
|
87
|
+
*/
|
|
88
|
+
export function normalizePathPolicyLiteral(pathValue: string): string {
|
|
89
|
+
const trimmed = pathValue.trim().replace(/^['"]|['"]$/g, "");
|
|
90
|
+
if (!trimmed) return "";
|
|
91
|
+
const unprefixed = trimmed.startsWith("@") ? trimmed.slice(1) : trimmed;
|
|
92
|
+
return expandHomePath(unprefixed);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Return equivalent lookup values for path-policy matching.
|
|
97
|
+
*
|
|
98
|
+
* The first value is the cwd/effective-base normalized absolute path when a
|
|
99
|
+
* base is available. The later values preserve project-relative and raw
|
|
100
|
+
* relative forms so existing rules like `src/*` and `*.env` continue to match.
|
|
101
|
+
*/
|
|
102
|
+
export function getPathPolicyValues(
|
|
103
|
+
pathValue: string,
|
|
104
|
+
options: PathPolicyValueOptions = {},
|
|
105
|
+
): string[] {
|
|
106
|
+
const literal = normalizePathPolicyLiteral(pathValue);
|
|
107
|
+
if (!literal) return [];
|
|
108
|
+
if (literal === "*") return ["*"];
|
|
109
|
+
|
|
110
|
+
return [
|
|
111
|
+
...new Set([...getAbsolutePathPolicyValues(pathValue, options), literal]),
|
|
112
|
+
];
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function getAbsolutePathPolicyValues(
|
|
116
|
+
pathValue: string,
|
|
117
|
+
options: PathPolicyValueOptions,
|
|
118
|
+
): string[] {
|
|
119
|
+
const resolveBase = options.resolveBase ?? options.cwd;
|
|
120
|
+
if (!resolveBase) return [];
|
|
121
|
+
|
|
122
|
+
const absolute = normalizePathForComparison(pathValue, resolveBase);
|
|
123
|
+
if (!absolute) return [];
|
|
124
|
+
|
|
125
|
+
return [absolute, ...getCwdRelativePathPolicyValues(absolute, options.cwd)];
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function getCwdRelativePathPolicyValues(
|
|
129
|
+
absolute: string,
|
|
130
|
+
cwd: string | undefined,
|
|
131
|
+
): string[] {
|
|
132
|
+
if (!cwd) return [];
|
|
133
|
+
|
|
134
|
+
const normalizedCwd = normalizePathForComparison(cwd, cwd);
|
|
135
|
+
if (!normalizedCwd) return [];
|
|
136
|
+
if (
|
|
137
|
+
absolute !== normalizedCwd &&
|
|
138
|
+
!isPathWithinDirectory(absolute, normalizedCwd)
|
|
139
|
+
) {
|
|
140
|
+
return [];
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const relativeValue = relative(normalizedCwd, absolute);
|
|
144
|
+
return relativeValue ? [relativeValue] : [];
|
|
145
|
+
}
|
|
146
|
+
|
|
66
147
|
/**
|
|
67
148
|
* Paths that are universally safe and should never trigger external-directory checks.
|
|
68
149
|
* These are OS device files: read returns EOF or process streams, write discards or goes to process streams.
|
|
@@ -3,6 +3,7 @@ import { isPermissionState } from "./common";
|
|
|
3
3
|
import { getGlobalConfigPath, getProjectConfigPath } from "./config-paths";
|
|
4
4
|
import { normalizeInput } from "./input-normalizer";
|
|
5
5
|
import { normalizeFlatConfig } from "./normalize";
|
|
6
|
+
import { PATH_SURFACES } from "./path-utils";
|
|
6
7
|
import {
|
|
7
8
|
FilePolicyLoader,
|
|
8
9
|
type PolicyLoader,
|
|
@@ -10,7 +11,7 @@ import {
|
|
|
10
11
|
type ResolvedPolicyPaths,
|
|
11
12
|
} from "./policy-loader";
|
|
12
13
|
import type { Rule, RuleOrigin, Ruleset } from "./rule";
|
|
13
|
-
import { evaluate, evaluateFirst } from "./rule";
|
|
14
|
+
import { evaluate, evaluateAnyValue, evaluateFirst } from "./rule";
|
|
14
15
|
import { mergeScopesWithOrigins } from "./scope-merge";
|
|
15
16
|
import {
|
|
16
17
|
composeRuleset,
|
|
@@ -63,6 +64,17 @@ export interface ScopedPermissionManager {
|
|
|
63
64
|
agentName?: string,
|
|
64
65
|
sessionRules?: Ruleset,
|
|
65
66
|
): PermissionCheckResult;
|
|
67
|
+
/**
|
|
68
|
+
* Evaluate the cross-cutting `path` surface against a caller-supplied set of
|
|
69
|
+
* equivalent policy values (e.g. bash tokens already resolved against a
|
|
70
|
+
* preceding literal `cd`). The values are trusted because they are computed
|
|
71
|
+
* internally, never read from a field on raw tool input.
|
|
72
|
+
*/
|
|
73
|
+
checkPathPolicy(
|
|
74
|
+
values: readonly string[],
|
|
75
|
+
agentName?: string,
|
|
76
|
+
sessionRules?: Ruleset,
|
|
77
|
+
): PermissionCheckResult;
|
|
66
78
|
getToolPermission(toolName: string, agentName?: string): PermissionState;
|
|
67
79
|
getConfigIssues(agentName?: string): string[];
|
|
68
80
|
getPolicyCacheStamp(agentName?: string): string;
|
|
@@ -79,6 +91,7 @@ export interface PermissionManagerOptions extends PolicyLoaderOptions {
|
|
|
79
91
|
|
|
80
92
|
export class PermissionManager implements ScopedPermissionManager {
|
|
81
93
|
private readonly agentDir: string | undefined;
|
|
94
|
+
private currentCwd: string | undefined;
|
|
82
95
|
private loader: PolicyLoader;
|
|
83
96
|
private readonly resolvedPermissionsCache = new Map<
|
|
84
97
|
string,
|
|
@@ -104,6 +117,8 @@ export class PermissionManager implements ScopedPermissionManager {
|
|
|
104
117
|
* built with explicit paths), only the cache is cleared.
|
|
105
118
|
*/
|
|
106
119
|
configureForCwd(cwd: string | undefined | null): void {
|
|
120
|
+
this.currentCwd =
|
|
121
|
+
typeof cwd === "string" && cwd.trim().length > 0 ? cwd : undefined;
|
|
107
122
|
if (this.agentDir !== undefined) {
|
|
108
123
|
this.loader = new FilePolicyLoader(
|
|
109
124
|
derivePolicyLoaderOptions(this.agentDir, cwd),
|
|
@@ -245,29 +260,78 @@ export class PermissionManager implements ScopedPermissionManager {
|
|
|
245
260
|
normalizedToolName,
|
|
246
261
|
input,
|
|
247
262
|
this.loader.getConfiguredMcpServerNames(),
|
|
263
|
+
this.currentCwd,
|
|
248
264
|
);
|
|
249
265
|
|
|
250
|
-
|
|
266
|
+
return buildCheckResult(
|
|
267
|
+
surface,
|
|
268
|
+
values,
|
|
269
|
+
resultExtras,
|
|
270
|
+
normalizedToolName,
|
|
271
|
+
toolName,
|
|
272
|
+
fullRules,
|
|
273
|
+
);
|
|
274
|
+
}
|
|
251
275
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
276
|
+
checkPathPolicy(
|
|
277
|
+
values: readonly string[],
|
|
278
|
+
agentName?: string,
|
|
279
|
+
sessionRules?: Ruleset,
|
|
280
|
+
): PermissionCheckResult {
|
|
281
|
+
const { composedRules } = this.resolvePermissions(agentName);
|
|
282
|
+
const fullRules: Ruleset = sessionRules?.length
|
|
283
|
+
? [...composedRules, ...sessionRules]
|
|
284
|
+
: composedRules;
|
|
256
285
|
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
...extras,
|
|
267
|
-
};
|
|
286
|
+
const lookupValues = values.length > 0 ? [...values] : ["*"];
|
|
287
|
+
return buildCheckResult(
|
|
288
|
+
"path",
|
|
289
|
+
lookupValues,
|
|
290
|
+
{},
|
|
291
|
+
"path",
|
|
292
|
+
"path",
|
|
293
|
+
fullRules,
|
|
294
|
+
);
|
|
268
295
|
}
|
|
269
296
|
}
|
|
270
297
|
|
|
298
|
+
/**
|
|
299
|
+
* Evaluate a normalized surface/values triple and shape the result.
|
|
300
|
+
*
|
|
301
|
+
* Path surfaces use {@link evaluateAnyValue} (last-match-wins across equivalent
|
|
302
|
+
* aliases); every other surface keeps {@link evaluateFirst}. Shared by
|
|
303
|
+
* `checkPermission` and `checkPathPolicy`.
|
|
304
|
+
*/
|
|
305
|
+
function buildCheckResult(
|
|
306
|
+
surface: string,
|
|
307
|
+
values: string[],
|
|
308
|
+
resultExtras: Record<string, unknown>,
|
|
309
|
+
normalizedToolName: string,
|
|
310
|
+
toolName: string,
|
|
311
|
+
fullRules: Ruleset,
|
|
312
|
+
): PermissionCheckResult {
|
|
313
|
+
const { rule, value } = PATH_SURFACES.has(surface)
|
|
314
|
+
? evaluateAnyValue(surface, values, fullRules)
|
|
315
|
+
: evaluateFirst(surface, values, fullRules);
|
|
316
|
+
|
|
317
|
+
// For MCP, replace the normalizer's fallback target with the actual
|
|
318
|
+
// matched candidate value so PermissionCheckResult.target is accurate.
|
|
319
|
+
const extras =
|
|
320
|
+
surface === "mcp" ? { ...resultExtras, target: value } : resultExtras;
|
|
321
|
+
|
|
322
|
+
return {
|
|
323
|
+
toolName,
|
|
324
|
+
state: rule.action,
|
|
325
|
+
matchedPattern:
|
|
326
|
+
rule.layer === "config" || rule.layer === "session"
|
|
327
|
+
? rule.pattern
|
|
328
|
+
: undefined,
|
|
329
|
+
source: deriveSource(rule, normalizedToolName),
|
|
330
|
+
origin: rule.origin,
|
|
331
|
+
...extras,
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
|
|
271
335
|
/**
|
|
272
336
|
* Derive `PolicyLoaderOptions` from an agentDir + an optional cwd.
|
|
273
337
|
* Setting agentsDir explicitly from agentDir removes the hidden
|