@gotgenes/pi-permission-system 16.2.0 → 17.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 +34 -0
- package/README.md +3 -2
- package/package.json +1 -1
- package/src/access-intent/access-intent.ts +8 -10
- package/src/access-intent/access-path.ts +39 -16
- package/src/access-intent/bash/command-enumeration.ts +79 -2
- package/src/access-intent/bash/cwd-projection.ts +16 -14
- package/src/access-intent/bash/parser.ts +2 -0
- package/src/access-intent/bash/program.ts +3 -0
- package/src/builtin-tool-input-formatters.ts +1 -1
- package/src/config-loader.ts +7 -7
- package/src/forwarded-permissions/permission-forwarder.ts +1 -1
- package/src/handlers/gates/bash-command.ts +15 -1
- package/src/handlers/gates/bash-external-directory.ts +1 -1
- package/src/handlers/gates/bash-path.ts +14 -14
- package/src/handlers/gates/external-directory.ts +3 -4
- package/src/handlers/gates/path.ts +11 -7
- package/src/handlers/gates/skill-read.ts +1 -1
- package/src/handlers/gates/tool-call-gate-pipeline.ts +1 -1
- package/src/handlers/permission-gate-handler.ts +1 -2
- package/src/handlers/tool-call-boundary.ts +1 -2
- package/src/input-normalizer.ts +1 -1
- package/src/mcp-targets.ts +1 -1
- package/src/normalize.ts +1 -1
- package/src/path-utils.ts +1 -1
- package/src/permission-manager.ts +1 -1
- package/src/permission-prompts.ts +1 -1
- package/src/policy-loader.ts +2 -2
- package/src/tool-input-prompt-formatters.ts +1 -1
- package/src/tool-preview-formatter.ts +1 -1
- package/src/tool-registry.ts +1 -1
- package/src/{common.ts → value-guards.ts} +0 -66
- package/src/yaml-frontmatter.ts +65 -0
- package/test/access-intent/access-path.test.ts +83 -23
- package/test/access-intent/bash/node-text.test.ts +1 -0
- package/test/access-intent/bash/program.test.ts +116 -17
- package/test/handlers/external-directory-integration.test.ts +40 -153
- package/test/handlers/external-directory-session-dedup.test.ts +38 -262
- package/test/handlers/gates/bash-command.test.ts +63 -0
- package/test/handlers/gates/bash-external-directory.test.ts +1 -2
- package/test/handlers/gates/bash-path.test.ts +9 -9
- package/test/handlers/gates/external-directory-policy.test.ts +10 -18
- package/test/handlers/gates/path.test.ts +43 -12
- package/test/helpers/external-directory-fixtures.ts +269 -0
- package/test/helpers/gate-fixtures.ts +1 -2
- package/test/permission-resolver.test.ts +5 -32
- package/test/{common.test.ts → value-guards.test.ts} +2 -96
- package/test/yaml-frontmatter.test.ts +91 -0
|
@@ -2,8 +2,6 @@ import type {
|
|
|
2
2
|
ExtensionContext,
|
|
3
3
|
InputEventResult,
|
|
4
4
|
} from "@earendil-works/pi-coding-agent";
|
|
5
|
-
|
|
6
|
-
import { toRecord } from "#src/common";
|
|
7
5
|
import {
|
|
8
6
|
formatMissingToolNameReason,
|
|
9
7
|
formatUnknownToolReason,
|
|
@@ -14,6 +12,7 @@ import {
|
|
|
14
12
|
getToolNameFromValue,
|
|
15
13
|
type ToolRegistry,
|
|
16
14
|
} from "#src/tool-registry";
|
|
15
|
+
import { toRecord } from "#src/value-guards";
|
|
17
16
|
import type { GateRunner } from "./gates/runner";
|
|
18
17
|
import type {
|
|
19
18
|
GateNotifier,
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
|
|
3
|
-
import { toRecord } from "#src/common";
|
|
4
2
|
import type { DecisionRecorder } from "#src/decision-audit";
|
|
5
3
|
import type { DecisionReporter } from "#src/decision-reporter";
|
|
4
|
+
import { toRecord } from "#src/value-guards";
|
|
6
5
|
import type { GateOutcome } from "./gates/types";
|
|
7
6
|
|
|
8
7
|
/** The SDK-facing result shape for a `tool_call` handler. */
|
package/src/input-normalizer.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { stripBashCommentLines } from "./bash-arity";
|
|
2
|
-
import { getNonEmptyString, toRecord } from "./common";
|
|
3
2
|
import { createMcpPermissionTargets } from "./mcp-targets";
|
|
4
3
|
import { getPathPolicyValues, PATH_BEARING_TOOLS } from "./path-utils";
|
|
4
|
+
import { getNonEmptyString, toRecord } from "./value-guards";
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* Construct a surface-appropriate input object from a raw value string.
|
package/src/mcp-targets.ts
CHANGED
package/src/normalize.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { isDenyWithReason, isPermissionState } from "./common";
|
|
2
1
|
import type { Rule, Ruleset } from "./rule";
|
|
3
2
|
import type { FlatPermissionConfig } from "./types";
|
|
3
|
+
import { isDenyWithReason, isPermissionState } from "./value-guards";
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* Convert a flat permission config into a Ruleset.
|
package/src/path-utils.ts
CHANGED
|
@@ -8,9 +8,9 @@ import {
|
|
|
8
8
|
} from "node:path";
|
|
9
9
|
|
|
10
10
|
import { canonicalizePath } from "./canonicalize-path";
|
|
11
|
-
import { getNonEmptyString, toRecord } from "./common";
|
|
12
11
|
import { expandHomePath } from "./expand-home";
|
|
13
12
|
import type { ToolAccessExtractorLookup } from "./tool-access-extractor-registry";
|
|
13
|
+
import { getNonEmptyString, toRecord } from "./value-guards";
|
|
14
14
|
import { wildcardMatch } from "./wildcard-matcher";
|
|
15
15
|
|
|
16
16
|
export function normalizePathForComparison(
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { join } from "node:path";
|
|
2
2
|
import type { ResolvedAccessIntent } from "./access-intent/access-intent";
|
|
3
|
-
import { isPermissionState } from "./common";
|
|
4
3
|
import {
|
|
5
4
|
getGlobalConfigPath,
|
|
6
5
|
getProjectAgentsDir,
|
|
@@ -28,6 +27,7 @@ import type {
|
|
|
28
27
|
PermissionCheckResult,
|
|
29
28
|
PermissionState,
|
|
30
29
|
} from "./types";
|
|
30
|
+
import { isPermissionState } from "./value-guards";
|
|
31
31
|
|
|
32
32
|
const BUILT_IN_TOOL_PERMISSION_NAMES = new Set([
|
|
33
33
|
"bash",
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { getNonEmptyString, toRecord } from "./common";
|
|
2
1
|
import { matchQualifier } from "./denial-messages";
|
|
3
2
|
import type { SkillPromptEntry } from "./skill-prompt-sanitizer";
|
|
4
3
|
import type { ToolPreviewFormatter } from "./tool-preview-formatter";
|
|
5
4
|
import type { PermissionCheckResult } from "./types";
|
|
5
|
+
import { getNonEmptyString, toRecord } from "./value-guards";
|
|
6
6
|
|
|
7
7
|
// NOTE: formatDenyReason, formatUserDeniedReason, and
|
|
8
8
|
// formatPermissionHardStopHint have been moved to denial-messages.ts.
|
package/src/policy-loader.ts
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
4
|
-
|
|
5
|
-
import { extractFrontmatter, parseSimpleYamlMap, toRecord } from "./common";
|
|
6
4
|
import {
|
|
7
5
|
loadUnifiedConfig,
|
|
8
6
|
normalizeUnifiedConfig,
|
|
@@ -10,6 +8,8 @@ import {
|
|
|
10
8
|
} from "./config-loader";
|
|
11
9
|
import { getGlobalConfigPath } from "./config-paths";
|
|
12
10
|
import type { ScopeConfig } from "./types";
|
|
11
|
+
import { toRecord } from "./value-guards";
|
|
12
|
+
import { extractFrontmatter, parseSimpleYamlMap } from "./yaml-frontmatter";
|
|
13
13
|
|
|
14
14
|
// ---------------------------------------------------------------------------
|
|
15
15
|
// File-stamp helper
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { getNonEmptyString, toRecord } from "./common";
|
|
2
1
|
import { countTextLines, formatCount } from "./tool-input-preview";
|
|
2
|
+
import { getNonEmptyString, toRecord } from "./value-guards";
|
|
3
3
|
|
|
4
4
|
export function getPromptPath(input: Record<string, unknown>): string | null {
|
|
5
5
|
return getNonEmptyString(input.path) ?? getNonEmptyString(input.file_path);
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { getNonEmptyString, toRecord } from "./common";
|
|
2
1
|
import type { PermissionSystemExtensionConfig } from "./extension-config";
|
|
3
2
|
import type { ToolInputFormatterLookup } from "./tool-input-formatter-registry";
|
|
4
3
|
import {
|
|
@@ -15,6 +14,7 @@ import {
|
|
|
15
14
|
getPromptPath,
|
|
16
15
|
} from "./tool-input-prompt-formatters";
|
|
17
16
|
import type { PermissionCheckResult } from "./types";
|
|
17
|
+
import { getNonEmptyString, toRecord } from "./value-guards";
|
|
18
18
|
|
|
19
19
|
export interface ToolPreviewFormatterOptions {
|
|
20
20
|
toolInputPreviewMaxLength: number;
|
package/src/tool-registry.ts
CHANGED
|
@@ -53,69 +53,3 @@ export function isDenyWithReason(value: unknown): value is DenyWithReason {
|
|
|
53
53
|
(record.reason === undefined || typeof record.reason === "string")
|
|
54
54
|
);
|
|
55
55
|
}
|
|
56
|
-
|
|
57
|
-
type StackNode = { indent: number; target: Record<string, unknown> };
|
|
58
|
-
|
|
59
|
-
export function parseSimpleYamlMap(input: string): Record<string, unknown> {
|
|
60
|
-
const root: Record<string, unknown> = {};
|
|
61
|
-
const stack: StackNode[] = [{ indent: -1, target: root }];
|
|
62
|
-
|
|
63
|
-
const lines = input.split(/\r?\n/);
|
|
64
|
-
for (const rawLine of lines) {
|
|
65
|
-
if (!rawLine.trim() || rawLine.trimStart().startsWith("#")) {
|
|
66
|
-
continue;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
const indent = rawLine.length - rawLine.trimStart().length;
|
|
70
|
-
const line = rawLine.trim();
|
|
71
|
-
const separatorIndex = line.indexOf(":");
|
|
72
|
-
if (separatorIndex <= 0) {
|
|
73
|
-
continue;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
const key = line
|
|
77
|
-
.slice(0, separatorIndex)
|
|
78
|
-
.trim()
|
|
79
|
-
.replace(/^['"]|['"]$/g, "");
|
|
80
|
-
const rawValue = line.slice(separatorIndex + 1).trim();
|
|
81
|
-
|
|
82
|
-
while (stack.length > 1 && indent <= stack[stack.length - 1].indent) {
|
|
83
|
-
stack.pop();
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
const current = stack[stack.length - 1].target;
|
|
87
|
-
|
|
88
|
-
if (!rawValue) {
|
|
89
|
-
const child: Record<string, unknown> = {};
|
|
90
|
-
current[key] = child;
|
|
91
|
-
stack.push({ indent, target: child });
|
|
92
|
-
continue;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
let scalar = rawValue;
|
|
96
|
-
if (
|
|
97
|
-
(scalar.startsWith('"') && scalar.endsWith('"')) ||
|
|
98
|
-
(scalar.startsWith("'") && scalar.endsWith("'"))
|
|
99
|
-
) {
|
|
100
|
-
scalar = scalar.slice(1, -1);
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
current[key] = scalar;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
return root;
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
export function extractFrontmatter(markdown: string): string {
|
|
110
|
-
const normalized = markdown.replace(/\r\n/g, "\n");
|
|
111
|
-
if (!normalized.startsWith("---\n")) {
|
|
112
|
-
return "";
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
const end = normalized.indexOf("\n---", 4);
|
|
116
|
-
if (end === -1) {
|
|
117
|
-
return "";
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
return normalized.slice(4, end);
|
|
121
|
-
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
type StackNode = { indent: number; target: Record<string, unknown> };
|
|
2
|
+
|
|
3
|
+
export function parseSimpleYamlMap(input: string): Record<string, unknown> {
|
|
4
|
+
const root: Record<string, unknown> = {};
|
|
5
|
+
const stack: StackNode[] = [{ indent: -1, target: root }];
|
|
6
|
+
|
|
7
|
+
const lines = input.split(/\r?\n/);
|
|
8
|
+
for (const rawLine of lines) {
|
|
9
|
+
if (!rawLine.trim() || rawLine.trimStart().startsWith("#")) {
|
|
10
|
+
continue;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const indent = rawLine.length - rawLine.trimStart().length;
|
|
14
|
+
const line = rawLine.trim();
|
|
15
|
+
const separatorIndex = line.indexOf(":");
|
|
16
|
+
if (separatorIndex <= 0) {
|
|
17
|
+
continue;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const key = line
|
|
21
|
+
.slice(0, separatorIndex)
|
|
22
|
+
.trim()
|
|
23
|
+
.replace(/^['"]|['"]$/g, "");
|
|
24
|
+
const rawValue = line.slice(separatorIndex + 1).trim();
|
|
25
|
+
|
|
26
|
+
while (stack.length > 1 && indent <= stack[stack.length - 1].indent) {
|
|
27
|
+
stack.pop();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const current = stack[stack.length - 1].target;
|
|
31
|
+
|
|
32
|
+
if (!rawValue) {
|
|
33
|
+
const child: Record<string, unknown> = {};
|
|
34
|
+
current[key] = child;
|
|
35
|
+
stack.push({ indent, target: child });
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
let scalar = rawValue;
|
|
40
|
+
if (
|
|
41
|
+
(scalar.startsWith('"') && scalar.endsWith('"')) ||
|
|
42
|
+
(scalar.startsWith("'") && scalar.endsWith("'"))
|
|
43
|
+
) {
|
|
44
|
+
scalar = scalar.slice(1, -1);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
current[key] = scalar;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return root;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function extractFrontmatter(markdown: string): string {
|
|
54
|
+
const normalized = markdown.replace(/\r\n/g, "\n");
|
|
55
|
+
if (!normalized.startsWith("---\n")) {
|
|
56
|
+
return "";
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const end = normalized.indexOf("\n---", 4);
|
|
60
|
+
if (end === -1) {
|
|
61
|
+
return "";
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return normalized.slice(4, end);
|
|
65
|
+
}
|
|
@@ -21,7 +21,7 @@ vi.mock("node:fs", () => ({
|
|
|
21
21
|
|
|
22
22
|
import { AccessPath } from "#src/access-intent/access-path";
|
|
23
23
|
|
|
24
|
-
describe("AccessPath.
|
|
24
|
+
describe("AccessPath.forPath", () => {
|
|
25
25
|
const cwd = "/projects/my-app";
|
|
26
26
|
|
|
27
27
|
beforeEach(() => {
|
|
@@ -35,30 +35,59 @@ describe("AccessPath.forExternalDirectory", () => {
|
|
|
35
35
|
realpathSync.mockImplementation((p: string) =>
|
|
36
36
|
p.startsWith("/tmp") ? `/private${p}` : p,
|
|
37
37
|
);
|
|
38
|
-
expect(
|
|
39
|
-
|
|
40
|
-
|
|
38
|
+
expect(AccessPath.forPath("/tmp/x", { cwd }).matchValues()).toEqual([
|
|
39
|
+
"/tmp/x",
|
|
40
|
+
"/private/tmp/x",
|
|
41
|
+
]);
|
|
41
42
|
});
|
|
42
43
|
|
|
43
44
|
test("deduplicates when the canonical form equals the lexical form", () => {
|
|
44
|
-
expect(
|
|
45
|
-
|
|
46
|
-
|
|
45
|
+
expect(AccessPath.forPath("/etc/hosts", { cwd }).matchValues()).toEqual([
|
|
46
|
+
"/etc/hosts",
|
|
47
|
+
]);
|
|
47
48
|
});
|
|
48
49
|
|
|
49
50
|
test("keeps the relative aliases for an in-cwd token without duplicating", () => {
|
|
50
|
-
expect(
|
|
51
|
-
|
|
52
|
-
|
|
51
|
+
expect(AccessPath.forPath("src/foo.ts", { cwd }).matchValues()).toEqual([
|
|
52
|
+
"/projects/my-app/src/foo.ts",
|
|
53
|
+
"src/foo.ts",
|
|
54
|
+
]);
|
|
53
55
|
});
|
|
54
56
|
|
|
55
57
|
test("includes only the lexical aliases when canonical is empty", () => {
|
|
56
58
|
// Force canonicalizePath to return the original (no-op symlink resolution
|
|
57
59
|
// effectively means canonical === lexical, handled by dedup).
|
|
58
60
|
expect(
|
|
59
|
-
AccessPath.
|
|
61
|
+
AccessPath.forPath("/etc/hosts", { cwd }).matchValues(),
|
|
60
62
|
).not.toHaveLength(0);
|
|
61
63
|
});
|
|
64
|
+
|
|
65
|
+
test("resolves a relative token against an explicit resolveBase", () => {
|
|
66
|
+
// The cd-folded effective base differs from cwd (the bash-path case).
|
|
67
|
+
expect(
|
|
68
|
+
AccessPath.forPath("foo.ts", {
|
|
69
|
+
cwd,
|
|
70
|
+
resolveBase: "/projects/my-app/sub",
|
|
71
|
+
}).matchValues(),
|
|
72
|
+
).toEqual(["/projects/my-app/sub/foo.ts", "sub/foo.ts", "foo.ts"]);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("adds the canonical alias resolved against resolveBase", () => {
|
|
76
|
+
realpathSync.mockImplementation((p: string) =>
|
|
77
|
+
p === "/projects/my-app/sub/foo.ts" ? "/real/foo.ts" : p,
|
|
78
|
+
);
|
|
79
|
+
expect(
|
|
80
|
+
AccessPath.forPath("foo.ts", {
|
|
81
|
+
cwd,
|
|
82
|
+
resolveBase: "/projects/my-app/sub",
|
|
83
|
+
}).matchValues(),
|
|
84
|
+
).toEqual([
|
|
85
|
+
"/projects/my-app/sub/foo.ts",
|
|
86
|
+
"sub/foo.ts",
|
|
87
|
+
"foo.ts",
|
|
88
|
+
"/real/foo.ts",
|
|
89
|
+
]);
|
|
90
|
+
});
|
|
62
91
|
});
|
|
63
92
|
|
|
64
93
|
describe("boundaryValue()", () => {
|
|
@@ -66,19 +95,19 @@ describe("AccessPath.forExternalDirectory", () => {
|
|
|
66
95
|
realpathSync.mockImplementation((p: string) =>
|
|
67
96
|
p.startsWith("/tmp") ? `/private${p}` : p,
|
|
68
97
|
);
|
|
69
|
-
expect(
|
|
70
|
-
|
|
71
|
-
)
|
|
98
|
+
expect(AccessPath.forPath("/tmp/x", { cwd }).boundaryValue()).toBe(
|
|
99
|
+
"/private/tmp/x",
|
|
100
|
+
);
|
|
72
101
|
});
|
|
73
102
|
|
|
74
103
|
test("returns the lexical form when path has no symlinks", () => {
|
|
75
|
-
expect(
|
|
76
|
-
|
|
77
|
-
)
|
|
104
|
+
expect(AccessPath.forPath("/etc/hosts", { cwd }).boundaryValue()).toBe(
|
|
105
|
+
"/etc/hosts",
|
|
106
|
+
);
|
|
78
107
|
});
|
|
79
108
|
|
|
80
109
|
test("returns empty string for empty input", () => {
|
|
81
|
-
expect(AccessPath.
|
|
110
|
+
expect(AccessPath.forPath("", { cwd }).boundaryValue()).toBe("");
|
|
82
111
|
});
|
|
83
112
|
});
|
|
84
113
|
|
|
@@ -88,20 +117,51 @@ describe("AccessPath.forExternalDirectory", () => {
|
|
|
88
117
|
p.startsWith("/tmp") ? `/private${p}` : p,
|
|
89
118
|
);
|
|
90
119
|
// Even when the path resolves to a different canonical, value() stays lexical.
|
|
91
|
-
expect(AccessPath.
|
|
92
|
-
"/tmp/x",
|
|
93
|
-
);
|
|
120
|
+
expect(AccessPath.forPath("/tmp/x", { cwd }).value()).toBe("/tmp/x");
|
|
94
121
|
});
|
|
95
122
|
|
|
96
123
|
test("normalizes the path against cwd", () => {
|
|
97
124
|
// A relative path becomes an absolute lexical value.
|
|
98
|
-
expect(AccessPath.
|
|
125
|
+
expect(AccessPath.forPath("src/foo.ts", { cwd }).value()).toBe(
|
|
99
126
|
"/projects/my-app/src/foo.ts",
|
|
100
127
|
);
|
|
101
128
|
});
|
|
102
129
|
|
|
130
|
+
test("normalizes a relative path against an explicit resolveBase", () => {
|
|
131
|
+
expect(
|
|
132
|
+
AccessPath.forPath("foo.ts", {
|
|
133
|
+
cwd,
|
|
134
|
+
resolveBase: "/projects/my-app/sub",
|
|
135
|
+
}).value(),
|
|
136
|
+
).toBe("/projects/my-app/sub/foo.ts");
|
|
137
|
+
});
|
|
138
|
+
|
|
103
139
|
test("returns empty string for empty input", () => {
|
|
104
|
-
expect(AccessPath.
|
|
140
|
+
expect(AccessPath.forPath("", { cwd }).value()).toBe("");
|
|
105
141
|
});
|
|
106
142
|
});
|
|
107
143
|
});
|
|
144
|
+
|
|
145
|
+
describe("AccessPath.forLiteral", () => {
|
|
146
|
+
beforeEach(() => {
|
|
147
|
+
realpathSync.mockReset();
|
|
148
|
+
realpathSync.mockImplementation((p: string) => p);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("matchValues() carries only the literal — no canonical, no absolute", () => {
|
|
152
|
+
expect(AccessPath.forLiteral("foo.ts").matchValues()).toEqual(["foo.ts"]);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("boundaryValue() is empty (no outside-cwd notion for an unknown base)", () => {
|
|
156
|
+
expect(AccessPath.forLiteral("foo.ts").boundaryValue()).toBe("");
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("value() returns the literal", () => {
|
|
160
|
+
expect(AccessPath.forLiteral("foo.ts").value()).toBe("foo.ts");
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("an empty literal yields no match values", () => {
|
|
164
|
+
expect(AccessPath.forLiteral("").matchValues()).toEqual([]);
|
|
165
|
+
expect(AccessPath.forLiteral("").value()).toBe("");
|
|
166
|
+
});
|
|
167
|
+
});
|
|
@@ -16,14 +16,20 @@ describe("BashProgram", () => {
|
|
|
16
16
|
describe("pathRuleCandidates", () => {
|
|
17
17
|
const cwd = "/projects/my-app";
|
|
18
18
|
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
realpathSync.mockReset();
|
|
21
|
+
realpathSync.mockImplementation((p: string) => p);
|
|
22
|
+
});
|
|
23
|
+
|
|
19
24
|
it("adds absolute and relative policy values for relative tokens", async () => {
|
|
20
25
|
const program = await BashProgram.parse("cat src/foo.ts", cwd);
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
+
const candidates = program.pathRuleCandidates();
|
|
27
|
+
expect(candidates.map(({ token }) => token)).toEqual(["src/foo.ts"]);
|
|
28
|
+
expect(candidates[0].path.matchValues()).toEqual([
|
|
29
|
+
"/projects/my-app/src/foo.ts",
|
|
30
|
+
"src/foo.ts",
|
|
26
31
|
]);
|
|
32
|
+
expect(candidates[0].path.value()).toBe("/projects/my-app/src/foo.ts");
|
|
27
33
|
});
|
|
28
34
|
|
|
29
35
|
it("resolves tokens after literal cd against the effective directory", async () => {
|
|
@@ -34,14 +40,28 @@ describe("BashProgram", () => {
|
|
|
34
40
|
const fileCandidate = program
|
|
35
41
|
.pathRuleCandidates()
|
|
36
42
|
.find((candidate) => candidate.token === "src/file.txt");
|
|
37
|
-
expect(fileCandidate).toEqual(
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
43
|
+
expect(fileCandidate?.path.matchValues()).toEqual([
|
|
44
|
+
"/projects/my-app/nested/src/file.txt",
|
|
45
|
+
"nested/src/file.txt",
|
|
46
|
+
"src/file.txt",
|
|
47
|
+
]);
|
|
48
|
+
expect(fileCandidate?.path.value()).toBe(
|
|
49
|
+
"/projects/my-app/nested/src/file.txt",
|
|
50
|
+
);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("adds the canonical alias for a symlinked token (#486)", async () => {
|
|
54
|
+
// /projects/my-app/src/foo.ts is a symlink to /vault/foo.ts.
|
|
55
|
+
realpathSync.mockImplementation((p: string) =>
|
|
56
|
+
p === "/projects/my-app/src/foo.ts" ? "/vault/foo.ts" : p,
|
|
57
|
+
);
|
|
58
|
+
const program = await BashProgram.parse("cat src/foo.ts", cwd);
|
|
59
|
+
const candidate = program.pathRuleCandidates()[0];
|
|
60
|
+
expect(candidate.path.matchValues()).toEqual([
|
|
61
|
+
"/projects/my-app/src/foo.ts",
|
|
62
|
+
"src/foo.ts",
|
|
63
|
+
"/vault/foo.ts",
|
|
64
|
+
]);
|
|
45
65
|
});
|
|
46
66
|
|
|
47
67
|
it("does not absolute-allow relative tokens after unknown cd", async () => {
|
|
@@ -52,10 +72,22 @@ describe("BashProgram", () => {
|
|
|
52
72
|
const fileCandidate = program
|
|
53
73
|
.pathRuleCandidates()
|
|
54
74
|
.find((candidate) => candidate.token === "src/foo.ts");
|
|
55
|
-
expect(fileCandidate).toEqual(
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
75
|
+
expect(fileCandidate?.path.matchValues()).toEqual(["src/foo.ts"]);
|
|
76
|
+
expect(fileCandidate?.path.value()).toBe("src/foo.ts");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("keeps an unknown-cd token literal-only even when it would resolve a symlink (#393)", async () => {
|
|
80
|
+
// A canonical alias here would resolve against the wrong (unknown) base.
|
|
81
|
+
realpathSync.mockImplementation(() => "/somewhere/else");
|
|
82
|
+
const program = await BashProgram.parse(
|
|
83
|
+
'cd "$DIR" && cat src/foo.ts',
|
|
84
|
+
cwd,
|
|
85
|
+
);
|
|
86
|
+
const fileCandidate = program
|
|
87
|
+
.pathRuleCandidates()
|
|
88
|
+
.find((candidate) => candidate.token === "src/foo.ts");
|
|
89
|
+
expect(fileCandidate?.path.matchValues()).toEqual(["src/foo.ts"]);
|
|
90
|
+
expect(fileCandidate?.path.boundaryValue()).toBe("");
|
|
59
91
|
});
|
|
60
92
|
});
|
|
61
93
|
|
|
@@ -435,6 +467,73 @@ describe("BashProgram", () => {
|
|
|
435
467
|
expect((await BashProgram.parse("", cwd)).commands()).toEqual([]);
|
|
436
468
|
expect((await BashProgram.parse(" ", cwd)).commands()).toEqual([]);
|
|
437
469
|
});
|
|
470
|
+
|
|
471
|
+
it("strips a leading env-var assignment prefix", async () => {
|
|
472
|
+
const program = await BashProgram.parse(
|
|
473
|
+
"AWS_PROFILE=prod aws ec2 terminate-instances --instance-ids i-1",
|
|
474
|
+
cwd,
|
|
475
|
+
);
|
|
476
|
+
expect(program.commands()).toEqual([
|
|
477
|
+
{ text: "aws ec2 terminate-instances --instance-ids i-1" },
|
|
478
|
+
]);
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
it("strips multiple leading env-var assignments", async () => {
|
|
482
|
+
const program = await BashProgram.parse("A=1 B=2 aws s3 ls", cwd);
|
|
483
|
+
expect(program.commands()).toEqual([{ text: "aws s3 ls" }]);
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
it("strips the env-var prefix of each command in a chain", async () => {
|
|
487
|
+
const program = await BashProgram.parse(
|
|
488
|
+
"X=1 aws sts get-caller-identity && ls",
|
|
489
|
+
cwd,
|
|
490
|
+
);
|
|
491
|
+
expect(program.commands()).toEqual([
|
|
492
|
+
{ text: "aws sts get-caller-identity" },
|
|
493
|
+
{ text: "ls" },
|
|
494
|
+
]);
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
it("keeps a pure assignment with no command unchanged", async () => {
|
|
498
|
+
const program = await BashProgram.parse("FOO=bar", cwd);
|
|
499
|
+
expect(program.commands()).toEqual([{ text: "FOO=bar" }]);
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
describe("opaque-payload wrappers", () => {
|
|
503
|
+
it.each([
|
|
504
|
+
['bash -c "rm -rf /"', 'bash -c "rm -rf /"'],
|
|
505
|
+
['sh -c "rm -rf /"', 'sh -c "rm -rf /"'],
|
|
506
|
+
['dash -c "rm -rf /"', 'dash -c "rm -rf /"'],
|
|
507
|
+
['zsh -c "rm -rf /"', 'zsh -c "rm -rf /"'],
|
|
508
|
+
['ksh -c "rm -rf /"', 'ksh -c "rm -rf /"'],
|
|
509
|
+
['eval "rm -rf /"', 'eval "rm -rf /"'],
|
|
510
|
+
['/bin/bash -c "rm -rf /"', '/bin/bash -c "rm -rf /"'],
|
|
511
|
+
['bash -ec "rm -rf /"', 'bash -ec "rm -rf /"'],
|
|
512
|
+
])("flags %s as opaque", async (command, text) => {
|
|
513
|
+
const program = await BashProgram.parse(command, cwd);
|
|
514
|
+
expect(program.commands()).toEqual([{ text, opaque: true }]);
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
it("flags an env-prefixed wrapper as opaque after stripping the prefix", async () => {
|
|
518
|
+
const program = await BashProgram.parse(
|
|
519
|
+
'AWS_PROFILE=prod bash -c "rm -rf /"',
|
|
520
|
+
cwd,
|
|
521
|
+
);
|
|
522
|
+
expect(program.commands()).toEqual([
|
|
523
|
+
{ text: 'bash -c "rm -rf /"', opaque: true },
|
|
524
|
+
]);
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
it.each([
|
|
528
|
+
"bash script.sh",
|
|
529
|
+
"bash",
|
|
530
|
+
"ls -la",
|
|
531
|
+
"grep -c foo file",
|
|
532
|
+
])("does not flag %s as opaque", async (command) => {
|
|
533
|
+
const program = await BashProgram.parse(command, cwd);
|
|
534
|
+
expect(program.commands()).toEqual([{ text: command }]);
|
|
535
|
+
});
|
|
536
|
+
});
|
|
438
537
|
});
|
|
439
538
|
|
|
440
539
|
it("derives both slices from a single parse", async () => {
|