feat: vendor permission system source

This commit is contained in:
云服务部-叶林立
2026-08-19 14:35:19 +08:00
parent 198584daf8
commit 410c50a3e5
809 changed files with 157793 additions and 139 deletions
@@ -0,0 +1,37 @@
import { realpathSync } from "node:fs";
import type { PathFlavor } from "#src/path/path-flavor";
/**
* Resolve symlinks in an absolute path, best-effort.
*
* Splits the path into components and tries `realpathSync` from the full path
* down to `/`, re-appending the non-existent tail to the first ancestor that
* resolves. Returns the input unchanged when no ancestor resolves (unreachable
* in practice since `/` always exists) or when a non-ENOENT/ENOTDIR error is
* encountered (e.g. `EACCES`, `ELOOP`), so callers fall back to lexical
* containment for paths that cannot be resolved.
*/
export function canonicalizePath(
absolutePath: string,
flavor: PathFlavor,
): string {
if (!absolutePath) return absolutePath;
const { impl } = flavor;
const root = impl.parse(absolutePath).root;
const rest = absolutePath.slice(root.length);
const parts = rest.split(impl.sep).filter(Boolean);
for (let i = parts.length; i >= 0; i--) {
const candidate = root + parts.slice(0, i).join(impl.sep);
try {
const real = realpathSync(candidate);
const tail = parts.slice(i);
return tail.length === 0 ? real : impl.join(real, ...tail);
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code !== "ENOENT" && code !== "ENOTDIR") return absolutePath;
}
}
return absolutePath;
}
@@ -0,0 +1,24 @@
import type { PathFlavor } from "#src/path/path-flavor";
import { isSafeSystemPath } from "#src/safe-system-paths";
/**
* Pure geometry: is `canonicalPath` outside `canonicalCwd`?
*
* Both operands must already be canonical (symlink-resolved, win32-lowercased)
* — the caller prepares them (see {@link PathNormalizer.isOutsideWorkingDirectory}).
* This predicate touches no filesystem and does no derivation; the containment
* geometry lives on {@link PathFlavor.isWithin}.
*/
export function isPathOutsideWorkingDirectory(
canonicalPath: string,
canonicalCwd: string,
flavor: PathFlavor,
): boolean {
if (!canonicalCwd || !canonicalPath) {
return false;
}
if (isSafeSystemPath(canonicalPath)) {
return false;
}
return !flavor.isWithin(canonicalPath, canonicalCwd);
}
@@ -0,0 +1,114 @@
import type { PlatformPath } from "node:path";
import { posix as posixPath, win32 as winPath } from "node:path";
import {
type BashTokenShape,
classifyWin32BashToken,
} from "#src/access-intent/bash/msys-bash-tokens";
import type { WildcardMatchOptions } from "#src/wildcard-matcher";
/**
* The resolved product of the single win32-vs-POSIX platform decision: the
* platform's path *language* as one immutable collaborator.
*
* The win32-vs-POSIX difference is not variant growth (the set is closed) but
* **connascence of algorithm** — every path leaf must re-derive the same
* mapping identically, and in a permission system a leaf that misses the case
* fold or separator fold is a silent bypass (the #382 / #508 class). `PathFlavor`
* captures that mapping once so the leaves consume the resolved capability
* instead of re-interpreting a raw `NodeJS.Platform` string. It owns platform
* **semantics** — syntax ({@link hasPathSeparator}), token shape
* ({@link bashTokenShape}), and the equivalence relation ({@link fold} /
* {@link comparable} / {@link isWithin} / {@link matchOptions}); domain policy
* (lexical cleanup, alias generation, safe-system-path exclusions, rule
* dispatch) stays in the functions that consume it.
*/
export interface PathFlavor {
/**
* Node's own platform path strategy (`path.win32` | `path.posix`). Exposed
* directly — its post-migration consumers are all path-domain primitives and
* `PlatformPath` is itself a maintained strategy object, so wrapping it would
* be pure forwarding.
*/
readonly impl: PlatformPath;
/**
* Wildcard match options for path-surface rule matching: the win32
* case-and-separator fold, or `undefined` on POSIX.
*/
readonly matchOptions: WildcardMatchOptions | undefined;
/** Comparison case fold: win32 lowercases, POSIX returns the value unchanged. */
fold(value: string): string;
/**
* Resolve `pathValue` against `base`, normalize, and fold — the single home
* of the #382 case-fold invariant for absolute comparison values.
*/
comparable(pathValue: string, base: string): string;
/** `path.relative`-based containment: is `pathValue` `directory` itself or nested inside it? */
isWithin(pathValue: string, directory: string): boolean;
/**
* True when `token` contains a path separator under this platform: `/` on
* POSIX; `/` or `\` on win32 (where a backslash is a separator, #520).
*/
hasPathSeparator(token: string): boolean;
/**
* The MSYS/Git-Bash interpretation of a bash-command token. On win32 this
* carries device / drive-mount / posix-absolute / plain semantics; on POSIX
* every token is an ordinary path, so the shape is always `{ kind: "plain" }`.
*/
bashTokenShape(token: string): BashTokenShape;
}
class PlatformPathFlavor implements PathFlavor {
readonly matchOptions: WildcardMatchOptions | undefined;
constructor(
readonly impl: PlatformPath,
private readonly windows: boolean,
) {
this.matchOptions = windows
? { caseInsensitive: true, windowsSeparators: true }
: undefined;
}
fold(value: string): string {
return this.windows ? value.toLowerCase() : value;
}
comparable(pathValue: string, base: string): string {
return this.fold(this.impl.normalize(this.impl.resolve(base, pathValue)));
}
isWithin(pathValue: string, directory: string): boolean {
if (!pathValue || !directory) return false;
if (pathValue === directory) return true;
const rel = this.impl.relative(directory, pathValue);
return (
rel !== "" &&
rel !== ".." &&
!rel.startsWith(`..${this.impl.sep}`) &&
!this.impl.isAbsolute(rel)
);
}
hasPathSeparator(token: string): boolean {
return token.includes("/") || (this.windows && token.includes("\\"));
}
bashTokenShape(token: string): BashTokenShape {
return this.windows ? classifyWin32BashToken(token) : { kind: "plain" };
}
}
export const posixPathFlavor: PathFlavor = new PlatformPathFlavor(
posixPath,
false,
);
export const win32PathFlavor: PathFlavor = new PlatformPathFlavor(
winPath,
true,
);
/** The one win32-vs-POSIX platform decision in the package. */
export function pathFlavorForPlatform(platform: NodeJS.Platform): PathFlavor {
return platform === "win32" ? win32PathFlavor : posixPathFlavor;
}
@@ -0,0 +1,59 @@
import { join } from "node:path";
import { READ_ONLY_PATH_BEARING_TOOLS } from "#src/access-intent/path-surfaces";
import { expandHomePath } from "#src/expand-home";
import type { PathFlavor } from "#src/path/path-flavor";
import { wildcardMatch } from "#src/wildcard-matcher";
function containsGlobChars(value: string): boolean {
return value.includes("*") || value.includes("?");
}
/**
* Returns true if the given tool + normalized path combination qualifies for
* automatic allow as a Pi infrastructure read.
*
* A path qualifies when:
* 1. The tool is read-only (in READ_ONLY_PATH_BEARING_TOOLS).
* 2. The normalized path is within one of the provided `infrastructureDirs`
* OR within the project-local Pi package directories
* (`<cwd>/.pi/npm/` or `<cwd>/.pi/git/`).
*
* `infrastructureDirs` entries may be absolute paths or patterns containing
* `~`/`$HOME` (expanded at call time) or glob characters (`*`, `?`).
* Project-local paths are computed fresh from `cwd` on each call so they
* follow working-directory changes without a runtime rebuild.
*/
export function isPiInfrastructureRead(
toolName: string,
normalizedPath: string,
infrastructureDirs: readonly string[],
cwd: string,
flavor: PathFlavor,
): boolean {
if (!READ_ONLY_PATH_BEARING_TOOLS.has(toolName)) {
return false;
}
// On Windows the path value is canonicalized + lowercased; the flavor's match
// options fold case (and separators) so mixed-case infra dirs and glob
// patterns still match.
for (const dir of infrastructureDirs) {
if (containsGlobChars(dir)) {
if (wildcardMatch(dir, normalizedPath, flavor.matchOptions)) return true;
} else {
if (flavor.isWithin(normalizedPath, expandHomePath(dir))) return true;
}
}
// Project-local Pi packages — checked fresh every call so CWD changes work.
const projectNpmDir = join(cwd, ".pi", "npm");
const projectGitDir = join(cwd, ".pi", "git");
if (flavor.isWithin(normalizedPath, projectNpmDir)) {
return true;
}
if (flavor.isWithin(normalizedPath, projectGitDir)) {
return true;
}
return false;
}