@gotgenes/pi-permission-system 3.11.0 → 4.0.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 +35 -0
- package/README.md +135 -168
- package/config/config.example.json +11 -21
- package/package.json +1 -1
- package/schemas/permissions.schema.json +34 -102
- package/src/config-loader.ts +87 -118
- package/src/defaults.ts +6 -62
- package/src/extension-config.ts +3 -4
- package/src/normalize.ts +22 -60
- package/src/permission-manager.ts +244 -348
- package/src/synthesize.ts +17 -82
- package/src/types.ts +12 -18
- package/tests/config-loader.test.ts +113 -63
- package/tests/defaults.test.ts +8 -101
- package/tests/extension-config.test.ts +12 -4
- package/tests/normalize.test.ts +67 -64
- package/tests/permission-system.test.ts +153 -714
- package/tests/session-start.test.ts +1 -7
- package/tests/synthesize.test.ts +46 -219
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
3
|
"$id": "https://raw.githubusercontent.com/gotgenes/pi-permission-system/main/schemas/permissions.schema.json",
|
|
4
4
|
"title": "PI Permission System Configuration",
|
|
5
|
-
"description": "Unified config file combining runtime knobs and permission policy for pi-permission-system.",
|
|
6
|
-
"markdownDescription": "Unified config file combining runtime knobs and permission policy for [pi-permission-system](https://github.com/gotgenes/pi-permission-system).\n\nPlace at `~/.pi/agent/extensions/pi-permission-system/config.json` (global) or `<project>/.pi/extensions/pi-permission-system/config.json` (project).",
|
|
5
|
+
"description": "Unified config file combining runtime knobs and flat permission policy for pi-permission-system.",
|
|
6
|
+
"markdownDescription": "Unified config file combining runtime knobs and flat permission policy for [pi-permission-system](https://github.com/gotgenes/pi-permission-system).\n\nPlace at `~/.pi/agent/extensions/pi-permission-system/config.json` (global) or `<project>/.pi/extensions/pi-permission-system/config.json` (project).",
|
|
7
7
|
"type": "object",
|
|
8
8
|
"additionalProperties": false,
|
|
9
9
|
"properties": {
|
|
@@ -29,111 +29,43 @@
|
|
|
29
29
|
"type": "boolean",
|
|
30
30
|
"default": false
|
|
31
31
|
},
|
|
32
|
-
"
|
|
33
|
-
"description": "
|
|
34
|
-
"markdownDescription": "
|
|
32
|
+
"permission": {
|
|
33
|
+
"description": "Flat permission policy. Each key is a surface name; values are a PermissionState string (catch-all) or a pattern→action map.",
|
|
34
|
+
"markdownDescription": "Flat permission policy.\n\nEach top-level key is a surface name:\n- `\"*\"` — universal fallback (replaces `defaultPolicy.tools` from the legacy format)\n- Tool names (`read`, `write`, `bash`, `mcp`, `skill`, `external_directory`, etc.)\n\nA **string** value is shorthand for `{ \"*\": action }` (surface-level catch-all).\nAn **object** value maps wildcard patterns to actions — last matching pattern wins.\n\n**Merge order (lowest → highest precedence):** global → project → per-agent frontmatter.",
|
|
35
35
|
"type": "object",
|
|
36
|
-
"
|
|
37
|
-
|
|
38
|
-
"
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
"$ref": "#/$defs/permissionState",
|
|
54
|
-
"default": "ask"
|
|
55
|
-
},
|
|
56
|
-
"skills": {
|
|
57
|
-
"description": "Default permission for skill loading when no pattern in the skills map matches.",
|
|
58
|
-
"markdownDescription": "Default permission for skill loading when no pattern in the `skills` map matches.",
|
|
59
|
-
"$ref": "#/$defs/permissionState",
|
|
60
|
-
"default": "ask"
|
|
61
|
-
},
|
|
62
|
-
"special": {
|
|
63
|
-
"description": "Default permission for special checks (external_directory) when no explicit rule matches.",
|
|
64
|
-
"markdownDescription": "Default permission for special checks (`external_directory`) when no explicit rule matches.",
|
|
65
|
-
"$ref": "#/$defs/permissionState",
|
|
66
|
-
"default": "ask"
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
},
|
|
70
|
-
"tools": {
|
|
71
|
-
"description": "Exact-name permissions for registered tools. Use this map for the canonical Pi built-ins and any extension-provided or third-party tools.",
|
|
72
|
-
"markdownDescription": "Exact-name permissions for registered tools. Use this map for the canonical Pi built-ins (`read`, `write`, `edit`, `bash`, `grep`, `find`, `ls`) and any extension-provided or third-party tools.\n\nNo wildcards — keys must match the registered tool name exactly.",
|
|
73
|
-
"$ref": "#/$defs/permissionMap",
|
|
36
|
+
"propertyNames": {
|
|
37
|
+
"description": "A surface name or the universal fallback key '*'.",
|
|
38
|
+
"type": "string",
|
|
39
|
+
"minLength": 1
|
|
40
|
+
},
|
|
41
|
+
"additionalProperties": {
|
|
42
|
+
"oneOf": [
|
|
43
|
+
{
|
|
44
|
+
"$ref": "#/$defs/permissionState",
|
|
45
|
+
"description": "Catch-all shorthand: equivalent to { \"*\": action }."
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
"$ref": "#/$defs/permissionMap",
|
|
49
|
+
"description": "Pattern→action map for this surface."
|
|
50
|
+
}
|
|
51
|
+
]
|
|
52
|
+
},
|
|
74
53
|
"examples": [
|
|
75
54
|
{
|
|
55
|
+
"*": "ask",
|
|
76
56
|
"read": "allow",
|
|
77
57
|
"write": "deny",
|
|
78
|
-
"
|
|
79
|
-
|
|
58
|
+
"bash": {
|
|
59
|
+
"*": "ask",
|
|
60
|
+
"git status": "allow",
|
|
61
|
+
"git diff": "allow",
|
|
62
|
+
"git *": "ask"
|
|
63
|
+
},
|
|
64
|
+
"mcp": { "*": "ask", "mcp_status": "allow", "exa:*": "allow" },
|
|
65
|
+
"skill": { "*": "ask", "librarian": "allow" },
|
|
66
|
+
"external_directory": "ask"
|
|
80
67
|
}
|
|
81
68
|
]
|
|
82
|
-
},
|
|
83
|
-
"bash": {
|
|
84
|
-
"description": "Wildcard pattern permissions for bash commands. Patterns use * globs matched against the full command string. Last matching rule wins.",
|
|
85
|
-
"markdownDescription": "Wildcard pattern permissions for bash commands. Patterns use `*` globs matched against the full command string.\n\n**Last matching rule wins** — put broad catch-all rules first and specific overrides after them.",
|
|
86
|
-
"$ref": "#/$defs/permissionMap",
|
|
87
|
-
"examples": [
|
|
88
|
-
{
|
|
89
|
-
"git status": "allow",
|
|
90
|
-
"git diff": "allow",
|
|
91
|
-
"git *": "ask",
|
|
92
|
-
"rm -rf *": "deny"
|
|
93
|
-
}
|
|
94
|
-
]
|
|
95
|
-
},
|
|
96
|
-
"mcp": {
|
|
97
|
-
"description": "Pattern-based permissions for targets invoked through a registered mcp tool when available.",
|
|
98
|
-
"markdownDescription": "Pattern-based permissions for targets invoked through a registered `mcp` tool when available.\n\nTargets include server names (`myServer`), qualified tool names (`myServer:search`, `myServer_search`), and baseline operations (`mcp_status`, `mcp_list`, `mcp_connect`, `mcp_describe`, `mcp_search`).",
|
|
99
|
-
"$ref": "#/$defs/permissionMap",
|
|
100
|
-
"examples": [
|
|
101
|
-
{
|
|
102
|
-
"mcp_status": "allow",
|
|
103
|
-
"mcp_list": "allow",
|
|
104
|
-
"myServer:*": "ask",
|
|
105
|
-
"dangerousServer": "deny"
|
|
106
|
-
}
|
|
107
|
-
]
|
|
108
|
-
},
|
|
109
|
-
"skills": {
|
|
110
|
-
"description": "Wildcard pattern permissions for skill names. Controls which skills can be loaded or read from disk.",
|
|
111
|
-
"markdownDescription": "Wildcard pattern permissions for skill names. Controls which skills can be loaded or read from disk.\n\nUse `\"*\": \"ask\"` to require confirmation for all skills, or `\"dangerous-*\": \"deny\"` to block a family of skills.",
|
|
112
|
-
"$ref": "#/$defs/permissionMap",
|
|
113
|
-
"examples": [
|
|
114
|
-
{
|
|
115
|
-
"*": "ask",
|
|
116
|
-
"dangerous-*": "deny"
|
|
117
|
-
}
|
|
118
|
-
]
|
|
119
|
-
},
|
|
120
|
-
"special": {
|
|
121
|
-
"description": "Reserved permission checks for special runtime behaviors.",
|
|
122
|
-
"type": "object",
|
|
123
|
-
"additionalProperties": false,
|
|
124
|
-
"properties": {
|
|
125
|
-
"external_directory": {
|
|
126
|
-
"description": "Enforces permission checks for path-bearing file tools (read, write, edit, find, grep, ls) when they target paths outside the active working directory.",
|
|
127
|
-
"markdownDescription": "Enforces permission checks for path-bearing file tools (`read`, `write`, `edit`, `find`, `grep`, `ls`) when they target paths outside the active working directory.\n\nEvaluated **before** the normal tool permission check — so `tools.read: \"allow\"` can permit ordinary reads while `external_directory: \"ask\"` still requires confirmation for paths outside `cwd`.",
|
|
128
|
-
"$ref": "#/$defs/permissionState"
|
|
129
|
-
},
|
|
130
|
-
"tool_call_limit": {
|
|
131
|
-
"description": "Deprecated and ignored. This key has no effect and should be removed from your config.",
|
|
132
|
-
"markdownDescription": "⚠️ **Deprecated and ignored.** This key has no effect and should be removed from your config.",
|
|
133
|
-
"deprecated": true,
|
|
134
|
-
"$ref": "#/$defs/permissionState"
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
69
|
}
|
|
138
70
|
},
|
|
139
71
|
"$defs": {
|
|
@@ -155,8 +87,8 @@
|
|
|
155
87
|
]
|
|
156
88
|
},
|
|
157
89
|
"permissionMap": {
|
|
158
|
-
"description": "A map of
|
|
159
|
-
"markdownDescription": "A map of
|
|
90
|
+
"description": "A map of wildcard patterns to permission states. Last matching pattern wins.",
|
|
91
|
+
"markdownDescription": "A map of wildcard patterns to permission states.\n\nUse `*` for wildcard matching. When multiple patterns match, the **last matching rule wins** — put broad catch-alls first and specific overrides after them.",
|
|
160
92
|
"type": "object",
|
|
161
93
|
"propertyNames": {
|
|
162
94
|
"description": "A non-empty pattern string. Use * for wildcard matching.",
|
package/src/config-loader.ts
CHANGED
|
@@ -9,10 +9,10 @@ import {
|
|
|
9
9
|
getLegacyProjectPolicyPath,
|
|
10
10
|
getProjectConfigPath,
|
|
11
11
|
} from "./config-paths";
|
|
12
|
-
import type {
|
|
12
|
+
import type { FlatPermissionConfig } from "./types";
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
|
-
* Unified config shape combining runtime knobs and
|
|
15
|
+
* Unified config shape combining runtime knobs and flat permission policy.
|
|
16
16
|
* All fields are optional so partial configs (project-only, global-only) work.
|
|
17
17
|
*/
|
|
18
18
|
export interface UnifiedPermissionConfig {
|
|
@@ -21,13 +21,8 @@ export interface UnifiedPermissionConfig {
|
|
|
21
21
|
permissionReviewLog?: boolean;
|
|
22
22
|
yoloMode?: boolean;
|
|
23
23
|
|
|
24
|
-
//
|
|
25
|
-
|
|
26
|
-
tools?: Record<string, PermissionState>;
|
|
27
|
-
bash?: Record<string, PermissionState>;
|
|
28
|
-
mcp?: Record<string, PermissionState>;
|
|
29
|
-
skills?: Record<string, PermissionState>;
|
|
30
|
-
special?: Record<string, PermissionState>;
|
|
24
|
+
// Flat permission policy
|
|
25
|
+
permission?: FlatPermissionConfig;
|
|
31
26
|
}
|
|
32
27
|
|
|
33
28
|
export interface UnifiedConfigLoadResult {
|
|
@@ -35,23 +30,6 @@ export interface UnifiedConfigLoadResult {
|
|
|
35
30
|
issues: string[];
|
|
36
31
|
}
|
|
37
32
|
|
|
38
|
-
const DEPRECATED_SPECIAL_KEYS: ReadonlySet<string> = new Set([
|
|
39
|
-
"doom_loop",
|
|
40
|
-
"tool_call_limit",
|
|
41
|
-
]);
|
|
42
|
-
|
|
43
|
-
const BUILT_IN_TOOL_PERMISSION_NAMES = new Set([
|
|
44
|
-
"bash",
|
|
45
|
-
"read",
|
|
46
|
-
"write",
|
|
47
|
-
"edit",
|
|
48
|
-
"grep",
|
|
49
|
-
"find",
|
|
50
|
-
"ls",
|
|
51
|
-
]);
|
|
52
|
-
|
|
53
|
-
const SPECIAL_PERMISSION_KEYS = new Set(["external_directory"]);
|
|
54
|
-
|
|
55
33
|
export function stripJsonComments(input: string): string {
|
|
56
34
|
let output = "";
|
|
57
35
|
let inString = false;
|
|
@@ -124,51 +102,86 @@ export function stripJsonComments(input: string): string {
|
|
|
124
102
|
return output;
|
|
125
103
|
}
|
|
126
104
|
|
|
127
|
-
function
|
|
128
|
-
value
|
|
129
|
-
|
|
130
|
-
const record = toRecord(value);
|
|
131
|
-
const normalized: Partial<PermissionDefaultPolicy> = {};
|
|
132
|
-
let hasAny = false;
|
|
133
|
-
|
|
134
|
-
for (const key of ["tools", "bash", "mcp", "skills", "special"] as const) {
|
|
135
|
-
if (isPermissionState(record[key])) {
|
|
136
|
-
normalized[key] = record[key] as PermissionState;
|
|
137
|
-
hasAny = true;
|
|
138
|
-
}
|
|
105
|
+
function normalizeOptionalBoolean(value: unknown): boolean | undefined {
|
|
106
|
+
if (typeof value === "boolean") {
|
|
107
|
+
return value;
|
|
139
108
|
}
|
|
140
|
-
|
|
141
|
-
return hasAny ? normalized : undefined;
|
|
109
|
+
return undefined;
|
|
142
110
|
}
|
|
143
111
|
|
|
144
|
-
|
|
112
|
+
/**
|
|
113
|
+
* Normalize a raw `permission` value from parsed JSON into a FlatPermissionConfig.
|
|
114
|
+
* Drops non-object top-level values, invalid PermissionState strings, and
|
|
115
|
+
* invalid action values inside object maps.
|
|
116
|
+
*/
|
|
117
|
+
function normalizeFlatPermissionValue(
|
|
145
118
|
value: unknown,
|
|
146
|
-
):
|
|
147
|
-
|
|
148
|
-
|
|
119
|
+
): FlatPermissionConfig | undefined {
|
|
120
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
121
|
+
return undefined;
|
|
122
|
+
}
|
|
123
|
+
const record = value as Record<string, unknown>;
|
|
124
|
+
const normalized: FlatPermissionConfig = {};
|
|
149
125
|
let hasAny = false;
|
|
150
126
|
|
|
151
|
-
for (const [key,
|
|
152
|
-
if (
|
|
153
|
-
|
|
154
|
-
|
|
127
|
+
for (const [key, val] of Object.entries(record)) {
|
|
128
|
+
if (typeof val === "string") {
|
|
129
|
+
if (isPermissionState(val)) {
|
|
130
|
+
normalized[key] = val;
|
|
131
|
+
hasAny = true;
|
|
132
|
+
}
|
|
133
|
+
} else if (typeof val === "object" && val !== null && !Array.isArray(val)) {
|
|
134
|
+
const map: Record<string, import("./types").PermissionState> = {};
|
|
135
|
+
let mapHasAny = false;
|
|
136
|
+
for (const [pattern, action] of Object.entries(
|
|
137
|
+
val as Record<string, unknown>,
|
|
138
|
+
)) {
|
|
139
|
+
if (isPermissionState(action)) {
|
|
140
|
+
map[pattern] = action;
|
|
141
|
+
mapHasAny = true;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
if (mapHasAny) {
|
|
145
|
+
normalized[key] = map;
|
|
146
|
+
hasAny = true;
|
|
147
|
+
}
|
|
155
148
|
}
|
|
156
149
|
}
|
|
157
150
|
|
|
158
151
|
return hasAny ? normalized : undefined;
|
|
159
152
|
}
|
|
160
153
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
154
|
+
/**
|
|
155
|
+
* Deep-shallow merge two flat permission configs.
|
|
156
|
+
* - Both objects for same key → shallow-merge the pattern maps.
|
|
157
|
+
* - Otherwise → override replaces base.
|
|
158
|
+
*/
|
|
159
|
+
function mergeFlatPermissions(
|
|
160
|
+
base: FlatPermissionConfig,
|
|
161
|
+
override: FlatPermissionConfig,
|
|
162
|
+
): FlatPermissionConfig {
|
|
163
|
+
const merged: FlatPermissionConfig = { ...base };
|
|
164
|
+
for (const [key, value] of Object.entries(override)) {
|
|
165
|
+
const baseVal = merged[key];
|
|
166
|
+
if (
|
|
167
|
+
typeof baseVal === "object" &&
|
|
168
|
+
baseVal !== null &&
|
|
169
|
+
typeof value === "object" &&
|
|
170
|
+
value !== null
|
|
171
|
+
) {
|
|
172
|
+
merged[key] = {
|
|
173
|
+
...(baseVal as Record<string, import("./types").PermissionState>),
|
|
174
|
+
...(value as Record<string, import("./types").PermissionState>),
|
|
175
|
+
};
|
|
176
|
+
} else {
|
|
177
|
+
merged[key] = value;
|
|
178
|
+
}
|
|
164
179
|
}
|
|
165
|
-
return
|
|
180
|
+
return merged;
|
|
166
181
|
}
|
|
167
182
|
|
|
168
183
|
/**
|
|
169
184
|
* Normalize raw parsed JSON into the unified config shape.
|
|
170
|
-
* Handles top-level shorthand keys (e.g. `bash: "allow"` at root)
|
|
171
|
-
* and deprecated special keys, collecting issues along the way.
|
|
172
185
|
*/
|
|
173
186
|
export function normalizeUnifiedConfig(raw: unknown): {
|
|
174
187
|
config: UnifiedPermissionConfig;
|
|
@@ -176,7 +189,6 @@ export function normalizeUnifiedConfig(raw: unknown): {
|
|
|
176
189
|
} {
|
|
177
190
|
const record = toRecord(raw);
|
|
178
191
|
const issues: string[] = [];
|
|
179
|
-
|
|
180
192
|
const config: UnifiedPermissionConfig = {};
|
|
181
193
|
|
|
182
194
|
// Runtime knobs
|
|
@@ -192,60 +204,18 @@ export function normalizeUnifiedConfig(raw: unknown): {
|
|
|
192
204
|
const yoloMode = normalizeOptionalBoolean(record.yoloMode);
|
|
193
205
|
if (yoloMode !== undefined) config.yoloMode = yoloMode;
|
|
194
206
|
|
|
195
|
-
//
|
|
196
|
-
const
|
|
197
|
-
if (
|
|
198
|
-
|
|
199
|
-
const tools = normalizePermissionRecord(record.tools);
|
|
200
|
-
if (tools) config.tools = tools;
|
|
201
|
-
|
|
202
|
-
const bash = normalizePermissionRecord(record.bash);
|
|
203
|
-
if (bash) config.bash = bash;
|
|
204
|
-
|
|
205
|
-
const mcp = normalizePermissionRecord(record.mcp);
|
|
206
|
-
if (mcp) config.mcp = mcp;
|
|
207
|
-
|
|
208
|
-
const skills = normalizePermissionRecord(record.skills);
|
|
209
|
-
if (skills) config.skills = skills;
|
|
210
|
-
|
|
211
|
-
const special = normalizePermissionRecord(record.special);
|
|
212
|
-
if (special) config.special = special;
|
|
213
|
-
|
|
214
|
-
// Detect deprecated special keys
|
|
215
|
-
const rawSpecial = toRecord(record.special);
|
|
216
|
-
for (const key of DEPRECATED_SPECIAL_KEYS) {
|
|
217
|
-
if (key in rawSpecial) {
|
|
218
|
-
issues.push(
|
|
219
|
-
`special.${key} is deprecated and ignored — remove it from your config file.`,
|
|
220
|
-
);
|
|
221
|
-
if (config.special) {
|
|
222
|
-
delete config.special[key];
|
|
223
|
-
if (Object.keys(config.special).length === 0) {
|
|
224
|
-
delete config.special;
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
// Handle top-level shorthand keys (e.g. `bash: "allow"` at root level)
|
|
231
|
-
for (const [key, value] of Object.entries(record)) {
|
|
232
|
-
if (!isPermissionState(value)) continue;
|
|
233
|
-
|
|
234
|
-
if (BUILT_IN_TOOL_PERMISSION_NAMES.has(key)) {
|
|
235
|
-
config.tools = { ...(config.tools || {}), [key]: value };
|
|
236
|
-
} else if (SPECIAL_PERMISSION_KEYS.has(key)) {
|
|
237
|
-
config.special = { ...(config.special || {}), [key]: value };
|
|
238
|
-
}
|
|
239
|
-
}
|
|
207
|
+
// Flat permission policy
|
|
208
|
+
const permission = normalizeFlatPermissionValue(record.permission);
|
|
209
|
+
if (permission !== undefined) config.permission = permission;
|
|
240
210
|
|
|
241
211
|
return { config, issues };
|
|
242
212
|
}
|
|
243
213
|
|
|
244
214
|
/**
|
|
245
|
-
* Merge two unified configs.
|
|
246
|
-
*
|
|
247
|
-
* fields (debugLog, permissionReviewLog, yoloMode) are replaced when
|
|
248
|
-
* in the override.
|
|
215
|
+
* Merge two unified configs.
|
|
216
|
+
* - `permission` is deep-shallow merged (surface-level object maps are shallow-merged).
|
|
217
|
+
* - Scalar fields (debugLog, permissionReviewLog, yoloMode) are replaced when
|
|
218
|
+
* present in the override.
|
|
249
219
|
*/
|
|
250
220
|
export function mergeUnifiedConfigs(
|
|
251
221
|
base: UnifiedPermissionConfig,
|
|
@@ -261,20 +231,15 @@ export function mergeUnifiedConfigs(
|
|
|
261
231
|
}
|
|
262
232
|
}
|
|
263
233
|
|
|
264
|
-
//
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
const baseVal = base[key];
|
|
274
|
-
const overrideVal = override[key];
|
|
275
|
-
if (baseVal || overrideVal) {
|
|
276
|
-
merged[key] = { ...(baseVal || {}), ...(overrideVal || {}) } as never;
|
|
277
|
-
}
|
|
234
|
+
// Permission: deep-shallow merge
|
|
235
|
+
const basePerm = base.permission;
|
|
236
|
+
const overridePerm = override.permission;
|
|
237
|
+
if (basePerm && overridePerm) {
|
|
238
|
+
merged.permission = mergeFlatPermissions(basePerm, overridePerm);
|
|
239
|
+
} else if (basePerm) {
|
|
240
|
+
merged.permission = basePerm;
|
|
241
|
+
} else if (overridePerm) {
|
|
242
|
+
merged.permission = overridePerm;
|
|
278
243
|
}
|
|
279
244
|
|
|
280
245
|
return merged;
|
|
@@ -297,6 +262,10 @@ export interface MergedConfigResult {
|
|
|
297
262
|
* 3. New global config
|
|
298
263
|
* 4. Legacy project policy (if present)
|
|
299
264
|
* 5. New project config — highest precedence
|
|
265
|
+
*
|
|
266
|
+
* Legacy files are detected and warned about. Their content is parsed with the
|
|
267
|
+
* flat-format parser — legacy-format keys (defaultPolicy, tools, bash, etc.)
|
|
268
|
+
* are not translated and contribute no permission rules.
|
|
300
269
|
*/
|
|
301
270
|
export function loadAndMergeConfigs(
|
|
302
271
|
agentDir: string,
|
package/src/defaults.ts
CHANGED
|
@@ -1,66 +1,10 @@
|
|
|
1
|
-
import type { PermissionDefaultPolicy, PermissionState } from "./types";
|
|
2
|
-
|
|
3
|
-
/** Hardcoded fallback — every surface defaults to "ask" (least privilege). */
|
|
4
|
-
export const DEFAULT_POLICY: PermissionDefaultPolicy = {
|
|
5
|
-
tools: "ask",
|
|
6
|
-
bash: "ask",
|
|
7
|
-
mcp: "ask",
|
|
8
|
-
skills: "ask",
|
|
9
|
-
special: "ask",
|
|
10
|
-
};
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* Map a surface name used in evaluate() to the corresponding
|
|
14
|
-
* defaultPolicy key. Surfaces not listed here fall through to
|
|
15
|
-
* either "tools" or "special" via getSurfaceDefault().
|
|
16
|
-
*/
|
|
17
|
-
const SURFACE_TO_DEFAULT_KEY: Record<string, keyof PermissionDefaultPolicy> = {
|
|
18
|
-
bash: "bash",
|
|
19
|
-
mcp: "mcp",
|
|
20
|
-
skill: "skills",
|
|
21
|
-
};
|
|
22
|
-
|
|
23
1
|
/**
|
|
24
|
-
*
|
|
2
|
+
* @deprecated This module has been removed as part of #66 (flat permission
|
|
3
|
+
* config format). It is kept as an empty stub to avoid breaking any lingering
|
|
4
|
+
* references during the migration; delete it in a follow-up cleanup.
|
|
25
5
|
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
* - everything else (tool-name surfaces) → defaults.tools
|
|
29
|
-
*
|
|
30
|
-
* @deprecated Default policy is now synthesized into the composed ruleset via
|
|
31
|
-
* `synthesizeDefaults()` in `src/synthesize.ts`. Call-sites that previously
|
|
32
|
-
* consulted this function can evaluate against the composed ruleset instead.
|
|
33
|
-
* This function is kept for backward compatibility and will be removed in a
|
|
34
|
-
* future cleanup.
|
|
6
|
+
* The universal default is now expressed as permission["*"] in the flat config.
|
|
7
|
+
* mergeDefaults() and getSurfaceDefault() have no replacement.
|
|
35
8
|
*/
|
|
36
|
-
export function getSurfaceDefault(
|
|
37
|
-
surface: string,
|
|
38
|
-
defaults: PermissionDefaultPolicy,
|
|
39
|
-
specialKeys: ReadonlySet<string>,
|
|
40
|
-
): PermissionState {
|
|
41
|
-
const key = SURFACE_TO_DEFAULT_KEY[surface];
|
|
42
|
-
if (key) return defaults[key];
|
|
43
|
-
if (specialKeys.has(surface)) return defaults.special;
|
|
44
|
-
return defaults.tools;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
/**
|
|
48
|
-
* Merge zero or more partial default policies on top of DEFAULT_POLICY.
|
|
49
|
-
* Later partials override earlier ones (shallow spread per key).
|
|
50
|
-
*/
|
|
51
|
-
export function mergeDefaults(
|
|
52
|
-
...partials: ReadonlyArray<Partial<PermissionDefaultPolicy> | undefined>
|
|
53
|
-
): PermissionDefaultPolicy {
|
|
54
|
-
const merged: PermissionDefaultPolicy = { ...DEFAULT_POLICY };
|
|
55
|
-
|
|
56
|
-
for (const partial of partials) {
|
|
57
|
-
if (!partial) continue;
|
|
58
|
-
if (partial.tools) merged.tools = partial.tools;
|
|
59
|
-
if (partial.bash) merged.bash = partial.bash;
|
|
60
|
-
if (partial.mcp) merged.mcp = partial.mcp;
|
|
61
|
-
if (partial.skills) merged.skills = partial.skills;
|
|
62
|
-
if (partial.special) merged.special = partial.special;
|
|
63
|
-
}
|
|
64
9
|
|
|
65
|
-
|
|
66
|
-
}
|
|
10
|
+
export {};
|
package/src/extension-config.ts
CHANGED
|
@@ -130,10 +130,9 @@ export function loadPermissionSystemConfig(
|
|
|
130
130
|
}
|
|
131
131
|
if (misplacedKeys.length > 0) {
|
|
132
132
|
warnings.push(
|
|
133
|
-
`config.json contains permission-rule keys that are ignored
|
|
134
|
-
"
|
|
135
|
-
"
|
|
136
|
-
"See config/config.example.json for the keys config.json supports.",
|
|
133
|
+
`config.json contains legacy permission-rule keys that are ignored: ${misplacedKeys.join(", ")}.\n` +
|
|
134
|
+
'Use the flat permission format: { "permission": { "*": "ask", "read": "allow", ... } }.\n' +
|
|
135
|
+
"See config/config.example.json for the new format.",
|
|
137
136
|
);
|
|
138
137
|
}
|
|
139
138
|
|
package/src/normalize.ts
CHANGED
|
@@ -1,70 +1,32 @@
|
|
|
1
|
+
import { isPermissionState } from "./common";
|
|
1
2
|
import type { Rule, Ruleset } from "./rule";
|
|
2
|
-
import type {
|
|
3
|
+
import type { FlatPermissionConfig } from "./types";
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
|
-
*
|
|
6
|
-
* Used as the input shape for normalizeConfig().
|
|
7
|
-
*/
|
|
8
|
-
export interface NormalizableConfig {
|
|
9
|
-
tools?: Record<string, PermissionState>;
|
|
10
|
-
bash?: Record<string, PermissionState>;
|
|
11
|
-
mcp?: Record<string, PermissionState>;
|
|
12
|
-
skills?: Record<string, PermissionState>;
|
|
13
|
-
special?: Record<string, PermissionState>;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* Keys in the `tools` map that serve as fallback defaults for their
|
|
18
|
-
* respective pattern-based surfaces rather than as tool-level rules.
|
|
19
|
-
*
|
|
20
|
-
* `tools.bash` sets the bash default (fallback when no bash pattern matches).
|
|
21
|
-
* `tools.mcp` sets the tool-level MCP fallback.
|
|
6
|
+
* Convert a flat permission config into a Ruleset.
|
|
22
7
|
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*/
|
|
27
|
-
export const TOOL_SURFACE_OVERRIDE_KEYS: ReadonlySet<string> = new Set([
|
|
28
|
-
"bash",
|
|
29
|
-
"mcp",
|
|
30
|
-
]);
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* Convert the on-disk config shape into a flat Ruleset.
|
|
8
|
+
* Each key is a surface name. A string value is shorthand for
|
|
9
|
+
* `{ "*": action }`. An object value maps patterns to actions.
|
|
10
|
+
* Invalid action values are silently skipped.
|
|
34
11
|
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
* 3. mcp entries (surface "mcp", pattern = target glob)
|
|
39
|
-
* 4. skills entries (surface "skill", pattern = skill glob)
|
|
40
|
-
* 5. special entries (surface "special", pattern = key name)
|
|
41
|
-
*
|
|
42
|
-
* `tools.bash` and `tools.mcp` are excluded — see TOOL_SURFACE_OVERRIDE_KEYS.
|
|
43
|
-
* `defaultPolicy` is NOT included — handled separately by the caller.
|
|
12
|
+
* The universal fallback key `"*"` is included if present — callers
|
|
13
|
+
* that use `"*"` only for `synthesizeDefaults()` should strip it before
|
|
14
|
+
* calling this function.
|
|
44
15
|
*/
|
|
45
|
-
export function
|
|
16
|
+
export function normalizeFlatConfig(permission: FlatPermissionConfig): Ruleset {
|
|
46
17
|
const rules: Rule[] = [];
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
18
|
+
for (const [surface, value] of Object.entries(permission)) {
|
|
19
|
+
if (typeof value === "string") {
|
|
20
|
+
if (isPermissionState(value)) {
|
|
21
|
+
rules.push({ surface, pattern: "*", action: value });
|
|
22
|
+
}
|
|
23
|
+
} else if (typeof value === "object" && value !== null) {
|
|
24
|
+
for (const [pattern, action] of Object.entries(value)) {
|
|
25
|
+
if (isPermissionState(action)) {
|
|
26
|
+
rules.push({ surface, pattern, action });
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
51
30
|
}
|
|
52
|
-
|
|
53
|
-
for (const [pattern, action] of Object.entries(config.bash ?? {})) {
|
|
54
|
-
rules.push({ surface: "bash", pattern, action });
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
for (const [pattern, action] of Object.entries(config.mcp ?? {})) {
|
|
58
|
-
rules.push({ surface: "mcp", pattern, action });
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
for (const [pattern, action] of Object.entries(config.skills ?? {})) {
|
|
62
|
-
rules.push({ surface: "skill", pattern, action });
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
for (const [name, action] of Object.entries(config.special ?? {})) {
|
|
66
|
-
rules.push({ surface: "special", pattern: name, action });
|
|
67
|
-
}
|
|
68
|
-
|
|
69
31
|
return rules;
|
|
70
32
|
}
|