@gotgenes/pi-permission-system 5.18.1 → 5.18.3
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 +31 -0
- package/README.md +19 -19
- package/package.json +11 -16
- package/src/handlers/gates/bash-path.ts +8 -0
- package/src/handlers/gates/path.ts +12 -1
- package/src/handlers/gates/skill-read.ts +4 -0
- package/src/handlers/lifecycle.ts +1 -1
- package/src/handlers/permission-gate-handler.ts +1 -1
- package/tests/config-modal.test.ts +43 -58
- package/tests/config-reporter.test.ts +31 -34
- package/tests/handlers/external-directory-integration.test.ts +3 -3
- package/tests/handlers/gates/bash-path.test.ts +57 -3
- package/tests/handlers/gates/path.test.ts +82 -9
- package/tests/handlers/tool-call.test.ts +2 -2
- package/tests/permission-manager-unified.test.ts +26 -0
- package/tests/permission-system.test.ts +313 -358
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,37 @@ 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.3](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v5.18.2...pi-permission-system-v5.18.3) (2026-05-17)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Documentation
|
|
12
|
+
|
|
13
|
+
* **retro:** add retro notes for issue [#58](https://github.com/gotgenes/pi-packages/issues/58) ([27c8a2d](https://github.com/gotgenes/pi-packages/commit/27c8a2d9910029a5e94e1b2eac76735a85c242eb))
|
|
14
|
+
|
|
15
|
+
## [5.18.2](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v5.18.1...pi-permission-system-v5.18.2) (2026-05-17)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
### Bug Fixes
|
|
19
|
+
|
|
20
|
+
* bash path gate skips tokens matching only universal default ([#58](https://github.com/gotgenes/pi-packages/issues/58)) ([33fd169](https://github.com/gotgenes/pi-packages/commit/33fd1693b0c409b16c6a05072f52df78427713a1))
|
|
21
|
+
* restore per-package lint:md and lint scripts ([0e42617](https://github.com/gotgenes/pi-packages/commit/0e42617c443a7f8695f33855fa17058fc1712f27))
|
|
22
|
+
* skip path gate when no explicit path rules configured ([#58](https://github.com/gotgenes/pi-packages/issues/58)) ([a6d55e1](https://github.com/gotgenes/pi-packages/commit/a6d55e17bea4fbb8d4b46259da38ac4c8b919455))
|
|
23
|
+
* use root markdownlint config from all packages ([30192f8](https://github.com/gotgenes/pi-packages/commit/30192f8ccfc5c3c420f9f9b602df174baf263e92))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
### Documentation
|
|
27
|
+
|
|
28
|
+
* add redirect AGENTS.md to each package subdirectory ([cbdcd29](https://github.com/gotgenes/pi-packages/commit/cbdcd297194c814f545ae93eaa7418e9337450d3))
|
|
29
|
+
* plan fix path gate firing for universal default ([#58](https://github.com/gotgenes/pi-packages/issues/58)) ([ae9bbab](https://github.com/gotgenes/pi-packages/commit/ae9bbab1b2f3062bfad754a1a8b9f1a2c3ea29f7))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
### Miscellaneous Chores
|
|
33
|
+
|
|
34
|
+
* consolidate configs into monorepo root ([8583eaf](https://github.com/gotgenes/pi-packages/commit/8583eaf0764ac98def1987f20fafcc25e912b134))
|
|
35
|
+
* remove per-package pi-autoformat configs ([b2d405a](https://github.com/gotgenes/pi-packages/commit/b2d405a0a278341e4f6ff1c8b607533eaa4f021a))
|
|
36
|
+
* replace markdownlint-cli2 with rumdl ([d8dc789](https://github.com/gotgenes/pi-packages/commit/d8dc7897d854bf11396b85bc8c365e8e2ed7e66c))
|
|
37
|
+
* update package.json URLs to monorepo ([b92dbfa](https://github.com/gotgenes/pi-packages/commit/b92dbfaeaeb6cf2823272cb6fb6f206fb99a5009))
|
|
38
|
+
|
|
8
39
|
## [5.18.1](https://github.com/gotgenes/pi-permission-system/compare/v5.18.0...v5.18.1) (2026-05-15)
|
|
9
40
|
|
|
10
41
|
|
package/README.md
CHANGED
|
@@ -54,11 +54,11 @@ pi install npm:@gotgenes/pi-permission-system
|
|
|
54
54
|
|
|
55
55
|
All permissions use one of three states:
|
|
56
56
|
|
|
57
|
-
|State|Behavior|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
57
|
+
| State | Behavior |
|
|
58
|
+
| ------- | ---------------------------------------- |
|
|
59
|
+
| `allow` | Permits the action silently |
|
|
60
|
+
| `deny` | Blocks the action with an error message |
|
|
61
|
+
| `ask` | Prompts the user for confirmation via UI |
|
|
62
62
|
|
|
63
63
|
When the dialog prompts, you can approve once or approve a pattern for the rest of the session.
|
|
64
64
|
See [docs/session-approvals.md](docs/session-approvals.md) for details on session-scoped rules and pattern suggestions.
|
|
@@ -75,10 +75,10 @@ Four layers compose with most-restrictive-wins: `path` (cross-cutting) → `exte
|
|
|
75
75
|
|
|
76
76
|
Config lives in one JSON file per scope:
|
|
77
77
|
|
|
78
|
-
|Scope|Path|
|
|
79
|
-
|
|
80
|
-
|Global
|
|
81
|
-
|Project
|
|
78
|
+
| Scope | Path |
|
|
79
|
+
| ------- | --------------------------------------------------------- |
|
|
80
|
+
| Global | `~/.pi/agent/extensions/pi-permission-system/config.json` |
|
|
81
|
+
| Project | `<cwd>/.pi/extensions/pi-permission-system/config.json` |
|
|
82
82
|
|
|
83
83
|
Project overrides global; per-agent YAML frontmatter overrides both.
|
|
84
84
|
|
|
@@ -88,16 +88,16 @@ For the full reference — all surfaces, runtime knobs, per-agent overrides, mer
|
|
|
88
88
|
|
|
89
89
|
## Documentation
|
|
90
90
|
|
|
91
|
-
|Document|Contents|
|
|
92
|
-
|
|
93
|
-
|[docs/configuration.md](docs/configuration.md)|Full policy reference, runtime knobs, per-agent overrides, recipes|
|
|
94
|
-
|[docs/session-approvals.md](docs/session-approvals.md)|Session-scoped rules, pattern suggestions, bash arity table|
|
|
95
|
-
|[docs/cross-extension-api.md](docs/cross-extension-api.md)|Cross-extension service accessor, event bus integration, decision broadcasts|
|
|
96
|
-
|[docs/subagent-integration.md](docs/subagent-integration.md)|Permission forwarding, coexistence with subagent extensions|
|
|
97
|
-
|[docs/guides/permission-frontmatter-for-subagent-extensions.md](docs/guides/permission-frontmatter-for-subagent-extensions.md)|Convention guide for subagent extension authors|
|
|
98
|
-
|[docs/opencode-compatibility.md](docs/opencode-compatibility.md)|OpenCode compatibility — shared concepts, divergences, porting guide|
|
|
99
|
-
|[docs/troubleshooting.md](docs/troubleshooting.md)|Common issues, diagnostic logging, threat model|
|
|
100
|
-
|[docs/migration/legacy-to-flat.md](docs/migration/legacy-to-flat.md)|Migration from pre-v2 config layout|
|
|
91
|
+
| Document | Contents |
|
|
92
|
+
| ------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------- |
|
|
93
|
+
| [docs/configuration.md](docs/configuration.md) | Full policy reference, runtime knobs, per-agent overrides, recipes |
|
|
94
|
+
| [docs/session-approvals.md](docs/session-approvals.md) | Session-scoped rules, pattern suggestions, bash arity table |
|
|
95
|
+
| [docs/cross-extension-api.md](docs/cross-extension-api.md) | Cross-extension service accessor, event bus integration, decision broadcasts |
|
|
96
|
+
| [docs/subagent-integration.md](docs/subagent-integration.md) | Permission forwarding, coexistence with subagent extensions |
|
|
97
|
+
| [docs/guides/permission-frontmatter-for-subagent-extensions.md](docs/guides/permission-frontmatter-for-subagent-extensions.md) | Convention guide for subagent extension authors |
|
|
98
|
+
| [docs/opencode-compatibility.md](docs/opencode-compatibility.md) | OpenCode compatibility — shared concepts, divergences, porting guide |
|
|
99
|
+
| [docs/troubleshooting.md](docs/troubleshooting.md) | Common issues, diagnostic logging, threat model |
|
|
100
|
+
| [docs/migration/legacy-to-flat.md](docs/migration/legacy-to-flat.md) | Migration from pre-v2 config layout |
|
|
101
101
|
|
|
102
102
|
## Development
|
|
103
103
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gotgenes/pi-permission-system",
|
|
3
|
-
"version": "5.18.
|
|
3
|
+
"version": "5.18.3",
|
|
4
4
|
"description": "Permission enforcement extension for the Pi coding agent.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -33,11 +33,12 @@
|
|
|
33
33
|
"license": "MIT",
|
|
34
34
|
"repository": {
|
|
35
35
|
"type": "git",
|
|
36
|
-
"url": "git+https://github.com/gotgenes/pi-
|
|
36
|
+
"url": "git+https://github.com/gotgenes/pi-packages.git",
|
|
37
|
+
"directory": "packages/pi-permission-system"
|
|
37
38
|
},
|
|
38
|
-
"homepage": "https://github.com/gotgenes/pi-permission-system#readme",
|
|
39
|
+
"homepage": "https://github.com/gotgenes/pi-packages/tree/main/packages/pi-permission-system#readme",
|
|
39
40
|
"bugs": {
|
|
40
|
-
"url": "https://github.com/gotgenes/pi-
|
|
41
|
+
"url": "https://github.com/gotgenes/pi-packages/issues"
|
|
41
42
|
},
|
|
42
43
|
"engines": {
|
|
43
44
|
"node": ">=20"
|
|
@@ -59,25 +60,19 @@
|
|
|
59
60
|
"@earendil-works/pi-coding-agent": "^0.74.0",
|
|
60
61
|
"@earendil-works/pi-tui": "^0.74.0",
|
|
61
62
|
"@types/node": "^25.6.2",
|
|
62
|
-
"
|
|
63
|
-
"
|
|
64
|
-
"
|
|
63
|
+
"typescript": "^6.0.3",
|
|
64
|
+
"vitest": "^4.1.5",
|
|
65
|
+
"rumdl": "^0.1.93"
|
|
65
66
|
},
|
|
66
67
|
"dependencies": {
|
|
67
68
|
"tree-sitter-bash": "^0.25.1",
|
|
68
69
|
"web-tree-sitter": "^0.26.8"
|
|
69
70
|
},
|
|
70
71
|
"scripts": {
|
|
71
|
-
"
|
|
72
|
-
"lint": "biome check .",
|
|
73
|
-
"lint:fix": "biome check --write .",
|
|
74
|
-
"lint:md": "markdownlint-cli2 '*.md' 'docs/**/*.md' '.pi/prompts/**/*.md'",
|
|
75
|
-
"lint:md:fix": "markdownlint-cli2 --fix '*.md' 'docs/**/*.md' '.pi/prompts/**/*.md'",
|
|
76
|
-
"lint:imports": "! grep -rn --include='*.ts' 'from \"\\.[./][^\"]*\\.js\"' src/ tests/",
|
|
77
|
-
"lint:all": "pnpm run lint && pnpm run lint:md && pnpm run lint:imports",
|
|
78
|
-
"format": "biome format --write .",
|
|
72
|
+
"check": "tsc --noEmit",
|
|
79
73
|
"test": "vitest run",
|
|
80
74
|
"test:watch": "vitest",
|
|
81
|
-
"
|
|
75
|
+
"lint:md": "rumdl check '*.md' 'docs/**/*.md' '.pi/prompts/**/*.md'",
|
|
76
|
+
"lint": "biome check . && pnpm run lint:md"
|
|
82
77
|
}
|
|
83
78
|
}
|
|
@@ -56,6 +56,14 @@ export async function describeBashPathGate(
|
|
|
56
56
|
sessionRules,
|
|
57
57
|
);
|
|
58
58
|
|
|
59
|
+
// No explicit path rule matched — only the universal default fired.
|
|
60
|
+
// Treat this token as unrestricted to preserve backward compatibility
|
|
61
|
+
// for configs without a "path" key (#58).
|
|
62
|
+
if (check.matchedPattern === undefined && check.source !== "session") {
|
|
63
|
+
allSessionCovered = false;
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
|
|
59
67
|
if (check.source !== "session") {
|
|
60
68
|
allSessionCovered = false;
|
|
61
69
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { getPathBearingToolPath } from "../../path-utils";
|
|
2
|
+
import type { Rule } from "../../rule";
|
|
2
3
|
import { deriveApprovalPattern } from "../../session-rules";
|
|
3
4
|
import type { PermissionCheckResult } from "../../types";
|
|
4
5
|
import type { GateDescriptor, GateResult } from "./descriptor";
|
|
@@ -9,30 +10,40 @@ type CheckPermissionFn = (
|
|
|
9
10
|
surface: string,
|
|
10
11
|
input: unknown,
|
|
11
12
|
agentName?: string,
|
|
13
|
+
sessionRules?: Rule[],
|
|
12
14
|
) => PermissionCheckResult;
|
|
13
15
|
|
|
14
16
|
/**
|
|
15
17
|
* Build a pure descriptor for the cross-cutting path permission gate (tools).
|
|
16
18
|
*
|
|
17
19
|
* Returns `null` when the gate does not apply (tool is not path-bearing,
|
|
18
|
-
* no extractable path,
|
|
20
|
+
* no extractable path, the `path` surface evaluates to `allow`, or no
|
|
21
|
+
* explicit `path` rule matched — i.e. only the universal default fired).
|
|
19
22
|
* Returns a `GateDescriptor` when the path matches a `deny` or `ask` rule.
|
|
20
23
|
*/
|
|
21
24
|
export function describePathGate(
|
|
22
25
|
tcc: ToolCallContext,
|
|
23
26
|
checkPermission: CheckPermissionFn,
|
|
27
|
+
getSessionRuleset: () => Rule[],
|
|
24
28
|
): GateResult {
|
|
25
29
|
const filePath = getPathBearingToolPath(tcc.toolName, tcc.input);
|
|
26
30
|
if (!filePath) return null;
|
|
27
31
|
|
|
32
|
+
const sessionRules = getSessionRuleset();
|
|
28
33
|
const check = checkPermission(
|
|
29
34
|
"path",
|
|
30
35
|
{ path: filePath },
|
|
31
36
|
tcc.agentName ?? undefined,
|
|
37
|
+
sessionRules,
|
|
32
38
|
);
|
|
33
39
|
|
|
34
40
|
if (check.state === "allow") return null;
|
|
35
41
|
|
|
42
|
+
// No explicit path rule matched — only the universal default fired.
|
|
43
|
+
// Skip the gate to preserve backward compatibility: configs without a
|
|
44
|
+
// "path" key should not trigger path-level prompts (#58).
|
|
45
|
+
if (check.matchedPattern === undefined) return null;
|
|
46
|
+
|
|
36
47
|
const pattern = deriveApprovalPattern(filePath);
|
|
37
48
|
|
|
38
49
|
const descriptor: GateDescriptor = {
|
|
@@ -32,6 +32,10 @@ export function describeSkillReadGate(
|
|
|
32
32
|
return null;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
if (tcc.cwd === undefined) {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
35
39
|
const normalizedReadPath = normalizePathForComparison(path, tcc.cwd);
|
|
36
40
|
const matchedSkill = findSkillPathMatch(
|
|
37
41
|
normalizedReadPath,
|
|
@@ -36,7 +36,7 @@ export class SessionLifecycleHandler {
|
|
|
36
36
|
session.logResolvedConfigPaths();
|
|
37
37
|
|
|
38
38
|
const agentName = session.resolveAgentName(ctx);
|
|
39
|
-
const policyIssues = session.getConfigIssues(agentName);
|
|
39
|
+
const policyIssues = session.getConfigIssues(agentName ?? undefined);
|
|
40
40
|
for (const issue of policyIssues) {
|
|
41
41
|
session.logger.warn(issue);
|
|
42
42
|
}
|
|
@@ -143,7 +143,7 @@ export class PermissionGateHandler {
|
|
|
143
143
|
}
|
|
144
144
|
|
|
145
145
|
// ── Path gate for tools (descriptor + runner) ────────────────────────────
|
|
146
|
-
const pathDesc = describePathGate(tcc, checkPermission);
|
|
146
|
+
const pathDesc = describePathGate(tcc, checkPermission, getSessionRuleset);
|
|
147
147
|
if (pathDesc) {
|
|
148
148
|
if (isGateBypass(pathDesc)) {
|
|
149
149
|
if (pathDesc.log) {
|
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
import assert from "node:assert/strict";
|
|
2
1
|
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
2
|
import { tmpdir } from "node:os";
|
|
4
3
|
import { join } from "node:path";
|
|
5
|
-
import { test, vi } from "vitest";
|
|
4
|
+
import { expect, test, vi } from "vitest";
|
|
6
5
|
import { registerPermissionSystemCommand } from "../src/config-modal";
|
|
7
6
|
import {
|
|
8
7
|
DEFAULT_EXTENSION_CONFIG,
|
|
@@ -105,21 +104,17 @@ test("permission-system command completions expose top-level config actions", ()
|
|
|
105
104
|
controller as never,
|
|
106
105
|
);
|
|
107
106
|
|
|
108
|
-
|
|
109
|
-
assert.ok(typeof definition?.getArgumentCompletions === "function");
|
|
107
|
+
expect(definition!.getArgumentCompletions).toBeTypeOf("function");
|
|
110
108
|
|
|
111
|
-
const topLevel = definition
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
109
|
+
const topLevel = definition!.getArgumentCompletions?.("");
|
|
110
|
+
expect(Array.isArray(topLevel)).toBeTruthy();
|
|
111
|
+
expect(topLevel?.some((item) => item.value === "show")).toBeTruthy();
|
|
112
|
+
expect(topLevel?.some((item) => item.value === "reset")).toBeTruthy();
|
|
115
113
|
|
|
116
|
-
const filtered = definition
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
);
|
|
121
|
-
assert.equal(definition?.getArgumentCompletions?.("path extra"), null);
|
|
122
|
-
assert.equal(definition?.getArgumentCompletions?.("zzz"), null);
|
|
114
|
+
const filtered = definition!.getArgumentCompletions?.("pa");
|
|
115
|
+
expect(filtered?.map((item) => item.value)).toEqual(["path"]);
|
|
116
|
+
expect(definition!.getArgumentCompletions?.("path extra")).toBe(null);
|
|
117
|
+
expect(definition!.getArgumentCompletions?.("zzz")).toBe(null);
|
|
123
118
|
} finally {
|
|
124
119
|
rmSync(baseDir, { recursive: true, force: true });
|
|
125
120
|
}
|
|
@@ -156,7 +151,7 @@ test("permission-system command handlers manage config summary, persistence, and
|
|
|
156
151
|
config = normalizePermissionSystemConfig(
|
|
157
152
|
JSON.parse(readFileSync(configPath, "utf-8")) as unknown,
|
|
158
153
|
);
|
|
159
|
-
|
|
154
|
+
expect(config).not.toEqual(currentConfig);
|
|
160
155
|
},
|
|
161
156
|
getConfigPath: () => configPath,
|
|
162
157
|
};
|
|
@@ -180,40 +175,33 @@ test("permission-system command handlers manage config summary, persistence, and
|
|
|
180
175
|
controller as never,
|
|
181
176
|
);
|
|
182
177
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
(definition?.description ?? "").includes(
|
|
187
|
-
"Configure pi-permission-system",
|
|
188
|
-
),
|
|
178
|
+
expect(registeredName).toBe("permission-system");
|
|
179
|
+
expect(definition!.description ?? "").toContain(
|
|
180
|
+
"Configure pi-permission-system",
|
|
189
181
|
);
|
|
190
182
|
|
|
191
183
|
const infoCtx = createCommandContext(true);
|
|
192
|
-
await definition
|
|
193
|
-
|
|
194
|
-
|
|
184
|
+
await definition!.handler("show", infoCtx.ctx);
|
|
185
|
+
expect(lastNotification(infoCtx.notifications).message).toContain(
|
|
186
|
+
"yoloMode=on",
|
|
195
187
|
);
|
|
196
|
-
|
|
197
|
-
|
|
188
|
+
expect(lastNotification(infoCtx.notifications).message).toContain(
|
|
189
|
+
"debugLog=on",
|
|
198
190
|
);
|
|
199
191
|
|
|
200
|
-
await definition
|
|
201
|
-
|
|
202
|
-
lastNotification(infoCtx.notifications).message,
|
|
192
|
+
await definition!.handler("path", infoCtx.ctx);
|
|
193
|
+
expect(lastNotification(infoCtx.notifications).message).toBe(
|
|
203
194
|
`permission-system config: ${configPath}`,
|
|
204
195
|
);
|
|
205
196
|
|
|
206
|
-
await definition
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
"Usage: /permission-system",
|
|
210
|
-
),
|
|
197
|
+
await definition!.handler("help", infoCtx.ctx);
|
|
198
|
+
expect(lastNotification(infoCtx.notifications).message).toContain(
|
|
199
|
+
"Usage: /permission-system",
|
|
211
200
|
);
|
|
212
201
|
|
|
213
|
-
await definition
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
lastNotification(infoCtx.notifications).message,
|
|
202
|
+
await definition!.handler("reset", infoCtx.ctx);
|
|
203
|
+
expect(config).toEqual(DEFAULT_EXTENSION_CONFIG);
|
|
204
|
+
expect(lastNotification(infoCtx.notifications).message).toBe(
|
|
217
205
|
"Permission system settings reset to defaults.",
|
|
218
206
|
);
|
|
219
207
|
|
|
@@ -221,26 +209,23 @@ test("permission-system command handlers manage config summary, persistence, and
|
|
|
221
209
|
string,
|
|
222
210
|
unknown
|
|
223
211
|
>;
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
await definition
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
"Usage: /permission-system",
|
|
231
|
-
),
|
|
212
|
+
expect(persisted).toEqual(DEFAULT_EXTENSION_CONFIG);
|
|
213
|
+
|
|
214
|
+
await definition!.handler("unknown", infoCtx.ctx);
|
|
215
|
+
expect(lastNotification(infoCtx.notifications).level).toBe("warning");
|
|
216
|
+
expect(lastNotification(infoCtx.notifications).message).toContain(
|
|
217
|
+
"Usage: /permission-system",
|
|
232
218
|
);
|
|
233
219
|
|
|
234
220
|
const headlessCtx = createCommandContext(false);
|
|
235
|
-
await definition
|
|
236
|
-
|
|
237
|
-
lastNotification(headlessCtx.notifications).message,
|
|
221
|
+
await definition!.handler("", headlessCtx.ctx);
|
|
222
|
+
expect(lastNotification(headlessCtx.notifications).message).toBe(
|
|
238
223
|
"/permission-system requires interactive TUI mode.",
|
|
239
224
|
);
|
|
240
225
|
|
|
241
226
|
const modalCtx = createCommandContext(true);
|
|
242
|
-
await definition
|
|
243
|
-
|
|
227
|
+
await definition!.handler("", modalCtx.ctx);
|
|
228
|
+
expect(modalCtx.getCustomCalls()).toBe(1);
|
|
244
229
|
} finally {
|
|
245
230
|
rmSync(baseDir, { recursive: true, force: true });
|
|
246
231
|
}
|
|
@@ -289,10 +274,10 @@ test("show output includes rule origins when getComposedRules is provided", asyn
|
|
|
289
274
|
await definition!.handler("show", ctx.ctx);
|
|
290
275
|
const msg = lastNotification(ctx.notifications).message;
|
|
291
276
|
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
277
|
+
expect(msg).toContain("global");
|
|
278
|
+
expect(msg).toContain("project");
|
|
279
|
+
expect(msg).toContain("read");
|
|
280
|
+
expect(msg).toContain("bash");
|
|
296
281
|
});
|
|
297
282
|
|
|
298
283
|
test("show output omits rule summary when getComposedRules is not provided", async () => {
|
|
@@ -323,7 +308,7 @@ test("show output omits rule summary when getComposedRules is not provided", asy
|
|
|
323
308
|
const msg = lastNotification(ctx.notifications).message;
|
|
324
309
|
|
|
325
310
|
// Config knobs still present.
|
|
326
|
-
|
|
311
|
+
expect(msg).toContain("yoloMode=on");
|
|
327
312
|
// No rule annotation lines.
|
|
328
|
-
|
|
313
|
+
expect(msg).not.toContain("(global)");
|
|
329
314
|
});
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import assert from "node:assert/strict";
|
|
2
1
|
import {
|
|
3
2
|
mkdirSync,
|
|
4
3
|
mkdtempSync,
|
|
@@ -8,7 +7,7 @@ import {
|
|
|
8
7
|
} from "node:fs";
|
|
9
8
|
import { tmpdir } from "node:os";
|
|
10
9
|
import { join } from "node:path";
|
|
11
|
-
import { test } from "vitest";
|
|
10
|
+
import { expect, test } from "vitest";
|
|
12
11
|
import { buildResolvedConfigLogEntry } from "../src/config-reporter";
|
|
13
12
|
import { createPermissionSystemLogger } from "../src/logging";
|
|
14
13
|
import type { ResolvedPolicyPaths } from "../src/permission-manager";
|
|
@@ -30,23 +29,21 @@ test("buildResolvedConfigLogEntry includes policy paths and legacy detection fla
|
|
|
30
29
|
|
|
31
30
|
const result = buildResolvedConfigLogEntry({ policyPaths });
|
|
32
31
|
|
|
33
|
-
|
|
34
|
-
result.globalConfigPath,
|
|
32
|
+
expect(result.globalConfigPath).toBe(
|
|
35
33
|
"/home/user/.pi/agent/extensions/pi-permission-system/config.json",
|
|
36
34
|
);
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
result.projectConfigPath,
|
|
35
|
+
expect(result.globalConfigExists).toBe(true);
|
|
36
|
+
expect(result.projectConfigPath).toBe(
|
|
40
37
|
"/projects/my-app/.pi/extensions/pi-permission-system/config.json",
|
|
41
38
|
);
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
39
|
+
expect(result.projectConfigExists).toBe(false);
|
|
40
|
+
expect(result.agentsDir).toBe("/home/user/.pi/agent/agents");
|
|
41
|
+
expect(result.agentsDirExists).toBe(true);
|
|
42
|
+
expect(result.projectAgentsDir).toBe("/projects/my-app/.pi/agent/agents");
|
|
43
|
+
expect(result.projectAgentsDirExists).toBe(false);
|
|
44
|
+
expect(result.legacyGlobalPolicyDetected).toBe(false);
|
|
45
|
+
expect(result.legacyProjectPolicyDetected).toBe(false);
|
|
46
|
+
expect(result.legacyExtensionConfigDetected).toBe(false);
|
|
50
47
|
});
|
|
51
48
|
|
|
52
49
|
test("buildResolvedConfigLogEntry handles null project paths", () => {
|
|
@@ -64,10 +61,10 @@ test("buildResolvedConfigLogEntry handles null project paths", () => {
|
|
|
64
61
|
|
|
65
62
|
const result = buildResolvedConfigLogEntry({ policyPaths });
|
|
66
63
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
64
|
+
expect(result.projectConfigPath).toBe(null);
|
|
65
|
+
expect(result.projectConfigExists).toBe(false);
|
|
66
|
+
expect(result.projectAgentsDir).toBe(null);
|
|
67
|
+
expect(result.projectAgentsDirExists).toBe(false);
|
|
71
68
|
});
|
|
72
69
|
|
|
73
70
|
test("buildResolvedConfigLogEntry surfaces legacy detection flags", () => {
|
|
@@ -89,9 +86,9 @@ test("buildResolvedConfigLogEntry surfaces legacy detection flags", () => {
|
|
|
89
86
|
legacyExtensionConfigDetected: true,
|
|
90
87
|
});
|
|
91
88
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
89
|
+
expect(result.legacyGlobalPolicyDetected).toBe(true);
|
|
90
|
+
expect(result.legacyProjectPolicyDetected).toBe(false);
|
|
91
|
+
expect(result.legacyExtensionConfigDetected).toBe(true);
|
|
95
92
|
});
|
|
96
93
|
|
|
97
94
|
test("config.resolved entry appears in review log via logger", () => {
|
|
@@ -132,18 +129,18 @@ test("config.resolved entry appears in review log via logger", () => {
|
|
|
132
129
|
const logContent = readFileSync(reviewLogPath, "utf-8").trim();
|
|
133
130
|
const parsed = JSON.parse(logContent) as Record<string, unknown>;
|
|
134
131
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
132
|
+
expect(parsed.event).toBe("config.resolved");
|
|
133
|
+
expect(parsed.globalConfigPath).toBe(globalConfigPath);
|
|
134
|
+
expect(parsed.globalConfigExists).toBe(true);
|
|
135
|
+
expect(parsed.agentsDir).toBe(agentsDir);
|
|
136
|
+
expect(parsed.agentsDirExists).toBe(false);
|
|
137
|
+
expect(parsed.projectConfigPath).toBe(null);
|
|
138
|
+
expect(parsed.projectConfigExists).toBe(false);
|
|
139
|
+
expect(parsed.projectAgentsDir).toBe(null);
|
|
140
|
+
expect(parsed.projectAgentsDirExists).toBe(false);
|
|
141
|
+
expect(parsed.legacyGlobalPolicyDetected).toBe(false);
|
|
142
|
+
expect(parsed.legacyProjectPolicyDetected).toBe(false);
|
|
143
|
+
expect(parsed.legacyExtensionConfigDetected).toBe(false);
|
|
147
144
|
} finally {
|
|
148
145
|
rmSync(tempDir, { recursive: true, force: true });
|
|
149
146
|
}
|
|
@@ -308,7 +308,7 @@ describe("external_directory policy state — allow", () => {
|
|
|
308
308
|
const reviewCalls = (session.logger.review as ReturnType<typeof vi.fn>).mock
|
|
309
309
|
.calls;
|
|
310
310
|
const blockEntries = reviewCalls.filter(
|
|
311
|
-
([eventName]: [
|
|
311
|
+
([eventName]: string[]) => eventName === "permission_request.blocked",
|
|
312
312
|
);
|
|
313
313
|
expect(blockEntries).toHaveLength(0);
|
|
314
314
|
});
|
|
@@ -404,7 +404,7 @@ describe("external_directory policy state — deny", () => {
|
|
|
404
404
|
const reviewCalls = (session.logger.review as ReturnType<typeof vi.fn>).mock
|
|
405
405
|
.calls;
|
|
406
406
|
const blockEntries = reviewCalls.filter(
|
|
407
|
-
([eventName]: [
|
|
407
|
+
([eventName]: string[]) => eventName === "permission_request.blocked",
|
|
408
408
|
);
|
|
409
409
|
expect(blockEntries.length).toBeGreaterThanOrEqual(1);
|
|
410
410
|
expect(blockEntries[0][1]).toMatchObject({
|
|
@@ -546,7 +546,7 @@ describe("external_directory policy state — ask", () => {
|
|
|
546
546
|
const reviewCalls = (session.logger.review as ReturnType<typeof vi.fn>).mock
|
|
547
547
|
.calls;
|
|
548
548
|
const blockEntries = reviewCalls.filter(
|
|
549
|
-
([eventName]: [
|
|
549
|
+
([eventName]: string[]) => eventName === "permission_request.blocked",
|
|
550
550
|
);
|
|
551
551
|
expect(blockEntries.length).toBeGreaterThanOrEqual(1);
|
|
552
552
|
expect(blockEntries[0][1]).toMatchObject({
|
|
@@ -119,7 +119,7 @@ describe("describeBashPathGate", () => {
|
|
|
119
119
|
it("returns GateDescriptor when a token evaluates to ask", async () => {
|
|
120
120
|
const checkPermission = vi
|
|
121
121
|
.fn<CheckPermissionFn>()
|
|
122
|
-
.mockReturnValue(makeCheckResult({ state: "ask" }));
|
|
122
|
+
.mockReturnValue(makeCheckResult({ state: "ask", matchedPattern: "*" }));
|
|
123
123
|
const getSessionRuleset = vi.fn<() => Rule[]>().mockReturnValue([]);
|
|
124
124
|
const result = await describeBashPathGate(
|
|
125
125
|
makeTcc({ input: { command: "cat .env" } }),
|
|
@@ -135,7 +135,9 @@ describe("describeBashPathGate", () => {
|
|
|
135
135
|
it("descriptor includes triggering token in prompt message", async () => {
|
|
136
136
|
const checkPermission = vi
|
|
137
137
|
.fn<CheckPermissionFn>()
|
|
138
|
-
.mockReturnValue(
|
|
138
|
+
.mockReturnValue(
|
|
139
|
+
makeCheckResult({ state: "deny", matchedPattern: "*.env" }),
|
|
140
|
+
);
|
|
139
141
|
const getSessionRuleset = vi.fn<() => Rule[]>().mockReturnValue([]);
|
|
140
142
|
const result = (await describeBashPathGate(
|
|
141
143
|
makeTcc({ input: { command: "cat .env" } }),
|
|
@@ -149,7 +151,9 @@ describe("describeBashPathGate", () => {
|
|
|
149
151
|
it("descriptor decision uses surface 'path'", async () => {
|
|
150
152
|
const checkPermission = vi
|
|
151
153
|
.fn<CheckPermissionFn>()
|
|
152
|
-
.mockReturnValue(
|
|
154
|
+
.mockReturnValue(
|
|
155
|
+
makeCheckResult({ state: "deny", matchedPattern: "*.env" }),
|
|
156
|
+
);
|
|
153
157
|
const getSessionRuleset = vi.fn<() => Rule[]>().mockReturnValue([]);
|
|
154
158
|
const result = (await describeBashPathGate(
|
|
155
159
|
makeTcc({ input: { command: "cat .env" } }),
|
|
@@ -257,4 +261,54 @@ describe("describeBashPathGate", () => {
|
|
|
257
261
|
expect(isGateDescriptor(result)).toBe(true);
|
|
258
262
|
expect((result as GateDescriptor).preCheck?.state).toBe("deny");
|
|
259
263
|
});
|
|
264
|
+
|
|
265
|
+
it("returns null when all tokens match only the universal default", async () => {
|
|
266
|
+
const checkPermission = vi.fn<CheckPermissionFn>().mockReturnValue(
|
|
267
|
+
makeCheckResult({
|
|
268
|
+
state: "ask",
|
|
269
|
+
matchedPattern: undefined,
|
|
270
|
+
source: "special",
|
|
271
|
+
origin: "builtin",
|
|
272
|
+
}),
|
|
273
|
+
);
|
|
274
|
+
const getSessionRuleset = vi.fn<() => Rule[]>().mockReturnValue([]);
|
|
275
|
+
const result = await describeBashPathGate(
|
|
276
|
+
makeTcc({ input: { command: "cat .env" } }),
|
|
277
|
+
checkPermission,
|
|
278
|
+
getSessionRuleset,
|
|
279
|
+
);
|
|
280
|
+
expect(result).toBeNull();
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it("ignores tokens matching universal default but fires for explicit rule matches", async () => {
|
|
284
|
+
const checkPermission = vi
|
|
285
|
+
.fn<CheckPermissionFn>()
|
|
286
|
+
.mockImplementation((_surface, input) => {
|
|
287
|
+
const record = input as Record<string, unknown>;
|
|
288
|
+
if (record.path === ".env") {
|
|
289
|
+
return makeCheckResult({
|
|
290
|
+
state: "deny",
|
|
291
|
+
matchedPattern: "*.env",
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
// Other tokens match only the universal default
|
|
295
|
+
return makeCheckResult({
|
|
296
|
+
state: "ask",
|
|
297
|
+
matchedPattern: undefined,
|
|
298
|
+
source: "special",
|
|
299
|
+
origin: "builtin",
|
|
300
|
+
});
|
|
301
|
+
});
|
|
302
|
+
const getSessionRuleset = vi.fn<() => Rule[]>().mockReturnValue([]);
|
|
303
|
+
const result = await describeBashPathGate(
|
|
304
|
+
makeTcc({ input: { command: "cat src/foo.ts .env" } }),
|
|
305
|
+
checkPermission,
|
|
306
|
+
getSessionRuleset,
|
|
307
|
+
);
|
|
308
|
+
expect(result).not.toBeNull();
|
|
309
|
+
expect(isGateDescriptor(result)).toBe(true);
|
|
310
|
+
const desc = result as GateDescriptor;
|
|
311
|
+
expect(desc.preCheck?.state).toBe("deny");
|
|
312
|
+
expect(desc.decision.value).toBe(".env");
|
|
313
|
+
});
|
|
260
314
|
});
|