@gotgenes/pi-permission-system 13.0.0 → 13.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +24 -0
- package/config/config.example.json +2 -1
- package/package.json +1 -1
- package/schemas/permissions.schema.json +27 -1
- package/src/common.ts +17 -1
- package/src/config-loader.ts +9 -5
- package/src/denial-messages.ts +2 -1
- package/src/forwarded-permissions/io.ts +28 -12
- package/src/forwarded-permissions/permission-forwarder.ts +15 -0
- package/src/normalize.ts +12 -2
- package/src/permission-manager.ts +1 -0
- package/src/rule.ts +2 -0
- package/src/types.ts +18 -3
- package/test/common.test.ts +28 -0
- package/test/config-loader.test.ts +43 -0
- package/test/denial-messages.test.ts +61 -0
- package/test/forwarded-permissions/io.test.ts +120 -1
- package/test/normalize.test.ts +81 -0
- package/test/permission-forwarder.test.ts +59 -0
- package/test/permission-manager-unified.test.ts +65 -0
- package/test/rule.test.ts +61 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,30 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [13.1.1](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v13.1.0...pi-permission-system-v13.1.1) (2026-06-13)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
|
|
13
|
+
* preserve forwarded-permission responses dir while requests pending ([#398](https://github.com/gotgenes/pi-packages/issues/398)) ([9914e70](https://github.com/gotgenes/pi-packages/commit/9914e7093c5addee80bd39f2ff99211991b4a238))
|
|
14
|
+
* recreate forwarded-permission responses dir before write ([#398](https://github.com/gotgenes/pi-packages/issues/398)) ([67d34ef](https://github.com/gotgenes/pi-packages/commit/67d34efb33dbda28f363a004d0945c2a4aacea29))
|
|
15
|
+
|
|
16
|
+
## [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)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
### Features
|
|
20
|
+
|
|
21
|
+
* **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)
|
|
22
|
+
* **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)
|
|
23
|
+
* **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)
|
|
24
|
+
* **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)
|
|
25
|
+
* **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)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
### Documentation
|
|
29
|
+
|
|
30
|
+
* **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)
|
|
31
|
+
|
|
8
32
|
## [13.0.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v12.0.0...pi-permission-system-v13.0.0) (2026-06-12)
|
|
9
33
|
|
|
10
34
|
|
|
@@ -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
|
@@ -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/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
|
/**
|
|
@@ -175,13 +175,20 @@ export function getExistingPermissionForwardingLocation(
|
|
|
175
175
|
return existsSync(location.requestsDir) ? location : null;
|
|
176
176
|
}
|
|
177
177
|
|
|
178
|
+
/**
|
|
179
|
+
* Attempt to remove a directory if it is empty.
|
|
180
|
+
*
|
|
181
|
+
* Returns `true` when the directory is absent after the call (successfully
|
|
182
|
+
* removed, or never existed). Returns `false` when the directory still exists
|
|
183
|
+
* (non-empty, or a filesystem error prevented removal).
|
|
184
|
+
*/
|
|
178
185
|
export function tryRemoveDirectoryIfEmpty(
|
|
179
186
|
logger: DebugReviewLogger | null,
|
|
180
187
|
path: string,
|
|
181
188
|
description: string,
|
|
182
|
-
):
|
|
189
|
+
): boolean {
|
|
183
190
|
if (!existsSync(path)) {
|
|
184
|
-
return;
|
|
191
|
+
return true;
|
|
185
192
|
}
|
|
186
193
|
|
|
187
194
|
let entries: string[];
|
|
@@ -193,18 +200,22 @@ export function tryRemoveDirectoryIfEmpty(
|
|
|
193
200
|
`Failed to inspect ${description} directory '${path}'`,
|
|
194
201
|
error,
|
|
195
202
|
);
|
|
196
|
-
return;
|
|
203
|
+
return false;
|
|
197
204
|
}
|
|
198
205
|
|
|
199
206
|
if (entries.length > 0) {
|
|
200
|
-
return;
|
|
207
|
+
return false;
|
|
201
208
|
}
|
|
202
209
|
|
|
203
210
|
try {
|
|
204
211
|
rmdirSync(path);
|
|
212
|
+
return true;
|
|
205
213
|
} catch (error) {
|
|
206
|
-
if (isErrnoCode(error, "ENOENT")
|
|
207
|
-
return;
|
|
214
|
+
if (isErrnoCode(error, "ENOENT")) {
|
|
215
|
+
return true;
|
|
216
|
+
}
|
|
217
|
+
if (isErrnoCode(error, "ENOTEMPTY")) {
|
|
218
|
+
return false;
|
|
208
219
|
}
|
|
209
220
|
|
|
210
221
|
logPermissionForwardingWarning(
|
|
@@ -212,6 +223,7 @@ export function tryRemoveDirectoryIfEmpty(
|
|
|
212
223
|
`Failed to remove empty ${description} directory '${path}'`,
|
|
213
224
|
error,
|
|
214
225
|
);
|
|
226
|
+
return false;
|
|
215
227
|
}
|
|
216
228
|
}
|
|
217
229
|
|
|
@@ -219,16 +231,20 @@ export function cleanupPermissionForwardingLocationIfEmpty(
|
|
|
219
231
|
logger: DebugReviewLogger | null,
|
|
220
232
|
location: PermissionForwardingLocation,
|
|
221
233
|
): void {
|
|
222
|
-
|
|
234
|
+
// Only remove responses/ when requests/ is already gone — removing responses/
|
|
235
|
+
// while a request is still pending causes the ENOENT write loop (issue #398).
|
|
236
|
+
const requestsGone = tryRemoveDirectoryIfEmpty(
|
|
223
237
|
logger,
|
|
224
238
|
location.requestsDir,
|
|
225
239
|
`${location.label} permission forwarding requests`,
|
|
226
240
|
);
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
241
|
+
if (requestsGone) {
|
|
242
|
+
tryRemoveDirectoryIfEmpty(
|
|
243
|
+
logger,
|
|
244
|
+
location.responsesDir,
|
|
245
|
+
`${location.label} permission forwarding responses`,
|
|
246
|
+
);
|
|
247
|
+
}
|
|
232
248
|
tryRemoveDirectoryIfEmpty(
|
|
233
249
|
logger,
|
|
234
250
|
location.sessionRootDir,
|
|
@@ -35,6 +35,7 @@ import { shouldAutoApprovePermissionState } from "#src/yolo-mode";
|
|
|
35
35
|
|
|
36
36
|
import {
|
|
37
37
|
cleanupPermissionForwardingLocationIfEmpty,
|
|
38
|
+
ensureDirectoryExists,
|
|
38
39
|
ensurePermissionForwardingLocation,
|
|
39
40
|
getExistingPermissionForwardingLocation,
|
|
40
41
|
listRequestFiles,
|
|
@@ -255,6 +256,20 @@ export class PermissionForwarder implements ApprovalRequester, InboxProcessor {
|
|
|
255
256
|
return;
|
|
256
257
|
}
|
|
257
258
|
|
|
259
|
+
// Defensively recreate responses/ before writing any response — a
|
|
260
|
+
// concurrent cleanup pass may have removed it between the requestsDir
|
|
261
|
+
// existence check above and the write inside processSingleForwardedRequest
|
|
262
|
+
// (the ENOENT write loop reported in issue #398).
|
|
263
|
+
if (
|
|
264
|
+
!ensureDirectoryExists(
|
|
265
|
+
this.logger,
|
|
266
|
+
location.responsesDir,
|
|
267
|
+
"permission forwarding responses",
|
|
268
|
+
)
|
|
269
|
+
) {
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
|
|
258
273
|
for (const fileName of requestFiles) {
|
|
259
274
|
const requestPath = join(location.requestsDir, fileName);
|
|
260
275
|
const request = readForwardedPermissionRequest(this.logger, requestPath);
|
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
|
}
|
package/src/rule.ts
CHANGED
|
@@ -27,6 +27,8 @@ export interface Rule {
|
|
|
27
27
|
pattern: string;
|
|
28
28
|
/** The permission decision. */
|
|
29
29
|
action: PermissionState;
|
|
30
|
+
/** Custom denial reason for deny rules (optional). */
|
|
31
|
+
reason?: string;
|
|
30
32
|
/**
|
|
31
33
|
* Origin layer — used to derive PermissionCheckResult.source after evaluation.
|
|
32
34
|
* Not used by evaluate(); purely informational metadata.
|
package/src/types.ts
CHANGED
|
@@ -4,14 +4,27 @@ import type { RuleOrigin } from "./rule";
|
|
|
4
4
|
|
|
5
5
|
export type { RuleOrigin };
|
|
6
6
|
|
|
7
|
+
/**
|
|
8
|
+
* A deny action with an optional reason annotation, used when a pattern maps
|
|
9
|
+
* to an object instead of a plain PermissionState string.
|
|
10
|
+
*/
|
|
11
|
+
export interface DenyWithReason {
|
|
12
|
+
action: "deny";
|
|
13
|
+
reason?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** A pattern value: a PermissionState string OR a DenyWithReason object. */
|
|
17
|
+
export type PatternValue = PermissionState | DenyWithReason;
|
|
18
|
+
|
|
7
19
|
/**
|
|
8
20
|
* The on-disk permission shape inside the `"permission"` key.
|
|
9
|
-
*
|
|
10
|
-
*
|
|
21
|
+
* A surface value is a PermissionState string (shorthand for `{ "*": action }`)
|
|
22
|
+
* or a pattern→value map. Pattern values may be a PermissionState string or a
|
|
23
|
+
* DenyWithReason object. A top-level value is never a bare DenyWithReason.
|
|
11
24
|
*/
|
|
12
25
|
export type FlatPermissionConfig = Record<
|
|
13
26
|
string,
|
|
14
|
-
PermissionState | Record<string,
|
|
27
|
+
PermissionState | Record<string, PatternValue>
|
|
15
28
|
>;
|
|
16
29
|
|
|
17
30
|
/**
|
|
@@ -34,6 +47,8 @@ export type BashCommandContext =
|
|
|
34
47
|
export interface PermissionCheckResult {
|
|
35
48
|
toolName: string;
|
|
36
49
|
state: PermissionState;
|
|
50
|
+
/** Custom denial reason from a deny-with-reason pattern, when present. */
|
|
51
|
+
reason?: string;
|
|
37
52
|
matchedPattern?: string;
|
|
38
53
|
command?: string;
|
|
39
54
|
target?: string;
|
package/test/common.test.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { afterEach, describe, expect, it, test, vi } from "vitest";
|
|
|
3
3
|
import {
|
|
4
4
|
extractFrontmatter,
|
|
5
5
|
getNonEmptyString,
|
|
6
|
+
isDenyWithReason,
|
|
6
7
|
isPermissionState,
|
|
7
8
|
normalizeOptionalPositiveInt,
|
|
8
9
|
normalizeOptionalStringArray,
|
|
@@ -102,6 +103,33 @@ describe("isPermissionState", () => {
|
|
|
102
103
|
});
|
|
103
104
|
});
|
|
104
105
|
|
|
106
|
+
describe("isDenyWithReason", () => {
|
|
107
|
+
test("returns true for { action: 'deny' } without a reason", () => {
|
|
108
|
+
expect(isDenyWithReason({ action: "deny" })).toBe(true);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("returns true for { action: 'deny', reason: '...' }", () => {
|
|
112
|
+
expect(isDenyWithReason({ action: "deny", reason: "Use pnpm" })).toBe(true);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("returns false for non-deny actions", () => {
|
|
116
|
+
expect(isDenyWithReason({ action: "allow" })).toBe(false);
|
|
117
|
+
expect(isDenyWithReason({ action: "ask" })).toBe(false);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("returns false for a non-string reason", () => {
|
|
121
|
+
expect(isDenyWithReason({ action: "deny", reason: 42 })).toBe(false);
|
|
122
|
+
expect(isDenyWithReason({ action: "deny", reason: null })).toBe(false);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("returns false for non-object types", () => {
|
|
126
|
+
expect(isDenyWithReason(null)).toBe(false);
|
|
127
|
+
expect(isDenyWithReason(undefined)).toBe(false);
|
|
128
|
+
expect(isDenyWithReason("deny")).toBe(false);
|
|
129
|
+
expect(isDenyWithReason(["deny"])).toBe(false);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
105
133
|
describe("extractFrontmatter", () => {
|
|
106
134
|
test("returns empty string when no frontmatter delimiter", () => {
|
|
107
135
|
expect(extractFrontmatter("# Hello\nSome content")).toBe("");
|
|
@@ -243,6 +243,49 @@ describe("loadUnifiedConfig", () => {
|
|
|
243
243
|
});
|
|
244
244
|
});
|
|
245
245
|
|
|
246
|
+
it("preserves a deny-with-reason object inside a pattern map", () => {
|
|
247
|
+
const configPath = join(tempDir, "config.json");
|
|
248
|
+
writeFileSync(
|
|
249
|
+
configPath,
|
|
250
|
+
JSON.stringify({
|
|
251
|
+
permission: {
|
|
252
|
+
bash: {
|
|
253
|
+
"git *": "allow",
|
|
254
|
+
"npm *": { action: "deny", reason: "Use pnpm instead" },
|
|
255
|
+
},
|
|
256
|
+
},
|
|
257
|
+
}),
|
|
258
|
+
);
|
|
259
|
+
|
|
260
|
+
const result = loadUnifiedConfig(configPath);
|
|
261
|
+
expect(result.config.permission).toEqual({
|
|
262
|
+
bash: {
|
|
263
|
+
"git *": "allow",
|
|
264
|
+
"npm *": { action: "deny", reason: "Use pnpm instead" },
|
|
265
|
+
},
|
|
266
|
+
});
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it("strips a deny object with a non-string reason (malformed)", () => {
|
|
270
|
+
const configPath = join(tempDir, "config.json");
|
|
271
|
+
writeFileSync(
|
|
272
|
+
configPath,
|
|
273
|
+
JSON.stringify({
|
|
274
|
+
permission: {
|
|
275
|
+
bash: {
|
|
276
|
+
"git *": "allow",
|
|
277
|
+
"npm *": { action: "deny", reason: 42 },
|
|
278
|
+
},
|
|
279
|
+
},
|
|
280
|
+
}),
|
|
281
|
+
);
|
|
282
|
+
|
|
283
|
+
const result = loadUnifiedConfig(configPath);
|
|
284
|
+
expect(result.config.permission).toEqual({
|
|
285
|
+
bash: { "git *": "allow" },
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
|
|
246
289
|
it("returns no permission when the permission field is absent", () => {
|
|
247
290
|
const configPath = join(tempDir, "config.json");
|
|
248
291
|
writeFileSync(configPath, JSON.stringify({ debugLog: false }));
|
|
@@ -114,6 +114,67 @@ describe("formatDenyReason", () => {
|
|
|
114
114
|
);
|
|
115
115
|
});
|
|
116
116
|
|
|
117
|
+
test("bash with a custom reason appended after the period", () => {
|
|
118
|
+
expect(
|
|
119
|
+
formatDenyReason(
|
|
120
|
+
toolCtx(
|
|
121
|
+
toolCheck("bash", {
|
|
122
|
+
command: "npm install",
|
|
123
|
+
matchedPattern: "npm *",
|
|
124
|
+
reason: "Use pnpm instead",
|
|
125
|
+
}),
|
|
126
|
+
),
|
|
127
|
+
),
|
|
128
|
+
).toBe(
|
|
129
|
+
"[pi-permission-system] is not permitted to run 'bash' command 'npm install' (matched 'npm *'). Reason: Use pnpm instead.",
|
|
130
|
+
);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("custom reason with no matched pattern", () => {
|
|
134
|
+
expect(
|
|
135
|
+
formatDenyReason(
|
|
136
|
+
toolCtx(
|
|
137
|
+
toolCheck("write", {
|
|
138
|
+
reason: "Write access is disabled for security",
|
|
139
|
+
}),
|
|
140
|
+
),
|
|
141
|
+
),
|
|
142
|
+
).toBe(
|
|
143
|
+
"[pi-permission-system] is not permitted to run 'write'. Reason: Write access is disabled for security.",
|
|
144
|
+
);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("custom reason is included alongside the agent name", () => {
|
|
148
|
+
expect(
|
|
149
|
+
formatDenyReason(
|
|
150
|
+
toolCtx(
|
|
151
|
+
toolCheck("bash", {
|
|
152
|
+
command: "yarn build",
|
|
153
|
+
matchedPattern: "yarn *",
|
|
154
|
+
reason: "Use pnpm instead",
|
|
155
|
+
}),
|
|
156
|
+
"dev-agent",
|
|
157
|
+
),
|
|
158
|
+
),
|
|
159
|
+
).toBe(
|
|
160
|
+
"[pi-permission-system] Agent 'dev-agent' is not permitted to run 'bash' command 'yarn build' (matched 'yarn *'). Reason: Use pnpm instead.",
|
|
161
|
+
);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("custom reason on an MCP target", () => {
|
|
165
|
+
expect(
|
|
166
|
+
formatDenyReason(
|
|
167
|
+
toolCtx(
|
|
168
|
+
mcpCheck("server:deploy", {
|
|
169
|
+
reason: "Deploy requires approval from a senior engineer",
|
|
170
|
+
}),
|
|
171
|
+
),
|
|
172
|
+
),
|
|
173
|
+
).toBe(
|
|
174
|
+
"[pi-permission-system] is not permitted to run MCP target 'server:deploy'. Reason: Deploy requires approval from a senior engineer.",
|
|
175
|
+
);
|
|
176
|
+
});
|
|
177
|
+
|
|
117
178
|
test("MCP source with target on non-mcp toolName", () => {
|
|
118
179
|
expect(
|
|
119
180
|
formatDenyReason(
|
|
@@ -1,11 +1,24 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
existsSync,
|
|
3
|
+
mkdirSync,
|
|
4
|
+
mkdtempSync,
|
|
5
|
+
rmSync,
|
|
6
|
+
writeFileSync,
|
|
7
|
+
} from "node:fs";
|
|
8
|
+
import { tmpdir } from "node:os";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
|
|
11
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
12
|
|
|
3
13
|
import {
|
|
14
|
+
cleanupPermissionForwardingLocationIfEmpty,
|
|
4
15
|
formatUnknownErrorMessage,
|
|
5
16
|
isErrnoCode,
|
|
6
17
|
logPermissionForwardingError,
|
|
7
18
|
logPermissionForwardingWarning,
|
|
19
|
+
tryRemoveDirectoryIfEmpty,
|
|
8
20
|
} from "#src/forwarded-permissions/io";
|
|
21
|
+
import { createPermissionForwardingLocation } from "#src/permission-forwarding";
|
|
9
22
|
import type { DebugReviewLogger } from "#src/session-logger";
|
|
10
23
|
|
|
11
24
|
// ── helpers ────────────────────────────────────────────────────────────────
|
|
@@ -130,3 +143,109 @@ describe("logPermissionForwardingError", () => {
|
|
|
130
143
|
expect(() => logPermissionForwardingError(null, "ignored")).not.toThrow();
|
|
131
144
|
});
|
|
132
145
|
});
|
|
146
|
+
|
|
147
|
+
// ── tryRemoveDirectoryIfEmpty ──────────────────────────────────────────────
|
|
148
|
+
|
|
149
|
+
describe("tryRemoveDirectoryIfEmpty", () => {
|
|
150
|
+
let root: string;
|
|
151
|
+
|
|
152
|
+
afterEach(() => {
|
|
153
|
+
rmSync(root, { recursive: true, force: true });
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("returns true when the directory does not exist", () => {
|
|
157
|
+
root = mkdtempSync(join(tmpdir(), "io-test-"));
|
|
158
|
+
const absent = join(root, "nonexistent");
|
|
159
|
+
expect(tryRemoveDirectoryIfEmpty(null, absent, "test")).toBe(true);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("returns true and removes an empty directory", () => {
|
|
163
|
+
root = mkdtempSync(join(tmpdir(), "io-test-"));
|
|
164
|
+
const dir = join(root, "empty");
|
|
165
|
+
mkdirSync(dir);
|
|
166
|
+
expect(tryRemoveDirectoryIfEmpty(null, dir, "test")).toBe(true);
|
|
167
|
+
expect(existsSync(dir)).toBe(false);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("returns false and leaves a non-empty directory in place", () => {
|
|
171
|
+
root = mkdtempSync(join(tmpdir(), "io-test-"));
|
|
172
|
+
const dir = join(root, "nonempty");
|
|
173
|
+
mkdirSync(dir);
|
|
174
|
+
writeFileSync(join(dir, "file.json"), "{}", "utf-8");
|
|
175
|
+
expect(tryRemoveDirectoryIfEmpty(null, dir, "test")).toBe(false);
|
|
176
|
+
expect(existsSync(dir)).toBe(true);
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
// ── cleanupPermissionForwardingLocationIfEmpty ─────────────────────────────
|
|
181
|
+
|
|
182
|
+
describe("cleanupPermissionForwardingLocationIfEmpty", () => {
|
|
183
|
+
let root: string;
|
|
184
|
+
|
|
185
|
+
afterEach(() => {
|
|
186
|
+
rmSync(root, { recursive: true, force: true });
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("preserves responses/ when requests/ is non-empty (the concurrent-request race)", () => {
|
|
190
|
+
root = mkdtempSync(join(tmpdir(), "io-cleanup-"));
|
|
191
|
+
const forwardingDir = join(root, "forwarding");
|
|
192
|
+
const location = createPermissionForwardingLocation(
|
|
193
|
+
forwardingDir,
|
|
194
|
+
"parent-session",
|
|
195
|
+
);
|
|
196
|
+
// Simulate: requests/ has a pending file, responses/ is momentarily empty
|
|
197
|
+
mkdirSync(location.requestsDir, { recursive: true });
|
|
198
|
+
mkdirSync(location.responsesDir, { recursive: true });
|
|
199
|
+
writeFileSync(join(location.requestsDir, "req-b.json"), "{}", "utf-8");
|
|
200
|
+
// responses/ is empty (sibling subagent A already cleaned up its response)
|
|
201
|
+
|
|
202
|
+
cleanupPermissionForwardingLocationIfEmpty(null, location);
|
|
203
|
+
|
|
204
|
+
// requests/ is non-empty → should NOT be removed
|
|
205
|
+
expect(existsSync(location.requestsDir)).toBe(true);
|
|
206
|
+
// responses/ must survive — removing it causes the ENOENT write loop
|
|
207
|
+
expect(existsSync(location.responsesDir)).toBe(true);
|
|
208
|
+
// sessionRoot must also survive while subdirs are present
|
|
209
|
+
expect(existsSync(location.sessionRootDir)).toBe(true);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("removes both subdirs and sessionRoot when both are empty (normal serial cleanup)", () => {
|
|
213
|
+
root = mkdtempSync(join(tmpdir(), "io-cleanup-"));
|
|
214
|
+
const forwardingDir = join(root, "forwarding");
|
|
215
|
+
const location = createPermissionForwardingLocation(
|
|
216
|
+
forwardingDir,
|
|
217
|
+
"parent-session",
|
|
218
|
+
);
|
|
219
|
+
mkdirSync(location.requestsDir, { recursive: true });
|
|
220
|
+
mkdirSync(location.responsesDir, { recursive: true });
|
|
221
|
+
// Both empty — normal end-of-lifecycle state
|
|
222
|
+
|
|
223
|
+
cleanupPermissionForwardingLocationIfEmpty(null, location);
|
|
224
|
+
|
|
225
|
+
expect(existsSync(location.requestsDir)).toBe(false);
|
|
226
|
+
expect(existsSync(location.responsesDir)).toBe(false);
|
|
227
|
+
expect(existsSync(location.sessionRootDir)).toBe(false);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it("leaves responses/ in place when it is non-empty even if requests/ is empty", () => {
|
|
231
|
+
root = mkdtempSync(join(tmpdir(), "io-cleanup-"));
|
|
232
|
+
const forwardingDir = join(root, "forwarding");
|
|
233
|
+
const location = createPermissionForwardingLocation(
|
|
234
|
+
forwardingDir,
|
|
235
|
+
"parent-session",
|
|
236
|
+
);
|
|
237
|
+
mkdirSync(location.requestsDir, { recursive: true });
|
|
238
|
+
mkdirSync(location.responsesDir, { recursive: true });
|
|
239
|
+
writeFileSync(join(location.responsesDir, "resp.json"), "{}", "utf-8");
|
|
240
|
+
// requests/ is empty, responses/ has a stale response
|
|
241
|
+
|
|
242
|
+
cleanupPermissionForwardingLocationIfEmpty(null, location);
|
|
243
|
+
|
|
244
|
+
// requests/ is empty so it gets removed
|
|
245
|
+
expect(existsSync(location.requestsDir)).toBe(false);
|
|
246
|
+
// responses/ is non-empty → survives
|
|
247
|
+
expect(existsSync(location.responsesDir)).toBe(true);
|
|
248
|
+
// sessionRoot survives because responses/ is still present
|
|
249
|
+
expect(existsSync(location.sessionRootDir)).toBe(true);
|
|
250
|
+
});
|
|
251
|
+
});
|
package/test/normalize.test.ts
CHANGED
|
@@ -163,4 +163,85 @@ describe("normalizeFlatConfig", () => {
|
|
|
163
163
|
]);
|
|
164
164
|
});
|
|
165
165
|
});
|
|
166
|
+
|
|
167
|
+
describe("deny with reason", () => {
|
|
168
|
+
test("{ action: 'deny', reason } produces a deny rule carrying the reason", () => {
|
|
169
|
+
const result = normalizeFlatConfig({
|
|
170
|
+
bash: { "npm *": { action: "deny", reason: "Use pnpm instead" } },
|
|
171
|
+
});
|
|
172
|
+
expect(result).toEqual([
|
|
173
|
+
{
|
|
174
|
+
surface: "bash",
|
|
175
|
+
pattern: "npm *",
|
|
176
|
+
action: "deny",
|
|
177
|
+
reason: "Use pnpm instead",
|
|
178
|
+
origin: "builtin",
|
|
179
|
+
},
|
|
180
|
+
]);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("{ action: 'deny' } without a reason produces a deny rule without reason", () => {
|
|
184
|
+
const result = normalizeFlatConfig({
|
|
185
|
+
bash: { "rm -rf *": { action: "deny" } },
|
|
186
|
+
});
|
|
187
|
+
expect(result).toEqual([
|
|
188
|
+
{
|
|
189
|
+
surface: "bash",
|
|
190
|
+
pattern: "rm -rf *",
|
|
191
|
+
action: "deny",
|
|
192
|
+
origin: "builtin",
|
|
193
|
+
},
|
|
194
|
+
]);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test("deny-with-reason and plain strings coexist in the same surface", () => {
|
|
198
|
+
const result = normalizeFlatConfig({
|
|
199
|
+
bash: {
|
|
200
|
+
"git *": "allow",
|
|
201
|
+
"npm *": { action: "deny", reason: "Use pnpm" },
|
|
202
|
+
"*": "ask",
|
|
203
|
+
},
|
|
204
|
+
});
|
|
205
|
+
expect(result).toEqual([
|
|
206
|
+
{
|
|
207
|
+
surface: "bash",
|
|
208
|
+
pattern: "git *",
|
|
209
|
+
action: "allow",
|
|
210
|
+
origin: "builtin",
|
|
211
|
+
},
|
|
212
|
+
{
|
|
213
|
+
surface: "bash",
|
|
214
|
+
pattern: "npm *",
|
|
215
|
+
action: "deny",
|
|
216
|
+
reason: "Use pnpm",
|
|
217
|
+
origin: "builtin",
|
|
218
|
+
},
|
|
219
|
+
{ surface: "bash", pattern: "*", action: "ask", origin: "builtin" },
|
|
220
|
+
]);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test("top-level deny-with-reason object is treated as a pattern map", () => {
|
|
224
|
+
// At the surface level, { action: "deny", reason: "..." } is parsed as a
|
|
225
|
+
// pattern→action map: "action" is a pattern key with action "deny", and
|
|
226
|
+
// "reason" maps to a non-PermissionState string that is dropped.
|
|
227
|
+
const result = normalizeFlatConfig({
|
|
228
|
+
bash: { action: "deny", reason: "Not allowed" } as never,
|
|
229
|
+
});
|
|
230
|
+
expect(result).toEqual([
|
|
231
|
+
{
|
|
232
|
+
surface: "bash",
|
|
233
|
+
pattern: "action",
|
|
234
|
+
action: "deny",
|
|
235
|
+
origin: "builtin",
|
|
236
|
+
},
|
|
237
|
+
]);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test("non-string reason is rejected (malformed config)", () => {
|
|
241
|
+
const result = normalizeFlatConfig({
|
|
242
|
+
bash: { "npm *": { action: "deny", reason: 42 } as never },
|
|
243
|
+
});
|
|
244
|
+
expect(result).toEqual([]);
|
|
245
|
+
});
|
|
246
|
+
});
|
|
166
247
|
});
|
|
@@ -307,4 +307,63 @@ describe("processInbox", () => {
|
|
|
307
307
|
rmSync(root, { recursive: true, force: true });
|
|
308
308
|
}
|
|
309
309
|
});
|
|
310
|
+
|
|
311
|
+
test("recreates a missing responses/ directory and still writes the response", async () => {
|
|
312
|
+
const root = mkdtempSync(join(tmpdir(), "permission-forwarding-"));
|
|
313
|
+
try {
|
|
314
|
+
const forwardingDir = join(root, "forwarding");
|
|
315
|
+
const location = createPermissionForwardingLocation(
|
|
316
|
+
forwardingDir,
|
|
317
|
+
"parent-session",
|
|
318
|
+
);
|
|
319
|
+
// Simulate the race: requests/ exists with a pending file, but
|
|
320
|
+
// responses/ was removed by a concurrent cleanup pass.
|
|
321
|
+
mkdirSync(location.requestsDir, { recursive: true });
|
|
322
|
+
// Deliberately do NOT create location.responsesDir.
|
|
323
|
+
writeFileSync(
|
|
324
|
+
join(location.requestsDir, "req-race.json"),
|
|
325
|
+
JSON.stringify({
|
|
326
|
+
id: "req-race",
|
|
327
|
+
createdAt: Date.now(),
|
|
328
|
+
requesterSessionId: "child-session",
|
|
329
|
+
targetSessionId: "parent-session",
|
|
330
|
+
requesterAgentName: "Explore",
|
|
331
|
+
message: "Allow read?",
|
|
332
|
+
}),
|
|
333
|
+
"utf-8",
|
|
334
|
+
);
|
|
335
|
+
|
|
336
|
+
const logger = { review: vi.fn(), debug: vi.fn() };
|
|
337
|
+
const requestPermissionDecisionFromUi = vi
|
|
338
|
+
.fn()
|
|
339
|
+
.mockResolvedValue({ approved: true, state: "approved" as const });
|
|
340
|
+
|
|
341
|
+
const forwarder = new PermissionForwarder(
|
|
342
|
+
makeDeps({
|
|
343
|
+
forwardingDir,
|
|
344
|
+
logger,
|
|
345
|
+
requestPermissionDecisionFromUi,
|
|
346
|
+
}),
|
|
347
|
+
);
|
|
348
|
+
|
|
349
|
+
await forwarder.processInbox(
|
|
350
|
+
makeCtx({
|
|
351
|
+
hasUI: true,
|
|
352
|
+
sessionManager: {
|
|
353
|
+
getSessionId: vi.fn(() => "parent-session"),
|
|
354
|
+
},
|
|
355
|
+
}),
|
|
356
|
+
);
|
|
357
|
+
|
|
358
|
+
// processInbox must have recreated responses/ and written a response
|
|
359
|
+
// file — no permission_forwarding.error should have been logged.
|
|
360
|
+
expect(logger.review).not.toHaveBeenCalledWith(
|
|
361
|
+
"permission_forwarding.error",
|
|
362
|
+
expect.anything(),
|
|
363
|
+
);
|
|
364
|
+
expect(requestPermissionDecisionFromUi).toHaveBeenCalled();
|
|
365
|
+
} finally {
|
|
366
|
+
rmSync(root, { recursive: true, force: true });
|
|
367
|
+
}
|
|
368
|
+
});
|
|
310
369
|
});
|
|
@@ -1245,6 +1245,71 @@ describe("cross-cutting path surface", () => {
|
|
|
1245
1245
|
}
|
|
1246
1246
|
});
|
|
1247
1247
|
|
|
1248
|
+
// ── Deny-with-reason ────────────────────────────────────────────────────
|
|
1249
|
+
|
|
1250
|
+
it("deny-with-reason: reason threads through to PermissionCheckResult", () => {
|
|
1251
|
+
const { manager, cleanup } = makeManagerWithConfig({
|
|
1252
|
+
bash: { "npm *": { action: "deny", reason: "Use pnpm instead" } },
|
|
1253
|
+
});
|
|
1254
|
+
try {
|
|
1255
|
+
const result = manager.checkPermission("bash", {
|
|
1256
|
+
command: "npm install",
|
|
1257
|
+
});
|
|
1258
|
+
expect(result.state).toBe("deny");
|
|
1259
|
+
expect(result.reason).toBe("Use pnpm instead");
|
|
1260
|
+
expect(result.matchedPattern).toBe("npm *");
|
|
1261
|
+
} finally {
|
|
1262
|
+
cleanup();
|
|
1263
|
+
}
|
|
1264
|
+
});
|
|
1265
|
+
|
|
1266
|
+
it("deny-without-reason: reason is undefined in PermissionCheckResult", () => {
|
|
1267
|
+
const { manager, cleanup } = makeManagerWithConfig({
|
|
1268
|
+
bash: { "rm -rf *": "deny" },
|
|
1269
|
+
});
|
|
1270
|
+
try {
|
|
1271
|
+
const result = manager.checkPermission("bash", { command: "rm -rf /" });
|
|
1272
|
+
expect(result.state).toBe("deny");
|
|
1273
|
+
expect(result.reason).toBeUndefined();
|
|
1274
|
+
} finally {
|
|
1275
|
+
cleanup();
|
|
1276
|
+
}
|
|
1277
|
+
});
|
|
1278
|
+
|
|
1279
|
+
it("deny-with-reason on a non-bash surface", () => {
|
|
1280
|
+
const { manager, cleanup } = makeManagerWithConfig({
|
|
1281
|
+
read: {
|
|
1282
|
+
"*.env": {
|
|
1283
|
+
action: "deny",
|
|
1284
|
+
reason: "Environment files contain secrets",
|
|
1285
|
+
},
|
|
1286
|
+
},
|
|
1287
|
+
});
|
|
1288
|
+
try {
|
|
1289
|
+
const result = manager.checkPermission("read", { path: ".env" });
|
|
1290
|
+
expect(result.state).toBe("deny");
|
|
1291
|
+
expect(result.reason).toBe("Environment files contain secrets");
|
|
1292
|
+
expect(result.matchedPattern).toBe("*.env");
|
|
1293
|
+
} finally {
|
|
1294
|
+
cleanup();
|
|
1295
|
+
}
|
|
1296
|
+
});
|
|
1297
|
+
|
|
1298
|
+
it("non-string reason falls through to the default (malformed config)", () => {
|
|
1299
|
+
const { manager, cleanup } = makeManagerWithConfig({
|
|
1300
|
+
bash: { "npm *": { action: "deny", reason: 42 } },
|
|
1301
|
+
});
|
|
1302
|
+
try {
|
|
1303
|
+
const result = manager.checkPermission("bash", {
|
|
1304
|
+
command: "npm install",
|
|
1305
|
+
});
|
|
1306
|
+
expect(result.state).toBe("ask");
|
|
1307
|
+
expect(result.reason).toBeUndefined();
|
|
1308
|
+
} finally {
|
|
1309
|
+
cleanup();
|
|
1310
|
+
}
|
|
1311
|
+
});
|
|
1312
|
+
|
|
1248
1313
|
// ── Last-match-wins ordering ────────────────────────────────────────────
|
|
1249
1314
|
|
|
1250
1315
|
it("last-match-wins: catch-all after deny overrides the deny", () => {
|
package/test/rule.test.ts
CHANGED
|
@@ -216,6 +216,67 @@ describe("evaluate", () => {
|
|
|
216
216
|
expect(result.origin).toBe("builtin");
|
|
217
217
|
});
|
|
218
218
|
|
|
219
|
+
test("evaluate() propagates reason from the matched deny rule", () => {
|
|
220
|
+
const rule: Rule = {
|
|
221
|
+
surface: "bash",
|
|
222
|
+
pattern: "npm *",
|
|
223
|
+
action: "deny",
|
|
224
|
+
reason: "Use pnpm instead",
|
|
225
|
+
layer: "config",
|
|
226
|
+
origin: "global",
|
|
227
|
+
};
|
|
228
|
+
const result = evaluate("bash", "npm install", [rule]);
|
|
229
|
+
expect(result.action).toBe("deny");
|
|
230
|
+
expect(result.reason).toBe("Use pnpm instead");
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test("evaluate() carries reason through last-match-wins when deny wins", () => {
|
|
234
|
+
const allowAll: Rule = {
|
|
235
|
+
surface: "bash",
|
|
236
|
+
pattern: "*",
|
|
237
|
+
action: "allow",
|
|
238
|
+
layer: "config",
|
|
239
|
+
origin: "global",
|
|
240
|
+
};
|
|
241
|
+
const denyNpm: Rule = {
|
|
242
|
+
surface: "bash",
|
|
243
|
+
pattern: "npm *",
|
|
244
|
+
action: "deny",
|
|
245
|
+
reason: "Use pnpm",
|
|
246
|
+
layer: "config",
|
|
247
|
+
origin: "global",
|
|
248
|
+
};
|
|
249
|
+
const result = evaluate("bash", "npm install", [allowAll, denyNpm]);
|
|
250
|
+
expect(result.action).toBe("deny");
|
|
251
|
+
expect(result.reason).toBe("Use pnpm");
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
test("evaluate() drops reason when a later allow overrides the deny", () => {
|
|
255
|
+
const denyNpm: Rule = {
|
|
256
|
+
surface: "bash",
|
|
257
|
+
pattern: "npm *",
|
|
258
|
+
action: "deny",
|
|
259
|
+
reason: "Use pnpm",
|
|
260
|
+
layer: "config",
|
|
261
|
+
origin: "global",
|
|
262
|
+
};
|
|
263
|
+
const allowInstall: Rule = {
|
|
264
|
+
surface: "bash",
|
|
265
|
+
pattern: "npm install",
|
|
266
|
+
action: "allow",
|
|
267
|
+
layer: "config",
|
|
268
|
+
origin: "global",
|
|
269
|
+
};
|
|
270
|
+
const result = evaluate("bash", "npm install", [denyNpm, allowInstall]);
|
|
271
|
+
expect(result.action).toBe("allow");
|
|
272
|
+
expect(result.reason).toBeUndefined();
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test("evaluate() synthetic fallback rule has no reason", () => {
|
|
276
|
+
const result = evaluate("bash", "npm install", []);
|
|
277
|
+
expect(result.reason).toBeUndefined();
|
|
278
|
+
});
|
|
279
|
+
|
|
219
280
|
test("RuleOrigin covers all seven provenance values", () => {
|
|
220
281
|
const origins: RuleOrigin[] = [
|
|
221
282
|
"global",
|