mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-08-28 08:35:57 +00:00
feat: vendor permission system source
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user