@gotgenes/pi-permission-system 16.0.1 → 16.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.
Files changed (37) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/package.json +1 -1
  3. package/src/access-intent/access-path.ts +88 -0
  4. package/src/access-intent/bash/command-enumeration.ts +154 -0
  5. package/src/access-intent/bash/cwd-projection.ts +498 -0
  6. package/src/access-intent/bash/node-text.ts +75 -0
  7. package/src/access-intent/bash/parser.ts +42 -0
  8. package/src/access-intent/bash/program.ts +102 -0
  9. package/src/{handlers/gates/bash-token-classification.ts → access-intent/bash/token-classification.ts} +1 -1
  10. package/src/access-intent/bash/token-collection.ts +351 -0
  11. package/src/handlers/gates/bash-command.ts +1 -1
  12. package/src/handlers/gates/bash-external-directory.ts +19 -33
  13. package/src/handlers/gates/bash-path-extractor.ts +10 -4
  14. package/src/handlers/gates/bash-path.ts +2 -2
  15. package/src/handlers/gates/external-directory-policy.ts +65 -0
  16. package/src/handlers/gates/external-directory.ts +8 -11
  17. package/src/handlers/gates/path.ts +1 -3
  18. package/src/handlers/gates/skill-read.ts +0 -4
  19. package/src/handlers/gates/tool-call-gate-pipeline.ts +2 -2
  20. package/src/handlers/gates/tool.ts +1 -1
  21. package/src/handlers/gates/types.ts +1 -1
  22. package/src/path-utils.ts +0 -20
  23. package/test/access-intent/access-path.test.ts +107 -0
  24. package/test/access-intent/bash/node-text.test.ts +147 -0
  25. package/test/access-intent/bash/parser.test.ts +19 -0
  26. package/test/{handlers/gates/bash-program.test.ts → access-intent/bash/program.test.ts} +114 -73
  27. package/test/{handlers/gates/bash-token-classification.test.ts → access-intent/bash/token-classification.test.ts} +1 -1
  28. package/test/access-intent/bash/token-collection.test.ts +300 -0
  29. package/test/handlers/external-directory-symlink-acceptance.test.ts +2 -3
  30. package/test/handlers/gates/bash-command-metamorphic.test.ts +2 -3
  31. package/test/handlers/gates/bash-external-directory.test.ts +2 -10
  32. package/test/handlers/gates/bash-path.test.ts +2 -2
  33. package/test/handlers/gates/external-directory-policy.test.ts +112 -0
  34. package/test/handlers/gates/external-directory.test.ts +0 -5
  35. package/test/handlers/gates/tool-call-gate-pipeline.test.ts +7 -3
  36. package/test/path-utils.test.ts +0 -34
  37. package/src/handlers/gates/bash-program.ts +0 -1143
