@gotgenes/pi-permission-system 12.0.0 → 13.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 +24 -0
- package/README.md +7 -9
- package/package.json +1 -1
- package/schemas/permissions.schema.json +1 -1
- package/src/config-modal.ts +3 -7
- 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/path-utils.ts +81 -0
- package/src/permission-manager.ts +81 -17
- package/src/permission-resolver.ts +24 -0
- package/src/permission-session.ts +2 -4
- package/src/rule.ts +61 -11
- package/test/bash-external-directory.test.ts +1 -81
- package/test/config-modal.test.ts +7 -10
- package/test/config-pipeline.test.ts +90 -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/path-utils.test.ts +72 -0
- package/test/permission-manager-unified.test.ts +134 -0
- package/test/permission-resolver.test.ts +69 -0
- package/test/rule.test.ts +74 -1
|
@@ -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
|
@@ -55,17 +55,8 @@ export function evaluate(
|
|
|
55
55
|
defaultAction?: PermissionState,
|
|
56
56
|
platform: NodeJS.Platform = process.platform,
|
|
57
57
|
): 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),
|
|
58
|
+
const rule = rules.findLast((r) =>
|
|
59
|
+
ruleMatches(r, surface, pattern, platform),
|
|
69
60
|
);
|
|
70
61
|
if (rule !== undefined) return rule;
|
|
71
62
|
return {
|
|
@@ -76,6 +67,33 @@ export function evaluate(
|
|
|
76
67
|
};
|
|
77
68
|
}
|
|
78
69
|
|
|
70
|
+
/**
|
|
71
|
+
* On Windows, path-surface values are canonicalized + lowercased; fold the
|
|
72
|
+
* pattern→value match (case and separators) so mixed-case / forward-slash
|
|
73
|
+
* overrides still match. The surface→surface match stays exact.
|
|
74
|
+
*/
|
|
75
|
+
function pathMatchOptions(
|
|
76
|
+
surface: string,
|
|
77
|
+
platform: NodeJS.Platform,
|
|
78
|
+
): { caseInsensitive: true; windowsSeparators: true } | undefined {
|
|
79
|
+
return platform === "win32" && PATH_SURFACES.has(surface)
|
|
80
|
+
? { caseInsensitive: true, windowsSeparators: true }
|
|
81
|
+
: undefined;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function ruleMatches(
|
|
85
|
+
rule: Rule,
|
|
86
|
+
surface: string,
|
|
87
|
+
value: string,
|
|
88
|
+
platform: NodeJS.Platform,
|
|
89
|
+
): boolean {
|
|
90
|
+
const matchOptions = pathMatchOptions(surface, platform);
|
|
91
|
+
return (
|
|
92
|
+
wildcardMatch(rule.surface, surface) &&
|
|
93
|
+
wildcardMatch(rule.pattern, value, matchOptions)
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
79
97
|
/**
|
|
80
98
|
* Evaluate a surface against an ordered list of candidate values, stopping at
|
|
81
99
|
* the first candidate that matches a non-default rule (last-match-wins within
|
|
@@ -134,3 +152,35 @@ export function evaluateFirst(
|
|
|
134
152
|
value: fallbackValue,
|
|
135
153
|
};
|
|
136
154
|
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Evaluate equivalent lookup values as aliases of the same path.
|
|
158
|
+
*
|
|
159
|
+
* Unlike `evaluateFirst()`, this preserves rule ordering across aliases: the
|
|
160
|
+
* last rule that matches any alias wins. This lets absolute allowlists and
|
|
161
|
+
* legacy relative rules coexist without a catch-all match on the first alias
|
|
162
|
+
* masking a later, more specific rule on another alias.
|
|
163
|
+
*/
|
|
164
|
+
export function evaluateAnyValue(
|
|
165
|
+
surface: string,
|
|
166
|
+
values: string[],
|
|
167
|
+
rules: Ruleset,
|
|
168
|
+
platform: NodeJS.Platform = process.platform,
|
|
169
|
+
): { rule: Rule; value: string } {
|
|
170
|
+
const fallbackValue = values[0] ?? "*";
|
|
171
|
+
const rule = rules.findLast((r) =>
|
|
172
|
+
values.some((value) => ruleMatches(r, surface, value, platform)),
|
|
173
|
+
);
|
|
174
|
+
if (rule !== undefined) {
|
|
175
|
+
return {
|
|
176
|
+
rule,
|
|
177
|
+
value:
|
|
178
|
+
values.find((value) => ruleMatches(rule, surface, value, platform)) ??
|
|
179
|
+
fallbackValue,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
return {
|
|
183
|
+
rule: evaluate(surface, fallbackValue, rules),
|
|
184
|
+
value: fallbackValue,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
@@ -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
|
-
});
|
|
@@ -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: {
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
5
|
+
|
|
6
|
+
import { loadAndMergeConfigs } from "#src/config-loader";
|
|
7
|
+
import { normalizePermissionSystemConfig } from "#src/extension-config";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Full-pipeline seam tests: write a temp config.json → loadAndMergeConfigs →
|
|
11
|
+
* normalizePermissionSystemConfig → assert values survive end to end.
|
|
12
|
+
*
|
|
13
|
+
* These tests guard the seam between the two normalizers — the class of bug
|
|
14
|
+
* fixed in #332, where a field declared on PermissionSystemExtensionConfig was
|
|
15
|
+
* silently dropped by the UnifiedPermissionConfig intermediate.
|
|
16
|
+
*/
|
|
17
|
+
describe("config pipeline seam", () => {
|
|
18
|
+
let tempDir: string;
|
|
19
|
+
let agentDir: string;
|
|
20
|
+
let cwd: string;
|
|
21
|
+
let extensionRoot: string;
|
|
22
|
+
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
tempDir = mkdtempSync(join(tmpdir(), "config-pipeline-test-"));
|
|
25
|
+
agentDir = join(tempDir, "agent");
|
|
26
|
+
cwd = join(tempDir, "project");
|
|
27
|
+
extensionRoot = join(tempDir, "ext");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
afterEach(() => {
|
|
31
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
function writeGlobal(content: Record<string, unknown>): void {
|
|
35
|
+
const dir = join(agentDir, "extensions", "pi-permission-system");
|
|
36
|
+
mkdirSync(dir, { recursive: true });
|
|
37
|
+
writeFileSync(join(dir, "config.json"), JSON.stringify(content));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
it("runtime knob and preview-length field both survive the full pipeline", () => {
|
|
41
|
+
writeGlobal({
|
|
42
|
+
debugLog: true,
|
|
43
|
+
toolInputPreviewMaxLength: 1000,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const mergeResult = loadAndMergeConfigs(agentDir, cwd, extensionRoot);
|
|
47
|
+
const config = normalizePermissionSystemConfig(mergeResult.merged);
|
|
48
|
+
|
|
49
|
+
expect(config.debugLog).toBe(true);
|
|
50
|
+
expect(config.toolInputPreviewMaxLength).toBe(1000);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("text summary length field survives the full pipeline", () => {
|
|
54
|
+
writeGlobal({
|
|
55
|
+
toolTextSummaryMaxLength: 250,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const mergeResult = loadAndMergeConfigs(agentDir, cwd, extensionRoot);
|
|
59
|
+
const config = normalizePermissionSystemConfig(mergeResult.merged);
|
|
60
|
+
|
|
61
|
+
expect(config.toolTextSummaryMaxLength).toBe(250);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("project config overrides global preview-length field end to end", () => {
|
|
65
|
+
writeGlobal({ toolInputPreviewMaxLength: 200 });
|
|
66
|
+
const projectDir = join(cwd, ".pi", "extensions", "pi-permission-system");
|
|
67
|
+
mkdirSync(projectDir, { recursive: true });
|
|
68
|
+
writeFileSync(
|
|
69
|
+
join(projectDir, "config.json"),
|
|
70
|
+
JSON.stringify({ toolInputPreviewMaxLength: 500 }),
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
const mergeResult = loadAndMergeConfigs(agentDir, cwd, extensionRoot);
|
|
74
|
+
const config = normalizePermissionSystemConfig(mergeResult.merged);
|
|
75
|
+
|
|
76
|
+
expect(config.toolInputPreviewMaxLength).toBe(500);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("defaults apply when config file is absent", () => {
|
|
80
|
+
// No config files written — agentDir and cwd directories don't exist.
|
|
81
|
+
const mergeResult = loadAndMergeConfigs(agentDir, cwd, extensionRoot);
|
|
82
|
+
const config = normalizePermissionSystemConfig(mergeResult.merged);
|
|
83
|
+
|
|
84
|
+
expect(config.debugLog).toBe(false);
|
|
85
|
+
expect(config.permissionReviewLog).toBe(true);
|
|
86
|
+
expect(config.yoloMode).toBe(false);
|
|
87
|
+
expect(config.toolInputPreviewMaxLength).toBeUndefined();
|
|
88
|
+
expect(config.toolTextSummaryMaxLength).toBeUndefined();
|
|
89
|
+
});
|
|
90
|
+
});
|
|
@@ -103,26 +103,6 @@ describe("normalizePermissionSystemConfig", () => {
|
|
|
103
103
|
expect(result.yoloMode).toBe(false);
|
|
104
104
|
});
|
|
105
105
|
|
|
106
|
-
it("coerces non-boolean values to their defaults", () => {
|
|
107
|
-
const result = normalizePermissionSystemConfig({
|
|
108
|
-
debugLog: "yes",
|
|
109
|
-
permissionReviewLog: 1,
|
|
110
|
-
yoloMode: null,
|
|
111
|
-
});
|
|
112
|
-
expect(result.debugLog).toBe(false);
|
|
113
|
-
expect(result.permissionReviewLog).toBe(true);
|
|
114
|
-
expect(result.yoloMode).toBe(false);
|
|
115
|
-
});
|
|
116
|
-
|
|
117
|
-
it("handles null/undefined input gracefully", () => {
|
|
118
|
-
const result = normalizePermissionSystemConfig(null);
|
|
119
|
-
expect(result).toEqual({
|
|
120
|
-
debugLog: false,
|
|
121
|
-
permissionReviewLog: true,
|
|
122
|
-
yoloMode: false,
|
|
123
|
-
});
|
|
124
|
-
});
|
|
125
|
-
|
|
126
106
|
it("includes toolInputPreviewMaxLength when a valid positive integer is provided", () => {
|
|
127
107
|
const result = normalizePermissionSystemConfig({
|
|
128
108
|
toolInputPreviewMaxLength: 400,
|
|
@@ -135,25 +115,6 @@ describe("normalizePermissionSystemConfig", () => {
|
|
|
135
115
|
expect("toolInputPreviewMaxLength" in result).toBe(false);
|
|
136
116
|
});
|
|
137
117
|
|
|
138
|
-
it("omits toolInputPreviewMaxLength for invalid values", () => {
|
|
139
|
-
expect(
|
|
140
|
-
normalizePermissionSystemConfig({ toolInputPreviewMaxLength: 0 })
|
|
141
|
-
.toolInputPreviewMaxLength,
|
|
142
|
-
).toBeUndefined();
|
|
143
|
-
expect(
|
|
144
|
-
normalizePermissionSystemConfig({ toolInputPreviewMaxLength: -1 })
|
|
145
|
-
.toolInputPreviewMaxLength,
|
|
146
|
-
).toBeUndefined();
|
|
147
|
-
expect(
|
|
148
|
-
normalizePermissionSystemConfig({ toolInputPreviewMaxLength: 200.5 })
|
|
149
|
-
.toolInputPreviewMaxLength,
|
|
150
|
-
).toBeUndefined();
|
|
151
|
-
expect(
|
|
152
|
-
normalizePermissionSystemConfig({ toolInputPreviewMaxLength: "200" })
|
|
153
|
-
.toolInputPreviewMaxLength,
|
|
154
|
-
).toBeUndefined();
|
|
155
|
-
});
|
|
156
|
-
|
|
157
118
|
it("includes toolTextSummaryMaxLength when a valid positive integer is provided", () => {
|
|
158
119
|
const result = normalizePermissionSystemConfig({
|
|
159
120
|
toolTextSummaryMaxLength: 120,
|
|
@@ -165,23 +126,4 @@ describe("normalizePermissionSystemConfig", () => {
|
|
|
165
126
|
const result = normalizePermissionSystemConfig({});
|
|
166
127
|
expect("toolTextSummaryMaxLength" in result).toBe(false);
|
|
167
128
|
});
|
|
168
|
-
|
|
169
|
-
it("omits toolTextSummaryMaxLength for invalid values", () => {
|
|
170
|
-
expect(
|
|
171
|
-
normalizePermissionSystemConfig({ toolTextSummaryMaxLength: 0 })
|
|
172
|
-
.toolTextSummaryMaxLength,
|
|
173
|
-
).toBeUndefined();
|
|
174
|
-
expect(
|
|
175
|
-
normalizePermissionSystemConfig({ toolTextSummaryMaxLength: -1 })
|
|
176
|
-
.toolTextSummaryMaxLength,
|
|
177
|
-
).toBeUndefined();
|
|
178
|
-
expect(
|
|
179
|
-
normalizePermissionSystemConfig({ toolTextSummaryMaxLength: 80.1 })
|
|
180
|
-
.toolTextSummaryMaxLength,
|
|
181
|
-
).toBeUndefined();
|
|
182
|
-
expect(
|
|
183
|
-
normalizePermissionSystemConfig({ toolTextSummaryMaxLength: true })
|
|
184
|
-
.toolTextSummaryMaxLength,
|
|
185
|
-
).toBeUndefined();
|
|
186
|
-
});
|
|
187
129
|
});
|
|
@@ -215,6 +215,49 @@ describe("describeBashPathGate", () => {
|
|
|
215
215
|
expect(desc.preCheck?.state).toBe("deny");
|
|
216
216
|
expect(desc.decision.value).toBe(".env");
|
|
217
217
|
});
|
|
218
|
+
|
|
219
|
+
it("resolves cd-aware policy values while keeping the raw prompt token", async () => {
|
|
220
|
+
const resolver = makeResolver(
|
|
221
|
+
makeCheckResult({ state: "deny", matchedPattern: "*" }),
|
|
222
|
+
);
|
|
223
|
+
const result = (await describeGate(
|
|
224
|
+
makeTcc({
|
|
225
|
+
input: { command: "cd nested && cat src/file.txt" },
|
|
226
|
+
cwd: "/test/project",
|
|
227
|
+
}),
|
|
228
|
+
resolver,
|
|
229
|
+
)) as GateDescriptor;
|
|
230
|
+
|
|
231
|
+
expect(resolver.resolvePathPolicy).toHaveBeenCalledWith(
|
|
232
|
+
[
|
|
233
|
+
"/test/project/nested/src/file.txt",
|
|
234
|
+
"nested/src/file.txt",
|
|
235
|
+
"src/file.txt",
|
|
236
|
+
],
|
|
237
|
+
undefined,
|
|
238
|
+
);
|
|
239
|
+
// The raw token drives the prompt, denial context, and session approval.
|
|
240
|
+
expect(result.denialContext).toMatchObject({ pathValue: "src/file.txt" });
|
|
241
|
+
expect(result.decision.value).toBe("src/file.txt");
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("does not resolve relative policy values through an unknown cd", async () => {
|
|
245
|
+
const resolver = makeResolver(
|
|
246
|
+
makeCheckResult({ state: "deny", matchedPattern: "*" }),
|
|
247
|
+
);
|
|
248
|
+
await describeGate(
|
|
249
|
+
makeTcc({
|
|
250
|
+
input: { command: 'cd "$DIR" && cat src/foo.ts' },
|
|
251
|
+
cwd: "/test/project",
|
|
252
|
+
}),
|
|
253
|
+
resolver,
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
expect(resolver.resolvePathPolicy).toHaveBeenCalledWith(
|
|
257
|
+
["src/foo.ts"],
|
|
258
|
+
undefined,
|
|
259
|
+
);
|
|
260
|
+
});
|
|
218
261
|
});
|
|
219
262
|
|
|
220
263
|
// Home-relative path characterization (#350) ──────────────────────────────
|
|
@@ -229,7 +272,7 @@ describe("describeBashPathGate — home-relative paths", () => {
|
|
|
229
272
|
// cat ~/.ssh/config → token "~/.ssh/config" extracted.
|
|
230
273
|
const resolver = makePathDispatchResolver(
|
|
231
274
|
{
|
|
232
|
-
"
|
|
275
|
+
"/mock/home/.ssh/config": makeCheckResult({
|
|
233
276
|
state: "deny",
|
|
234
277
|
matchedPattern: "~/.ssh/*",
|
|
235
278
|
}),
|
|
@@ -253,7 +296,7 @@ describe("describeBashPathGate — home-relative paths", () => {
|
|
|
253
296
|
it("extracts $HOME/... token and builds descriptor on deny", async () => {
|
|
254
297
|
const resolver = makePathDispatchResolver(
|
|
255
298
|
{
|
|
256
|
-
"
|
|
299
|
+
"/mock/home/.ssh/config": makeCheckResult({
|
|
257
300
|
state: "deny",
|
|
258
301
|
matchedPattern: "$HOME/.ssh/*",
|
|
259
302
|
}),
|
|
@@ -13,20 +13,50 @@ vi.mock("node:fs", () => ({
|
|
|
13
13
|
import { BashProgram } from "#src/handlers/gates/bash-program";
|
|
14
14
|
|
|
15
15
|
describe("BashProgram", () => {
|
|
16
|
-
describe("
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
16
|
+
describe("pathRuleCandidates", () => {
|
|
17
|
+
const cwd = "/projects/my-app";
|
|
18
|
+
|
|
19
|
+
it("adds absolute and relative policy values for relative tokens", async () => {
|
|
20
|
+
const program = await BashProgram.parse("cat src/foo.ts");
|
|
21
|
+
expect(program.pathRuleCandidates(cwd)).toEqual([
|
|
22
|
+
{
|
|
23
|
+
token: "src/foo.ts",
|
|
24
|
+
policyValues: ["/projects/my-app/src/foo.ts", "src/foo.ts"],
|
|
25
|
+
},
|
|
26
|
+
]);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("returns the literal token only when no cwd is provided", async () => {
|
|
30
|
+
const program = await BashProgram.parse("cat src/foo.ts");
|
|
31
|
+
expect(program.pathRuleCandidates()).toEqual([
|
|
32
|
+
{ token: "src/foo.ts", policyValues: ["src/foo.ts"] },
|
|
33
|
+
]);
|
|
20
34
|
});
|
|
21
35
|
|
|
22
|
-
it("
|
|
23
|
-
const program = await BashProgram.parse("
|
|
24
|
-
|
|
36
|
+
it("resolves tokens after literal cd against the effective directory", async () => {
|
|
37
|
+
const program = await BashProgram.parse("cd nested && cat src/file.txt");
|
|
38
|
+
const fileCandidate = program
|
|
39
|
+
.pathRuleCandidates(cwd)
|
|
40
|
+
.find((candidate) => candidate.token === "src/file.txt");
|
|
41
|
+
expect(fileCandidate).toEqual({
|
|
42
|
+
token: "src/file.txt",
|
|
43
|
+
policyValues: [
|
|
44
|
+
"/projects/my-app/nested/src/file.txt",
|
|
45
|
+
"nested/src/file.txt",
|
|
46
|
+
"src/file.txt",
|
|
47
|
+
],
|
|
48
|
+
});
|
|
25
49
|
});
|
|
26
50
|
|
|
27
|
-
it("
|
|
28
|
-
const program = await BashProgram.parse("
|
|
29
|
-
|
|
51
|
+
it("does not absolute-allow relative tokens after unknown cd", async () => {
|
|
52
|
+
const program = await BashProgram.parse('cd "$DIR" && cat src/foo.ts');
|
|
53
|
+
const fileCandidate = program
|
|
54
|
+
.pathRuleCandidates(cwd)
|
|
55
|
+
.find((candidate) => candidate.token === "src/foo.ts");
|
|
56
|
+
expect(fileCandidate).toEqual({
|
|
57
|
+
token: "src/foo.ts",
|
|
58
|
+
policyValues: ["src/foo.ts"],
|
|
59
|
+
});
|
|
30
60
|
});
|
|
31
61
|
});
|
|
32
62
|
|
|
@@ -322,7 +352,10 @@ describe("BashProgram", () => {
|
|
|
322
352
|
|
|
323
353
|
it("derives both slices from a single parse", async () => {
|
|
324
354
|
const program = await BashProgram.parse("cat .env /etc/hosts");
|
|
325
|
-
expect(program.
|
|
355
|
+
expect(program.pathRuleCandidates().map(({ token }) => token)).toEqual([
|
|
356
|
+
".env",
|
|
357
|
+
"/etc/hosts",
|
|
358
|
+
]);
|
|
326
359
|
const external = program.externalPaths("/projects/my-app");
|
|
327
360
|
expect(external).toContain("/etc/hosts");
|
|
328
361
|
expect(external).not.toContain(".env");
|
|
@@ -23,7 +23,7 @@ vi.mock("#src/handlers/gates/bash-program", () => ({
|
|
|
23
23
|
function makeMockBashProgram() {
|
|
24
24
|
return {
|
|
25
25
|
commands: vi.fn<() => []>(() => []),
|
|
26
|
-
|
|
26
|
+
pathRuleCandidates: vi.fn<() => []>(() => []),
|
|
27
27
|
externalPaths: vi.fn<() => []>(() => []),
|
|
28
28
|
};
|
|
29
29
|
}
|