@gotgenes/pi-permission-system 12.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 +40 -0
- package/README.md +7 -9
- package/config/config.example.json +2 -1
- package/package.json +1 -1
- package/schemas/permissions.schema.json +28 -2
- package/src/common.ts +17 -1
- package/src/config-loader.ts +9 -5
- package/src/config-modal.ts +3 -7
- package/src/denial-messages.ts +2 -1
- package/src/extension-config.ts +11 -25
- package/src/handlers/gates/bash-path-extractor.ts +0 -12
- package/src/handlers/gates/bash-path.ts +12 -10
- package/src/handlers/gates/bash-program.ts +52 -11
- package/src/handlers/gates/bash-token-classification.ts +1 -1
- package/src/index.ts +4 -2
- package/src/input-normalizer.ts +17 -11
- package/src/normalize.ts +12 -2
- package/src/path-utils.ts +81 -0
- package/src/permission-manager.ts +82 -17
- package/src/permission-resolver.ts +24 -0
- package/src/permission-session.ts +2 -4
- package/src/rule.ts +63 -11
- package/src/types.ts +18 -3
- package/test/bash-external-directory.test.ts +1 -81
- package/test/common.test.ts +28 -0
- package/test/config-loader.test.ts +43 -0
- package/test/config-modal.test.ts +7 -10
- package/test/config-pipeline.test.ts +90 -0
- package/test/denial-messages.test.ts +61 -0
- package/test/extension-config.test.ts +0 -58
- package/test/handlers/gates/bash-path.test.ts +45 -2
- package/test/handlers/gates/bash-program.test.ts +44 -11
- package/test/handlers/gates/tool-call-gate-pipeline.test.ts +1 -1
- package/test/helpers/gate-fixtures.ts +23 -3
- package/test/helpers/handler-fixtures.ts +14 -2
- package/test/helpers/session-fixtures.ts +14 -0
- package/test/input-normalizer.test.ts +52 -0
- package/test/normalize.test.ts +81 -0
- package/test/path-utils.test.ts +72 -0
- package/test/permission-manager-unified.test.ts +199 -0
- package/test/permission-resolver.test.ts +69 -0
- package/test/rule.test.ts +135 -1
package/src/path-utils.ts
CHANGED
|
@@ -2,6 +2,7 @@ import {
|
|
|
2
2
|
join,
|
|
3
3
|
normalize,
|
|
4
4
|
posix as posixPath,
|
|
5
|
+
relative,
|
|
5
6
|
resolve,
|
|
6
7
|
win32 as winPath,
|
|
7
8
|
} from "node:path";
|
|
@@ -63,6 +64,86 @@ export function isPathWithinDirectory(
|
|
|
63
64
|
);
|
|
64
65
|
}
|
|
65
66
|
|
|
67
|
+
export interface PathPolicyValueOptions {
|
|
68
|
+
/**
|
|
69
|
+
* Current Pi working directory. When provided, returned values include a
|
|
70
|
+
* project-relative alias for paths that resolve inside this directory.
|
|
71
|
+
*/
|
|
72
|
+
cwd?: string;
|
|
73
|
+
/**
|
|
74
|
+
* Directory used to resolve `pathValue` into an absolute policy value.
|
|
75
|
+
* Defaults to `cwd`. Bash uses this for tokens seen after a literal `cd`.
|
|
76
|
+
*/
|
|
77
|
+
resolveBase?: string;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Normalize a single path-like lookup value without resolving it against CWD.
|
|
82
|
+
*
|
|
83
|
+
* Preserves compatibility with existing relative path rules (`src/*`, `*.env`)
|
|
84
|
+
* while applying the same lexical cleanup as
|
|
85
|
+
* {@link normalizePathForComparison}: trim, strip simple wrapping quotes,
|
|
86
|
+
* strip the OpenCode-style leading `@`, and expand `~` / `$HOME`.
|
|
87
|
+
*/
|
|
88
|
+
export function normalizePathPolicyLiteral(pathValue: string): string {
|
|
89
|
+
const trimmed = pathValue.trim().replace(/^['"]|['"]$/g, "");
|
|
90
|
+
if (!trimmed) return "";
|
|
91
|
+
const unprefixed = trimmed.startsWith("@") ? trimmed.slice(1) : trimmed;
|
|
92
|
+
return expandHomePath(unprefixed);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Return equivalent lookup values for path-policy matching.
|
|
97
|
+
*
|
|
98
|
+
* The first value is the cwd/effective-base normalized absolute path when a
|
|
99
|
+
* base is available. The later values preserve project-relative and raw
|
|
100
|
+
* relative forms so existing rules like `src/*` and `*.env` continue to match.
|
|
101
|
+
*/
|
|
102
|
+
export function getPathPolicyValues(
|
|
103
|
+
pathValue: string,
|
|
104
|
+
options: PathPolicyValueOptions = {},
|
|
105
|
+
): string[] {
|
|
106
|
+
const literal = normalizePathPolicyLiteral(pathValue);
|
|
107
|
+
if (!literal) return [];
|
|
108
|
+
if (literal === "*") return ["*"];
|
|
109
|
+
|
|
110
|
+
return [
|
|
111
|
+
...new Set([...getAbsolutePathPolicyValues(pathValue, options), literal]),
|
|
112
|
+
];
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function getAbsolutePathPolicyValues(
|
|
116
|
+
pathValue: string,
|
|
117
|
+
options: PathPolicyValueOptions,
|
|
118
|
+
): string[] {
|
|
119
|
+
const resolveBase = options.resolveBase ?? options.cwd;
|
|
120
|
+
if (!resolveBase) return [];
|
|
121
|
+
|
|
122
|
+
const absolute = normalizePathForComparison(pathValue, resolveBase);
|
|
123
|
+
if (!absolute) return [];
|
|
124
|
+
|
|
125
|
+
return [absolute, ...getCwdRelativePathPolicyValues(absolute, options.cwd)];
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function getCwdRelativePathPolicyValues(
|
|
129
|
+
absolute: string,
|
|
130
|
+
cwd: string | undefined,
|
|
131
|
+
): string[] {
|
|
132
|
+
if (!cwd) return [];
|
|
133
|
+
|
|
134
|
+
const normalizedCwd = normalizePathForComparison(cwd, cwd);
|
|
135
|
+
if (!normalizedCwd) return [];
|
|
136
|
+
if (
|
|
137
|
+
absolute !== normalizedCwd &&
|
|
138
|
+
!isPathWithinDirectory(absolute, normalizedCwd)
|
|
139
|
+
) {
|
|
140
|
+
return [];
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const relativeValue = relative(normalizedCwd, absolute);
|
|
144
|
+
return relativeValue ? [relativeValue] : [];
|
|
145
|
+
}
|
|
146
|
+
|
|
66
147
|
/**
|
|
67
148
|
* Paths that are universally safe and should never trigger external-directory checks.
|
|
68
149
|
* These are OS device files: read returns EOF or process streams, write discards or goes to process streams.
|
|
@@ -3,6 +3,7 @@ import { isPermissionState } from "./common";
|
|
|
3
3
|
import { getGlobalConfigPath, getProjectConfigPath } from "./config-paths";
|
|
4
4
|
import { normalizeInput } from "./input-normalizer";
|
|
5
5
|
import { normalizeFlatConfig } from "./normalize";
|
|
6
|
+
import { PATH_SURFACES } from "./path-utils";
|
|
6
7
|
import {
|
|
7
8
|
FilePolicyLoader,
|
|
8
9
|
type PolicyLoader,
|
|
@@ -10,7 +11,7 @@ import {
|
|
|
10
11
|
type ResolvedPolicyPaths,
|
|
11
12
|
} from "./policy-loader";
|
|
12
13
|
import type { Rule, RuleOrigin, Ruleset } from "./rule";
|
|
13
|
-
import { evaluate, evaluateFirst } from "./rule";
|
|
14
|
+
import { evaluate, evaluateAnyValue, evaluateFirst } from "./rule";
|
|
14
15
|
import { mergeScopesWithOrigins } from "./scope-merge";
|
|
15
16
|
import {
|
|
16
17
|
composeRuleset,
|
|
@@ -63,6 +64,17 @@ export interface ScopedPermissionManager {
|
|
|
63
64
|
agentName?: string,
|
|
64
65
|
sessionRules?: Ruleset,
|
|
65
66
|
): PermissionCheckResult;
|
|
67
|
+
/**
|
|
68
|
+
* Evaluate the cross-cutting `path` surface against a caller-supplied set of
|
|
69
|
+
* equivalent policy values (e.g. bash tokens already resolved against a
|
|
70
|
+
* preceding literal `cd`). The values are trusted because they are computed
|
|
71
|
+
* internally, never read from a field on raw tool input.
|
|
72
|
+
*/
|
|
73
|
+
checkPathPolicy(
|
|
74
|
+
values: readonly string[],
|
|
75
|
+
agentName?: string,
|
|
76
|
+
sessionRules?: Ruleset,
|
|
77
|
+
): PermissionCheckResult;
|
|
66
78
|
getToolPermission(toolName: string, agentName?: string): PermissionState;
|
|
67
79
|
getConfigIssues(agentName?: string): string[];
|
|
68
80
|
getPolicyCacheStamp(agentName?: string): string;
|
|
@@ -79,6 +91,7 @@ export interface PermissionManagerOptions extends PolicyLoaderOptions {
|
|
|
79
91
|
|
|
80
92
|
export class PermissionManager implements ScopedPermissionManager {
|
|
81
93
|
private readonly agentDir: string | undefined;
|
|
94
|
+
private currentCwd: string | undefined;
|
|
82
95
|
private loader: PolicyLoader;
|
|
83
96
|
private readonly resolvedPermissionsCache = new Map<
|
|
84
97
|
string,
|
|
@@ -104,6 +117,8 @@ export class PermissionManager implements ScopedPermissionManager {
|
|
|
104
117
|
* built with explicit paths), only the cache is cleared.
|
|
105
118
|
*/
|
|
106
119
|
configureForCwd(cwd: string | undefined | null): void {
|
|
120
|
+
this.currentCwd =
|
|
121
|
+
typeof cwd === "string" && cwd.trim().length > 0 ? cwd : undefined;
|
|
107
122
|
if (this.agentDir !== undefined) {
|
|
108
123
|
this.loader = new FilePolicyLoader(
|
|
109
124
|
derivePolicyLoaderOptions(this.agentDir, cwd),
|
|
@@ -245,29 +260,79 @@ export class PermissionManager implements ScopedPermissionManager {
|
|
|
245
260
|
normalizedToolName,
|
|
246
261
|
input,
|
|
247
262
|
this.loader.getConfiguredMcpServerNames(),
|
|
263
|
+
this.currentCwd,
|
|
248
264
|
);
|
|
249
265
|
|
|
250
|
-
|
|
266
|
+
return buildCheckResult(
|
|
267
|
+
surface,
|
|
268
|
+
values,
|
|
269
|
+
resultExtras,
|
|
270
|
+
normalizedToolName,
|
|
271
|
+
toolName,
|
|
272
|
+
fullRules,
|
|
273
|
+
);
|
|
274
|
+
}
|
|
251
275
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
276
|
+
checkPathPolicy(
|
|
277
|
+
values: readonly string[],
|
|
278
|
+
agentName?: string,
|
|
279
|
+
sessionRules?: Ruleset,
|
|
280
|
+
): PermissionCheckResult {
|
|
281
|
+
const { composedRules } = this.resolvePermissions(agentName);
|
|
282
|
+
const fullRules: Ruleset = sessionRules?.length
|
|
283
|
+
? [...composedRules, ...sessionRules]
|
|
284
|
+
: composedRules;
|
|
256
285
|
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
...extras,
|
|
267
|
-
};
|
|
286
|
+
const lookupValues = values.length > 0 ? [...values] : ["*"];
|
|
287
|
+
return buildCheckResult(
|
|
288
|
+
"path",
|
|
289
|
+
lookupValues,
|
|
290
|
+
{},
|
|
291
|
+
"path",
|
|
292
|
+
"path",
|
|
293
|
+
fullRules,
|
|
294
|
+
);
|
|
268
295
|
}
|
|
269
296
|
}
|
|
270
297
|
|
|
298
|
+
/**
|
|
299
|
+
* Evaluate a normalized surface/values triple and shape the result.
|
|
300
|
+
*
|
|
301
|
+
* Path surfaces use {@link evaluateAnyValue} (last-match-wins across equivalent
|
|
302
|
+
* aliases); every other surface keeps {@link evaluateFirst}. Shared by
|
|
303
|
+
* `checkPermission` and `checkPathPolicy`.
|
|
304
|
+
*/
|
|
305
|
+
function buildCheckResult(
|
|
306
|
+
surface: string,
|
|
307
|
+
values: string[],
|
|
308
|
+
resultExtras: Record<string, unknown>,
|
|
309
|
+
normalizedToolName: string,
|
|
310
|
+
toolName: string,
|
|
311
|
+
fullRules: Ruleset,
|
|
312
|
+
): PermissionCheckResult {
|
|
313
|
+
const { rule, value } = PATH_SURFACES.has(surface)
|
|
314
|
+
? evaluateAnyValue(surface, values, fullRules)
|
|
315
|
+
: evaluateFirst(surface, values, fullRules);
|
|
316
|
+
|
|
317
|
+
// For MCP, replace the normalizer's fallback target with the actual
|
|
318
|
+
// matched candidate value so PermissionCheckResult.target is accurate.
|
|
319
|
+
const extras =
|
|
320
|
+
surface === "mcp" ? { ...resultExtras, target: value } : resultExtras;
|
|
321
|
+
|
|
322
|
+
return {
|
|
323
|
+
toolName,
|
|
324
|
+
state: rule.action,
|
|
325
|
+
reason: rule.reason,
|
|
326
|
+
matchedPattern:
|
|
327
|
+
rule.layer === "config" || rule.layer === "session"
|
|
328
|
+
? rule.pattern
|
|
329
|
+
: undefined,
|
|
330
|
+
source: deriveSource(rule, normalizedToolName),
|
|
331
|
+
origin: rule.origin,
|
|
332
|
+
...extras,
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
|
|
271
336
|
/**
|
|
272
337
|
* Derive `PolicyLoaderOptions` from an agentDir + an optional cwd.
|
|
273
338
|
* Setting agentsDir explicitly from agentDir removes the hidden
|
|
@@ -17,6 +17,15 @@ export interface ScopedPermissionResolver {
|
|
|
17
17
|
input: unknown,
|
|
18
18
|
agentName?: string,
|
|
19
19
|
): PermissionCheckResult;
|
|
20
|
+
/**
|
|
21
|
+
* Resolve the cross-cutting `path` surface against a caller-supplied set of
|
|
22
|
+
* equivalent policy values, applying the current session rules. Used by the
|
|
23
|
+
* bash path gate, which computes cd-aware policy values per token.
|
|
24
|
+
*/
|
|
25
|
+
resolvePathPolicy(
|
|
26
|
+
values: readonly string[],
|
|
27
|
+
agentName?: string,
|
|
28
|
+
): PermissionCheckResult;
|
|
20
29
|
}
|
|
21
30
|
|
|
22
31
|
/**
|
|
@@ -53,6 +62,21 @@ export class PermissionResolver implements ScopedPermissionResolver {
|
|
|
53
62
|
);
|
|
54
63
|
}
|
|
55
64
|
|
|
65
|
+
/**
|
|
66
|
+
* Resolve the `path` surface for precomputed policy values, composing the
|
|
67
|
+
* current session ruleset so callers never thread it by hand.
|
|
68
|
+
*/
|
|
69
|
+
resolvePathPolicy(
|
|
70
|
+
values: readonly string[],
|
|
71
|
+
agentName?: string,
|
|
72
|
+
): PermissionCheckResult {
|
|
73
|
+
return this.permissionManager.checkPathPolicy(
|
|
74
|
+
values,
|
|
75
|
+
agentName,
|
|
76
|
+
this.sessionRules.getRuleset(),
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
56
80
|
checkPermission(
|
|
57
81
|
surface: string,
|
|
58
82
|
input: unknown,
|
|
@@ -150,10 +150,8 @@ export class PermissionSession implements ToolCallGateInputs {
|
|
|
150
150
|
return this.knownAgentName;
|
|
151
151
|
}
|
|
152
152
|
|
|
153
|
-
// Read by config-modal
|
|
154
|
-
//
|
|
155
|
-
// wiring, so it reports a false positive here.
|
|
156
|
-
// fallow-ignore-next-line unused-class-member
|
|
153
|
+
// Read by the `index.ts` config-modal adapter closure:
|
|
154
|
+
// `permissionManager.getComposedConfigRules(session.lastKnownActiveAgentName ?? undefined)`.
|
|
157
155
|
get lastKnownActiveAgentName(): string | null {
|
|
158
156
|
return this.knownAgentName;
|
|
159
157
|
}
|
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.
|
|
@@ -55,17 +57,8 @@ export function evaluate(
|
|
|
55
57
|
defaultAction?: PermissionState,
|
|
56
58
|
platform: NodeJS.Platform = process.platform,
|
|
57
59
|
): Rule {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
// overrides still match. The surface→surface match stays exact.
|
|
61
|
-
const matchOptions =
|
|
62
|
-
platform === "win32" && PATH_SURFACES.has(surface)
|
|
63
|
-
? { caseInsensitive: true, windowsSeparators: true }
|
|
64
|
-
: undefined;
|
|
65
|
-
const rule = rules.findLast(
|
|
66
|
-
(r) =>
|
|
67
|
-
wildcardMatch(r.surface, surface) &&
|
|
68
|
-
wildcardMatch(r.pattern, pattern, matchOptions),
|
|
60
|
+
const rule = rules.findLast((r) =>
|
|
61
|
+
ruleMatches(r, surface, pattern, platform),
|
|
69
62
|
);
|
|
70
63
|
if (rule !== undefined) return rule;
|
|
71
64
|
return {
|
|
@@ -76,6 +69,33 @@ export function evaluate(
|
|
|
76
69
|
};
|
|
77
70
|
}
|
|
78
71
|
|
|
72
|
+
/**
|
|
73
|
+
* On Windows, path-surface values are canonicalized + lowercased; fold the
|
|
74
|
+
* pattern→value match (case and separators) so mixed-case / forward-slash
|
|
75
|
+
* overrides still match. The surface→surface match stays exact.
|
|
76
|
+
*/
|
|
77
|
+
function pathMatchOptions(
|
|
78
|
+
surface: string,
|
|
79
|
+
platform: NodeJS.Platform,
|
|
80
|
+
): { caseInsensitive: true; windowsSeparators: true } | undefined {
|
|
81
|
+
return platform === "win32" && PATH_SURFACES.has(surface)
|
|
82
|
+
? { caseInsensitive: true, windowsSeparators: true }
|
|
83
|
+
: undefined;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function ruleMatches(
|
|
87
|
+
rule: Rule,
|
|
88
|
+
surface: string,
|
|
89
|
+
value: string,
|
|
90
|
+
platform: NodeJS.Platform,
|
|
91
|
+
): boolean {
|
|
92
|
+
const matchOptions = pathMatchOptions(surface, platform);
|
|
93
|
+
return (
|
|
94
|
+
wildcardMatch(rule.surface, surface) &&
|
|
95
|
+
wildcardMatch(rule.pattern, value, matchOptions)
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
79
99
|
/**
|
|
80
100
|
* Evaluate a surface against an ordered list of candidate values, stopping at
|
|
81
101
|
* the first candidate that matches a non-default rule (last-match-wins within
|
|
@@ -134,3 +154,35 @@ export function evaluateFirst(
|
|
|
134
154
|
value: fallbackValue,
|
|
135
155
|
};
|
|
136
156
|
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Evaluate equivalent lookup values as aliases of the same path.
|
|
160
|
+
*
|
|
161
|
+
* Unlike `evaluateFirst()`, this preserves rule ordering across aliases: the
|
|
162
|
+
* last rule that matches any alias wins. This lets absolute allowlists and
|
|
163
|
+
* legacy relative rules coexist without a catch-all match on the first alias
|
|
164
|
+
* masking a later, more specific rule on another alias.
|
|
165
|
+
*/
|
|
166
|
+
export function evaluateAnyValue(
|
|
167
|
+
surface: string,
|
|
168
|
+
values: string[],
|
|
169
|
+
rules: Ruleset,
|
|
170
|
+
platform: NodeJS.Platform = process.platform,
|
|
171
|
+
): { rule: Rule; value: string } {
|
|
172
|
+
const fallbackValue = values[0] ?? "*";
|
|
173
|
+
const rule = rules.findLast((r) =>
|
|
174
|
+
values.some((value) => ruleMatches(r, surface, value, platform)),
|
|
175
|
+
);
|
|
176
|
+
if (rule !== undefined) {
|
|
177
|
+
return {
|
|
178
|
+
rule,
|
|
179
|
+
value:
|
|
180
|
+
values.find((value) => ruleMatches(rule, surface, value, platform)) ??
|
|
181
|
+
fallbackValue,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
return {
|
|
185
|
+
rule: evaluate(surface, fallbackValue, rules),
|
|
186
|
+
value: fallbackValue,
|
|
187
|
+
};
|
|
188
|
+
}
|
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;
|
|
@@ -18,10 +18,7 @@ vi.mock("node:fs", () => ({
|
|
|
18
18
|
}));
|
|
19
19
|
|
|
20
20
|
import { formatDenyReason } from "#src/denial-messages";
|
|
21
|
-
import {
|
|
22
|
-
extractExternalPathsFromBashCommand,
|
|
23
|
-
extractTokensForPathRules,
|
|
24
|
-
} from "#src/handlers/gates/bash-path-extractor";
|
|
21
|
+
import { extractExternalPathsFromBashCommand } from "#src/handlers/gates/bash-path-extractor";
|
|
25
22
|
import { formatBashExternalDirectoryAskPrompt } from "#src/handlers/gates/external-directory-messages";
|
|
26
23
|
|
|
27
24
|
afterEach(() => {
|
|
@@ -957,80 +954,3 @@ describe("bash external-directory denial messages (centralized)", () => {
|
|
|
957
954
|
expect(result).not.toContain("Hard stop");
|
|
958
955
|
});
|
|
959
956
|
});
|
|
960
|
-
|
|
961
|
-
describe("extractTokensForPathRules", () => {
|
|
962
|
-
test("extracts dot-files: cat .env", async () => {
|
|
963
|
-
const tokens = await extractTokensForPathRules("cat .env");
|
|
964
|
-
expect(tokens).toContain(".env");
|
|
965
|
-
});
|
|
966
|
-
|
|
967
|
-
test("extracts relative dot-paths: git add src/.env", async () => {
|
|
968
|
-
const tokens = await extractTokensForPathRules("git add src/.env");
|
|
969
|
-
expect(tokens).toContain("src/.env");
|
|
970
|
-
});
|
|
971
|
-
|
|
972
|
-
test("extracts nothing from plain words: echo hello", async () => {
|
|
973
|
-
const tokens = await extractTokensForPathRules("echo hello");
|
|
974
|
-
expect(tokens).toHaveLength(0);
|
|
975
|
-
});
|
|
976
|
-
|
|
977
|
-
test("extracts ./src and skips flags: rm -rf ./src", async () => {
|
|
978
|
-
const tokens = await extractTokensForPathRules("rm -rf ./src");
|
|
979
|
-
expect(tokens).toContain("./src");
|
|
980
|
-
expect(tokens).not.toContain("-rf");
|
|
981
|
-
});
|
|
982
|
-
|
|
983
|
-
test("extracts absolute paths: cat /etc/hosts", async () => {
|
|
984
|
-
const tokens = await extractTokensForPathRules("cat /etc/hosts");
|
|
985
|
-
expect(tokens).toContain("/etc/hosts");
|
|
986
|
-
});
|
|
987
|
-
|
|
988
|
-
test("skips URLs: curl https://example.com", async () => {
|
|
989
|
-
const tokens = await extractTokensForPathRules("curl https://example.com");
|
|
990
|
-
expect(tokens).not.toContain("https://example.com");
|
|
991
|
-
});
|
|
992
|
-
|
|
993
|
-
test("extracts slash-containing tokens: cat src/foo.ts", async () => {
|
|
994
|
-
const tokens = await extractTokensForPathRules("cat src/foo.ts");
|
|
995
|
-
expect(tokens).toContain("src/foo.ts");
|
|
996
|
-
});
|
|
997
|
-
|
|
998
|
-
test("skips heredoc content", async () => {
|
|
999
|
-
const tokens = await extractTokensForPathRules("cat <<EOF\n.env\nEOF");
|
|
1000
|
-
expect(tokens).not.toContain(".env");
|
|
1001
|
-
});
|
|
1002
|
-
|
|
1003
|
-
test("skips @scope/package patterns", async () => {
|
|
1004
|
-
const tokens = await extractTokensForPathRules(
|
|
1005
|
-
"npm install @scope/package",
|
|
1006
|
-
);
|
|
1007
|
-
expect(tokens).not.toContain("@scope/package");
|
|
1008
|
-
});
|
|
1009
|
-
|
|
1010
|
-
test("skips env assignments", async () => {
|
|
1011
|
-
const tokens = await extractTokensForPathRules("FOO=/bar command");
|
|
1012
|
-
expect(tokens).not.toContain("FOO=/bar");
|
|
1013
|
-
});
|
|
1014
|
-
|
|
1015
|
-
test("skips bare-slash tokens", async () => {
|
|
1016
|
-
const tokens = await extractTokensForPathRules("ls /");
|
|
1017
|
-
expect(tokens).not.toContain("/");
|
|
1018
|
-
});
|
|
1019
|
-
|
|
1020
|
-
test("extracts redirect targets: echo test > .env", async () => {
|
|
1021
|
-
const tokens = await extractTokensForPathRules("echo test > .env");
|
|
1022
|
-
expect(tokens).toContain(".env");
|
|
1023
|
-
});
|
|
1024
|
-
|
|
1025
|
-
test("extracts multiple path tokens: cp .env .env.backup", async () => {
|
|
1026
|
-
const tokens = await extractTokensForPathRules("cp .env .env.backup");
|
|
1027
|
-
expect(tokens).toContain(".env");
|
|
1028
|
-
expect(tokens).toContain(".env.backup");
|
|
1029
|
-
});
|
|
1030
|
-
|
|
1031
|
-
test("deduplicates repeated tokens", async () => {
|
|
1032
|
-
const tokens = await extractTokensForPathRules("cat .env && rm .env");
|
|
1033
|
-
const envCount = tokens.filter((t) => t === ".env").length;
|
|
1034
|
-
expect(envCount).toBe(1);
|
|
1035
|
-
});
|
|
1036
|
-
});
|
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 }));
|
|
@@ -2,6 +2,7 @@ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
|
2
2
|
import { tmpdir } from "node:os";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { expect, test, vi } from "vitest";
|
|
5
|
+
import { loadUnifiedConfig } from "#src/config-loader";
|
|
5
6
|
import { registerPermissionSystemCommand } from "#src/config-modal";
|
|
6
7
|
import type { CommandConfigStore } from "#src/config-store";
|
|
7
8
|
import {
|
|
@@ -89,8 +90,7 @@ test("permission-system command completions expose top-level config actions", ()
|
|
|
89
90
|
const controller = {
|
|
90
91
|
config: configStore,
|
|
91
92
|
configPath,
|
|
92
|
-
|
|
93
|
-
session: { lastKnownActiveAgentName: null },
|
|
93
|
+
getActiveAgentConfigRules: () => [] as Ruleset,
|
|
94
94
|
};
|
|
95
95
|
|
|
96
96
|
let definition: {
|
|
@@ -146,7 +146,7 @@ test("permission-system command handlers manage config summary, persistence, and
|
|
|
146
146
|
current: () => config,
|
|
147
147
|
save: (next) => {
|
|
148
148
|
const currentConfig = normalizePermissionSystemConfig(
|
|
149
|
-
|
|
149
|
+
loadUnifiedConfig(configPath).config,
|
|
150
150
|
);
|
|
151
151
|
const normalized = normalizePermissionSystemConfig(next);
|
|
152
152
|
writeFileSync(
|
|
@@ -155,7 +155,7 @@ test("permission-system command handlers manage config summary, persistence, and
|
|
|
155
155
|
"utf-8",
|
|
156
156
|
);
|
|
157
157
|
config = normalizePermissionSystemConfig(
|
|
158
|
-
|
|
158
|
+
loadUnifiedConfig(configPath).config,
|
|
159
159
|
);
|
|
160
160
|
expect(config).not.toEqual(currentConfig);
|
|
161
161
|
},
|
|
@@ -163,8 +163,7 @@ test("permission-system command handlers manage config summary, persistence, and
|
|
|
163
163
|
const controller = {
|
|
164
164
|
config: configStore,
|
|
165
165
|
configPath,
|
|
166
|
-
|
|
167
|
-
session: { lastKnownActiveAgentName: null },
|
|
166
|
+
getActiveAgentConfigRules: () => [] as Ruleset,
|
|
168
167
|
};
|
|
169
168
|
|
|
170
169
|
let registeredName = "";
|
|
@@ -262,8 +261,7 @@ test("show output includes rule origins when getComposedRules is provided", asyn
|
|
|
262
261
|
const controller = {
|
|
263
262
|
config: { current: () => config, save: () => {} } as CommandConfigStore,
|
|
264
263
|
configPath: "/fake/config.json",
|
|
265
|
-
|
|
266
|
-
session: { lastKnownActiveAgentName: null },
|
|
264
|
+
getActiveAgentConfigRules: () => composedRules,
|
|
267
265
|
};
|
|
268
266
|
|
|
269
267
|
let definition: {
|
|
@@ -295,8 +293,7 @@ test("show output omits rule summary when getComposedRules is not provided", asy
|
|
|
295
293
|
const controller = {
|
|
296
294
|
config: { current: () => config, save: () => {} } as CommandConfigStore,
|
|
297
295
|
configPath: "/fake/config.json",
|
|
298
|
-
|
|
299
|
-
session: { lastKnownActiveAgentName: null },
|
|
296
|
+
getActiveAgentConfigRules: () => [] as Ruleset,
|
|
300
297
|
};
|
|
301
298
|
|
|
302
299
|
let definition: {
|