@@ -1,6 +1,5 @@
1
+ import { AccessPath } from "#src/access-intent/access-path";
1
2
  import {
2
- canonicalNormalizePathForComparison,
3
- getExternalDirectoryPolicyValues,
4
3
  getToolInputPath,
5
4
  isPathOutsideWorkingDirectory,
6
5
  isPiInfrastructureRead,
@@ -12,6 +11,7 @@ import { deriveApprovalPattern } from "#src/session-rules";
12
11
  import type { ToolAccessExtractorLookup } from "#src/tool-access-extractor-registry";
13
12
  import type { GateResult } from "./descriptor";
14
13
  import { formatExternalDirectoryAskPrompt } from "./external-directory-messages";
14
+ import { resolveExternalDirectoryPolicy } from "./external-directory-policy";
15
15
  import type { ToolCallContext } from "./types";
16
16
 
17
17
  /**
@@ -28,8 +28,6 @@ export function describeExternalDirectoryGate(
28
28
  resolver: ScopedPermissionResolver,
29
29
  extractors?: ToolAccessExtractorLookup,
30
30
  ): GateResult {
31
- if (!tcc.cwd) return null;
32
-
33
31
  const externalDirectoryPath = getToolInputPath(
34
32
  tcc.toolName,
35
33
  tcc.input,
@@ -44,10 +42,11 @@ export function describeExternalDirectoryGate(
44
42
  // The boundary decision (above) and the infrastructure-read containment
45
43
  // check (below) use the canonical, symlink-resolved path; pattern matching
46
44
  // uses the typed and resolved aliases (#418).
47
- const canonicalExtPath = canonicalNormalizePathForComparison(
45
+ const accessPath = AccessPath.forExternalDirectory(
48
46
  externalDirectoryPath,
49
47
  tcc.cwd,
50
48
  );
49
+ const canonicalExtPath = accessPath.boundaryValue();
51
50
 
52
51
  // ── Pi infrastructure read bypass ──────────────────────────────────────
53
52
  if (
@@ -85,13 +84,11 @@ export function describeExternalDirectoryGate(
85
84
  tcc.agentName ?? undefined,
86
85
  );
87
86
 
88
- // Match against both the typed and symlink-resolved aliases on the
89
- // external_directory surface, so a config pattern on either form applies
90
- // (#418). The runner consumes this preCheck and skips its own resolve.
91
- const preCheck = resolver.resolvePathPolicy(
92
- getExternalDirectoryPolicyValues(externalDirectoryPath, tcc.cwd),
87
+ // The runner consumes this preCheck and skips its own resolve.
88
+ const preCheck = resolveExternalDirectoryPolicy(
89
+ accessPath,
90
+ resolver,
93
91
  tcc.agentName ?? undefined,
94
- "external_directory",
95
92
  );
96
93
  const pattern = deriveApprovalPattern(
97
94
  normalizePathForComparison(externalDirectoryPath, tcc.cwd),
@@ -37,9 +37,7 @@ export function describePathGate(
37
37
 
38
38
  // Resolve to the canonical (cwd-anchored, absolute) path so the approval
39
39
  // pattern matches the policy values a later call produces.
40
- const approvalPath = tcc.cwd
41
- ? normalizePathForComparison(filePath, tcc.cwd)
42
- : filePath;
40
+ const approvalPath = normalizePathForComparison(filePath, tcc.cwd);
43
41
  const pattern = deriveApprovalPattern(approvalPath);
44
42
 
45
43
  const descriptor: GateDescriptor = {
@@ -29,10 +29,6 @@ export function describeSkillReadGate(
29
29
  return null;
30
30
  }
31
31
 
32
- if (tcc.cwd === undefined) {
33
- return null;
34
- }
35
-
36
32
  const normalizedReadPath = normalizePathForComparison(path, tcc.cwd);
37
33
  const matchedSkill = findSkillPathMatch(
38
34
  normalizedReadPath,
@@ -1,3 +1,4 @@
1
+ import { BashProgram } from "#src/access-intent/bash/program";
1
2
  import { getNonEmptyString, toRecord } from "#src/common";
2
3
  import type { ScopedPermissionResolver } from "#src/permission-resolver";
3
4
  import type { SkillPromptEntry } from "#src/skill-prompt-sanitizer";
@@ -10,7 +11,6 @@ import {
10
11
  import { resolveBashCommandCheck } from "./bash-command";
11
12
  import { describeBashExternalDirectoryGate } from "./bash-external-directory";
12
13
  import { describeBashPathGate } from "./bash-path";
13
- import { BashProgram } from "./bash-program";
14
14
  import type { GateResult } from "./descriptor";
15
15
  import { describeExternalDirectoryGate } from "./external-directory";
16
16
  import { describePathGate } from "./path";
@@ -66,7 +66,7 @@ export class ToolCallGatePipeline {
66
66
  const command = getNonEmptyString(toRecord(tcc.input).command);
67
67
  const bashProgram =
68
68
  tcc.toolName === "bash" && command
69
- ? await BashProgram.parse(command)
69
+ ? await BashProgram.parse(command, tcc.cwd)
70
70
  : null;
71
71
 
72
72
  const formatter = new ToolPreviewFormatter(
@@ -28,7 +28,7 @@ function deriveSuggestionValue(
28
28
  if (tcc.toolName === "mcp") return check.target ?? "mcp";
29
29
  const path = getPathBearingToolPath(tcc.toolName, tcc.input);
30
30
  if (path === null) return "*";
31
- return tcc.cwd ? normalizePathForComparison(path, tcc.cwd) : path;
31
+ return normalizePathForComparison(path, tcc.cwd);
32
32
  }
33
33
 
34
34
  /**
@@ -9,5 +9,5 @@ export interface ToolCallContext {
9
9
  agentName: string | null;
10
10
  input: unknown;
11
11
  toolCallId: string;
12
- cwd: string | undefined;
12
+ cwd: string;
13
13
  }
package/src/path-utils.ts CHANGED
@@ -112,26 +112,6 @@ export function getPathPolicyValues(
112
112
  ];
113
113
  }
114
114
 
115
- /**
116
- * Equivalent `external_directory` policy-match values for a path: the lexical
117
- * (as-typed) alias list plus the canonical (symlink-resolved) absolute path.
118
- *
119
- * The outside-CWD boundary decision uses the canonical form separately; this
120
- * helper exists only for pattern matching, so a user's pattern on the typed
121
- * path (`/tmp/*`) and on the resolved path (`/private/tmp/*`) both match under
122
- * the last-match-wins alias evaluation. On systems where the path is not a
123
- * symlink the canonical form equals the lexical absolute alias and the `Set`
124
- * collapses it, leaving today's behavior unchanged.
125
- */
126
- export function getExternalDirectoryPolicyValues(
127
- pathValue: string,
128
- cwd: string,
129
- ): string[] {
130
- const lexical = getPathPolicyValues(pathValue, { cwd });
131
- const canonical = canonicalNormalizePathForComparison(pathValue, cwd);
132
- return canonical ? [...new Set([...lexical, canonical])] : lexical;
133
- }
134
-
135
115
  function getAbsolutePathPolicyValues(
136
116
  pathValue: string,
137
117
  options: PathPolicyValueOptions,
@@ -0,0 +1,107 @@
1
+ import { beforeEach, describe, expect, test, vi } from "vitest";
2
+
3
+ // Mock node:os so tilde-expansion is deterministic across platforms.
4
+ vi.mock("node:os", () => {
5
+ const homedir = vi.fn(() => "/mock/home");
6
+ return {
7
+ homedir,
8
+ default: { homedir },
9
+ };
10
+ });
11
+
12
+ // Mock node:fs so realpathSync (used by canonicalizePath) is controllable.
13
+ // Default implementation is identity — lexical tests are unaffected.
14
+ const realpathSync = vi.hoisted(() =>
15
+ vi.fn<(path: string) => string>((p) => p),
16
+ );
17
+ vi.mock("node:fs", () => ({
18
+ realpathSync,
19
+ default: { realpathSync },
20
+ }));
21
+
22
+ import { AccessPath } from "#src/access-intent/access-path";
23
+
24
+ describe("AccessPath.forExternalDirectory", () => {
25
+ const cwd = "/projects/my-app";
26
+
27
+ beforeEach(() => {
28
+ realpathSync.mockReset();
29
+ realpathSync.mockImplementation((p: string) => p);
30
+ });
31
+
32
+ describe("matchValues()", () => {
33
+ test("adds the symlink-resolved alias alongside the typed path", () => {
34
+ // /tmp -> /private/tmp (the macOS symlink from the bug report, #418).
35
+ realpathSync.mockImplementation((p: string) =>
36
+ p.startsWith("/tmp") ? `/private${p}` : p,
37
+ );
38
+ expect(
39
+ AccessPath.forExternalDirectory("/tmp/x", cwd).matchValues(),
40
+ ).toEqual(["/tmp/x", "/private/tmp/x"]);
41
+ });
42
+
43
+ test("deduplicates when the canonical form equals the lexical form", () => {
44
+ expect(
45
+ AccessPath.forExternalDirectory("/etc/hosts", cwd).matchValues(),
46
+ ).toEqual(["/etc/hosts"]);
47
+ });
48
+
49
+ test("keeps the relative aliases for an in-cwd token without duplicating", () => {
50
+ expect(
51
+ AccessPath.forExternalDirectory("src/foo.ts", cwd).matchValues(),
52
+ ).toEqual(["/projects/my-app/src/foo.ts", "src/foo.ts"]);
53
+ });
54
+
55
+ test("includes only the lexical aliases when canonical is empty", () => {
56
+ // Force canonicalizePath to return the original (no-op symlink resolution
57
+ // effectively means canonical === lexical, handled by dedup).
58
+ expect(
59
+ AccessPath.forExternalDirectory("/etc/hosts", cwd).matchValues(),
60
+ ).not.toHaveLength(0);
61
+ });
62
+ });
63
+
64
+ describe("boundaryValue()", () => {
65
+ test("returns the canonical (symlink-resolved) form", () => {
66
+ realpathSync.mockImplementation((p: string) =>
67
+ p.startsWith("/tmp") ? `/private${p}` : p,
68
+ );
69
+ expect(
70
+ AccessPath.forExternalDirectory("/tmp/x", cwd).boundaryValue(),
71
+ ).toBe("/private/tmp/x");
72
+ });
73
+
74
+ test("returns the lexical form when path has no symlinks", () => {
75
+ expect(
76
+ AccessPath.forExternalDirectory("/etc/hosts", cwd).boundaryValue(),
77
+ ).toBe("/etc/hosts");
78
+ });
79
+
80
+ test("returns empty string for empty input", () => {
81
+ expect(AccessPath.forExternalDirectory("", cwd).boundaryValue()).toBe("");
82
+ });
83
+ });
84
+
85
+ describe("value()", () => {
86
+ test("returns the lexical (as-typed, normalized) form", () => {
87
+ realpathSync.mockImplementation((p: string) =>
88
+ p.startsWith("/tmp") ? `/private${p}` : p,
89
+ );
90
+ // Even when the path resolves to a different canonical, value() stays lexical.
91
+ expect(AccessPath.forExternalDirectory("/tmp/x", cwd).value()).toBe(
92
+ "/tmp/x",
93
+ );
94
+ });
95
+
96
+ test("normalizes the path against cwd", () => {
97
+ // A relative path becomes an absolute lexical value.
98
+ expect(AccessPath.forExternalDirectory("src/foo.ts", cwd).value()).toBe(
99
+ "/projects/my-app/src/foo.ts",
100
+ );
101
+ });
102
+
103
+ test("returns empty string for empty input", () => {
104
+ expect(AccessPath.forExternalDirectory("", cwd).value()).toBe("");
105
+ });
106
+ });
107
+ });
@@ -0,0 +1,147 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ resolveNodeText,
4
+ SKIP_SUBTREE_TYPES,
5
+ } from "#src/access-intent/bash/node-text";
6
+ import type { TSNode } from "#src/access-intent/bash/parser";
7
+
8
+ // Minimal fake TSNode builder — only fills the fields resolveNodeText reads.
9
+ function makeNode(type: string, text: string, children: TSNode[] = []): TSNode {
10
+ return {
11
+ type,
12
+ text,
13
+ childCount: children.length,
14
+ isNamed: true,
15
+ child: (i) => children[i] ?? null,
16
+ };
17
+ }
18
+
19
+ describe("SKIP_SUBTREE_TYPES", () => {
20
+ it("contains the three node types that must not be descended", () => {
21
+ expect(SKIP_SUBTREE_TYPES.has("heredoc_body")).toBe(true);
22
+ expect(SKIP_SUBTREE_TYPES.has("heredoc_end")).toBe(true);
23
+ expect(SKIP_SUBTREE_TYPES.has("comment")).toBe(true);
24
+ });
25
+
26
+ it("does not contain common argument node types", () => {
27
+ expect(SKIP_SUBTREE_TYPES.has("word")).toBe(false);
28
+ expect(SKIP_SUBTREE_TYPES.has("string")).toBe(false);
29
+ expect(SKIP_SUBTREE_TYPES.has("raw_string")).toBe(false);
30
+ });
31
+ });
32
+
33
+ describe("resolveNodeText", () => {
34
+ describe("word nodes", () => {
35
+ it("returns the node text unchanged", () => {
36
+ expect(resolveNodeText(makeNode("word", "hello"))).toBe("hello");
37
+ });
38
+ });
39
+
40
+ describe("raw_string nodes (single-quoted)", () => {
41
+ it("strips surrounding single quotes", () => {
42
+ expect(resolveNodeText(makeNode("raw_string", "'content'"))).toBe(
43
+ "content",
44
+ );
45
+ });
46
+
47
+ it("strips single quotes around a path", () => {
48
+ expect(resolveNodeText(makeNode("raw_string", "'/etc/hosts'"))).toBe(
49
+ "/etc/hosts",
50
+ );
51
+ });
52
+
53
+ it("returns text as-is when not fully single-quoted", () => {
54
+ // A raw_string node without enclosing quotes (defensive fallback)
55
+ expect(resolveNodeText(makeNode("raw_string", "noquotes"))).toBe(
56
+ "noquotes",
57
+ );
58
+ });
59
+ });
60
+
61
+ describe("string nodes (double-quoted)", () => {
62
+ it("concatenates inner word children, skipping quote delimiters", () => {
63
+ const quoteOpen = makeNode('"', '"');
64
+ const content = makeNode("string_content", "hello world");
65
+ const quoteClose = makeNode('"', '"');
66
+ const node = makeNode("string", '"hello world"', [
67
+ quoteOpen,
68
+ content,
69
+ quoteClose,
70
+ ]);
71
+ expect(resolveNodeText(node)).toBe("hello world");
72
+ });
73
+
74
+ it("concatenates multiple inner children", () => {
75
+ const quoteOpen = makeNode('"', '"');
76
+ const part1 = makeNode("string_content", "foo");
77
+ const part2 = makeNode("simple_expansion", "$BAR");
78
+ const quoteClose = makeNode('"', '"');
79
+ const node = makeNode("string", '"foo$BAR"', [
80
+ quoteOpen,
81
+ part1,
82
+ part2,
83
+ quoteClose,
84
+ ]);
85
+ expect(resolveNodeText(node)).toBe("foo$BAR");
86
+ });
87
+
88
+ it("returns empty string for an empty double-quoted string", () => {
89
+ const quoteOpen = makeNode('"', '"');
90
+ const quoteClose = makeNode('"', '"');
91
+ const node = makeNode("string", '""', [quoteOpen, quoteClose]);
92
+ expect(resolveNodeText(node)).toBe("");
93
+ });
94
+ });
95
+
96
+ describe("string_content, simple_expansion, and expansion nodes", () => {
97
+ it("returns text as-is for string_content", () => {
98
+ expect(resolveNodeText(makeNode("string_content", "plain text"))).toBe(
99
+ "plain text",
100
+ );
101
+ });
102
+
103
+ it("returns text as-is for simple_expansion (e.g. $HOME)", () => {
104
+ // retro 0350: $HOME returns the literal text of a simple_expansion node
105
+ expect(resolveNodeText(makeNode("simple_expansion", "$HOME"))).toBe(
106
+ "$HOME",
107
+ );
108
+ });
109
+
110
+ it("returns text as-is for expansion", () => {
111
+ // biome-ignore lint/suspicious/noTemplateCurlyInString: intentional literal — testing that expansion node text is returned verbatim
112
+ expect(resolveNodeText(makeNode("expansion", "${VAR}"))).toBe("${VAR}");
113
+ });
114
+ });
115
+
116
+ describe("concatenation nodes", () => {
117
+ it("concatenates resolved children", () => {
118
+ const word = makeNode("word", "/etc/");
119
+ const expansion = makeNode("simple_expansion", "$FILE");
120
+ const node = makeNode("concatenation", "/etc/$FILE", [word, expansion]);
121
+ expect(resolveNodeText(node)).toBe("/etc/$FILE");
122
+ });
123
+
124
+ it("handles nested concatenation-of-string", () => {
125
+ // A concatenation whose child is a double-quoted string
126
+ const quoteOpen = makeNode('"', '"');
127
+ const content = makeNode("string_content", "bar");
128
+ const quoteClose = makeNode('"', '"');
129
+ const inner = makeNode("string", '"bar"', [
130
+ quoteOpen,
131
+ content,
132
+ quoteClose,
133
+ ]);
134
+ const prefix = makeNode("word", "foo");
135
+ const node = makeNode("concatenation", 'foo"bar"', [prefix, inner]);
136
+ expect(resolveNodeText(node)).toBe("foobar");
137
+ });
138
+ });
139
+
140
+ describe("default fallback", () => {
141
+ it("returns the raw text for unknown node types", () => {
142
+ expect(resolveNodeText(makeNode("unknown_type", "rawtext"))).toBe(
143
+ "rawtext",
144
+ );
145
+ });
146
+ });
147
+ });
@@ -0,0 +1,19 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { getParser } from "#src/access-intent/bash/parser";
3
+
4
+ describe("getParser", () => {
5
+ it("parses a simple bash command and returns a non-null root node", async () => {
6
+ const parser = await getParser();
7
+ const tree = parser.parse("echo hi");
8
+ expect(tree).not.toBeNull();
9
+ expect(tree?.rootNode).toBeDefined();
10
+ expect(tree?.rootNode.type).toBe("program");
11
+ tree?.delete();
12
+ });
13
+
14
+ it("returns the same memoized parser instance on repeated calls", async () => {
15
+ const first = await getParser();
16
+ const second = await getParser();
17
+ expect(first).toBe(second);
18
+ });
19
+ });