@gotgenes/pi-permission-system 5.16.0 → 5.18.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 +37 -0
- package/README.md +9 -4
- package/config/config.example.json +3 -1
- package/package.json +4 -1
- package/schemas/permissions.schema.json +4 -2
- package/src/handlers/gates/bash-path-extractor.ts +75 -0
- package/src/handlers/gates/bash-path.ts +146 -0
- package/src/handlers/gates/index.ts +2 -0
- package/src/handlers/gates/path.ts +104 -0
- package/src/handlers/permission-gate-handler.ts +46 -0
- package/src/index.ts +21 -0
- package/src/input-normalizer.ts +29 -1
- package/src/permission-event-rpc.ts +1 -20
- package/src/permission-events.ts +25 -3
- package/src/permission-manager.ts +1 -1
- package/src/rule.ts +27 -0
- package/src/service.ts +75 -0
- package/tests/bash-external-directory.test.ts +81 -1
- package/tests/handlers/external-directory-integration.test.ts +84 -3
- package/tests/handlers/gates/bash-path.test.ts +260 -0
- package/tests/handlers/gates/path.test.ts +149 -0
- package/tests/handlers/tool-call.test.ts +78 -0
- package/tests/input-normalizer.test.ts +24 -0
- package/tests/permission-manager-unified.test.ts +210 -0
- package/tests/rule.test.ts +77 -1
- package/tests/service.test.ts +144 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,43 @@ 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
|
+
## [5.18.0](https://github.com/gotgenes/pi-permission-system/compare/v5.17.0...v5.18.0) (2026-05-14)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* add package.json exports field for cross-extension import ([#145](https://github.com/gotgenes/pi-permission-system/issues/145)) ([1091de5](https://github.com/gotgenes/pi-permission-system/commit/1091de5eb673050c3b83448ee69cffb876c407d9))
|
|
14
|
+
* add Symbol.for()-backed service accessor module ([#145](https://github.com/gotgenes/pi-permission-system/issues/145)) ([6a7ddab](https://github.com/gotgenes/pi-permission-system/commit/6a7ddab6e3e58ca0f93807255e9e48716d96ca24))
|
|
15
|
+
* publish permissions service on startup, clear on shutdown ([#145](https://github.com/gotgenes/pi-permission-system/issues/145)) ([97bea7b](https://github.com/gotgenes/pi-permission-system/commit/97bea7bec043de4f6abb823846cef5c04a069517))
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
### Documentation
|
|
19
|
+
|
|
20
|
+
* deprecate permissions:rpc:check types in favor of service accessor ([#145](https://github.com/gotgenes/pi-permission-system/issues/145)) ([a64b1b9](https://github.com/gotgenes/pi-permission-system/commit/a64b1b91f141e1a98b576433280ad09d95ec3011))
|
|
21
|
+
* document service accessor and deprecate RPC check ([#145](https://github.com/gotgenes/pi-permission-system/issues/145)) ([931a14e](https://github.com/gotgenes/pi-permission-system/commit/931a14efec1ef9bc53075f8974bfd1eeff7e0749))
|
|
22
|
+
* plan Symbol.for()-backed service accessor ([#145](https://github.com/gotgenes/pi-permission-system/issues/145)) ([d9448bc](https://github.com/gotgenes/pi-permission-system/commit/d9448bc8c9ee71714599a18f03cf516a5ffca2cb))
|
|
23
|
+
* **retro:** add retro notes for issue [#148](https://github.com/gotgenes/pi-permission-system/issues/148) ([84e0262](https://github.com/gotgenes/pi-permission-system/commit/84e026264e292357c18c0333b1d1bd561f70149b))
|
|
24
|
+
|
|
25
|
+
## [5.17.0](https://github.com/gotgenes/pi-permission-system/compare/v5.16.0...v5.17.0) (2026-05-14)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
### Features
|
|
29
|
+
|
|
30
|
+
* bash path gate with broader token extraction ([#148](https://github.com/gotgenes/pi-permission-system/issues/148)) ([affe202](https://github.com/gotgenes/pi-permission-system/commit/affe20284c7b579facc46ba489a1b6b0e2acc949))
|
|
31
|
+
* broader token extraction for path rules ([#148](https://github.com/gotgenes/pi-permission-system/issues/148)) ([6303641](https://github.com/gotgenes/pi-permission-system/commit/6303641a6efc3265e209799b93d4c8bcbc17c6a0))
|
|
32
|
+
* evaluateMostRestrictive helper for cross-cutting path evaluation ([#148](https://github.com/gotgenes/pi-permission-system/issues/148)) ([5260f21](https://github.com/gotgenes/pi-permission-system/commit/5260f21f149f8cd9b3331c4e418bc9091db2acdb))
|
|
33
|
+
* integrate path gates into permission pipeline ([#148](https://github.com/gotgenes/pi-permission-system/issues/148)) ([36fb30e](https://github.com/gotgenes/pi-permission-system/commit/36fb30e2564a8707b8e6eb8b798b90d536623c53))
|
|
34
|
+
* path gate for tool path restrictions ([#148](https://github.com/gotgenes/pi-permission-system/issues/148)) ([cc53681](https://github.com/gotgenes/pi-permission-system/commit/cc5368103686eee0849644cffce463c2851dff3c))
|
|
35
|
+
* register path as a special permission surface ([#148](https://github.com/gotgenes/pi-permission-system/issues/148)) ([356bcf7](https://github.com/gotgenes/pi-permission-system/commit/356bcf74b3028894319ea3c63b5d4b014b7bfe48))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
### Documentation
|
|
39
|
+
|
|
40
|
+
* document cross-cutting path permission surface ([#148](https://github.com/gotgenes/pi-permission-system/issues/148)) ([3bd4478](https://github.com/gotgenes/pi-permission-system/commit/3bd4478c95197550485910c4364d35203ff53ada))
|
|
41
|
+
* include edit alongside write in config examples ([#147](https://github.com/gotgenes/pi-permission-system/issues/147)) ([f083ccc](https://github.com/gotgenes/pi-permission-system/commit/f083ccc0316e0689390e5ecc16e14ab40baca1d9))
|
|
42
|
+
* plan path-aware bash permission rules ([#148](https://github.com/gotgenes/pi-permission-system/issues/148)) ([71ff973](https://github.com/gotgenes/pi-permission-system/commit/71ff973edd57d75801b75044e238139e90a8490f))
|
|
43
|
+
* **retro:** add retro notes for issue [#147](https://github.com/gotgenes/pi-permission-system/issues/147) ([e40402b](https://github.com/gotgenes/pi-permission-system/commit/e40402b018703c37c8af436dc4e15665216f59c7))
|
|
44
|
+
|
|
8
45
|
## [5.16.0](https://github.com/gotgenes/pi-permission-system/compare/v5.15.0...v5.16.0) (2026-05-13)
|
|
9
46
|
|
|
10
47
|
|
package/README.md
CHANGED
|
@@ -34,7 +34,7 @@ pi install npm:@gotgenes/pi-permission-system
|
|
|
34
34
|
{
|
|
35
35
|
"permission": {
|
|
36
36
|
"*": "allow",
|
|
37
|
-
"
|
|
37
|
+
"path": {
|
|
38
38
|
"*": "allow",
|
|
39
39
|
"*.env": "deny",
|
|
40
40
|
"*.env.*": "deny",
|
|
@@ -62,8 +62,13 @@ All permissions use one of three states:
|
|
|
62
62
|
When the dialog prompts, you can approve once or approve a pattern for the rest of the session.
|
|
63
63
|
See [docs/session-approvals.md](docs/session-approvals.md) for details on session-scoped rules and pattern suggestions.
|
|
64
64
|
|
|
65
|
-
|
|
66
|
-
|
|
65
|
+
The `path` surface is a cross-cutting gate that applies to **all** file access — both Pi tools and bash commands.
|
|
66
|
+
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.
|
|
67
|
+
|
|
68
|
+
For per-tool path patterns (`read`, `write`, `edit`, `find`, `grep`, `ls`), patterns are matched against the file path from `input.path`.
|
|
69
|
+
This lets you express rules like "allow reads but deny `.env` files" at the individual tool level.
|
|
70
|
+
|
|
71
|
+
Four layers compose with most-restrictive-wins: `path` (cross-cutting) → `external_directory` (CWD boundary) → per-tool patterns → `bash` command patterns.
|
|
67
72
|
|
|
68
73
|
## Configuration
|
|
69
74
|
|
|
@@ -86,7 +91,7 @@ For the full reference — all surfaces, runtime knobs, per-agent overrides, mer
|
|
|
86
91
|
|---|---|
|
|
87
92
|
|[docs/configuration.md](docs/configuration.md)|Full policy reference, runtime knobs, per-agent overrides, recipes|
|
|
88
93
|
|[docs/session-approvals.md](docs/session-approvals.md)|Session-scoped rules, pattern suggestions, bash arity table|
|
|
89
|
-
|[docs/
|
|
94
|
+
|[docs/cross-extension-api.md](docs/cross-extension-api.md)|Cross-extension service accessor, event bus integration, decision broadcasts|
|
|
90
95
|
|[docs/subagent-integration.md](docs/subagent-integration.md)|Permission forwarding, coexistence with subagent extensions|
|
|
91
96
|
|[docs/guides/permission-frontmatter-for-subagent-extensions.md](docs/guides/permission-frontmatter-for-subagent-extensions.md)|Convention guide for subagent extension authors|
|
|
92
97
|
|[docs/opencode-compatibility.md](docs/opencode-compatibility.md)|OpenCode compatibility — shared concepts, divergences, porting guide|
|
|
@@ -9,13 +9,15 @@
|
|
|
9
9
|
|
|
10
10
|
"permission": {
|
|
11
11
|
"*": "ask",
|
|
12
|
-
"
|
|
12
|
+
"path": {
|
|
13
13
|
"*": "allow",
|
|
14
14
|
"*.env": "deny",
|
|
15
15
|
"*.env.*": "deny",
|
|
16
16
|
"*.env.example": "allow"
|
|
17
17
|
},
|
|
18
|
+
"read": "allow",
|
|
18
19
|
"write": "deny",
|
|
20
|
+
"edit": "deny",
|
|
19
21
|
"bash": {
|
|
20
22
|
"*": "ask",
|
|
21
23
|
"git status": "allow",
|
package/package.json
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gotgenes/pi-permission-system",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.18.0",
|
|
4
4
|
"description": "Permission enforcement extension for the Pi coding agent.",
|
|
5
5
|
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": "./src/service.ts"
|
|
8
|
+
},
|
|
6
9
|
"files": [
|
|
7
10
|
"src",
|
|
8
11
|
"tests",
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
},
|
|
42
42
|
"permission": {
|
|
43
43
|
"description": "Flat permission policy. Each key is a surface name; values are a PermissionState string (catch-all) or a pattern→action map.",
|
|
44
|
-
"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`, 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 path-bearing 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\n**Merge order (lowest → highest precedence):** global → project → per-agent frontmatter.",
|
|
44
|
+
"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 path-bearing 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 — both Pi tools and bash commands. A `path` deny cannot be overridden by a per-tool allow. Use it to protect sensitive files (`.env`, `~/.ssh/*`) from all tools at once.\n\n**Merge order (lowest → highest precedence):** global → project → per-agent frontmatter.",
|
|
45
45
|
"type": "object",
|
|
46
46
|
"propertyNames": {
|
|
47
47
|
"description": "A surface name or the universal fallback key '*'.",
|
|
@@ -63,13 +63,15 @@
|
|
|
63
63
|
"examples": [
|
|
64
64
|
{
|
|
65
65
|
"*": "ask",
|
|
66
|
-
"
|
|
66
|
+
"path": {
|
|
67
67
|
"*": "allow",
|
|
68
68
|
"*.env": "deny",
|
|
69
69
|
"*.env.*": "deny",
|
|
70
70
|
"*.env.example": "allow"
|
|
71
71
|
},
|
|
72
|
+
"read": "allow",
|
|
72
73
|
"write": "deny",
|
|
74
|
+
"edit": "deny",
|
|
73
75
|
"bash": {
|
|
74
76
|
"*": "ask",
|
|
75
77
|
"git status": "allow",
|
|
@@ -433,6 +433,43 @@ const URL_PATTERN = /^[a-z][a-z0-9+.-]*:\/\//i;
|
|
|
433
433
|
*/
|
|
434
434
|
const REGEX_METACHAR_PATTERN = /\.\*|\.\+|\\\||\\\(|\\\)|\[.*?\]|\^\//;
|
|
435
435
|
|
|
436
|
+
/**
|
|
437
|
+
* Broader token classification for cross-cutting `path` rules.
|
|
438
|
+
*
|
|
439
|
+
* Accepts the same rejections as `classifyTokenAsPathCandidate` (empty, flags,
|
|
440
|
+
* env assignments, URLs, @scope/package, bare-slash, regex metacharacters),
|
|
441
|
+
* but also accepts:
|
|
442
|
+
* - Tokens starting with `.` (dot-files: `.env`, `./src`)
|
|
443
|
+
* - Tokens containing `/` (relative paths: `src/foo.ts`)
|
|
444
|
+
*
|
|
445
|
+
* Does NOT require the strict "must start with `/` or `~/` or contain `..`"
|
|
446
|
+
* gate that the external-directory classifier uses.
|
|
447
|
+
*/
|
|
448
|
+
function classifyTokenAsRuleCandidate(token: string): string | null {
|
|
449
|
+
if (!token) return null;
|
|
450
|
+
if (token.startsWith("-")) return null;
|
|
451
|
+
|
|
452
|
+
const eqIndex = token.indexOf("=");
|
|
453
|
+
const slashIndex = token.indexOf("/");
|
|
454
|
+
if (eqIndex !== -1 && (slashIndex === -1 || eqIndex < slashIndex)) {
|
|
455
|
+
return null;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
if (URL_PATTERN.test(token)) return null;
|
|
459
|
+
if (token.startsWith("@") && !token.startsWith("@/")) return null;
|
|
460
|
+
if (/^\/+$/.test(token)) return null;
|
|
461
|
+
if (REGEX_METACHAR_PATTERN.test(token)) return null;
|
|
462
|
+
|
|
463
|
+
// Accept: starts with . (dot-files, ./ relative), contains / (paths),
|
|
464
|
+
// starts with / or ~/ (absolute/home), or contains .. (parent traversal).
|
|
465
|
+
if (token.startsWith(".")) return token;
|
|
466
|
+
if (token.includes("/")) return token;
|
|
467
|
+
if (token.startsWith("~/")) return token;
|
|
468
|
+
if (token.includes("..")) return token;
|
|
469
|
+
|
|
470
|
+
return null;
|
|
471
|
+
}
|
|
472
|
+
|
|
436
473
|
/**
|
|
437
474
|
* Determines whether a token looks like a path candidate worth resolving.
|
|
438
475
|
* Returns the raw token string if it's a candidate, or null to skip.
|
|
@@ -516,3 +553,41 @@ export async function extractExternalPathsFromBashCommand(
|
|
|
516
553
|
|
|
517
554
|
return externalPaths;
|
|
518
555
|
}
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* Extract tokens from a bash command that may be file paths, using a broader
|
|
559
|
+
* filter suitable for cross-cutting `path` permission rules.
|
|
560
|
+
*
|
|
561
|
+
* Unlike `extractExternalPathsFromBashCommand`, this function:
|
|
562
|
+
* - Accepts relative paths (`.env`, `src/foo.ts`, `./build`)
|
|
563
|
+
* - Does NOT filter by CWD — returns raw tokens for rule evaluation
|
|
564
|
+
* - Returns deduplicated tokens
|
|
565
|
+
*/
|
|
566
|
+
export async function extractTokensForPathRules(
|
|
567
|
+
command: string,
|
|
568
|
+
): Promise<string[]> {
|
|
569
|
+
const parser = await getParser();
|
|
570
|
+
const tree = parser.parse(command);
|
|
571
|
+
if (!tree) return [];
|
|
572
|
+
|
|
573
|
+
const tokens: string[] = [];
|
|
574
|
+
try {
|
|
575
|
+
collectPathCandidateTokens(tree.rootNode, tokens);
|
|
576
|
+
} finally {
|
|
577
|
+
tree.delete();
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
const seen = new Set<string>();
|
|
581
|
+
const result: string[] = [];
|
|
582
|
+
|
|
583
|
+
for (const token of tokens) {
|
|
584
|
+
const candidate = classifyTokenAsRuleCandidate(token);
|
|
585
|
+
if (!candidate) continue;
|
|
586
|
+
if (!seen.has(candidate)) {
|
|
587
|
+
seen.add(candidate);
|
|
588
|
+
result.push(candidate);
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
return result;
|
|
593
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { getNonEmptyString, toRecord } from "../../common";
|
|
2
|
+
import type { Rule } from "../../rule";
|
|
3
|
+
import { deriveApprovalPattern } from "../../session-rules";
|
|
4
|
+
import type { PermissionCheckResult } from "../../types";
|
|
5
|
+
import { extractTokensForPathRules } from "./bash-path-extractor";
|
|
6
|
+
import type { GateResult } from "./descriptor";
|
|
7
|
+
import { formatPathAskPrompt, formatPathDenyReason } from "./path";
|
|
8
|
+
import type { ToolCallContext } from "./types";
|
|
9
|
+
|
|
10
|
+
/** Function type for checkPermission used by the descriptor factory. */
|
|
11
|
+
type CheckPermissionFn = (
|
|
12
|
+
surface: string,
|
|
13
|
+
input: unknown,
|
|
14
|
+
agentName?: string,
|
|
15
|
+
sessionRules?: Rule[],
|
|
16
|
+
) => PermissionCheckResult;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Build a pure descriptor for the cross-cutting path permission gate (bash).
|
|
20
|
+
*
|
|
21
|
+
* Extracts path-candidate tokens from a bash command using tree-sitter with
|
|
22
|
+
* the broader filter (accepts dot-files, relative paths). Evaluates each
|
|
23
|
+
* token against the `path` permission surface and returns the most
|
|
24
|
+
* restrictive result.
|
|
25
|
+
*
|
|
26
|
+
* Returns `null` when the gate does not apply (tool is not bash, no command,
|
|
27
|
+
* no tokens extracted, or all tokens evaluate to `allow`).
|
|
28
|
+
* Returns a `GateBypass` when all tokens are session-covered.
|
|
29
|
+
* Returns a `GateDescriptor` for the most restrictive token needing a check.
|
|
30
|
+
*/
|
|
31
|
+
export async function describeBashPathGate(
|
|
32
|
+
tcc: ToolCallContext,
|
|
33
|
+
checkPermission: CheckPermissionFn,
|
|
34
|
+
getSessionRuleset: () => Rule[],
|
|
35
|
+
): Promise<GateResult> {
|
|
36
|
+
if (tcc.toolName !== "bash") return null;
|
|
37
|
+
|
|
38
|
+
const command = getNonEmptyString(toRecord(tcc.input).command);
|
|
39
|
+
if (!command) return null;
|
|
40
|
+
|
|
41
|
+
const tokens = await extractTokensForPathRules(command);
|
|
42
|
+
if (tokens.length === 0) return null;
|
|
43
|
+
|
|
44
|
+
// Check each token against path rules with session rules appended.
|
|
45
|
+
const sessionRules = getSessionRuleset();
|
|
46
|
+
|
|
47
|
+
let worstCheck: PermissionCheckResult | null = null;
|
|
48
|
+
let worstToken: string | null = null;
|
|
49
|
+
let allSessionCovered = true;
|
|
50
|
+
|
|
51
|
+
for (const token of tokens) {
|
|
52
|
+
const check = checkPermission(
|
|
53
|
+
"path",
|
|
54
|
+
{ path: token },
|
|
55
|
+
tcc.agentName ?? undefined,
|
|
56
|
+
sessionRules,
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
if (check.source !== "session") {
|
|
60
|
+
allSessionCovered = false;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (check.state === "deny") {
|
|
64
|
+
worstCheck = check;
|
|
65
|
+
worstToken = token;
|
|
66
|
+
break; // Short-circuit on deny.
|
|
67
|
+
}
|
|
68
|
+
if (check.state === "ask" && (!worstCheck || worstCheck.state !== "ask")) {
|
|
69
|
+
worstCheck = check;
|
|
70
|
+
worstToken = token;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// All tokens are session-covered — bypass.
|
|
75
|
+
if (allSessionCovered) {
|
|
76
|
+
return {
|
|
77
|
+
action: "allow",
|
|
78
|
+
log: {
|
|
79
|
+
event: "permission_request.session_approved",
|
|
80
|
+
details: {
|
|
81
|
+
source: "tool_call",
|
|
82
|
+
toolCallId: tcc.toolCallId,
|
|
83
|
+
toolName: tcc.toolName,
|
|
84
|
+
agentName: tcc.agentName,
|
|
85
|
+
command,
|
|
86
|
+
tokens,
|
|
87
|
+
resolution: "session_approved",
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// All tokens evaluate to allow — no restriction.
|
|
94
|
+
if (!worstCheck || !worstToken) return null;
|
|
95
|
+
|
|
96
|
+
const pattern = deriveApprovalPattern(worstToken);
|
|
97
|
+
const askMessage = formatPathAskPrompt(
|
|
98
|
+
tcc.toolName,
|
|
99
|
+
worstToken,
|
|
100
|
+
tcc.agentName ?? undefined,
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
surface: "path",
|
|
105
|
+
input: { path: worstToken },
|
|
106
|
+
messages: {
|
|
107
|
+
denyReason: formatPathDenyReason(
|
|
108
|
+
tcc.toolName,
|
|
109
|
+
worstToken,
|
|
110
|
+
tcc.agentName ?? undefined,
|
|
111
|
+
),
|
|
112
|
+
unavailableReason: `Bash command '${command}' accesses path '${worstToken}' which requires approval, but no interactive UI is available.`,
|
|
113
|
+
userDeniedReason: (decision) => {
|
|
114
|
+
const reasonSuffix = decision.denialReason
|
|
115
|
+
? ` Reason: ${decision.denialReason}.`
|
|
116
|
+
: "";
|
|
117
|
+
return `User denied path access for bash command '${command}' (path '${worstToken}').${reasonSuffix} Hard stop: this path permission denial is policy-enforced. Do not retry this path, do not attempt a filesystem bypass, and report the block to the user.`;
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
sessionApproval: {
|
|
121
|
+
surface: "path",
|
|
122
|
+
pattern,
|
|
123
|
+
},
|
|
124
|
+
promptDetails: {
|
|
125
|
+
source: "tool_call",
|
|
126
|
+
agentName: tcc.agentName,
|
|
127
|
+
message: askMessage,
|
|
128
|
+
toolCallId: tcc.toolCallId,
|
|
129
|
+
toolName: tcc.toolName,
|
|
130
|
+
command,
|
|
131
|
+
},
|
|
132
|
+
logContext: {
|
|
133
|
+
source: "tool_call",
|
|
134
|
+
toolCallId: tcc.toolCallId,
|
|
135
|
+
toolName: tcc.toolName,
|
|
136
|
+
agentName: tcc.agentName,
|
|
137
|
+
command,
|
|
138
|
+
path: worstToken,
|
|
139
|
+
},
|
|
140
|
+
decision: {
|
|
141
|
+
surface: "path",
|
|
142
|
+
value: worstToken,
|
|
143
|
+
},
|
|
144
|
+
preCheck: worstCheck,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { describeBashExternalDirectoryGate } from "./bash-external-directory";
|
|
2
|
+
export { describeBashPathGate } from "./bash-path";
|
|
2
3
|
export type {
|
|
3
4
|
GateBypass,
|
|
4
5
|
GateDescriptor,
|
|
@@ -8,6 +9,7 @@ export type {
|
|
|
8
9
|
export { isGateBypass, isGateDescriptor } from "./descriptor";
|
|
9
10
|
export { describeExternalDirectoryGate } from "./external-directory";
|
|
10
11
|
export { deriveDecisionValue, deriveResolution } from "./helpers";
|
|
12
|
+
export { describePathGate } from "./path";
|
|
11
13
|
export { runGateCheck } from "./runner";
|
|
12
14
|
export { describeSkillReadGate } from "./skill-read";
|
|
13
15
|
export { describeToolGate } from "./tool";
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { getPathBearingToolPath } from "../../path-utils";
|
|
2
|
+
import { deriveApprovalPattern } from "../../session-rules";
|
|
3
|
+
import type { PermissionCheckResult } from "../../types";
|
|
4
|
+
import type { GateDescriptor, GateResult } from "./descriptor";
|
|
5
|
+
import type { ToolCallContext } from "./types";
|
|
6
|
+
|
|
7
|
+
/** Function type for checkPermission used by the descriptor factory. */
|
|
8
|
+
type CheckPermissionFn = (
|
|
9
|
+
surface: string,
|
|
10
|
+
input: unknown,
|
|
11
|
+
agentName?: string,
|
|
12
|
+
) => PermissionCheckResult;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Build a pure descriptor for the cross-cutting path permission gate (tools).
|
|
16
|
+
*
|
|
17
|
+
* Returns `null` when the gate does not apply (tool is not path-bearing,
|
|
18
|
+
* no extractable path, or the `path` surface evaluates to `allow`).
|
|
19
|
+
* Returns a `GateDescriptor` when the path matches a `deny` or `ask` rule.
|
|
20
|
+
*/
|
|
21
|
+
export function describePathGate(
|
|
22
|
+
tcc: ToolCallContext,
|
|
23
|
+
checkPermission: CheckPermissionFn,
|
|
24
|
+
): GateResult {
|
|
25
|
+
const filePath = getPathBearingToolPath(tcc.toolName, tcc.input);
|
|
26
|
+
if (!filePath) return null;
|
|
27
|
+
|
|
28
|
+
const check = checkPermission(
|
|
29
|
+
"path",
|
|
30
|
+
{ path: filePath },
|
|
31
|
+
tcc.agentName ?? undefined,
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
if (check.state === "allow") return null;
|
|
35
|
+
|
|
36
|
+
const pattern = deriveApprovalPattern(filePath);
|
|
37
|
+
|
|
38
|
+
const descriptor: GateDescriptor = {
|
|
39
|
+
surface: "path",
|
|
40
|
+
input: { path: filePath },
|
|
41
|
+
messages: {
|
|
42
|
+
denyReason: formatPathDenyReason(
|
|
43
|
+
tcc.toolName,
|
|
44
|
+
filePath,
|
|
45
|
+
tcc.agentName ?? undefined,
|
|
46
|
+
),
|
|
47
|
+
unavailableReason: `Accessing '${filePath}' requires approval, but no interactive UI is available.`,
|
|
48
|
+
userDeniedReason: (decision) => {
|
|
49
|
+
const reasonSuffix = decision.denialReason
|
|
50
|
+
? ` Reason: ${decision.denialReason}.`
|
|
51
|
+
: "";
|
|
52
|
+
return `User denied access to path '${filePath}'.${reasonSuffix} Hard stop: this path permission denial is policy-enforced. Do not retry this path, do not attempt a filesystem bypass, and report the block to the user.`;
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
sessionApproval: {
|
|
56
|
+
surface: "path",
|
|
57
|
+
pattern,
|
|
58
|
+
},
|
|
59
|
+
promptDetails: {
|
|
60
|
+
source: "tool_call",
|
|
61
|
+
agentName: tcc.agentName,
|
|
62
|
+
message: formatPathAskPrompt(
|
|
63
|
+
tcc.toolName,
|
|
64
|
+
filePath,
|
|
65
|
+
tcc.agentName ?? undefined,
|
|
66
|
+
),
|
|
67
|
+
toolCallId: tcc.toolCallId,
|
|
68
|
+
toolName: tcc.toolName,
|
|
69
|
+
path: filePath,
|
|
70
|
+
},
|
|
71
|
+
logContext: {
|
|
72
|
+
source: "tool_call",
|
|
73
|
+
toolCallId: tcc.toolCallId,
|
|
74
|
+
toolName: tcc.toolName,
|
|
75
|
+
agentName: tcc.agentName,
|
|
76
|
+
path: filePath,
|
|
77
|
+
},
|
|
78
|
+
decision: {
|
|
79
|
+
surface: "path",
|
|
80
|
+
value: filePath,
|
|
81
|
+
},
|
|
82
|
+
preCheck: check,
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
return descriptor;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function formatPathDenyReason(
|
|
89
|
+
toolName: string,
|
|
90
|
+
pathValue: string,
|
|
91
|
+
agentName?: string,
|
|
92
|
+
): string {
|
|
93
|
+
const subject = agentName ? `Agent '${agentName}'` : "Current agent";
|
|
94
|
+
return `${subject} is not permitted to access path '${pathValue}' via tool '${toolName}'. Hard stop: this path permission denial is policy-enforced. Do not retry this path, do not attempt a filesystem bypass, and report the block to the user.`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function formatPathAskPrompt(
|
|
98
|
+
toolName: string,
|
|
99
|
+
pathValue: string,
|
|
100
|
+
agentName?: string,
|
|
101
|
+
): string {
|
|
102
|
+
const subject = agentName ? `Agent '${agentName}'` : "Current agent";
|
|
103
|
+
return `${subject} requested tool '${toolName}' for path '${pathValue}'. Allow this path access?`;
|
|
104
|
+
}
|
|
@@ -22,9 +22,11 @@ import {
|
|
|
22
22
|
type ToolRegistry,
|
|
23
23
|
} from "../tool-registry";
|
|
24
24
|
import { describeBashExternalDirectoryGate } from "./gates/bash-external-directory";
|
|
25
|
+
import { describeBashPathGate } from "./gates/bash-path";
|
|
25
26
|
import type { GateRunnerDeps } from "./gates/descriptor";
|
|
26
27
|
import { isGateBypass } from "./gates/descriptor";
|
|
27
28
|
import { describeExternalDirectoryGate } from "./gates/external-directory";
|
|
29
|
+
import { describePathGate } from "./gates/path";
|
|
28
30
|
import { runGateCheck } from "./gates/runner";
|
|
29
31
|
import { describeSkillReadGate } from "./gates/skill-read";
|
|
30
32
|
import { describeToolGate } from "./gates/tool";
|
|
@@ -140,6 +142,26 @@ export class PermissionGateHandler {
|
|
|
140
142
|
}
|
|
141
143
|
}
|
|
142
144
|
|
|
145
|
+
// ── Path gate for tools (descriptor + runner) ────────────────────────────
|
|
146
|
+
const pathDesc = describePathGate(tcc, checkPermission);
|
|
147
|
+
if (pathDesc) {
|
|
148
|
+
if (isGateBypass(pathDesc)) {
|
|
149
|
+
if (pathDesc.log) {
|
|
150
|
+
writeReviewLog(pathDesc.log.event, pathDesc.log.details);
|
|
151
|
+
}
|
|
152
|
+
} else {
|
|
153
|
+
const pathResult = await runGateCheck(
|
|
154
|
+
pathDesc,
|
|
155
|
+
tcc.agentName,
|
|
156
|
+
tcc.toolCallId,
|
|
157
|
+
runnerDeps,
|
|
158
|
+
);
|
|
159
|
+
if (pathResult.action === "block") {
|
|
160
|
+
return { block: true, reason: pathResult.reason };
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
143
165
|
// ── External-directory gate (descriptor + runner) ────────────────────────
|
|
144
166
|
const infraDirs = [
|
|
145
167
|
...session.getInfrastructureDirs(),
|
|
@@ -191,6 +213,30 @@ export class PermissionGateHandler {
|
|
|
191
213
|
}
|
|
192
214
|
}
|
|
193
215
|
|
|
216
|
+
// ── Bash path gate (descriptor + runner) ────────────────────────────────
|
|
217
|
+
const bashPathDesc = await describeBashPathGate(
|
|
218
|
+
tcc,
|
|
219
|
+
checkPermission,
|
|
220
|
+
getSessionRuleset,
|
|
221
|
+
);
|
|
222
|
+
if (bashPathDesc) {
|
|
223
|
+
if (isGateBypass(bashPathDesc)) {
|
|
224
|
+
if (bashPathDesc.log) {
|
|
225
|
+
writeReviewLog(bashPathDesc.log.event, bashPathDesc.log.details);
|
|
226
|
+
}
|
|
227
|
+
} else {
|
|
228
|
+
const bashPathResult = await runGateCheck(
|
|
229
|
+
bashPathDesc,
|
|
230
|
+
tcc.agentName,
|
|
231
|
+
tcc.toolCallId,
|
|
232
|
+
runnerDeps,
|
|
233
|
+
);
|
|
234
|
+
if (bashPathResult.action === "block") {
|
|
235
|
+
return { block: true, reason: bashPathResult.reason };
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
194
240
|
// ── Normal tool permission gate (descriptor + runner) ────────────────────
|
|
195
241
|
const toolCheck = checkPermission(
|
|
196
242
|
tcc.toolName,
|
package/src/index.ts
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
PermissionGateHandler,
|
|
9
9
|
SessionLifecycleHandler,
|
|
10
10
|
} from "./handlers";
|
|
11
|
+
import { buildInputForSurface } from "./input-normalizer";
|
|
11
12
|
import { requestPermissionDecisionFromUi } from "./permission-dialog";
|
|
12
13
|
import { registerPermissionRpcHandlers } from "./permission-event-rpc";
|
|
13
14
|
import { emitReadyEvent } from "./permission-events";
|
|
@@ -19,6 +20,11 @@ import {
|
|
|
19
20
|
refreshExtensionConfig,
|
|
20
21
|
saveExtensionConfig,
|
|
21
22
|
} from "./runtime";
|
|
23
|
+
import type { PermissionsService } from "./service";
|
|
24
|
+
import {
|
|
25
|
+
publishPermissionsService,
|
|
26
|
+
unpublishPermissionsService,
|
|
27
|
+
} from "./service";
|
|
22
28
|
import { createSessionLogger } from "./session-logger";
|
|
23
29
|
import { isSubagentExecutionContext } from "./subagent-context";
|
|
24
30
|
import {
|
|
@@ -91,6 +97,20 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
91
97
|
writeReviewLog: runtime.writeReviewLog.bind(runtime),
|
|
92
98
|
});
|
|
93
99
|
|
|
100
|
+
const permissionsService: PermissionsService = {
|
|
101
|
+
checkPermission(surface, value, agentName) {
|
|
102
|
+
const input = buildInputForSurface(surface, value);
|
|
103
|
+
const sessionRules = runtime.sessionRules.getRuleset();
|
|
104
|
+
return runtime.permissionManager.checkPermission(
|
|
105
|
+
surface,
|
|
106
|
+
input,
|
|
107
|
+
agentName,
|
|
108
|
+
sessionRules,
|
|
109
|
+
);
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
publishPermissionsService(permissionsService);
|
|
113
|
+
|
|
94
114
|
emitReadyEvent(pi.events);
|
|
95
115
|
|
|
96
116
|
const toolRegistry = {
|
|
@@ -101,6 +121,7 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
101
121
|
const lifecycle = new SessionLifecycleHandler(session, () => {
|
|
102
122
|
rpcHandles.unsubCheck();
|
|
103
123
|
rpcHandles.unsubPrompt();
|
|
124
|
+
unpublishPermissionsService();
|
|
104
125
|
});
|
|
105
126
|
const agentPrep = new AgentPrepHandler(session, toolRegistry);
|
|
106
127
|
const gates = new PermissionGateHandler(session, pi.events, toolRegistry);
|
package/src/input-normalizer.ts
CHANGED
|
@@ -2,6 +2,34 @@ import { toRecord } from "./common";
|
|
|
2
2
|
import { createMcpPermissionTargets } from "./mcp-targets";
|
|
3
3
|
import { getPathBearingToolPath, PATH_BEARING_TOOLS } from "./path-utils";
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* Construct a surface-appropriate input object from a raw value string.
|
|
7
|
+
*
|
|
8
|
+
* This is the inverse of `normalizeInput()` — it builds the minimal input
|
|
9
|
+
* object that `PermissionManager.checkPermission()` expects for a given
|
|
10
|
+
* surface, from a single string value.
|
|
11
|
+
*
|
|
12
|
+
* Used by the event-bus RPC handler and the `Symbol.for()` service accessor
|
|
13
|
+
* so external callers can query policy with `(surface, value)` instead of
|
|
14
|
+
* constructing a full tool-call input payload.
|
|
15
|
+
*
|
|
16
|
+
* Note: MCP inputs are complex (server name + tool name derivation). Callers
|
|
17
|
+
* providing an MCP surface receive a best-effort policy evaluation using the
|
|
18
|
+
* value as a pre-qualified target string. Pass the fully-qualified target
|
|
19
|
+
* (e.g. "exa:search" or "exa") directly.
|
|
20
|
+
*/
|
|
21
|
+
export function buildInputForSurface(
|
|
22
|
+
surface: string,
|
|
23
|
+
value: string | undefined,
|
|
24
|
+
): unknown {
|
|
25
|
+
const v = value ?? "";
|
|
26
|
+
if (surface === "bash") return { command: v };
|
|
27
|
+
if (surface === "skill") return { name: v };
|
|
28
|
+
if (surface === "external_directory") return { path: v };
|
|
29
|
+
// MCP and tool surfaces: normalizeInput handles them from the surface alone.
|
|
30
|
+
return {};
|
|
31
|
+
}
|
|
32
|
+
|
|
5
33
|
/**
|
|
6
34
|
* Surface-normalized representation of a tool invocation used by
|
|
7
35
|
* `checkPermission()` to feed a single `evaluateFirst()` call.
|
|
@@ -22,7 +50,7 @@ export interface NormalizedInput {
|
|
|
22
50
|
resultExtras: Record<string, unknown>;
|
|
23
51
|
}
|
|
24
52
|
|
|
25
|
-
const SPECIAL_PERMISSION_KEYS = new Set(["external_directory"]);
|
|
53
|
+
const SPECIAL_PERMISSION_KEYS = new Set(["external_directory", "path"]);
|
|
26
54
|
|
|
27
55
|
/**
|
|
28
56
|
* Map a raw tool invocation to the surface/values/extras triple needed by
|