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,146 @@
import type { PathFlavor } from "#src/path/path-flavor";
import {
canonicalNormalizePathForComparison,
getPathPolicyValues,
normalizePathForComparison,
} from "./path-normalization";
/**
* A path's two representations held behind type-distinct accessors.
*
* A single `string` carrying both meanings was the root cause of [#418]:
* both external-directory gates matched config patterns against the
* symlink-resolved (canonical) path instead of the typed (lexical) path,
* defeating a configured `/tmp/*` allow.
*
* `AccessPath` makes the misuse a compile error:
* - {@link matchValues} returns `string[]` — the lexical alias union canonical,
* for `external_directory` pattern matching.
* - {@link boundaryValue} returns `string` — the canonical form, for
* outside-CWD containment and infra-read checks.
* - {@link value} returns `string` — the lexical absolute form, for display,
* approval patterns, decision values, and logs.
* - {@link resolvedAlias} returns `string | undefined` — the canonical form
* only when it names a location distinct from the lexical form, for
* disclosing a symlink target in a prompt or denial message.
*
* Construct via {@link forPath} (resolved, with optional cd-folded base) or
* {@link forLiteral} (literal-only, for an unknown base); the constructor is
* private.
*/
export class AccessPath {
private constructor(
private readonly lexical: string,
private readonly matchAliases: readonly string[],
private readonly canonical: string,
) {}
/**
* Pattern-match values for the `external_directory` surface: the lexical
* alias union plus the canonical alias, so a config pattern on either the
* typed form (`/tmp/*`) or the symlink-resolved form (`/private/tmp/*`)
* matches (#418).
*
* Collapses to the lexical aliases when the canonical equals one of them
* (e.g. when the path is not a symlink).
*/
matchValues(): string[] {
return this.canonical
? [...new Set([...this.matchAliases, this.canonical])]
: [...this.matchAliases];
}
/**
* Canonical (symlink-resolved, win32-lowercased) form, for the outside-CWD
* boundary decision and Pi infrastructure-read containment checks.
*
* Returns `""` when the path could not be resolved (empty input).
*/
boundaryValue(): string {
return this.canonical;
}
/**
* Lexical (as-typed, normalized but not symlink-resolved) form, for display,
* approval patterns, decision values, and log messages.
*
* Returns `""` for empty input.
*/
value(): string {
return this.lexical;
}
/**
* The canonical (symlink-resolved) form when it names a location distinct
* from the lexical form — for disclosing the resolved target in a prompt or
* denial message. `undefined` when the path is not a symlink (canonical
* equals lexical) or has no canonical (literal-only / empty input).
*/
resolvedAlias(): string | undefined {
if (!this.canonical || this.canonical === this.lexical) {
return undefined;
}
return this.canonical;
}
/**
* Build an `AccessPath` for a tool-input or bash-token path, resolved against
* `resolveBase` (the cd-folded effective directory; defaults to `cwd`).
*
* Serves every path surface: the tool path gate, the tool external-directory
* gate, and the bash path/external-directory gates (which pass a cd-resolved
* `resolveBase`).
*
* - `matchValues()` returns the lexical alias union from `getPathPolicyValues`
* plus the canonical alias from `canonicalNormalizePathForComparison`
* (#418), so a config pattern on either the typed or symlink-resolved form
* matches.
* - `boundaryValue()` returns
* `canonicalNormalizePathForComparison(pathValue, resolveBase)`, which is
* win32-lowercased (#382) — do not substitute a raw `canonicalizePath`
* output here.
* - `value()` returns `normalizePathForComparison(pathValue, resolveBase)`,
* the absolute lexical form.
*/
static forPath(
pathValue: string,
options: { cwd: string; resolveBase?: string; flavor: PathFlavor },
): AccessPath {
const { cwd, resolveBase = cwd, flavor } = options;
return new AccessPath(
normalizePathForComparison(pathValue, resolveBase, flavor),
getPathPolicyValues(pathValue, { cwd, resolveBase }, flavor),
canonicalNormalizePathForComparison(pathValue, resolveBase, flavor),
);
}
/**
* Build a literal-only `AccessPath` for a path whose effective base is
* unknown (a relative bash token after a non-literal `cd`).
*
* Carries no canonical alias and no absolute resolution — `matchValues()` is
* `[literal]` (or `[]` when empty) and `boundaryValue()` is `""` — so no
* spurious absolute or symlink-resolved rule can match (#393).
*/
static forLiteral(literal: string): AccessPath {
if (!literal) return new AccessPath("", [], "");
return new AccessPath(literal, [literal], "");
}
/**
* Build an `AccessPath` for a Git Bash/MSYS device path (`/dev/null`,
* `/dev/std{in,out,err}`) seen in a bash command on a win32 host.
*
* The token names an MSYS runtime device, not a filesystem path, so it is
* preserved verbatim across all three representations — `value()`,
* `boundaryValue()`, and `matchValues()` are the device path itself, never
* `win32.resolve`-mangled into `c:\dev\null`. The identical lexical and
* canonical forms let the boundary check reach `isSafeSystemPath` (so the
* device never triggers `external_directory`) while a config rule still
* matches the path as typed.
*/
static forDevice(devicePath: string): AccessPath {
return new AccessPath(devicePath, [devicePath], devicePath);
}
}