@gotgenes/pi-permission-system 12.0.0 → 13.1.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 +7 -9
- package/config/config.example.json +2 -1
- package/package.json +1 -1
- package/schemas/permissions.schema.json +28 -2
- package/src/common.ts +17 -1
- package/src/config-loader.ts +9 -5
- package/src/config-modal.ts +3 -7
- package/src/denial-messages.ts +2 -1
- 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/normalize.ts +12 -2
- package/src/path-utils.ts +81 -0
- package/src/permission-manager.ts +82 -17
- package/src/permission-resolver.ts +24 -0
- package/src/permission-session.ts +2 -4
- package/src/rule.ts +63 -11
- package/src/types.ts +18 -3
- package/test/bash-external-directory.test.ts +1 -81
- package/test/common.test.ts +28 -0
- package/test/config-loader.test.ts +43 -0
- package/test/config-modal.test.ts +7 -10
- package/test/config-pipeline.test.ts +90 -0
- package/test/denial-messages.test.ts +61 -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/normalize.test.ts +81 -0
- package/test/path-utils.test.ts +72 -0
- package/test/permission-manager-unified.test.ts +199 -0
- package/test/permission-resolver.test.ts +69 -0
- package/test/rule.test.ts +135 -1
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
|
+
## [13.1.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v13.0.0...pi-permission-system-v13.1.0) (2026-06-13)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* **pi-permission-system:** add DenyWithReason type and shared guard ([51750e1](https://github.com/gotgenes/pi-packages/commit/51750e188592520798eaf9676a15a709a779cf96)), closes [#395](https://github.com/gotgenes/pi-packages/issues/395)
|
|
14
|
+
* **pi-permission-system:** append custom reason to denial messages ([d8e5756](https://github.com/gotgenes/pi-packages/commit/d8e575632678b806d381f1436dbb06197d742104)), closes [#395](https://github.com/gotgenes/pi-packages/issues/395)
|
|
15
|
+
* **pi-permission-system:** build deny rules with reason in normalizeFlatConfig ([186c15a](https://github.com/gotgenes/pi-packages/commit/186c15a74944bc2800bcea738984021169fabc8d)), closes [#395](https://github.com/gotgenes/pi-packages/issues/395)
|
|
16
|
+
* **pi-permission-system:** preserve deny-with-reason from JSON config ([3201bfd](https://github.com/gotgenes/pi-packages/commit/3201bfd55d68aac1ee87ac452723f6d0783dba6d)), closes [#395](https://github.com/gotgenes/pi-packages/issues/395)
|
|
17
|
+
* **pi-permission-system:** thread deny reason into PermissionCheckResult ([ed712e4](https://github.com/gotgenes/pi-packages/commit/ed712e47458a662e3d1159e2f5096c709ab2ddf5)), closes [#395](https://github.com/gotgenes/pi-packages/issues/395)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
### Documentation
|
|
21
|
+
|
|
22
|
+
* **pi-permission-system:** document deny-with-reason config form ([45be4e7](https://github.com/gotgenes/pi-packages/commit/45be4e72c0ca43040cb0f55ca196a0cab0b9fc14)), closes [#395](https://github.com/gotgenes/pi-packages/issues/395)
|
|
23
|
+
|
|
24
|
+
## [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)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
### ⚠ BREAKING CHANGES
|
|
28
|
+
|
|
29
|
+
* 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.
|
|
30
|
+
* 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.
|
|
31
|
+
|
|
32
|
+
### Features
|
|
33
|
+
|
|
34
|
+
* add alias-aware evaluateAnyValue ([#393](https://github.com/gotgenes/pi-packages/issues/393)) ([2b7d240](https://github.com/gotgenes/pi-packages/commit/2b7d24091fbeb078bcfbc363bc0062199ee1de24))
|
|
35
|
+
* add cd-aware pathRuleCandidates to BashProgram ([#393](https://github.com/gotgenes/pi-packages/issues/393)) ([102a491](https://github.com/gotgenes/pi-packages/commit/102a491ef73e225a6a93008195ff958e4c5bd315))
|
|
36
|
+
* add path-policy value derivation ([#393](https://github.com/gotgenes/pi-packages/issues/393)) ([d34e57f](https://github.com/gotgenes/pi-packages/commit/d34e57fe96c74b2ba87e9d0ebe9be5055db0855f))
|
|
37
|
+
* add resolvePathPolicy resolver method ([#393](https://github.com/gotgenes/pi-packages/issues/393)) ([8ec81da](https://github.com/gotgenes/pi-packages/commit/8ec81da65e7f994beb5f65bead8b11174277fa53))
|
|
38
|
+
* 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))
|
|
39
|
+
* 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))
|
|
40
|
+
* 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))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
### Documentation
|
|
44
|
+
|
|
45
|
+
* document cwd-aware path policy matching ([#393](https://github.com/gotgenes/pi-packages/issues/393)) ([8ab53a2](https://github.com/gotgenes/pi-packages/commit/8ab53a2de6c4deea5bbcd9c72a36ec0943e1a69a))
|
|
46
|
+
* **pi-permission-system:** update Development section to current scripts and tooling ([ebda301](https://github.com/gotgenes/pi-packages/commit/ebda301798290f528f930917b6792a5c21379a5d))
|
|
47
|
+
|
|
8
48
|
## [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
49
|
|
|
10
50
|
|
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
|
|
@@ -25,7 +25,8 @@
|
|
|
25
25
|
"*": "ask",
|
|
26
26
|
"git *": "ask",
|
|
27
27
|
"git status": "allow",
|
|
28
|
-
"git diff": "allow"
|
|
28
|
+
"git diff": "allow",
|
|
29
|
+
"npm *": { "action": "deny", "reason": "Use pnpm instead" }
|
|
29
30
|
},
|
|
30
31
|
"mcp": { "*": "ask", "mcp_status": "allow", "mcp_list": "allow" },
|
|
31
32
|
"skill": { "*": "ask" },
|
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 '*'.",
|
|
@@ -125,8 +125,34 @@
|
|
|
125
125
|
"minLength": 1
|
|
126
126
|
},
|
|
127
127
|
"additionalProperties": {
|
|
128
|
-
"
|
|
128
|
+
"oneOf": [
|
|
129
|
+
{
|
|
130
|
+
"$ref": "#/$defs/permissionState",
|
|
131
|
+
"description": "A permission decision for this pattern."
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
"$ref": "#/$defs/denyWithReason",
|
|
135
|
+
"description": "Deny this pattern with an optional custom reason."
|
|
136
|
+
}
|
|
137
|
+
]
|
|
129
138
|
}
|
|
139
|
+
},
|
|
140
|
+
"denyWithReason": {
|
|
141
|
+
"type": "object",
|
|
142
|
+
"description": "Deny with an optional custom reason shown to the agent when the action is blocked.",
|
|
143
|
+
"properties": {
|
|
144
|
+
"action": {
|
|
145
|
+
"const": "deny",
|
|
146
|
+
"description": "The permission decision \u2014 must be \"deny\"."
|
|
147
|
+
},
|
|
148
|
+
"reason": {
|
|
149
|
+
"type": "string",
|
|
150
|
+
"maxLength": 500,
|
|
151
|
+
"description": "Optional reason shown to the agent when this action is denied."
|
|
152
|
+
}
|
|
153
|
+
},
|
|
154
|
+
"required": ["action"],
|
|
155
|
+
"additionalProperties": false
|
|
130
156
|
}
|
|
131
157
|
}
|
|
132
158
|
}
|
package/src/common.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { PermissionState } from "./types";
|
|
1
|
+
import type { DenyWithReason, PermissionState } from "./types";
|
|
2
2
|
|
|
3
3
|
export function toRecord(value: unknown): Record<string, unknown> {
|
|
4
4
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
@@ -38,6 +38,22 @@ export function isPermissionState(value: unknown): value is PermissionState {
|
|
|
38
38
|
return value === "allow" || value === "deny" || value === "ask";
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
/**
|
|
42
|
+
* Narrow type guard: a raw value representing a DenyWithReason object.
|
|
43
|
+
* Accepts `{ action: "deny" }` and `{ action: "deny", reason: "…" }`.
|
|
44
|
+
* Rejects a non-string `reason` to keep malformed config out of the rule set.
|
|
45
|
+
*/
|
|
46
|
+
export function isDenyWithReason(value: unknown): value is DenyWithReason {
|
|
47
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
const record = value as Record<string, unknown>;
|
|
51
|
+
return (
|
|
52
|
+
record.action === "deny" &&
|
|
53
|
+
(record.reason === undefined || typeof record.reason === "string")
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
41
57
|
type StackNode = { indent: number; target: Record<string, unknown> };
|
|
42
58
|
|
|
43
59
|
export function parseSimpleYamlMap(input: string): Record<string, unknown> {
|
package/src/config-loader.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "node:fs";
|
|
2
2
|
import { normalize } from "node:path";
|
|
3
|
-
|
|
4
3
|
import {
|
|
4
|
+
isDenyWithReason,
|
|
5
5
|
isPermissionState,
|
|
6
6
|
normalizeOptionalPositiveInt,
|
|
7
7
|
normalizeOptionalStringArray,
|
|
@@ -15,7 +15,7 @@ import {
|
|
|
15
15
|
getProjectConfigPath,
|
|
16
16
|
} from "./config-paths";
|
|
17
17
|
import { mergeFlatPermissions } from "./permission-merge";
|
|
18
|
-
import type { FlatPermissionConfig } from "./types";
|
|
18
|
+
import type { FlatPermissionConfig, PatternValue } from "./types";
|
|
19
19
|
|
|
20
20
|
/**
|
|
21
21
|
* Unified config shape combining runtime knobs and flat permission policy.
|
|
@@ -127,7 +127,8 @@ function normalizeOptionalBoolean(value: unknown): boolean | undefined {
|
|
|
127
127
|
|
|
128
128
|
/**
|
|
129
129
|
* Normalize a raw `permission` value from parsed JSON into a FlatPermissionConfig.
|
|
130
|
-
*
|
|
130
|
+
* Accepts PermissionState strings and DenyWithReason objects inside pattern
|
|
131
|
+
* maps. Drops non-object top-level values, invalid PermissionState strings, and
|
|
131
132
|
* invalid action values inside object maps.
|
|
132
133
|
*/
|
|
133
134
|
function normalizeFlatPermissionValue(
|
|
@@ -147,12 +148,15 @@ function normalizeFlatPermissionValue(
|
|
|
147
148
|
hasAny = true;
|
|
148
149
|
}
|
|
149
150
|
} else if (typeof val === "object" && val !== null && !Array.isArray(val)) {
|
|
150
|
-
const map: Record<string,
|
|
151
|
+
const map: Record<string, PatternValue> = {};
|
|
151
152
|
let mapHasAny = false;
|
|
152
153
|
for (const [pattern, action] of Object.entries(
|
|
153
154
|
val as Record<string, unknown>,
|
|
154
155
|
)) {
|
|
155
|
-
if (
|
|
156
|
+
if (isDenyWithReason(action)) {
|
|
157
|
+
map[pattern] = action;
|
|
158
|
+
mapHasAny = true;
|
|
159
|
+
} else if (isPermissionState(action)) {
|
|
156
160
|
map[pattern] = action;
|
|
157
161
|
mapHasAny = true;
|
|
158
162
|
}
|
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/denial-messages.ts
CHANGED
|
@@ -126,7 +126,8 @@ function buildToolDenyBody(
|
|
|
126
126
|
parts.push(qualifier);
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
-
|
|
129
|
+
// reasonSuffix appends ` Reason: <reason>.` after the sentence-ending period.
|
|
130
|
+
return `${parts.join(" ")}.${reasonSuffix(check.reason)}`;
|
|
130
131
|
}
|
|
131
132
|
|
|
132
133
|
/**
|
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/normalize.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { isPermissionState } from "./common";
|
|
1
|
+
import { isDenyWithReason, isPermissionState } from "./common";
|
|
2
2
|
import type { Rule, Ruleset } from "./rule";
|
|
3
3
|
import type { FlatPermissionConfig } from "./types";
|
|
4
4
|
|
|
@@ -7,6 +7,8 @@ import type { FlatPermissionConfig } from "./types";
|
|
|
7
7
|
*
|
|
8
8
|
* Each key is a surface name. A string value is shorthand for
|
|
9
9
|
* `{ "*": action }`. An object value maps patterns to actions.
|
|
10
|
+
* A pattern value may be a PermissionState string or a `DenyWithReason`
|
|
11
|
+
* object (`{ action: "deny", reason?: string }`).
|
|
10
12
|
* Invalid action values are silently skipped.
|
|
11
13
|
*
|
|
12
14
|
* The universal fallback key `"*"` is included if present — callers
|
|
@@ -23,7 +25,15 @@ export function normalizeFlatConfig(permission: FlatPermissionConfig): Ruleset {
|
|
|
23
25
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- defensive null check; value type does not include null but runtime JSON may
|
|
24
26
|
} else if (typeof value === "object" && value !== null) {
|
|
25
27
|
for (const [pattern, action] of Object.entries(value)) {
|
|
26
|
-
if (
|
|
28
|
+
if (isDenyWithReason(action)) {
|
|
29
|
+
rules.push({
|
|
30
|
+
surface,
|
|
31
|
+
pattern,
|
|
32
|
+
action: "deny",
|
|
33
|
+
reason: action.reason,
|
|
34
|
+
origin: "builtin",
|
|
35
|
+
});
|
|
36
|
+
} else if (isPermissionState(action)) {
|
|
27
37
|
rules.push({ surface, pattern, action, origin: "builtin" });
|
|
28
38
|
}
|
|
29
39
|
}
|