@gotgenes/pi-permission-system 18.0.0 → 18.0.1

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 CHANGED
@@ -5,6 +5,13 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [18.0.1](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v18.0.0...pi-permission-system-v18.0.1) (2026-06-30)
9
+
10
+
11
+ ### Documentation
12
+
13
+ * **pi-permission-system:** name the path-values boundary contract and guard it ([#506](https://github.com/gotgenes/pi-packages/issues/506)) ([a47648c](https://github.com/gotgenes/pi-packages/commit/a47648c1fcff3226b6ea3269392f9d8548654ee1))
14
+
8
15
  ## [18.0.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v17.1.1...pi-permission-system-v18.0.0) (2026-06-29)
9
16
 
10
17
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "18.0.0",
3
+ "version": "18.0.1",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -20,6 +20,10 @@ export interface ToolAccessIntent {
20
20
  * Not gate-emitted: the resolver produces it internally by unwrapping an
21
21
  * `access-path` intent via `matchValues()`, keeping the low-level manager
22
22
  * string-based (it never imports `AccessPath`). See {@link ResolvedAccessIntent}.
23
+ *
24
+ * This string seam is a deliberate, formalized boundary — not transitional
25
+ * scaffolding to collapse into the manager (ADR-0002,
26
+ * `docs/decisions/0002-path-values-string-boundary.md`).
23
27
  */
24
28
  export interface PathValuesAccessIntent {
25
29
  kind: "path-values";
@@ -53,6 +57,9 @@ export type AccessIntent = ToolAccessIntent | AccessPathAccessIntent;
53
57
  * What the manager consumes — the `access-path` variant has already been
54
58
  * unwrapped to `path-values` by the resolver via `path.matchValues()`.
55
59
  *
56
- * The manager stays string-based and never imports `AccessPath`.
60
+ * The manager stays string-based and never imports `AccessPath`: this is the
61
+ * deliberate boundary formalized in ADR-0002
62
+ * (`docs/decisions/0002-path-values-string-boundary.md`), guarded by a
63
+ * `no-restricted-imports` lint rule on `permission-manager.ts`.
57
64
  */
58
65
  export type ResolvedAccessIntent = ToolAccessIntent | PathValuesAccessIntent;
@@ -2,7 +2,7 @@ import {
2
2
  canonicalNormalizePathForComparison,
3
3
  getPathPolicyValues,
4
4
  normalizePathForComparison,
5
- } from "#src/path-utils";
5
+ } from "./path-normalization";
6
6
 
7
7
  /**
8
8
  * A path's two representations held behind type-distinct accessors.
@@ -14,8 +14,9 @@ import {
14
14
  collectRedirectTokens,
15
15
  extractCommandName,
16
16
  } from "#src/access-intent/bash/token-collection";
17
+ import { normalizePathPolicyLiteral } from "#src/access-intent/path-normalization";
17
18
  import type { PathNormalizer } from "#src/path-normalizer";
18
- import { isSafeSystemPath, normalizePathPolicyLiteral } from "#src/path-utils";
19
+ import { isSafeSystemPath } from "#src/safe-system-paths";
19
20
 
20
21
  // ── Internal types ───────────────────────────────────────────────────────────
21
22
 
@@ -0,0 +1,139 @@
1
+ import { posix as posixPath, win32 as winPath } from "node:path";
2
+
3
+ import { canonicalizePath } from "#src/canonicalize-path";
4
+ import { expandHomePath } from "#src/expand-home";
5
+ import { isPathWithinDirectory } from "#src/path-containment";
6
+
7
+ /**
8
+ * Representation derivation backing {@link AccessPath}: turn an accessed path
9
+ * into the lexical / canonical / policy-value forms the resolver matches
10
+ * against rules. Pure (no filesystem access except `canonicalizePath`'s
11
+ * best-effort symlink resolution); the `platform` is injected, never read
12
+ * ambiently.
13
+ */
14
+ export function normalizePathForComparison(
15
+ pathValue: string,
16
+ cwd: string,
17
+ platform: NodeJS.Platform,
18
+ ): string {
19
+ const trimmed = pathValue.trim().replace(/^['"]|['"]$/g, "");
20
+ if (!trimmed) {
21
+ return "";
22
+ }
23
+
24
+ let normalizedPath = trimmed.startsWith("@") ? trimmed.slice(1) : trimmed;
25
+ normalizedPath = expandHomePath(normalizedPath);
26
+
27
+ const impl = platform === "win32" ? winPath : posixPath;
28
+ const absolutePath = impl.resolve(cwd, normalizedPath);
29
+ const normalizedAbsolutePath = impl.normalize(absolutePath);
30
+ return platform === "win32"
31
+ ? normalizedAbsolutePath.toLowerCase()
32
+ : normalizedAbsolutePath;
33
+ }
34
+
35
+ export interface PathPolicyValueOptions {
36
+ /**
37
+ * Current Pi working directory. When provided, returned values include a
38
+ * project-relative alias for paths that resolve inside this directory.
39
+ */
40
+ cwd?: string;
41
+ /**
42
+ * Directory used to resolve `pathValue` into an absolute policy value.
43
+ * Defaults to `cwd`. Bash uses this for tokens seen after a literal `cd`.
44
+ */
45
+ resolveBase?: string;
46
+ }
47
+
48
+ /**
49
+ * Normalize a single path-like lookup value without resolving it against CWD.
50
+ *
51
+ * Preserves compatibility with existing relative path rules (`src/*`, `*.env`)
52
+ * while applying the same lexical cleanup as
53
+ * {@link normalizePathForComparison}: trim, strip simple wrapping quotes,
54
+ * strip the OpenCode-style leading `@`, and expand `~` / `$HOME`.
55
+ */
56
+ export function normalizePathPolicyLiteral(pathValue: string): string {
57
+ const trimmed = pathValue.trim().replace(/^['"]|['"]$/g, "");
58
+ if (!trimmed) return "";
59
+ const unprefixed = trimmed.startsWith("@") ? trimmed.slice(1) : trimmed;
60
+ return expandHomePath(unprefixed);
61
+ }
62
+
63
+ /**
64
+ * Return equivalent lookup values for path-policy matching.
65
+ *
66
+ * The first value is the cwd/effective-base normalized absolute path when a
67
+ * base is available. The later values preserve project-relative and raw
68
+ * relative forms so existing rules like `src/*` and `*.env` continue to match.
69
+ */
70
+ export function getPathPolicyValues(
71
+ pathValue: string,
72
+ options: PathPolicyValueOptions,
73
+ platform: NodeJS.Platform,
74
+ ): string[] {
75
+ const literal = normalizePathPolicyLiteral(pathValue);
76
+ if (!literal) return [];
77
+ if (literal === "*") return ["*"];
78
+
79
+ return [
80
+ ...new Set([
81
+ ...getAbsolutePathPolicyValues(pathValue, options, platform),
82
+ literal,
83
+ ]),
84
+ ];
85
+ }
86
+
87
+ function getAbsolutePathPolicyValues(
88
+ pathValue: string,
89
+ options: PathPolicyValueOptions,
90
+ platform: NodeJS.Platform,
91
+ ): string[] {
92
+ const resolveBase = options.resolveBase ?? options.cwd;
93
+ if (!resolveBase) return [];
94
+
95
+ const absolute = normalizePathForComparison(pathValue, resolveBase, platform);
96
+ if (!absolute) return [];
97
+
98
+ return [
99
+ absolute,
100
+ ...getCwdRelativePathPolicyValues(absolute, options.cwd, platform),
101
+ ];
102
+ }
103
+
104
+ function getCwdRelativePathPolicyValues(
105
+ absolute: string,
106
+ cwd: string | undefined,
107
+ platform: NodeJS.Platform,
108
+ ): string[] {
109
+ if (!cwd) return [];
110
+
111
+ const normalizedCwd = normalizePathForComparison(cwd, cwd, platform);
112
+ if (!normalizedCwd) return [];
113
+ if (
114
+ absolute !== normalizedCwd &&
115
+ !isPathWithinDirectory(absolute, normalizedCwd, platform)
116
+ ) {
117
+ return [];
118
+ }
119
+
120
+ const impl = platform === "win32" ? winPath : posixPath;
121
+ const relativeValue = impl.relative(normalizedCwd, absolute);
122
+ return relativeValue ? [relativeValue] : [];
123
+ }
124
+
125
+ /**
126
+ * Like {@link normalizePathForComparison} but also resolves symlinks via
127
+ * `realpathSync` (best-effort). Use this for containment decisions where the
128
+ * OS-followed path matters, not for pattern matching.
129
+ */
130
+ export function canonicalNormalizePathForComparison(
131
+ pathValue: string,
132
+ cwd: string,
133
+ platform: NodeJS.Platform,
134
+ ): string {
135
+ const lexical = normalizePathForComparison(pathValue, cwd, platform);
136
+ if (!lexical) return "";
137
+ const canonical = canonicalizePath(lexical, platform);
138
+ return platform === "win32" ? canonical.toLowerCase() : canonical;
139
+ }
@@ -1,9 +1,9 @@
1
1
  import type { PathNormalizer } from "#src/path-normalizer";
2
- import { getToolInputPath } from "#src/path-utils";
3
2
  import type { ScopedPermissionResolver } from "#src/permission-resolver";
4
3
  import { SessionApproval } from "#src/session-approval";
5
4
  import { deriveApprovalPattern } from "#src/session-rules";
6
5
  import type { ToolAccessExtractorLookup } from "#src/tool-access-extractor-registry";
6
+ import { getToolInputPath } from "#src/tool-input-path";
7
7
  import type { GateResult } from "./descriptor";
8
8
  import { formatExternalDirectoryAskPrompt } from "./external-directory-messages";
9
9
  import { resolveExternalDirectoryPolicy } from "./external-directory-policy";
@@ -1,9 +1,9 @@
1
1
  import type { PathNormalizer } from "#src/path-normalizer";
2
- import { getToolInputPath } from "#src/path-utils";
3
2
  import type { ScopedPermissionResolver } from "#src/permission-resolver";
4
3
  import { SessionApproval } from "#src/session-approval";
5
4
  import { deriveApprovalPattern } from "#src/session-rules";
6
5
  import type { ToolAccessExtractorLookup } from "#src/tool-access-extractor-registry";
6
+ import { getToolInputPath } from "#src/tool-input-path";
7
7
  import type { GateDescriptor, GateResult } from "./descriptor";
8
8
  import type { ToolCallContext } from "./types";
9
9
 
@@ -1,11 +1,11 @@
1
1
  import type { AccessPath } from "#src/access-intent/access-path";
2
2
  import { BashProgram } from "#src/access-intent/bash/program";
3
3
  import type { PathNormalizer } from "#src/path-normalizer";
4
- import { getPathBearingToolPath } from "#src/path-utils";
5
4
  import type { ScopedPermissionResolver } from "#src/permission-resolver";
6
5
  import type { SkillPromptEntry } from "#src/skill-prompt-sanitizer";
7
6
  import type { ToolAccessExtractorLookup } from "#src/tool-access-extractor-registry";
8
7
  import type { ToolInputFormatterLookup } from "#src/tool-input-formatter-registry";
8
+ import { getPathBearingToolPath } from "#src/tool-input-path";
9
9
  import {
10
10
  ToolPreviewFormatter,
11
11
  type ToolPreviewFormatterOptions,
@@ -1,8 +1,9 @@
1
1
  import type { AccessPath } from "#src/access-intent/access-path";
2
- import { getPathBearingToolPath, PATH_BEARING_TOOLS } from "#src/path-utils";
2
+ import { PATH_BEARING_TOOLS } from "#src/path-surfaces";
3
3
  import { suggestSessionPattern } from "#src/pattern-suggest";
4
4
  import { formatAskPrompt } from "#src/permission-prompts";
5
5
  import { SessionApproval } from "#src/session-approval";
6
+ import { getPathBearingToolPath } from "#src/tool-input-path";
6
7
  import type { ToolPreviewFormatter } from "#src/tool-preview-formatter";
7
8
  import type { PermissionCheckResult } from "#src/types";
8
9
  import type { GateDescriptor } from "./descriptor";
@@ -2,7 +2,7 @@ import type { AccessIntent } from "./access-intent/access-intent";
2
2
  import { stripBashCommentLines } from "./bash-arity";
3
3
  import { createMcpPermissionTargets } from "./mcp-targets";
4
4
  import type { PathNormalizer } from "./path-normalizer";
5
- import { PATH_SURFACES } from "./path-utils";
5
+ import { PATH_SURFACES } from "./path-surfaces";
6
6
  import { getNonEmptyString, toRecord } from "./value-guards";
7
7
 
8
8
  /**
@@ -0,0 +1,56 @@
1
+ import { posix as posixPath, win32 as winPath } from "node:path";
2
+
3
+ import { isSafeSystemPath } from "./safe-system-paths";
4
+
5
+ /**
6
+ * Returns true when `pathValue` is `directory` itself or nested inside it.
7
+ *
8
+ * Containment is decided with Node's platform-native `path.relative` rather
9
+ * than a hand-rolled prefix check: on `win32` the comparison folds case (and
10
+ * tolerates either separator), matching the case-insensitive filesystem.
11
+ * `platform` is injected from the composition root so Windows behavior is
12
+ * testable on a POSIX CI.
13
+ */
14
+ export function isPathWithinDirectory(
15
+ pathValue: string,
16
+ directory: string,
17
+ platform: NodeJS.Platform,
18
+ ): boolean {
19
+ if (!pathValue || !directory) {
20
+ return false;
21
+ }
22
+
23
+ if (pathValue === directory) {
24
+ return true;
25
+ }
26
+
27
+ const impl = platform === "win32" ? winPath : posixPath;
28
+ const rel = impl.relative(directory, pathValue);
29
+ return (
30
+ rel !== "" &&
31
+ rel !== ".." &&
32
+ !rel.startsWith(`..${impl.sep}`) &&
33
+ !impl.isAbsolute(rel)
34
+ );
35
+ }
36
+
37
+ /**
38
+ * Pure geometry: is `canonicalPath` outside `canonicalCwd`?
39
+ *
40
+ * Both operands must already be canonical (symlink-resolved, win32-lowercased)
41
+ * — the caller prepares them (see {@link PathNormalizer.isOutsideWorkingDirectory}).
42
+ * This predicate touches no filesystem and does no derivation.
43
+ */
44
+ export function isPathOutsideWorkingDirectory(
45
+ canonicalPath: string,
46
+ canonicalCwd: string,
47
+ platform: NodeJS.Platform,
48
+ ): boolean {
49
+ if (!canonicalCwd || !canonicalPath) {
50
+ return false;
51
+ }
52
+ if (isSafeSystemPath(canonicalPath)) {
53
+ return false;
54
+ }
55
+ return !isPathWithinDirectory(canonicalPath, canonicalCwd, platform);
56
+ }
@@ -1,12 +1,15 @@
1
1
  import { posix as posixPath, win32 as winPath } from "node:path";
2
2
 
3
3
  import { AccessPath } from "./access-intent/access-path";
4
+ import {
5
+ canonicalNormalizePathForComparison,
6
+ normalizePathForComparison,
7
+ } from "./access-intent/path-normalization";
4
8
  import {
5
9
  isPathOutsideWorkingDirectory,
6
10
  isPathWithinDirectory,
7
- isPiInfrastructureRead,
8
- normalizePathForComparison,
9
- } from "./path-utils";
11
+ } from "./path-containment";
12
+ import { isPiInfrastructureRead } from "./pi-infrastructure-read";
10
13
 
11
14
  /**
12
15
  * Path-interpretation collaborator, constructed once at the session edge with
@@ -19,16 +22,19 @@ import {
19
22
  * prepared {@link AccessPath} values, instead of reading `process.platform`
20
23
  * ambiently or threading `cwd` through every call. Internally it selects the
21
24
  * `win32`/`posix` path flavor once and delegates to the platform-parameterized
22
- * `path-utils` / `AccessPath` primitives.
25
+ * `path-containment` / `path-normalization` / `AccessPath` primitives.
23
26
  */
24
27
  export class PathNormalizer {
25
28
  private readonly impl: typeof posixPath;
29
+ /** Canonical form of the baked cwd, resolved once (the symlink target is stable per session). */
30
+ private readonly canonicalCwd: string;
26
31
 
27
32
  constructor(
28
33
  private readonly platform: NodeJS.Platform,
29
34
  private readonly cwd: string,
30
35
  ) {
31
36
  this.impl = platform === "win32" ? winPath : posixPath;
37
+ this.canonicalCwd = canonicalNormalizePathForComparison(cwd, cwd, platform);
32
38
  }
33
39
 
34
40
  /** Build an AccessPath for a token, resolved against `resolveBase` (default cwd). */
@@ -67,7 +73,16 @@ export class PathNormalizer {
67
73
 
68
74
  /** Canonical (symlink-resolved) outside-cwd test against the baked cwd. */
69
75
  isOutsideWorkingDirectory(pathValue: string): boolean {
70
- return isPathOutsideWorkingDirectory(pathValue, this.cwd, this.platform);
76
+ const canonicalPath = canonicalNormalizePathForComparison(
77
+ pathValue,
78
+ this.cwd,
79
+ this.platform,
80
+ );
81
+ return isPathOutsideWorkingDirectory(
82
+ canonicalPath,
83
+ this.canonicalCwd,
84
+ this.platform,
85
+ );
71
86
  }
72
87
 
73
88
  /**
@@ -0,0 +1,30 @@
1
+ /**
2
+ * File tools that only read — never write — the filesystem.
3
+ * Only these tools are eligible for the Pi infrastructure auto-allow.
4
+ */
5
+ export const READ_ONLY_PATH_BEARING_TOOLS: ReadonlySet<string> = new Set([
6
+ "read",
7
+ "find",
8
+ "grep",
9
+ "ls",
10
+ ]);
11
+
12
+ export const PATH_BEARING_TOOLS = new Set([
13
+ "read",
14
+ "write",
15
+ "edit",
16
+ "find",
17
+ "grep",
18
+ "ls",
19
+ ]);
20
+
21
+ /**
22
+ * Surfaces whose patterns are matched against filesystem paths and therefore
23
+ * fold case (and separators) on Windows: the path-bearing tools plus the
24
+ * cross-cutting `path` gate and the `external_directory` boundary gate.
25
+ */
26
+ export const PATH_SURFACES: ReadonlySet<string> = new Set([
27
+ ...PATH_BEARING_TOOLS,
28
+ "external_directory",
29
+ "path",
30
+ ]);
@@ -1,5 +1,5 @@
1
1
  import { prefix, stripBashCommentLines } from "./bash-arity";
2
- import { PATH_BEARING_TOOLS } from "./path-utils";
2
+ import { PATH_BEARING_TOOLS } from "./path-surfaces";
3
3
  import { deriveApprovalPattern } from "./session-rules";
4
4
 
5
5
  /** The suggestion returned for a "Yes, for this session" dialog option. */
@@ -7,7 +7,7 @@ import {
7
7
  } from "./config-paths";
8
8
  import { normalizeInput } from "./input-normalizer";
9
9
  import { normalizeFlatConfig } from "./normalize";
10
- import { PATH_SURFACES } from "./path-utils";
10
+ import { PATH_SURFACES } from "./path-surfaces";
11
11
  import {
12
12
  FilePolicyLoader,
13
13
  type PolicyLoader,
@@ -249,6 +249,12 @@ export class PermissionManager implements ScopedPermissionManager {
249
249
  * extension surfaces). Path-bearing surfaces arrive as `"path-values"` via
250
250
  * the access-path gate (#502) or service/RPC builder (#503).
251
251
  * `"path-values"` → evaluates the precomputed values directly.
252
+ *
253
+ * The manager stays string-based by design: it consumes `ResolvedAccessIntent`
254
+ * (`tool | path-values`) and never imports `AccessPath`. This deliberate
255
+ * boundary is formalized in ADR-0002
256
+ * (`docs/decisions/0002-path-values-string-boundary.md`) and guarded by a
257
+ * `no-restricted-imports` lint rule on this file.
252
258
  */
253
259
  check(
254
260
  intent: ResolvedAccessIntent,
@@ -26,6 +26,11 @@ export interface ScopedPermissionResolver {
26
26
  *
27
27
  * Tell-Don't-Ask: the resolver asks an `AccessPath` for its `matchValues()`,
28
28
  * so the low-level manager never imports the value object.
29
+ *
30
+ * This is the sole `matchValues()` unwrap site — the single place the lexical ∪
31
+ * canonical alias set (#418) is derived. Keeping it here (not in the manager)
32
+ * is the deliberate boundary formalized in ADR-0002
33
+ * (`docs/decisions/0002-path-values-string-boundary.md`).
29
34
  */
30
35
  function toResolvedIntent(intent: AccessIntent): ResolvedAccessIntent {
31
36
  if (intent.kind === "access-path") {
@@ -0,0 +1,65 @@
1
+ import { join } from "node:path";
2
+
3
+ import { expandHomePath } from "./expand-home";
4
+ import { isPathWithinDirectory } from "./path-containment";
5
+ import { READ_ONLY_PATH_BEARING_TOOLS } from "./path-surfaces";
6
+ import { wildcardMatch } from "./wildcard-matcher";
7
+
8
+ function containsGlobChars(value: string): boolean {
9
+ return value.includes("*") || value.includes("?");
10
+ }
11
+
12
+ /**
13
+ * Returns true if the given tool + normalized path combination qualifies for
14
+ * automatic allow as a Pi infrastructure read.
15
+ *
16
+ * A path qualifies when:
17
+ * 1. The tool is read-only (in READ_ONLY_PATH_BEARING_TOOLS).
18
+ * 2. The normalized path is within one of the provided `infrastructureDirs`
19
+ * OR within the project-local Pi package directories
20
+ * (`<cwd>/.pi/npm/` or `<cwd>/.pi/git/`).
21
+ *
22
+ * `infrastructureDirs` entries may be absolute paths or patterns containing
23
+ * `~`/`$HOME` (expanded at call time) or glob characters (`*`, `?`).
24
+ * Project-local paths are computed fresh from `cwd` on each call so they
25
+ * follow working-directory changes without a runtime rebuild.
26
+ */
27
+ export function isPiInfrastructureRead(
28
+ toolName: string,
29
+ normalizedPath: string,
30
+ infrastructureDirs: readonly string[],
31
+ cwd: string,
32
+ platform: NodeJS.Platform,
33
+ ): boolean {
34
+ if (!READ_ONLY_PATH_BEARING_TOOLS.has(toolName)) {
35
+ return false;
36
+ }
37
+
38
+ // On Windows the path value is canonicalized + lowercased; fold case (and
39
+ // separators) so mixed-case infra dirs and glob patterns still match.
40
+ const matchOptions =
41
+ platform === "win32"
42
+ ? { caseInsensitive: true, windowsSeparators: true }
43
+ : undefined;
44
+
45
+ for (const dir of infrastructureDirs) {
46
+ if (containsGlobChars(dir)) {
47
+ if (wildcardMatch(dir, normalizedPath, matchOptions)) return true;
48
+ } else {
49
+ if (isPathWithinDirectory(normalizedPath, expandHomePath(dir), platform))
50
+ return true;
51
+ }
52
+ }
53
+
54
+ // Project-local Pi packages — checked fresh every call so CWD changes work.
55
+ const projectNpmDir = join(cwd, ".pi", "npm");
56
+ const projectGitDir = join(cwd, ".pi", "git");
57
+ if (isPathWithinDirectory(normalizedPath, projectNpmDir, platform)) {
58
+ return true;
59
+ }
60
+ if (isPathWithinDirectory(normalizedPath, projectGitDir, platform)) {
61
+ return true;
62
+ }
63
+
64
+ return false;
65
+ }
package/src/rule.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { PATH_SURFACES } from "./path-utils";
1
+ import { PATH_SURFACES } from "./path-surfaces";
2
2
  import type { PermissionState } from "./types";
3
3
  import { wildcardMatch } from "./wildcard-matcher";
4
4
 
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Paths that are universally safe and should never trigger external-directory checks.
3
+ * These are OS device files: read returns EOF or process streams, write discards or goes to process streams.
4
+ */
5
+ export const SAFE_SYSTEM_PATHS: ReadonlySet<string> = new Set([
6
+ "/dev/null",
7
+ "/dev/stdin",
8
+ "/dev/stdout",
9
+ "/dev/stderr",
10
+ ]);
11
+
12
+ /**
13
+ * Returns true if the given normalized path is a safe OS device file
14
+ * that should never trigger external-directory checks.
15
+ */
16
+ export function isSafeSystemPath(normalizedPath: string): boolean {
17
+ return SAFE_SYSTEM_PATHS.has(normalizedPath);
18
+ }
@@ -0,0 +1,54 @@
1
+ import { PATH_BEARING_TOOLS } from "./path-surfaces";
2
+ import type { ToolAccessExtractorLookup } from "./tool-access-extractor-registry";
3
+ import { getNonEmptyString, toRecord } from "./value-guards";
4
+
5
+ export function getPathBearingToolPath(
6
+ toolName: string,
7
+ input: unknown,
8
+ ): string | null {
9
+ if (!PATH_BEARING_TOOLS.has(toolName)) {
10
+ return null;
11
+ }
12
+
13
+ return getNonEmptyString(toRecord(input).path);
14
+ }
15
+
16
+ /**
17
+ * Extract the filesystem path a tool will access, for the cross-cutting `path`
18
+ * and `external_directory` gates.
19
+ *
20
+ * Unlike {@link getPathBearingToolPath} (built-in tools only), this recognizes
21
+ * extension and MCP tools so they are no longer exempt from path gating:
22
+ *
23
+ * - `bash` → `null` (bash has its own token-based path gates).
24
+ * - Built-in path-bearing tools → `input.path`.
25
+ * - `mcp` → `input.arguments.path`.
26
+ * - Any other tool → a registered {@link ToolAccessExtractor}'s path, else the
27
+ * default `input.path` convention.
28
+ */
29
+ export function getToolInputPath(
30
+ toolName: string,
31
+ input: unknown,
32
+ extractors?: ToolAccessExtractorLookup,
33
+ ): string | null {
34
+ if (toolName === "bash") {
35
+ return null;
36
+ }
37
+
38
+ const record = toRecord(input);
39
+
40
+ if (PATH_BEARING_TOOLS.has(toolName)) {
41
+ return getNonEmptyString(record.path);
42
+ }
43
+
44
+ if (toolName === "mcp") {
45
+ return getNonEmptyString(toRecord(record.arguments).path);
46
+ }
47
+
48
+ const custom = extractors?.get(toolName);
49
+ if (custom) {
50
+ return getNonEmptyString(custom(record));
51
+ }
52
+
53
+ return getNonEmptyString(record.path);
54
+ }