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
+233
View File
@@ -0,0 +1,233 @@
import type { PathFlavor } from "#src/path/path-flavor";
import { PATH_SURFACES } from "./access-intent/path-surfaces";
import type { PermissionState } from "./types";
import { type WildcardMatchOptions, wildcardMatch } from "./wildcard-matcher";
/**
* Provenance of a rule — which source contributed it.
*
* Config scopes: "global", "project", "agent", "project-agent".
* Synthesized: "builtin" (universal default / evaluate() fallback),
* "baseline" (conditional MCP metadata auto-allow).
* Runtime: "session" (session approvals).
* Rewrite: "yolo" (composition-stage ask→allow rewrite under yolo mode),
* "fail-closed" (composition-stage allow→ask floor when an
* invalid non-global config scope is detected).
*/
export type RuleOrigin =
| "global"
| "project"
| "agent"
| "project-agent"
| "builtin"
| "baseline"
| "session"
| "yolo"
| "fail-closed";
/** A single permission rule — the atomic unit of policy. */
export interface Rule {
/** The permission surface: "bash", "read", "mcp", "skill", "external_directory", etc. */
surface: string;
/** The match pattern: a command glob, tool name, skill name, or "*". */
pattern: string;
/** The permission decision. */
action: PermissionState;
/** Custom denial reason for deny rules (optional). */
reason?: string;
/**
* Origin layer — used to derive PermissionCheckResult.source after evaluation.
* Not used by evaluate(); purely informational metadata.
*/
layer?: "default" | "baseline" | "config" | "session";
/** Which source contributed this rule. */
origin: RuleOrigin;
}
/** An ordered list of rules. Later rules take priority (last-match-wins). */
export type Ruleset = Rule[];
/**
* Rewrite every `ask` rule to `allow`, tagged `origin: "yolo"`.
*
* The composition-stage expression of yolo mode as recorded authority: when
* enabled, an `ask` becomes a standing `allow` grant in the ruleset rather than
* a live prompt-path concern. `deny` and existing `allow` rules pass through
* untouched, so yolo suppresses prompts but preserves hard denies.
*
* Pure and non-mutating — `surface`, `pattern`, and `layer` are preserved so
* downstream `matchedPattern` / source derivation is unaffected; only `action`
* and `origin` change.
*/
export function rewriteAsksToYolo(rules: Ruleset): Ruleset {
return rules.map((rule) =>
rule.action === "ask" ? { ...rule, action: "allow", origin: "yolo" } : rule,
);
}
/**
* Floor every `allow` rule to `ask`, tagged `origin: "fail-closed"`.
*
* The mirror image of {@link rewriteAsksToYolo}: the composition-stage
* expression of the fail-closed clamp applied when an invalid non-global config
* scope is detected. A permissive `allow` inherited from a lower-precedence
* scope becomes an `ask` prompt rather than a silent grant, while `deny` and
* existing `ask` rules pass through untouched — so the clamp removes only
* permissive inheritance and never weakens a hard deny.
*
* Pure and non-mutating — `surface`, `pattern`, and `layer` are preserved so
* downstream `matchedPattern` / source derivation is unaffected; only `action`
* and `origin` change.
*/
export function floorAllowsToAsk(rules: Ruleset): Ruleset {
return rules.map((rule) =>
rule.action === "allow"
? { ...rule, action: "ask", origin: "fail-closed" }
: rule,
);
}
/**
* Pure permission evaluation.
*
* Returns the last rule in `rules` whose surface and pattern both
* wildcard-match the supplied values (last-match-wins).
*
* When no rule matches, returns a synthetic rule with `defaultAction`
* (defaults to "ask" — least privilege).
*/
export function evaluate(
surface: string,
pattern: string,
rules: Ruleset,
flavor: PathFlavor,
defaultAction?: PermissionState,
): Rule {
const rule = rules.findLast((r) => ruleMatches(r, surface, pattern, flavor));
if (rule !== undefined) return rule;
return {
surface,
pattern,
action: defaultAction ?? "ask",
origin: "builtin",
};
}
/**
* On Windows, path-surface values are canonicalized + lowercased; fold the
* pattern→value match (case and separators) so mixed-case / forward-slash
* overrides still match. The surface→surface match stays exact.
*/
export function pathMatchOptions(
surface: string,
flavor: PathFlavor,
): WildcardMatchOptions | undefined {
return PATH_SURFACES.has(surface) ? flavor.matchOptions : undefined;
}
function ruleMatches(
rule: Rule,
surface: string,
value: string,
flavor: PathFlavor,
): boolean {
const matchOptions = pathMatchOptions(surface, flavor);
return (
wildcardMatch(rule.surface, surface) &&
wildcardMatch(rule.pattern, value, matchOptions)
);
}
/**
* Evaluate a surface against an ordered list of candidate values, stopping at
* the first candidate that matches a non-default rule (last-match-wins within
* each candidate, first-non-default-wins across candidates).
*
* Used by MCP (multi-candidate target list) and, uniformly, by all other
* surfaces (single-element candidate list).
*
* Returns the matched rule and the candidate value that produced it.
* When every candidate matches only the synthesized default, falls back to
* evaluating the first candidate so the caller always receives a concrete
* result.
*/
/**
* Evaluate a surface against multiple values, returning the most restrictive
* non-allow result (deny > ask > allow).
*
* Used by the cross-cutting `path` surface to aggregate permission decisions
* across multiple file paths extracted from a single tool call or bash command.
*
* Returns `null` when all values evaluate to `allow` (no restriction).
* Returns the first `deny` immediately (short-circuit).
* Returns the first `ask` if no `deny` is found.
*/
export function evaluateMostRestrictive(
surface: string,
values: string[],
rules: Ruleset,
flavor: PathFlavor,
): { rule: Rule; value: string } | null {
let worst: { rule: Rule; value: string } | null = null;
for (const value of values) {
const rule = evaluate(surface, value, rules, flavor);
if (rule.action === "deny") return { rule, value };
if (rule.action === "ask" && worst?.rule.action !== "ask") {
worst = { rule, value };
}
}
return worst;
}
export function evaluateFirst(
surface: string,
values: string[],
rules: Ruleset,
flavor: PathFlavor,
): { rule: Rule; value: string } {
for (const value of values) {
const rule = evaluate(surface, value, rules, flavor);
if (rule.layer !== "default") {
return { rule, value };
}
}
// All candidates matched only the synthesized default — use the first.
const fallbackValue = values[0] ?? "*";
return {
rule: evaluate(surface, fallbackValue, rules, flavor),
value: fallbackValue,
};
}
/**
* Evaluate equivalent lookup values as aliases of the same path.
*
* Unlike `evaluateFirst()`, this preserves rule ordering across aliases: the
* last rule that matches any alias wins. This lets absolute allowlists and
* legacy relative rules coexist without a catch-all match on the first alias
* masking a later, more specific rule on another alias.
*/
export function evaluateAnyValue(
surface: string,
values: string[],
rules: Ruleset,
flavor: PathFlavor,
): { rule: Rule; value: string } {
const fallbackValue = values[0] ?? "*";
const rule = rules.findLast((r) =>
values.some((value) => ruleMatches(r, surface, value, flavor)),
);
if (rule !== undefined) {
return {
rule,
value:
values.find((value) => ruleMatches(rule, surface, value, flavor)) ??
fallbackValue,
};
}
return {
rule: evaluate(surface, fallbackValue, rules, flavor),
value: fallbackValue,
};
}