@gotgenes/pi-permission-system 13.0.0 → 13.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +16 -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/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/normalize.test.ts +81 -0
- package/test/permission-manager-unified.test.ts +65 -0
- package/test/rule.test.ts +61 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,22 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [13.1.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v13.0.0...pi-permission-system-v13.1.0) (2026-06-13)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* **pi-permission-system:** add DenyWithReason type and shared guard ([51750e1](https://github.com/gotgenes/pi-packages/commit/51750e188592520798eaf9676a15a709a779cf96)), closes [#395](https://github.com/gotgenes/pi-packages/issues/395)
|
|
14
|
+
* **pi-permission-system:** append custom reason to denial messages ([d8e5756](https://github.com/gotgenes/pi-packages/commit/d8e575632678b806d381f1436dbb06197d742104)), closes [#395](https://github.com/gotgenes/pi-packages/issues/395)
|
|
15
|
+
* **pi-permission-system:** build deny rules with reason in normalizeFlatConfig ([186c15a](https://github.com/gotgenes/pi-packages/commit/186c15a74944bc2800bcea738984021169fabc8d)), closes [#395](https://github.com/gotgenes/pi-packages/issues/395)
|
|
16
|
+
* **pi-permission-system:** preserve deny-with-reason from JSON config ([3201bfd](https://github.com/gotgenes/pi-packages/commit/3201bfd55d68aac1ee87ac452723f6d0783dba6d)), closes [#395](https://github.com/gotgenes/pi-packages/issues/395)
|
|
17
|
+
* **pi-permission-system:** thread deny reason into PermissionCheckResult ([ed712e4](https://github.com/gotgenes/pi-packages/commit/ed712e47458a662e3d1159e2f5096c709ab2ddf5)), closes [#395](https://github.com/gotgenes/pi-packages/issues/395)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
### Documentation
|
|
21
|
+
|
|
22
|
+
* **pi-permission-system:** document deny-with-reason config form ([45be4e7](https://github.com/gotgenes/pi-packages/commit/45be4e72c0ca43040cb0f55ca196a0cab0b9fc14)), closes [#395](https://github.com/gotgenes/pi-packages/issues/395)
|
|
23
|
+
|
|
8
24
|
## [13.0.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v12.0.0...pi-permission-system-v13.0.0) (2026-06-12)
|
|
9
25
|
|
|
10
26
|
|
|
@@ -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
|
/**
|
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(
|
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
|
});
|
|
@@ -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",
|