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,65 @@
|
||||
import type { AccessPath } from "#src/access-intent/access-path";
|
||||
|
||||
/**
|
||||
* Raw tool input the manager must normalize (path / bash / MCP / extension tools).
|
||||
*
|
||||
* The `surface` is the tool name fed to `normalizeInput` (e.g. `"read"`, `"bash"`,
|
||||
* an MCP server name).
|
||||
*/
|
||||
export interface ToolAccessIntent {
|
||||
kind: "tool";
|
||||
/** Tool name fed to input normalization. */
|
||||
surface: string;
|
||||
input: unknown;
|
||||
agentName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Precomputed equivalent policy values for a path-shaped surface.
|
||||
*
|
||||
* Not gate-emitted: the resolver produces it internally by unwrapping an
|
||||
* `access-path` intent via `matchValues()`, keeping the low-level manager
|
||||
* string-based (it never imports `AccessPath`). See {@link ResolvedAccessIntent}.
|
||||
*
|
||||
* This string seam is a deliberate, formalized boundary — not transitional
|
||||
* scaffolding to collapse into the manager (ADR-0002,
|
||||
* `docs/decisions/0002-path-values-string-boundary.md`).
|
||||
*/
|
||||
export interface PathValuesAccessIntent {
|
||||
kind: "path-values";
|
||||
/** `"path"` or `"external_directory"`. */
|
||||
surface: string;
|
||||
values: readonly string[];
|
||||
agentName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* An `AccessPath` value object for a path-shaped surface.
|
||||
*
|
||||
* Built for every path-shaped surface: the cross-cutting `path` and
|
||||
* `external_directory` gates, the per-tool path-bearing surfaces
|
||||
* (`read`/`write`/`edit`/`grep`/`find`/`ls`, #502), and the service/RPC policy
|
||||
* queries for those surfaces (#503). Lets `AccessPath` flow into the resolver
|
||||
* as a first-class variant so the resolver — not the producer — asks it for
|
||||
* `matchValues()` (Tell-Don't-Ask).
|
||||
*/
|
||||
export interface AccessPathAccessIntent {
|
||||
kind: "access-path";
|
||||
surface: string;
|
||||
path: AccessPath;
|
||||
agentName?: string;
|
||||
}
|
||||
|
||||
/** What a gate emits — a raw tool input or an `AccessPath`. */
|
||||
export type AccessIntent = ToolAccessIntent | AccessPathAccessIntent;
|
||||
|
||||
/**
|
||||
* What the manager consumes — the `access-path` variant has already been
|
||||
* unwrapped to `path-values` by the resolver via `path.matchValues()`.
|
||||
*
|
||||
* The manager stays string-based and never imports `AccessPath`: this is the
|
||||
* deliberate boundary formalized in ADR-0002
|
||||
* (`docs/decisions/0002-path-values-string-boundary.md`), guarded by a
|
||||
* `no-restricted-imports` lint rule on `permission-manager.ts`.
|
||||
*/
|
||||
export type ResolvedAccessIntent = ToolAccessIntent | PathValuesAccessIntent;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,692 @@
|
||||
import type { AccessPath } from "#src/access-intent/access-path";
|
||||
import {
|
||||
ARG_NODE_TYPES,
|
||||
SKIP_SUBTREE_TYPES,
|
||||
} from "#src/access-intent/bash/node-text";
|
||||
import type { TSNode } from "#src/access-intent/bash/parser";
|
||||
import {
|
||||
classifyBareTokenCandidate,
|
||||
classifyTokenAsPathCandidate,
|
||||
classifyTokenAsRuleCandidate,
|
||||
} from "#src/access-intent/bash/token-classification";
|
||||
import {
|
||||
collectCommandTokens,
|
||||
collectPathCandidateTokens,
|
||||
collectRedirectTokens,
|
||||
extractCommandName,
|
||||
} from "#src/access-intent/bash/token-collection";
|
||||
import { normalizePathPolicyLiteral } from "#src/access-intent/path-normalization";
|
||||
import type { PathNormalizer } from "#src/path-normalizer";
|
||||
import { isSafeSystemPath } from "#src/safe-system-paths";
|
||||
|
||||
// ── Internal types ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The working directory in force where a path candidate appears.
|
||||
*
|
||||
* A `known` base carries an `offset` to be joined with `cwd` at resolution
|
||||
* time: a relative-or-absolute path string built by folding the literal targets
|
||||
* of current-shell `cd` commands (`""` = `cwd`); an absolute offset (from
|
||||
* `cd /abs`) ignores `cwd` at resolution time.
|
||||
* An `unknown` base marks a non-literal `cd` target (`cd "$DIR"`, `cd $(…)`,
|
||||
* `cd -`, bare `cd`, `cd ~…`) that made the effective directory unresolvable.
|
||||
*/
|
||||
type EffectiveBase =
|
||||
| { readonly kind: "known"; readonly offset: string }
|
||||
| { readonly kind: "unknown" };
|
||||
|
||||
/**
|
||||
* A path-candidate token paired with the effective working directory projected
|
||||
* onto the point in the command stream where it appears.
|
||||
*/
|
||||
interface PathCandidate {
|
||||
readonly token: string;
|
||||
readonly base: EffectiveBase;
|
||||
}
|
||||
|
||||
// ── Public output types ──────────────────────────────────────────────────────
|
||||
|
||||
export interface BashPathRuleCandidate {
|
||||
/** Raw path-like token shown in prompts, logs, and session approvals. */
|
||||
readonly token: string;
|
||||
/** The path's lexical and canonical forms for permission policy matching. */
|
||||
readonly path: AccessPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* The filesystem paths a bash program references, resolved against the working
|
||||
* directory and platform — the two typed slices {@link BashProgram} exposes.
|
||||
*/
|
||||
export interface ResolvedBashPaths {
|
||||
/** Deduplicated paths resolving outside the working directory (#418). */
|
||||
readonly externalPaths: readonly AccessPath[];
|
||||
/** Every path-rule token paired with its cd-aware policy values (#393). */
|
||||
readonly ruleCandidates: readonly BashPathRuleCandidate[];
|
||||
}
|
||||
|
||||
// ── Walk-time constants ──────────────────────────────────────────────────────
|
||||
|
||||
/** The working directory in force at the start of a program (`cwd`). */
|
||||
const CWD_BASE: EffectiveBase = { kind: "known", offset: "" };
|
||||
|
||||
/** The effective directory after a non-literal or unresolvable `cd`. */
|
||||
const UNKNOWN_BASE: EffectiveBase = { kind: "unknown" };
|
||||
|
||||
/**
|
||||
* Resolves the filesystem paths a parsed bash program references.
|
||||
*
|
||||
* Holds a {@link PathNormalizer} (platform + cwd baked in) as its primary
|
||||
* collaborator and answers all platform/cwd-dependent questions through it —
|
||||
* `cd`-base folding (`isAbsolute`/`joinBase`), per-candidate resolution
|
||||
* (`forPath`/`forLiteral`/`resolveBase`), and the outside-cwd boundary
|
||||
* decision — so no walk step re-reads the platform or threads the cwd.
|
||||
*
|
||||
* A bare token that fails both shape gates is admitted when the normalizer's
|
||||
* existence probe says it names a real filesystem entry (ADR 0009, #645). The
|
||||
* resolver consults no ruleset: candidacy is a filesystem question, and the
|
||||
* policy decision belongs to the gates downstream.
|
||||
*
|
||||
* Tell-don't-ask: callers hand it a parsed tree and receive the resolved
|
||||
* {@link ResolvedBashPaths} slices in one {@link resolve} call; the AST walk,
|
||||
* the `cd`-folding state, and the intermediate path candidates stay private.
|
||||
* One instance per parse ({@link BashProgram.parse} constructs it with the
|
||||
* session normalizer).
|
||||
*/
|
||||
export class BashPathResolver {
|
||||
constructor(
|
||||
private readonly normalizer: PathNormalizer,
|
||||
private readonly workdir?: string,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Resolve a parsed bash program's path references into its external-path and
|
||||
* rule-candidate slices, walking the AST exactly once.
|
||||
*
|
||||
* When a `workdir` is set (an aliased shell tool's working directory, #574),
|
||||
* it seeds the initial effective base — as if the program were prefixed with
|
||||
* `cd <workdir>` — so relative tokens resolve against it, and the `workdir`
|
||||
* itself is added to the external paths when it resolves outside the cwd.
|
||||
* Containment is always measured against the session cwd baked into the
|
||||
* normalizer, so a `workdir` outside the cwd does not widen the sandbox.
|
||||
*/
|
||||
resolve(rootNode: TSNode): ResolvedBashPaths {
|
||||
const initialBase =
|
||||
this.workdir === undefined
|
||||
? CWD_BASE
|
||||
: this.deriveBaseFromCdTarget(CWD_BASE, this.workdir);
|
||||
const candidates = this.collectPathCandidates(rootNode, initialBase);
|
||||
return {
|
||||
externalPaths: this.withWorkdirExternal(
|
||||
this.projectExternalPaths(candidates),
|
||||
),
|
||||
ruleCandidates: this.projectRuleCandidates(candidates),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepend the `workdir`'s own {@link AccessPath} to the external paths when it
|
||||
* resolves outside the cwd. A real `cd /etc` flags `/etc` via its argument
|
||||
* token; the seeded base carries no such token, so it is added explicitly and
|
||||
* deduplicated against the command's own external tokens (#574).
|
||||
*/
|
||||
private withWorkdirExternal(
|
||||
tokenExternals: readonly AccessPath[],
|
||||
): AccessPath[] {
|
||||
if (this.workdir === undefined) return [...tokenExternals];
|
||||
const wdPath = this.normalizer.forBashToken(this.workdir);
|
||||
const canonical = wdPath.boundaryValue();
|
||||
const isExternal = canonical
|
||||
? this.normalizer.isBoundaryOutsideWorkingDirectory(canonical)
|
||||
: true;
|
||||
if (!isExternal) return [...tokenExternals];
|
||||
const key = canonical || wdPath.value();
|
||||
const alreadyPresent = tokenExternals.some(
|
||||
(p) => (p.boundaryValue() || p.value()) === key,
|
||||
);
|
||||
return alreadyPresent ? [...tokenExternals] : [wdPath, ...tokenExternals];
|
||||
}
|
||||
|
||||
// ── AST walk — collect PathCandidates ──────────────────────────────────
|
||||
|
||||
/**
|
||||
* Walk the AST once, collecting every path-candidate token tagged with the
|
||||
* effective working directory projected onto its position.
|
||||
*
|
||||
* The effective directory is stateful: it starts at `cwd` and each
|
||||
* current-shell `cd <literal>` (joined by `&&`, `||`, `;`, or a newline)
|
||||
* folds into it for subsequent commands.
|
||||
* A `cd` inside a pipeline or a backgrounded command runs in a subshell and
|
||||
* does not update the running directory; subshell and brace-group interiors
|
||||
* inherit the enclosing base without folding their own `cd`s (a conservative
|
||||
* first tier).
|
||||
*/
|
||||
private collectPathCandidates(
|
||||
rootNode: TSNode,
|
||||
initialBase: EffectiveBase,
|
||||
): PathCandidate[] {
|
||||
const out: PathCandidate[] = [];
|
||||
this.walkForCandidates(rootNode, initialBase, out);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect a single node's candidates tagged with `base`, returning the
|
||||
* effective base in force *after* the node (the input base unless the node is
|
||||
* a current-shell `cd <literal>` that folds the running directory).
|
||||
*/
|
||||
private walkForCandidates(
|
||||
node: TSNode,
|
||||
base: EffectiveBase,
|
||||
out: PathCandidate[],
|
||||
): EffectiveBase {
|
||||
switch (node.type) {
|
||||
case "program":
|
||||
case "list":
|
||||
case "redirected_statement":
|
||||
return this.walkCurrentShellSequence(node, base, out);
|
||||
case "command":
|
||||
tagTokens(collectCommandTokens(node), base, out);
|
||||
return this.foldCd(node, base);
|
||||
case "pipeline":
|
||||
// tree-sitter-bash mis-groups a redirect-bearing `&&`/`;` list as the
|
||||
// first stage of a pipeline (`cd a && pnpm x 2>&1 | tail` parses as
|
||||
// `(cd a && pnpm x 2>&1) | tail`), burying a current-shell `cd` inside
|
||||
// a node the `default` case treats as non-folding. Recover bash operator
|
||||
// precedence (`|` binds tighter than `&&`/`||`/`;`): fold the first
|
||||
// stage's leading current-shell commands while keeping its terminal
|
||||
// command and every downstream stage as non-folding subshells (#454).
|
||||
return this.walkPipeline(node, base, out);
|
||||
case "subshell":
|
||||
// A subshell runs in a child shell: its interior `cd`s fold within the
|
||||
// subshell but reset on exit, so the folded base is discarded.
|
||||
this.walkCurrentShellSequence(node, base, out);
|
||||
return base;
|
||||
case "compound_statement":
|
||||
// A `{ … }` brace group runs in the current shell, so its `cd`s persist
|
||||
// to following commands — thread and return the folded base.
|
||||
return this.walkCurrentShellSequence(node, base, out);
|
||||
default:
|
||||
// Pipelines, control-flow bodies, redirect targets, and command/process
|
||||
// substitution interiors: collect every candidate in the subtree tagged
|
||||
// with the enclosing base and do not fold their internal `cd`s. (Folding
|
||||
// inside substitutions is deferred — conservative, never under-flags.)
|
||||
tagTokens(collectPathCandidateTokens(node), base, out);
|
||||
return base;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold a current-shell sequence (`program` / `list` / `redirected_statement`):
|
||||
* thread the effective base left-to-right through the children so a `cd`
|
||||
* updates the base for following siblings.
|
||||
* A statement immediately followed by the background operator (`&`) runs in a
|
||||
* subshell, so its folded base is discarded.
|
||||
*/
|
||||
private walkCurrentShellSequence(
|
||||
seqNode: TSNode,
|
||||
base: EffectiveBase,
|
||||
out: PathCandidate[],
|
||||
): EffectiveBase {
|
||||
let current = base;
|
||||
for (let i = 0; i < seqNode.childCount; i++) {
|
||||
const child = seqNode.child(i);
|
||||
if (!child?.isNamed) continue;
|
||||
if (SKIP_SUBTREE_TYPES.has(child.type)) continue;
|
||||
const after = this.walkForCandidates(child, current, out);
|
||||
current = isBackgrounded(seqNode, i) ? current : after;
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk a `pipeline` node, returning the effective base in force after it.
|
||||
*
|
||||
* Each stage of a true pipeline (`A | B | C`) runs in a subshell, so a `cd`
|
||||
* inside any stage must not leak — the base normally passes through unchanged.
|
||||
* The exception is the first stage: tree-sitter-bash wraps a redirect-bearing
|
||||
* current-shell `&&`/`;` list (`cd a && pnpm x 2>&1 | tail`) as that stage,
|
||||
* and bash precedence makes the list's leading commands current-shell, so they
|
||||
* fold and the folded base persists past the pipeline to following siblings.
|
||||
*
|
||||
* The terminal command of the first stage is the real pipe stage (a subshell)
|
||||
* and must not fold; every stage after a `|` is a downstream subshell stage
|
||||
* and collects tokens against the folded base without folding (#454).
|
||||
*/
|
||||
private walkPipeline(
|
||||
node: TSNode,
|
||||
base: EffectiveBase,
|
||||
out: PathCandidate[],
|
||||
): EffectiveBase {
|
||||
let current = base;
|
||||
let first = true;
|
||||
for (let i = 0; i < node.childCount; i++) {
|
||||
const child = node.child(i);
|
||||
if (!child?.isNamed) continue;
|
||||
if (SKIP_SUBTREE_TYPES.has(child.type)) continue;
|
||||
if (first) {
|
||||
current = this.foldPipelineFirstStage(child, current, out);
|
||||
first = false;
|
||||
continue;
|
||||
}
|
||||
// Downstream stage (after a `|`): subshell — collect against the folded
|
||||
// base, do not fold.
|
||||
tagTokens(collectPathCandidateTokens(child), current, out);
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect the first pipe stage's candidates, folding its leading current-shell
|
||||
* `cd` commands when tree-sitter wrapped a `list` or `redirected_statement`
|
||||
* around them.
|
||||
* The terminal command of that container is the real pipe stage (a subshell)
|
||||
* and is collected without folding.
|
||||
* A bare `command` first stage (a true pipeline first stage such as
|
||||
* `cd nested | cat ../b`) is a subshell: it collects against the input base
|
||||
* and does not fold.
|
||||
*/
|
||||
private foldPipelineFirstStage(
|
||||
node: TSNode,
|
||||
base: EffectiveBase,
|
||||
out: PathCandidate[],
|
||||
): EffectiveBase {
|
||||
if (node.type === "list")
|
||||
return this.foldListExceptTerminal(node, base, out);
|
||||
if (node.type === "redirected_statement") {
|
||||
let current = base;
|
||||
for (let i = 0; i < node.childCount; i++) {
|
||||
const child = node.child(i);
|
||||
if (!child?.isNamed) continue;
|
||||
if (child.type === "file_redirect") {
|
||||
// Redirect destinations are part of the piped stage; collect them
|
||||
// against the folded base without folding.
|
||||
tagTokens(collectRedirectTokens(child), current, out);
|
||||
continue;
|
||||
}
|
||||
// The inner statement is the `list`/`command` being redirected; fold its
|
||||
// leading current-shell commands via the terminal-excluding walk.
|
||||
current = this.foldPipelineFirstStage(child, current, out);
|
||||
}
|
||||
return current;
|
||||
}
|
||||
// Bare `command` or any other shape: a true subshell first stage.
|
||||
tagTokens(collectPathCandidateTokens(node), base, out);
|
||||
return base;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold every named, non-skip child of a `list` except the last, threading the
|
||||
* effective base left-to-right through the leading current-shell commands; the
|
||||
* terminal child is the real pipe stage and is collected without folding.
|
||||
*/
|
||||
private foldListExceptTerminal(
|
||||
node: TSNode,
|
||||
base: EffectiveBase,
|
||||
out: PathCandidate[],
|
||||
): EffectiveBase {
|
||||
const namedChildren: TSNode[] = [];
|
||||
for (let i = 0; i < node.childCount; i++) {
|
||||
const child = node.child(i);
|
||||
if (child?.isNamed && !SKIP_SUBTREE_TYPES.has(child.type)) {
|
||||
namedChildren.push(child);
|
||||
}
|
||||
}
|
||||
let current = base;
|
||||
for (let i = 0; i < namedChildren.length; i++) {
|
||||
const child = namedChildren[i];
|
||||
if (i < namedChildren.length - 1) {
|
||||
current = this.walkForCandidates(child, current, out);
|
||||
} else {
|
||||
// Terminal child = the real pipe stage; collect without folding.
|
||||
tagTokens(collectPathCandidateTokens(child), current, out);
|
||||
}
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the effective base after a command runs.
|
||||
* Returns `base` unchanged unless the command is `cd`:
|
||||
*
|
||||
* - `cd /abs` (absolute literal) → a fresh known base, recovering from an
|
||||
* earlier unknown base. On win32 a drive-mount target (`cd /c/x`) folds to
|
||||
* its translated Windows base, while a non-mount POSIX absolute
|
||||
* (`cd /tmp`) is not deterministically resolvable and yields unknown (#533).
|
||||
* - `cd rel` (relative literal) → fold into a known base, or stay unknown if
|
||||
* the base was already unknown.
|
||||
* - `cd "$DIR"` / `cd $(…)` / `cd -` / bare `cd` / `cd ~…` (non-literal) →
|
||||
* unknown.
|
||||
*
|
||||
* The target's platform/MSYS interpretation is delegated to the
|
||||
* {@link PathNormalizer}; this method owns only the base-folding state.
|
||||
*/
|
||||
private foldCd(commandNode: TSNode, base: EffectiveBase): EffectiveBase {
|
||||
if (extractCommandName(commandNode) !== "cd") return base;
|
||||
const target = cdLiteralTarget(commandNode);
|
||||
if (target === null) return UNKNOWN_BASE;
|
||||
return this.deriveBaseFromCdTarget(base, target);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold a literal `cd`/working-directory target string into the effective
|
||||
* base, delegating the platform/MSYS interpretation to the
|
||||
* {@link PathNormalizer}. Owns only the base-folding state:
|
||||
*
|
||||
* - `absolute` → a fresh known base (recovers from an earlier unknown base).
|
||||
* - `unknown` → the base becomes conservatively unknown.
|
||||
* - `relative` → join into a known base, or stay unknown if already unknown.
|
||||
*
|
||||
* Shared by {@link foldCd} (inline `cd` commands) and the initial-base seed
|
||||
* (an aliased shell tool's `workdir`, an implicit leading `cd <workdir>`).
|
||||
*/
|
||||
private deriveBaseFromCdTarget(
|
||||
base: EffectiveBase,
|
||||
target: string,
|
||||
): EffectiveBase {
|
||||
const interpreted = this.normalizer.interpretBashCdTarget(target);
|
||||
switch (interpreted.kind) {
|
||||
case "absolute":
|
||||
return { kind: "known", offset: interpreted.value };
|
||||
case "unknown":
|
||||
return UNKNOWN_BASE;
|
||||
case "relative":
|
||||
if (base.kind === "unknown") return UNKNOWN_BASE;
|
||||
return {
|
||||
kind: "known",
|
||||
offset: this.normalizer.joinBase(base.offset, target),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ── Projection ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Project the collected candidates into deduplicated external paths.
|
||||
*
|
||||
* Filters candidates through the strict path classifier
|
||||
* (`classifyTokenAsPathCandidate`), resolves each against its effective working
|
||||
* directory base, and returns only paths that resolve outside the baked cwd in
|
||||
* their lexical (as-typed, normalized but not symlink-resolved) form.
|
||||
*
|
||||
* The outside-cwd decision and the dedup identity use the canonical
|
||||
* (symlink-resolved) form so `external_directory` config patterns match the
|
||||
* path as the user typed it (#418).
|
||||
*/
|
||||
private projectExternalPaths(
|
||||
candidates: readonly PathCandidate[],
|
||||
): AccessPath[] {
|
||||
const seen = new Set<string>();
|
||||
const externalPaths: AccessPath[] = [];
|
||||
|
||||
for (const { token, base } of candidates) {
|
||||
const candidate = classifyTokenAsPathCandidate(token);
|
||||
if (!candidate) {
|
||||
// A bare token the strict shape gate rejects can still escape the tree
|
||||
// through a symlink, so probe it and apply the ordinary boundary
|
||||
// decision to whatever it resolves to (#645).
|
||||
const probed = this.probeBareToken(token, base);
|
||||
if (probed) this.collectIfExternal(probed.path, seen, externalPaths);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Unknown effective directory: a relative candidate could resolve
|
||||
// anywhere, so flag it conservatively (resolved against the baked cwd
|
||||
// only for a display path). Absolute / `~` candidates are base-independent
|
||||
// below.
|
||||
if (base.kind === "unknown" && this.isRelativeCandidate(candidate)) {
|
||||
const accessPath = this.normalizer.forPath(candidate);
|
||||
const canonical = accessPath.boundaryValue();
|
||||
if (canonical && !isSafeSystemPath(canonical) && !seen.has(canonical)) {
|
||||
seen.add(canonical);
|
||||
externalPaths.push(accessPath);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const resolveBase =
|
||||
base.kind === "known"
|
||||
? this.normalizer.resolveBase(base.offset)
|
||||
: undefined;
|
||||
this.collectIfExternal(
|
||||
this.normalizer.forBashToken(candidate, { resolveBase }),
|
||||
seen,
|
||||
externalPaths,
|
||||
);
|
||||
}
|
||||
|
||||
return externalPaths;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record `accessPath` when it resolves outside the working directory and has
|
||||
* not already been collected.
|
||||
*
|
||||
* The boundary decision and dedup identity use the canonical
|
||||
* (symlink-resolved) form the {@link AccessPath} already derived, while the
|
||||
* stored value keeps the lexical form so config patterns match the path as
|
||||
* the user typed it (#418). A win32 device path preserves `/dev/null` as its
|
||||
* boundary value, so `isBoundaryOutsideWorkingDirectory` reaches the
|
||||
* safe-path exclusion (#533).
|
||||
*
|
||||
* A literal-only bash token (a win32 non-mount POSIX absolute like `/tmp`)
|
||||
* has no canonical form; it is foreign to the win32 cwd, so it is always
|
||||
* external. Its lexical value is the dedup identity so two distinct
|
||||
* literal-only paths do not collapse (#533).
|
||||
*/
|
||||
private collectIfExternal(
|
||||
accessPath: AccessPath,
|
||||
seen: Set<string>,
|
||||
out: AccessPath[],
|
||||
): void {
|
||||
const lexical = accessPath.value();
|
||||
if (!lexical) return;
|
||||
const canonical = accessPath.boundaryValue();
|
||||
const isExternal = canonical
|
||||
? this.normalizer.isBoundaryOutsideWorkingDirectory(canonical)
|
||||
: true;
|
||||
const dedupKey = canonical || lexical;
|
||||
if (isExternal && !seen.has(dedupKey)) {
|
||||
seen.add(dedupKey);
|
||||
out.push(accessPath);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Project the collected candidates into rule candidates with their cd-aware
|
||||
* policy lookup values.
|
||||
*
|
||||
* Filters candidates through the broad path classifier
|
||||
* (`classifyTokenAsRuleCandidate`), falling back to {@link probeBareToken}
|
||||
* for a bare token the broad classifier rejects for shape — admitted only
|
||||
* when it names an existing filesystem entry (#645).
|
||||
* On win32 the broad classifier is told to treat a backslash as a path
|
||||
* separator, so a backslash-relative token (`dir\file`) is recognized as a
|
||||
* rule candidate the same as its forward-slash equivalent (#520); on POSIX
|
||||
* `\` is a legal filename character, so the token stays bare there.
|
||||
* Pairs each qualifying token with its set of policy values (absolute +
|
||||
* project-relative + raw).
|
||||
* A token after a non-literal `cd` keeps only its literal value so no
|
||||
* spurious absolute rule can match (#393).
|
||||
*/
|
||||
private projectRuleCandidates(
|
||||
candidates: readonly PathCandidate[],
|
||||
): BashPathRuleCandidate[] {
|
||||
const seen = new Set<string>();
|
||||
const result: BashPathRuleCandidate[] = [];
|
||||
|
||||
for (const { token, base } of candidates) {
|
||||
const shaped = classifyTokenAsRuleCandidate(
|
||||
token,
|
||||
this.normalizer.flavor,
|
||||
);
|
||||
const candidate =
|
||||
shaped === null
|
||||
? this.probeBareToken(token, base)
|
||||
: { token: shaped, path: this.buildRuleCandidatePath(shaped, base) };
|
||||
if (!candidate) continue;
|
||||
|
||||
const matchValues = candidate.path.matchValues();
|
||||
if (matchValues.length === 0) continue;
|
||||
|
||||
const key = matchValues.join("\0");
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
result.push(candidate);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Promote a bare token the shape gates rejected, when it names an existing
|
||||
* filesystem entry — the existence probe (ADR 0009, #645).
|
||||
*
|
||||
* Most bash argument tokens are not paths (`status`, `build`, `main`), so a
|
||||
* bare token is admitted only when the filesystem confirms it names something
|
||||
* real. Candidacy therefore comes from the filesystem and never from the
|
||||
* ruleset, which keeps the classifiers pure and lets a symlink be matched by
|
||||
* rules naming its *target* — the case raw-token matching could not see.
|
||||
*
|
||||
* Returns `null` when the token's shape rules out a path, when the effective
|
||||
* base is unknown (no concrete directory to resolve against, so the token
|
||||
* stays unpromoted per #393 conservatism), or when nothing exists at the
|
||||
* resolved location.
|
||||
*
|
||||
* Shared by both projections so a promoted token is identical whether it is
|
||||
* being matched against `path` rules or tested against the cwd boundary.
|
||||
*/
|
||||
private probeBareToken(
|
||||
token: string,
|
||||
base: EffectiveBase,
|
||||
): BashPathRuleCandidate | null {
|
||||
const bare = classifyBareTokenCandidate(token);
|
||||
if (bare === null) return null;
|
||||
if (base.kind !== "known") return null;
|
||||
|
||||
const path = this.normalizer.forBashToken(bare, {
|
||||
resolveBase: this.normalizer.resolveBase(base.offset),
|
||||
});
|
||||
const lexical = path.value();
|
||||
if (!lexical || !this.normalizer.entryExists(lexical)) return null;
|
||||
return { token: bare, path };
|
||||
}
|
||||
|
||||
private buildRuleCandidatePath(
|
||||
candidate: string,
|
||||
base: EffectiveBase,
|
||||
): AccessPath {
|
||||
// An unknown base + relative candidate stays literal-only: a resolved
|
||||
// absolute or canonical alias would resolve against the wrong directory and
|
||||
// could spuriously match a rule (#393).
|
||||
if (base.kind === "unknown" && this.isRelativeCandidate(candidate)) {
|
||||
return this.normalizer.forLiteral(normalizePathPolicyLiteral(candidate));
|
||||
}
|
||||
|
||||
const resolveBase =
|
||||
base.kind === "known"
|
||||
? this.normalizer.resolveBase(base.offset)
|
||||
: undefined;
|
||||
return this.normalizer.forBashToken(candidate, { resolveBase });
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a path candidate is relative (resolved against the effective
|
||||
* directory) rather than absolute or home-relative (`~…`), which are
|
||||
* base-independent.
|
||||
*
|
||||
* Delegates the absoluteness decision to the platform-aware `PathNormalizer`
|
||||
* rather than a POSIX-only `startsWith("/")` check, so Windows drive-letter
|
||||
* paths (`C:/…`, `C:\…`) are correctly treated as absolute on win32 and as
|
||||
* relative on POSIX (where they denote an in-CWD path).
|
||||
*/
|
||||
private isRelativeCandidate(candidate: string): boolean {
|
||||
return !this.normalizer.isAbsolute(candidate) && !candidate.startsWith("~");
|
||||
}
|
||||
}
|
||||
|
||||
// ── Pure AST/string helpers ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* True when the statement at `index` is immediately followed by the background
|
||||
* operator (`&`) — distinct from the `&&` / `||` / `;` current-shell
|
||||
* separators.
|
||||
*/
|
||||
function isBackgrounded(seqNode: TSNode, index: number): boolean {
|
||||
const next = seqNode.child(index + 1);
|
||||
if (!next || next.isNamed) return false;
|
||||
return next.type === "&";
|
||||
}
|
||||
|
||||
function tagTokens(
|
||||
tokens: readonly string[],
|
||||
base: EffectiveBase,
|
||||
out: PathCandidate[],
|
||||
): void {
|
||||
for (const token of tokens) out.push({ token, base });
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the literal target of a `cd` command, or `null` when the first
|
||||
* argument is not a static literal (contains an expansion or command
|
||||
* substitution) or cannot be resolved against the working directory (`cd -`,
|
||||
* `cd ~…`, bare `cd`).
|
||||
*/
|
||||
function cdLiteralTarget(commandNode: TSNode): string | null {
|
||||
for (let i = 0; i < commandNode.childCount; i++) {
|
||||
const child = commandNode.child(i);
|
||||
if (!child) continue;
|
||||
if (child.type === "command_name" || child.type === "variable_assignment")
|
||||
continue;
|
||||
if (!child.isNamed) continue;
|
||||
// Skip the `--` end-of-flags marker; the next argument is the target.
|
||||
if (child.type === "word" && child.text === "--") continue;
|
||||
if (!ARG_NODE_TYPES.has(child.type)) return null;
|
||||
return literalTextOf(child);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The literal string value of an argument node, or `null` when it contains a
|
||||
* variable expansion / command substitution or is a non-resolvable `cd`
|
||||
* destination (`-`, `~…`).
|
||||
*/
|
||||
function literalTextOf(node: TSNode): string | null {
|
||||
switch (node.type) {
|
||||
case "word": {
|
||||
const text = node.text;
|
||||
if (text === "-" || text.startsWith("~")) return null;
|
||||
return text;
|
||||
}
|
||||
case "raw_string": {
|
||||
const text = node.text;
|
||||
return text.length >= 2 && text.startsWith("'") && text.endsWith("'")
|
||||
? text.slice(1, -1)
|
||||
: text;
|
||||
}
|
||||
case "concatenation": {
|
||||
let result = "";
|
||||
for (let i = 0; i < node.childCount; i++) {
|
||||
const child = node.child(i);
|
||||
if (!child) continue;
|
||||
const part = literalTextOf(child);
|
||||
if (part === null) return null;
|
||||
result += part;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
case "string": {
|
||||
let result = "";
|
||||
for (let i = 0; i < node.childCount; i++) {
|
||||
const child = node.child(i);
|
||||
if (!child) continue;
|
||||
if (child.type === '"') continue;
|
||||
if (child.type !== "string_content") return null;
|
||||
result += child.text;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
import {
|
||||
EXECUTION_HOST_TYPES,
|
||||
forEachNestedExecution,
|
||||
} from "#src/access-intent/bash/nested-execution";
|
||||
import type { TSNode } from "#src/access-intent/bash/parser";
|
||||
import {
|
||||
type CommandWord,
|
||||
classifyWrapperWords,
|
||||
executedUnitOf,
|
||||
type WrapperKind,
|
||||
} from "#src/access-intent/bash/wrapper-analysis";
|
||||
import type { BashCommandContext } from "#src/types";
|
||||
|
||||
export type { WrapperKind } from "#src/access-intent/bash/wrapper-analysis";
|
||||
|
||||
// ── Command type ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* One command-pattern unit of a parsed bash program.
|
||||
*
|
||||
* Minimal by design — `text` is the simple-command (or whole compound
|
||||
* statement) string matched against the bash rules.
|
||||
* The type is the stable extension point: #306 adds an execution `context`,
|
||||
* #307 adds per-command path candidates and an effective working directory.
|
||||
*/
|
||||
export interface BashCommand {
|
||||
readonly text: string;
|
||||
/**
|
||||
* Execution context for a nested command (substitution or subshell); absent
|
||||
* for a current-shell (top-level) command.
|
||||
*/
|
||||
readonly context?: BashCommandContext;
|
||||
/**
|
||||
* Set when this unit is a floored indirection wrapper; its decision is floored
|
||||
* to at least `ask` so the wrapped command cannot ride a permissive `allow`.
|
||||
* Absent for an ordinary command.
|
||||
*/
|
||||
readonly wrapperKind?: WrapperKind;
|
||||
/**
|
||||
* The command this wrapper unit actually runs (#713). Display-only — it is
|
||||
* never gated on its own, so the wrapper floor still applies. Absent for an
|
||||
* ordinary command, and for a wrapper whose inner command cannot be
|
||||
* established.
|
||||
*/
|
||||
readonly executedUnit?: string;
|
||||
}
|
||||
|
||||
// ── Command enumeration ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Container node types descended into when enumerating command units.
|
||||
*/
|
||||
const COMMAND_ENUM_DESCEND = new Set([
|
||||
"program",
|
||||
"list",
|
||||
"pipeline",
|
||||
"redirected_statement",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Named node types abandoned during command enumeration: they are neither
|
||||
* commands nor able to host one, so nothing in their subtree ever runs.
|
||||
*
|
||||
* A redirect and a heredoc body are deliberately NOT listed here. Neither is a
|
||||
* command, but each can host a substitution that really executes, so both are
|
||||
* {@link EXECUTION_HOST_TYPES} members instead — conflating the two questions
|
||||
* ("is this a command?" and "can this host one?") is the bypass #741 fixed.
|
||||
*
|
||||
* Anonymous tokens (chain operators `&&`/`;`/`|`, substitution and subshell
|
||||
* delimiters `$(`/`)`/`` ` ``/`(`) are filtered by the `isNamed` guard, not
|
||||
* listed here.
|
||||
*/
|
||||
const COMMAND_ENUM_SKIP = new Set(["comment", "heredoc_end"]);
|
||||
|
||||
/**
|
||||
* Enumerate the command units of a bash program, in source order.
|
||||
*
|
||||
* Descends container nodes (`program`, `list`, `pipeline`,
|
||||
* `redirected_statement`) and emits each `command` node whole.
|
||||
* Additionally descends into the three nested execution contexts — command
|
||||
* substitution (`$(…)`, backticks), process substitution (`<(…)`/`>(…)`), and
|
||||
* subshells (`( … )`) — emitting each inner command as its own unit *in
|
||||
* addition to* the enclosing command, since those inner commands really execute
|
||||
* (#306).
|
||||
* Control-flow bodies and `{ … }` brace groups are emitted whole without
|
||||
* descending (deferred).
|
||||
*
|
||||
* The enclosing command/subshell is always still emitted whole, so adding the
|
||||
* nested units can only ever produce a more-restrictive decision, never weaker.
|
||||
*
|
||||
* Each emitted command unit has any leading `variable_assignment` prefix
|
||||
* stripped (so an env-var prefix cannot defeat a command-pattern rule), and a
|
||||
* wrapper unit (`bash -c`/`eval`, or an indirection wrapper such as `sudo`) is
|
||||
* tagged with a {@link WrapperKind} so its decision is later floored to `ask`.
|
||||
*/
|
||||
export function collectCommands(node: TSNode): BashCommand[] {
|
||||
const out: BashCommand[] = [];
|
||||
collectCommandsInto(node, undefined, out);
|
||||
return out;
|
||||
}
|
||||
|
||||
function collectCommandsInto(
|
||||
node: TSNode,
|
||||
context: BashCommandContext | undefined,
|
||||
out: BashCommand[],
|
||||
): void {
|
||||
// Anonymous tokens (operators `&&`/`;`/`|`, delimiters `$(`/`)`/`` ` ``/`(`)
|
||||
// carry no command.
|
||||
if (!node.isNamed) return;
|
||||
if (COMMAND_ENUM_SKIP.has(node.type)) return;
|
||||
|
||||
if (node.type === "command") {
|
||||
out.push(makeCommandUnit(node, context));
|
||||
// A command's text already contains any substitution; descend its subtree
|
||||
// to ALSO emit the inner commands of command/process substitutions.
|
||||
collectHostedCommands(node, out);
|
||||
return;
|
||||
}
|
||||
|
||||
if (EXECUTION_HOST_TYPES.has(node.type)) {
|
||||
// Not a command itself, but its subtree can host one that really runs
|
||||
// (`> $(rm x)`, `< <(rm c)`). Emit only what it hosts (#741).
|
||||
collectHostedCommands(node, out);
|
||||
return;
|
||||
}
|
||||
|
||||
if (node.type === "subshell") {
|
||||
out.push(makeUnit(node.text, context)); // never-weaker whole emit
|
||||
descendCommandChildren(node, "subshell", out);
|
||||
return;
|
||||
}
|
||||
|
||||
if (COMMAND_ENUM_DESCEND.has(node.type)) {
|
||||
descendCommandChildren(node, context, out);
|
||||
return;
|
||||
}
|
||||
|
||||
// Any other named statement (compound_statement `{ … }`, if/while/for/case,
|
||||
// function_definition): emit whole, do not descend — deferred (#306).
|
||||
out.push(makeUnit(node.text, context));
|
||||
}
|
||||
|
||||
function makeUnit(
|
||||
text: string,
|
||||
context: BashCommandContext | undefined,
|
||||
wrapperKind?: WrapperKind,
|
||||
executedUnit?: string,
|
||||
): BashCommand {
|
||||
const unit: BashCommand = context ? { text, context } : { text };
|
||||
const flagged = wrapperKind ? { ...unit, wrapperKind } : unit;
|
||||
return executedUnit === undefined ? flagged : { ...flagged, executedUnit };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the unit for a `command` node, reading its words once to answer both
|
||||
* wrapper questions: whether the unit is floored, and what it actually runs.
|
||||
*/
|
||||
function makeCommandUnit(
|
||||
node: TSNode,
|
||||
context: BashCommandContext | undefined,
|
||||
): BashCommand {
|
||||
const text = commandUnitText(node);
|
||||
const words = readCommandWords(node);
|
||||
return makeUnit(
|
||||
text,
|
||||
context,
|
||||
classifyWrapperWords(words),
|
||||
executedUnitOf(text, words) ?? undefined,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A `command` node's words — its `command_name` followed by its arguments — each
|
||||
* carrying its offset into the unit text `commandUnitText` produces.
|
||||
*
|
||||
* A leading `variable_assignment` prefix is skipped (matching
|
||||
* `commandUnitText`), so offsets are relative to the `command_name`. An empty
|
||||
* list means a pure assignment with no `command_name`.
|
||||
*/
|
||||
function readCommandWords(node: TSNode): CommandWord[] {
|
||||
const words: CommandWord[] = [];
|
||||
let unitStart: number | undefined;
|
||||
for (let i = 0; i < node.childCount; i++) {
|
||||
const child = node.child(i);
|
||||
if (!child?.isNamed) continue;
|
||||
if (child.type === "variable_assignment") continue;
|
||||
unitStart ??= child.startIndex;
|
||||
words.push({ text: child.text, offset: child.startIndex - unitStart });
|
||||
}
|
||||
return words;
|
||||
}
|
||||
|
||||
/**
|
||||
* The command-pattern text of a `command` node, with any leading
|
||||
* `variable_assignment` prefix stripped.
|
||||
*
|
||||
* An env-var prefix (`AWS_PROFILE=prod aws …`, `PGPASSWORD=…`) is part of the
|
||||
* `command` node's text but must not defeat a rule that gates the underlying
|
||||
* command, so matching targets the text from the first non-assignment child
|
||||
* (the `command_name`) onward, sliced verbatim to preserve spacing. A pure
|
||||
* assignment (`FOO=bar`, no `command_name`) runs no command and is returned
|
||||
* unchanged.
|
||||
*/
|
||||
function commandUnitText(node: TSNode): string {
|
||||
for (let i = 0; i < node.childCount; i++) {
|
||||
const child = node.child(i);
|
||||
if (child?.isNamed && child.type !== "variable_assignment") {
|
||||
return node.text.slice(child.startIndex - node.startIndex);
|
||||
}
|
||||
}
|
||||
return node.text;
|
||||
}
|
||||
|
||||
function descendCommandChildren(
|
||||
node: TSNode,
|
||||
context: BashCommandContext | undefined,
|
||||
out: BashCommand[],
|
||||
): void {
|
||||
for (let i = 0; i < node.childCount; i++) {
|
||||
const child = node.child(i);
|
||||
if (child) collectCommandsInto(child, context, out);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enumerate the commands of every nested execution context in a subtree, each
|
||||
* tagged with the context it was found in.
|
||||
*
|
||||
* The traversal itself lives in `nested-execution.ts` so the bash path surface
|
||||
* shares one definition of what counts as a nested execution (#741); this
|
||||
* function supplies the command-surface interpretation of each one found.
|
||||
*/
|
||||
function collectHostedCommands(node: TSNode, out: BashCommand[]): void {
|
||||
forEachNestedExecution(node, (contextNode, context) => {
|
||||
descendCommandChildren(contextNode, context, out);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Pure shape classifier for a bash-command token on a win32 host, where Pi core
|
||||
* executes commands through Git Bash and POSIX-shaped absolute tokens carry
|
||||
* MSYS mount semantics rather than `node:path.win32` semantics.
|
||||
*
|
||||
* Consumed only by {@link PathNormalizer.forBashToken}; kept as a standalone
|
||||
* module so the shape knowledge is unit-testable in isolation (no filesystem,
|
||||
* no platform read).
|
||||
*/
|
||||
import { isSafeSystemPath } from "#src/safe-system-paths";
|
||||
|
||||
/**
|
||||
* The MSYS interpretation of a win32 bash token:
|
||||
*
|
||||
* - `device` — a safe MSYS runtime device (`/dev/null`, `/dev/std{in,out,err}`);
|
||||
* never a filesystem path.
|
||||
* - `drive-mount` — an MSYS drive mount (`/c/…`, `/d/…`); `windowsPath` is its
|
||||
* deterministic Windows equivalent (`C:\…`).
|
||||
* - `posix-absolute` — any other absolute POSIX path (`/tmp/foo`, `/usr/bin`);
|
||||
* its Windows target is install-dependent and not deterministically knowable,
|
||||
* so it is treated literally.
|
||||
* - `plain` — everything else (relative tokens, `~/…`, native Windows drive
|
||||
* paths); handled by ordinary win32 resolution.
|
||||
*/
|
||||
export type BashTokenShape =
|
||||
| { kind: "device" }
|
||||
| { kind: "drive-mount"; windowsPath: string }
|
||||
| { kind: "posix-absolute" }
|
||||
| { kind: "plain" };
|
||||
|
||||
/**
|
||||
* A single-letter first path segment identifies an MSYS drive mount: `/c`,
|
||||
* `/c/`, or `/c/rest`. A multi-letter first segment (`/dev`, `/tmp`) is not a
|
||||
* mount. The device set is checked before this pattern, so `/dev/*` never
|
||||
* reaches it.
|
||||
*/
|
||||
const MSYS_DRIVE_MOUNT_PATTERN = /^\/([a-zA-Z])(\/.*)?$/;
|
||||
|
||||
export function classifyWin32BashToken(token: string): BashTokenShape {
|
||||
if (isSafeSystemPath(token)) return { kind: "device" };
|
||||
|
||||
const driveMatch = MSYS_DRIVE_MOUNT_PATTERN.exec(token);
|
||||
if (driveMatch) {
|
||||
return {
|
||||
kind: "drive-mount",
|
||||
windowsPath: toWindowsDrivePath(driveMatch[1], driveMatch[2]),
|
||||
};
|
||||
}
|
||||
|
||||
if (token.startsWith("/")) return { kind: "posix-absolute" };
|
||||
|
||||
return { kind: "plain" };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the Windows equivalent of an MSYS drive mount: uppercase drive letter,
|
||||
* `:\`, and the remainder with `/` separators rewritten to `\`. A bare or
|
||||
* trailing-slash mount (`/c`, `/c/`) maps to the drive root (`C:\`).
|
||||
*/
|
||||
function toWindowsDrivePath(letter: string, rest: string | undefined): string {
|
||||
const drive = `${letter.toUpperCase()}:`;
|
||||
const tail = (rest ?? "").replace(/^\//, "").replaceAll("/", "\\");
|
||||
return tail ? `${drive}\\${tail}` : `${drive}\\`;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { TSNode } from "#src/access-intent/bash/parser";
|
||||
import type { BashCommandContext } from "#src/types";
|
||||
|
||||
/**
|
||||
* AST node types whose interior commands really execute when the shell runs the
|
||||
* program: command substitution (`$(…)`, backticks) and process substitution
|
||||
* (`<(…)`/`>(…)`).
|
||||
*
|
||||
* Subshells (`( … )`) are deliberately absent — a subshell is also a command
|
||||
* unit in its own right, so the command enumerator emits it whole and descends
|
||||
* it separately rather than treating it as a pure nesting wrapper.
|
||||
*
|
||||
* This map is the single vocabulary shared by the bash command surface and the
|
||||
* bash path surface, so the two cannot disagree about what counts as a nested
|
||||
* execution (#741).
|
||||
*/
|
||||
export const NESTED_EXECUTION_CONTEXTS: ReadonlyMap<
|
||||
string,
|
||||
BashCommandContext
|
||||
> = new Map([
|
||||
["command_substitution", "command_substitution"],
|
||||
["process_substitution", "process_substitution"],
|
||||
] satisfies [string, BashCommandContext][]);
|
||||
|
||||
/**
|
||||
* AST node types that are neither commands nor argument values themselves, but
|
||||
* whose subtree can host a nested execution context that really runs.
|
||||
*
|
||||
* A redirect destination is the motivating case: tree-sitter-bash parses
|
||||
* `echo hi > $(rm x)` with the `file_redirect` as a *sibling* of the `command`,
|
||||
* so a consumer that abandons the redirect never sees the substitution inside
|
||||
* it — the bypass #741 fixed.
|
||||
*
|
||||
* An interpolating heredoc body is the second case: `cat <<EOF` with `$(rm e)`
|
||||
* in the body really runs `rm e`. Quoting needs no special handling here —
|
||||
* tree-sitter-bash emits a `command_substitution` node under `heredoc_body`
|
||||
* only for a bare `<<EOF`, never for `<<'EOF'` or `<<"EOF"`, so the parser
|
||||
* already encodes the interpolation rule.
|
||||
*
|
||||
* Membership means "do not read this subtree's own text, but do descend it for
|
||||
* executions"; each consumer keeps its own handling of the destination tokens.
|
||||
*/
|
||||
export const EXECUTION_HOST_TYPES: ReadonlySet<string> = new Set([
|
||||
"file_redirect",
|
||||
"heredoc_redirect",
|
||||
"herestring_redirect",
|
||||
"heredoc_body",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Visit every nested execution context in `node`'s subtree, in source order.
|
||||
*
|
||||
* The walk does not descend *past* a context it finds: `visit` receives the
|
||||
* context node itself and decides how to treat its interior (the command
|
||||
* enumerator enumerates commands there; the path collector collects operand
|
||||
* tokens), which keeps recursion policy with the consumer that understands it.
|
||||
*
|
||||
* A substitution can nest under `command_name` (when the whole command is
|
||||
* `$(…)`), under an argument, inside a redirect destination, or inside an
|
||||
* interpolating heredoc body, so the entire subtree is searched.
|
||||
*/
|
||||
export function forEachNestedExecution(
|
||||
node: TSNode,
|
||||
visit: (contextNode: TSNode, context: BashCommandContext) => void,
|
||||
): void {
|
||||
for (let i = 0; i < node.childCount; i++) {
|
||||
const child = node.child(i);
|
||||
if (!child) continue;
|
||||
const context = NESTED_EXECUTION_CONTEXTS.get(child.type);
|
||||
if (context) {
|
||||
visit(child, context);
|
||||
} else {
|
||||
forEachNestedExecution(child, visit);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { TSNode } from "#src/access-intent/bash/parser";
|
||||
import { resolvePlainVariableExpansion } from "#src/access-intent/bash/shell-variable-expansion";
|
||||
|
||||
/**
|
||||
* Node types whose text content is never a command argument, so no path
|
||||
* candidate is ever read from it.
|
||||
*
|
||||
* This governs the subtree's *text*, not whether it is visited at all: an
|
||||
* interpolating `heredoc_body` is also an execution host, so it is still
|
||||
* descended for the commands it runs while its prose stays out of the path
|
||||
* surface (#741). See `EXECUTION_HOST_TYPES` in `nested-execution.ts`.
|
||||
*/
|
||||
export const SKIP_SUBTREE_TYPES = new Set([
|
||||
"heredoc_body",
|
||||
"heredoc_end",
|
||||
"comment",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Node types that represent argument values in the AST
|
||||
* (word, concatenation, single-quoted string, double-quoted string).
|
||||
*/
|
||||
export const ARG_NODE_TYPES = new Set([
|
||||
"word",
|
||||
"concatenation",
|
||||
"string",
|
||||
"raw_string",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Resolve the "shell value" of an argument node — the string the shell
|
||||
* would pass to the command after quote removal.
|
||||
*
|
||||
* - `word` → `.text` (already unquoted)
|
||||
* - `raw_string` → strip surrounding single quotes
|
||||
* - `string` → strip surrounding double quotes, concatenate children text
|
||||
* - `concatenation` → concatenate resolved children
|
||||
* - expansions → the resolved value of a plain `$HOME`/`$PWD` reference,
|
||||
* else `.text` (see `shell-variable-expansion.ts`)
|
||||
* - other → `.text` as fallback
|
||||
*/
|
||||
export function resolveNodeText(node: TSNode): string {
|
||||
switch (node.type) {
|
||||
case "word":
|
||||
return node.text;
|
||||
case "raw_string": {
|
||||
// Strip surrounding single quotes: 'content' → content
|
||||
const t = node.text;
|
||||
if (t.length >= 2 && t.startsWith("'") && t.endsWith("'")) {
|
||||
return t.slice(1, -1);
|
||||
}
|
||||
return t;
|
||||
}
|
||||
case "string": {
|
||||
// Double-quoted string: concatenate the resolved text of inner children,
|
||||
// skipping the quote-delimiter nodes (literal `"`).
|
||||
let result = "";
|
||||
for (let i = 0; i < node.childCount; i++) {
|
||||
const child = node.child(i);
|
||||
if (!child) continue;
|
||||
// Skip the literal `"` delimiters
|
||||
if (child.type === '"') continue;
|
||||
result += resolveNodeText(child);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
case "string_content":
|
||||
return node.text;
|
||||
case "simple_expansion":
|
||||
case "expansion":
|
||||
return resolvePlainVariableExpansion(node) ?? node.text;
|
||||
case "concatenation": {
|
||||
let result = "";
|
||||
for (let i = 0; i < node.childCount; i++) {
|
||||
const child = node.child(i);
|
||||
if (!child) continue;
|
||||
result += resolveNodeText(child);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
default:
|
||||
return node.text;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { createRequire } from "node:module";
|
||||
import { memoizeAsyncWithRetry } from "#src/async-cache";
|
||||
|
||||
/**
|
||||
* Minimal subset of web-tree-sitter's SyntaxNode used by the AST walker.
|
||||
* Defined locally so callers do not need to import web-tree-sitter types.
|
||||
*/
|
||||
export interface TSNode {
|
||||
readonly type: string;
|
||||
readonly text: string;
|
||||
/** Absolute byte offset of this node's start in the parsed source. */
|
||||
readonly startIndex: number;
|
||||
readonly childCount: number;
|
||||
/** False for anonymous tokens (operators, delimiters); true for named nodes. */
|
||||
readonly isNamed: boolean;
|
||||
child(index: number): TSNode | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal subset of web-tree-sitter's Parser used by this module.
|
||||
*/
|
||||
interface TSParser {
|
||||
parse(input: string): { rootNode: TSNode; delete(): void } | null;
|
||||
delete(): void;
|
||||
}
|
||||
|
||||
async function initParser(): Promise<TSParser> {
|
||||
// Use named imports — web-tree-sitter exports Parser as a named class.
|
||||
const { Parser, Language } = await import("web-tree-sitter");
|
||||
const req = createRequire(import.meta.url);
|
||||
const treeSitterWasm = req.resolve("web-tree-sitter/web-tree-sitter.wasm");
|
||||
await Parser.init({ locateFile: () => treeSitterWasm });
|
||||
|
||||
const parser = new Parser();
|
||||
const bashWasm = req.resolve("tree-sitter-bash/tree-sitter-bash.wasm");
|
||||
const bash = await Language.load(bashWasm);
|
||||
parser.setLanguage(bash);
|
||||
return parser;
|
||||
}
|
||||
|
||||
// Memoize on success but drop a rejected result so a transient init failure
|
||||
// (e.g. a slow WASM load) is retried on the next tool call instead of poisoning
|
||||
// the parser for the process lifetime.
|
||||
export const getParser = memoizeAsyncWithRetry(initParser);
|
||||
|
||||
// Resolved parser cached for synchronous access after warm-up. The tree-sitter
|
||||
// parser is stateless (parse is a pure function of its input), so caching it at
|
||||
// module scope is safe even though module state now persists across same-cwd
|
||||
// session switches.
|
||||
let warmedParser: TSParser | null = null;
|
||||
|
||||
/**
|
||||
* Warm the tree-sitter parser so {@link getWarmBashParser} can hand it out
|
||||
* synchronously. Triggered at `before_agent_start` (which precedes any tool
|
||||
* call) so the synchronous advisory bash path can decompose at gate parity
|
||||
* (#309).
|
||||
*
|
||||
* Best-effort and idempotent: it swallows a WASM init failure (the sync
|
||||
* accessor stays cold and callers fall back to whole-string matching), and it
|
||||
* returns immediately once warm, so calling it every turn is free.
|
||||
*/
|
||||
export async function warmBashParser(): Promise<void> {
|
||||
if (warmedParser) return;
|
||||
try {
|
||||
warmedParser = await getParser();
|
||||
} catch {
|
||||
// Leave cold → advisory bash queries fall back to whole-string matching.
|
||||
// getParser's own retry memoization re-attempts init on the next call.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The warmed parser for synchronous use, or `null` when it has not been warmed
|
||||
* yet (the pre-warm window). Callers that get `null` must degrade gracefully.
|
||||
*/
|
||||
export function getWarmBashParser(): TSParser | null {
|
||||
return warmedParser;
|
||||
}
|
||||
|
||||
/** Test-only: clear the warmed-parser cache so cold/warm cases are isolatable. */
|
||||
export function resetWarmBashParser(): void {
|
||||
warmedParser = null;
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import type { AccessPath } from "#src/access-intent/access-path";
|
||||
import {
|
||||
BashPathResolver,
|
||||
type BashPathRuleCandidate,
|
||||
} from "#src/access-intent/bash/bash-path-resolver";
|
||||
import {
|
||||
type BashCommand,
|
||||
collectCommands,
|
||||
} from "#src/access-intent/bash/command-enumeration";
|
||||
import { getParser } from "#src/access-intent/bash/parser";
|
||||
import type { PathNormalizer } from "#src/path-normalizer";
|
||||
|
||||
export type { BashCommand, BashPathRuleCandidate };
|
||||
|
||||
/**
|
||||
* A bash command parsed once into a born-ready representation.
|
||||
*
|
||||
* Parsing is the expensive step (tree-sitter WASM); `BashProgram` performs it
|
||||
* a single time and eagerly resolves all three typed slices so the bash
|
||||
* permission gates do not each re-parse or re-walk the command, and so the
|
||||
* slices are guaranteed to agree.
|
||||
*
|
||||
* Construct via the async `parse()` factory; the constructor is private.
|
||||
*/
|
||||
export class BashProgram {
|
||||
private constructor(
|
||||
private readonly sourceCommand: string,
|
||||
private readonly commandUnits: readonly BashCommand[],
|
||||
private readonly resolvedExternalPaths: readonly AccessPath[],
|
||||
private readonly resolvedRuleCandidates: readonly BashPathRuleCandidate[],
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Parse a bash command into a born-ready `BashProgram`.
|
||||
*
|
||||
* Uses tree-sitter-bash to build the full AST, enumerates command units and
|
||||
* walks path-candidate tokens once, then eagerly resolves all three slices
|
||||
* through the injected {@link PathNormalizer} (platform + cwd baked in).
|
||||
* Heredoc bodies, comments, and other non-argument content are skipped. An
|
||||
* unparseable command yields an empty program.
|
||||
*
|
||||
* A bare token (e.g. `id_rsa`, `outside-link`) enters both slices when it
|
||||
* names an existing filesystem entry — the existence probe the resolver owns
|
||||
* (ADR 0009, #645). No policy is consulted, so every caller gets identical
|
||||
* slices for a given command and working directory.
|
||||
*
|
||||
* `options.workdir`, when supplied (an aliased shell tool's working directory,
|
||||
* #574), seeds the initial effective base — as if the command were prefixed
|
||||
* with `cd <workdir>` — so relative tokens resolve against it, and the workdir
|
||||
* itself is flagged as external when it resolves outside the cwd.
|
||||
*/
|
||||
static async parse(
|
||||
command: string,
|
||||
normalizer: PathNormalizer,
|
||||
options?: { workdir?: string },
|
||||
): Promise<BashProgram> {
|
||||
const parser = await getParser();
|
||||
const tree = parser.parse(command);
|
||||
if (!tree) return new BashProgram(command, [], [], []);
|
||||
|
||||
try {
|
||||
const { externalPaths, ruleCandidates } = new BashPathResolver(
|
||||
normalizer,
|
||||
options?.workdir,
|
||||
).resolve(tree.rootNode);
|
||||
return new BashProgram(
|
||||
command,
|
||||
collectCommands(tree.rootNode),
|
||||
externalPaths,
|
||||
ruleCandidates,
|
||||
);
|
||||
} finally {
|
||||
tree.delete();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The source command string this program was parsed from.
|
||||
*
|
||||
* The bash gates read this for prompts, logs, and decision display instead of
|
||||
* receiving the command as a separate parameter — the program is the parsed
|
||||
* command, so it owns its source text (#574). Native `bash` and an aliased
|
||||
* shell tool alike reach the gates through this single collaborator.
|
||||
*/
|
||||
commandText(): string {
|
||||
return this.sourceCommand;
|
||||
}
|
||||
|
||||
/**
|
||||
* The top-level command-pattern units of the chain, in source order.
|
||||
*
|
||||
* Splits on the shell chain operators (`&&`, `||`, `;`, `|`, `&`, newlines);
|
||||
* quotes, command substitution, and subshells are respected by the parser and
|
||||
* are NOT split — a subshell or other compound statement is emitted whole.
|
||||
* Each unit has any leading `variable_assignment` prefix stripped, and a
|
||||
* wrapper unit (`bash -c`/`eval`, or an indirection wrapper such as `sudo`) is
|
||||
* tagged with a `wrapperKind` so its decision is floored to `ask`.
|
||||
* May be empty (e.g. an empty command or a comment-only line); callers fall
|
||||
* back to the whole command so the surface is never evaluated weaker than
|
||||
* before.
|
||||
*/
|
||||
commands(): BashCommand[] {
|
||||
return [...this.commandUnits];
|
||||
}
|
||||
|
||||
/**
|
||||
* Deduplicated paths that resolve outside `cwd`, as {@link AccessPath} value
|
||||
* objects holding both the lexical (as-typed) and canonical (symlink-resolved)
|
||||
* forms behind distinct accessors.
|
||||
*
|
||||
* Resolved eagerly at parse time through the `PathNormalizer` supplied to
|
||||
* `parse()` (platform + cwd baked in).
|
||||
* Use `.matchValues()` for `external_directory` pattern matching and
|
||||
* `.boundaryValue()` for containment checks; `.value()` for display and logs.
|
||||
*/
|
||||
externalPaths(): AccessPath[] {
|
||||
return [...this.resolvedExternalPaths];
|
||||
}
|
||||
|
||||
/**
|
||||
* Path-rule candidates paired with their policy lookup values.
|
||||
*
|
||||
* Resolved eagerly at parse time through the `PathNormalizer` supplied to
|
||||
* `parse()` (platform + cwd baked in).
|
||||
* Each token is resolved against the effective working directory in force at
|
||||
* the token's position (folding literal current-shell `cd` commands), while
|
||||
* raw and project-relative aliases are retained for backward-compatible
|
||||
* relative rules. A token after a non-literal `cd` keeps only its literal
|
||||
* value so no spurious absolute rule can match (#393).
|
||||
*/
|
||||
pathRuleCandidates(): BashPathRuleCandidate[] {
|
||||
return [...this.resolvedRuleCandidates];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Resolution of the shell variable references the bash path projection can
|
||||
* settle statically.
|
||||
*
|
||||
* Runs at token collection, upstream of classification: by the time a token
|
||||
* reaches `classifyTokenAsPathCandidate` it already carries the expanded path,
|
||||
* so `$HOME/x` is accepted by the ordinary absolute-shape branch and needs no
|
||||
* per-variable knowledge in the classifiers (#694). Keeping the vocabulary here
|
||||
* — rather than teaching each classifier a `$HOME` prefix — is what stops the
|
||||
* two from drifting apart, which is the defect this module closes.
|
||||
*
|
||||
* The resolvable set is deliberately tiny and closed. `HOME` is the spelling
|
||||
* `expandHomePath` already resolves for config patterns and path literals, so
|
||||
* resolving it here removes an inconsistency rather than widening the
|
||||
* determinism boundary; `PWD` reads no environment at all. Every other name
|
||||
* keeps its literal text, so ADR 0003's exclusion of ambient host state stands.
|
||||
* See `docs/decisions/0009-bash-path-projection-completeness-contract.md`.
|
||||
*/
|
||||
import { homedir } from "node:os";
|
||||
|
||||
import type { TSNode } from "#src/access-intent/bash/parser";
|
||||
|
||||
/**
|
||||
* The value of a plain `$NAME` / `${NAME}` reference, or `null` when the node
|
||||
* is not a plain reference or names a variable outside the resolvable set.
|
||||
*
|
||||
* Plainness is decided structurally, not by matching the node's text: a plain
|
||||
* reference carries exactly one `variable_name` child and nothing else but
|
||||
* delimiters. An operator form (`${HOME:-/tmp}`, `${#HOME}`, `${HOME%/*}`)
|
||||
* carries additional children and is therefore rejected without this module
|
||||
* needing to enumerate bash's expansion operators.
|
||||
*/
|
||||
export function resolvePlainVariableExpansion(node: TSNode): string | null {
|
||||
const name = plainVariableName(node);
|
||||
return name === null ? null : (RESOLVABLE_VARIABLES.get(name)?.() ?? null);
|
||||
}
|
||||
|
||||
/**
|
||||
* How each resolvable variable is spelled as a path.
|
||||
*
|
||||
* `PWD` resolves to the base-relative marker rather than a directory: the
|
||||
* shell's working directory at a given point *is* the projection's effective
|
||||
* base, which the resolver already applies via `resolveBase`. Handing back `.`
|
||||
* therefore lands `$PWD/x` on the same footing as `./x` — correct after any
|
||||
* `cd` folding, conservative under an unknown base (#393), and free of both a
|
||||
* threaded base parameter and a platform branch.
|
||||
*/
|
||||
const RESOLVABLE_VARIABLES: ReadonlyMap<string, () => string> = new Map([
|
||||
["HOME", homedir],
|
||||
["PWD", () => "."],
|
||||
]);
|
||||
|
||||
/** Node types that delimit an expansion without altering what it evaluates to. */
|
||||
const EXPANSION_DELIMITERS: ReadonlySet<string> = new Set(["$", "${", "}"]);
|
||||
|
||||
/**
|
||||
* The variable a node plainly references, or `null` when it references none —
|
||||
* because it has no `variable_name` child, has more than one, or carries a
|
||||
* child that is neither the name nor a delimiter (an expansion operator and its
|
||||
* operand, or an assignment's `=` and value).
|
||||
*/
|
||||
function plainVariableName(node: TSNode): string | null {
|
||||
let name: string | null = null;
|
||||
|
||||
for (let i = 0; i < node.childCount; i++) {
|
||||
const child = node.child(i);
|
||||
if (!child) continue;
|
||||
if (child.type === "variable_name") {
|
||||
if (name !== null) return null;
|
||||
name = child.text;
|
||||
continue;
|
||||
}
|
||||
if (!EXPANSION_DELIMITERS.has(child.type)) return null;
|
||||
}
|
||||
|
||||
return name;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import {
|
||||
type BashCommand,
|
||||
collectCommands,
|
||||
} from "#src/access-intent/bash/command-enumeration";
|
||||
import { getWarmBashParser } from "#src/access-intent/bash/parser";
|
||||
|
||||
/**
|
||||
* Synchronously enumerate the command-pattern units of a bash command using the
|
||||
* warmed tree-sitter parser.
|
||||
*
|
||||
* Returns `null` when the parser has not been warmed yet (the pre-warm window),
|
||||
* so the caller can fall back to whole-string matching rather than block. Once
|
||||
* warm it mirrors the enumeration the gate performs (`BashProgram.commands()`):
|
||||
* chains split, nested substitutions/subshells descend, opaque wrappers flagged
|
||||
* (#306). Only the command-pattern surface is produced — no path slices, so no
|
||||
* `PathNormalizer` is needed.
|
||||
*
|
||||
* An unparseable command yields an empty array (the caller's decompose path
|
||||
* fails it closed via `resolveBashCommandCheck`, #452).
|
||||
*/
|
||||
export function parseBashCommandsSync(command: string): BashCommand[] | null {
|
||||
const parser = getWarmBashParser();
|
||||
if (!parser) return null;
|
||||
const tree = parser.parse(command);
|
||||
if (!tree) return [];
|
||||
try {
|
||||
return collectCommands(tree.rootNode);
|
||||
} finally {
|
||||
tree.delete();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* Pure, synchronous token-classification helpers for bash path extraction.
|
||||
*
|
||||
* Exports three classifiers consumed by `bash-path-resolver.ts`:
|
||||
* - `classifyTokenAsPathCandidate` — strict gate for the external-directory guard.
|
||||
* - `classifyTokenAsRuleCandidate` — broader gate for cross-cutting `path` rules.
|
||||
* - `classifyBareTokenCandidate` — prelude-only gate for a bare token (e.g.
|
||||
* `id_rsa`, `outside-link`) that `classifyTokenAsRuleCandidate` rejects for
|
||||
* shape. It answers only "is this shape capable of naming a path?"; whether
|
||||
* it *does* name one is settled by the resolver's existence probe (#645).
|
||||
*
|
||||
* Token classification is three-valued: definitely-path (shape), definitely-not
|
||||
* (prelude), and unknown (a bare word). These functions own the first two; the
|
||||
* third is resolved against the filesystem rather than against policy, so no
|
||||
* classifier here consults the ruleset — see
|
||||
* `docs/decisions/0009-bash-path-projection-completeness-contract.md`.
|
||||
*
|
||||
* All three classifiers share the private `rejectNonPathToken` predicate that
|
||||
* captures the six rejection cases common to them (the production clone this
|
||||
* module was extracted to eliminate).
|
||||
*
|
||||
* Both `classifyTokenAsPathCandidate` and `classifyTokenAsRuleCandidate` recognize
|
||||
* Windows drive-letter absolute paths (`C:/…`, `C:\…`) unconditionally on all
|
||||
* platforms. On POSIX the token resolves as a real in-CWD relative path and is
|
||||
* gated by the `path` surface; on Windows the `PathNormalizer` routes it through
|
||||
* the absolute-path branch. Shape recognition is platform-independent string
|
||||
* matching; the platform-sensitive absoluteness decision belongs to `PathNormalizer`.
|
||||
*
|
||||
* `classifyTokenAsRuleCandidate` takes the resolved {@link PathFlavor}: a
|
||||
* backslash-relative token (`dir\file`, no leading `.`, no `/`, no `..`, not a
|
||||
* drive-letter absolute) is accepted as path-shaped only under the win32 flavor,
|
||||
* whose `hasPathSeparator` counts `\` as a separator (#520). This is the one
|
||||
* genuinely platform-sensitive shape rule the classifier owns — on POSIX `\` is
|
||||
* a legal filename character — and the flavor owns the bit, so the classifier
|
||||
* never reads `process.platform` itself.
|
||||
*/
|
||||
import type { PathFlavor } from "#src/path/path-flavor";
|
||||
|
||||
// ── Public classifiers ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Strict path-candidate classifier for the external-directory guard.
|
||||
*
|
||||
* Accepts tokens that unambiguously look like filesystem paths:
|
||||
* - Absolute paths (starting with `/`)
|
||||
* - Home-relative paths (starting with `~/`)
|
||||
* - Parent-traversal paths (containing `..`)
|
||||
* - Windows drive-letter absolute paths (`C:/…` or `C:\…`)
|
||||
*
|
||||
* Returns the raw token string if it qualifies, or `null` to skip.
|
||||
*/
|
||||
export function classifyTokenAsPathCandidate(token: string): string | null {
|
||||
if (rejectNonPathToken(token)) return null;
|
||||
|
||||
if (token.startsWith("/")) return token;
|
||||
if (token.startsWith("~/")) return token;
|
||||
if (token.includes("..")) return token;
|
||||
if (WINDOWS_DRIVE_PATH_PATTERN.test(token)) return token;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Broader token classifier for cross-cutting `path` permission rules.
|
||||
*
|
||||
* Accepts the same shapes as `classifyTokenAsPathCandidate`, plus:
|
||||
* - Dot-files and `./`-relative paths (starting with `.`)
|
||||
* - Any token carrying a path separator under `flavor` (`src/foo.ts`, and on
|
||||
* win32 the backslash-relative `dir\file`, #520) — `flavor.hasPathSeparator`
|
||||
* owns the platform bit (POSIX: `/` only; win32: `/` or `\`), so this
|
||||
* classifier never reads `process.platform`.
|
||||
* - Windows drive-letter absolute paths (`C:/…` or `C:\…`)
|
||||
*
|
||||
* The `~/foo` case is covered by `hasPathSeparator` — no separate `~/` branch needed.
|
||||
* The forward-slash drive form (`C:/…`) is also caught by `hasPathSeparator`, but the
|
||||
* explicit `WINDOWS_DRIVE_PATH_PATTERN` branch makes both separator forms first-class
|
||||
* and order-independent, and covers the backslash-only form (`D:\…`) which the POSIX
|
||||
* flavor's `hasPathSeparator` cannot reach.
|
||||
*
|
||||
* Does NOT require the strict "must start with `/` or `~/` or contain `..`"
|
||||
* gate that the external-directory classifier uses.
|
||||
*
|
||||
* Returns the raw token string if it qualifies, or `null` to skip.
|
||||
*/
|
||||
export function classifyTokenAsRuleCandidate(
|
||||
token: string,
|
||||
flavor: PathFlavor,
|
||||
): string | null {
|
||||
if (rejectNonPathToken(token)) return null;
|
||||
|
||||
if (token.startsWith(".")) return token;
|
||||
if (flavor.hasPathSeparator(token)) return token; // ~/ paths, relative paths with /, and win32 dir\file
|
||||
if (token.includes("..")) return token; // bare ".." (no slash)
|
||||
if (WINDOWS_DRIVE_PATH_PATTERN.test(token)) return token; // backslash-only drive form
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prelude-only classifier for a bare token (#645).
|
||||
*
|
||||
* A bare token (`id_rsa`, `outside-link`) has none of the shapes
|
||||
* `classifyTokenAsRuleCandidate` accepts, because most bash argument tokens are
|
||||
* not file paths (subcommands, branch names, search patterns). This classifier
|
||||
* answers the narrower question the existence probe needs: could this token's
|
||||
* *shape* name a path at all?
|
||||
*
|
||||
* It runs only the shared `rejectNonPathToken` prelude, so a flag,
|
||||
* env-assignment, URL, `@scope` token, or regex-shaped token is never a
|
||||
* candidate. Everything else is returned for the caller to probe.
|
||||
*
|
||||
* Deliberately consults no policy: candidacy is settled by the filesystem and
|
||||
* the decision by the ruleset, which keeps this module a pure shape function
|
||||
* (ADR 0009). It replaced the rule-driven promotion of #509, which matched a
|
||||
* token's *spelling* against `path` rules and so could never see that a
|
||||
* symlink's target is what a rule names.
|
||||
*
|
||||
* Returns the raw token string if it qualifies, or `null` to skip.
|
||||
*/
|
||||
export function classifyBareTokenCandidate(token: string): string | null {
|
||||
return rejectNonPathToken(token) ? null : token;
|
||||
}
|
||||
|
||||
// ── Private rejection predicate ────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Windows drive-letter absolute path: a single ASCII letter, a colon, then a
|
||||
* separator (`/` or `\`). Matches `C:/…` and `C:\…` but not drive-relative
|
||||
* `C:foo` (no separator) or multi-letter schemes (`https:`, `mailto:`).
|
||||
* Single-letter schemes with `//` (e.g. `c://x`) are already rejected by
|
||||
* `URL_PATTERN` before this pattern is tested.
|
||||
*/
|
||||
const WINDOWS_DRIVE_PATH_PATTERN = /^[a-zA-Z]:[/\\]/;
|
||||
|
||||
/**
|
||||
* URL pattern to skip tokens that look like URLs rather than paths.
|
||||
*/
|
||||
const URL_PATTERN = /^[a-z][a-z0-9+.-]*:\/\//i;
|
||||
|
||||
/**
|
||||
* Regex metacharacter sequences that are never found in real filesystem paths.
|
||||
* If a token contains any of these, it is almost certainly a regex pattern
|
||||
* (e.g. a grep argument) rather than a path.
|
||||
*/
|
||||
const REGEX_METACHAR_PATTERN = /\.\*|\.\+|\\\||\\\(|\\\)|\[.*?\]|\^\//;
|
||||
|
||||
/**
|
||||
* Shared rejection prelude: returns `true` when a token can never be a
|
||||
* filesystem path, regardless of which classifier is asking.
|
||||
*
|
||||
* Rejects: empty tokens, flags (leading `-`), env assignments (`FOO=/bar`),
|
||||
* URLs, `@scope/package` patterns, and regex metacharacter sequences.
|
||||
*
|
||||
* A bare `/` (or `//`, `///`) is NOT rejected: it denotes the filesystem root,
|
||||
* a deliberate external-directory access (`find /`, `ls /`), so it must reach
|
||||
* the path surfaces like any other absolute token (#583).
|
||||
*/
|
||||
function rejectNonPathToken(token: string): boolean {
|
||||
if (!token) return true;
|
||||
if (token.startsWith("-")) return true;
|
||||
|
||||
// Env assignment: = appears before any / (FOO=/bar is an assignment,
|
||||
// /foo=bar is not because the slash comes first).
|
||||
const eqIndex = token.indexOf("=");
|
||||
const slashIndex = token.indexOf("/");
|
||||
if (eqIndex !== -1 && (slashIndex === -1 || eqIndex < slashIndex))
|
||||
return true;
|
||||
|
||||
if (URL_PATTERN.test(token)) return true;
|
||||
|
||||
// @scope/package patterns (npm scoped packages) — but @/ is allowed through
|
||||
// since it looks like an absolute-rooted path, not an npm scope.
|
||||
if (token.startsWith("@") && !token.startsWith("@/")) return true;
|
||||
|
||||
if (REGEX_METACHAR_PATTERN.test(token)) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,433 @@
|
||||
import { basename } from "node:path";
|
||||
import {
|
||||
EXECUTION_HOST_TYPES,
|
||||
forEachNestedExecution,
|
||||
NESTED_EXECUTION_CONTEXTS,
|
||||
} from "#src/access-intent/bash/nested-execution";
|
||||
import {
|
||||
ARG_NODE_TYPES,
|
||||
resolveNodeText,
|
||||
SKIP_SUBTREE_TYPES,
|
||||
} from "#src/access-intent/bash/node-text";
|
||||
import type { TSNode } from "#src/access-intent/bash/parser";
|
||||
|
||||
// ── Public surface ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Recursively visit the AST and collect resolved text of nodes that
|
||||
* represent command arguments or redirect destinations.
|
||||
*
|
||||
* Reads no text from `heredoc_body`, `heredoc_end`, or `comment` subtrees, but
|
||||
* still descends an execution host for the commands it hosts — an interpolating
|
||||
* heredoc body runs its substitution even though its prose is never an operand
|
||||
* (#741). That is why the {@link EXECUTION_HOST_TYPES} branch sits above the
|
||||
* {@link SKIP_SUBTREE_TYPES} check: `heredoc_body` is in both sets, and the
|
||||
* host reading is the one that must win.
|
||||
*
|
||||
* For commands in `PATTERN_FIRST_COMMANDS`, uses position-based
|
||||
* argument skipping to avoid collecting inline patterns/scripts
|
||||
* as path candidates. For all other commands, collects all
|
||||
* arguments generically.
|
||||
*/
|
||||
export function collectPathCandidateTokens(node: TSNode): string[] {
|
||||
if (node.type === "command") return collectCommandTokens(node);
|
||||
if (node.type === "file_redirect") return collectRedirectTokens(node);
|
||||
if (EXECUTION_HOST_TYPES.has(node.type)) {
|
||||
return collectHostedExecutionTokens(node);
|
||||
}
|
||||
if (SKIP_SUBTREE_TYPES.has(node.type)) return [];
|
||||
|
||||
const tokens: string[] = [];
|
||||
for (let i = 0; i < node.childCount; i++) {
|
||||
const child = node.child(i);
|
||||
if (child) tokens.push(...collectPathCandidateTokens(child));
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* Select the collection strategy for a `command` node: pattern-first
|
||||
* commands use `collectPatternCommandTokens`; all others use
|
||||
* `collectGenericCommandTokens`.
|
||||
*/
|
||||
export function collectCommandTokens(node: TSNode): string[] {
|
||||
const commandName = extractCommandName(node);
|
||||
const config = commandName
|
||||
? PATTERN_FIRST_COMMANDS.get(commandName)
|
||||
: undefined;
|
||||
const tokens = config
|
||||
? collectPatternCommandTokens(node, config)
|
||||
: collectGenericCommandTokens(node);
|
||||
return [...tokens, ...collectEmbeddedOptionValues(node)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect redirect-destination tokens from a `file_redirect` node.
|
||||
*
|
||||
* The destination itself is an argument value (`> out.txt`), but it can also
|
||||
* host a command that really runs (`> $(cat /etc/shadow)`, `< <(cmd)`), whose
|
||||
* own operands are path candidates too — so each child is both read for its
|
||||
* text and searched for nested executions (#741).
|
||||
*
|
||||
* Both passes are needed: a substitution can be the destination outright, or be
|
||||
* concatenated into it (`> ${DIR}/$(cmd)`), and a `concatenation` is itself an
|
||||
* argument node.
|
||||
*/
|
||||
export function collectRedirectTokens(node: TSNode): string[] {
|
||||
const tokens: string[] = [];
|
||||
for (let i = 0; i < node.childCount; i++) {
|
||||
const child = node.child(i);
|
||||
if (!child) continue;
|
||||
if (ARG_NODE_TYPES.has(child.type)) {
|
||||
tokens.push(resolveNodeText(child));
|
||||
}
|
||||
tokens.push(...collectHostedExecutionTokens(child));
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect the path-candidate tokens of every command nested inside `node`'s
|
||||
* execution contexts, reading none of the host subtree's own text.
|
||||
*
|
||||
* This is what lets a heredoc body contribute its substitution's operands while
|
||||
* its prose stays out of the path surface entirely.
|
||||
*
|
||||
* `node` may be a context outright (`> $(cmd)`) or merely contain one
|
||||
* (`> ${DIR}/$(cmd)`); `forEachNestedExecution` searches strictly within a
|
||||
* subtree, so the first case is checked here.
|
||||
*/
|
||||
function collectHostedExecutionTokens(node: TSNode): string[] {
|
||||
if (NESTED_EXECUTION_CONTEXTS.has(node.type)) {
|
||||
return collectPathCandidateTokens(node);
|
||||
}
|
||||
const tokens: string[] = [];
|
||||
forEachNestedExecution(node, (contextNode) => {
|
||||
tokens.push(...collectPathCandidateTokens(contextNode));
|
||||
});
|
||||
return tokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the command name from a `command` node.
|
||||
* Returns the basename (e.g. `/usr/bin/sed` → `sed`), or undefined
|
||||
* if the command name cannot be determined (e.g. variable expansion).
|
||||
*/
|
||||
export function extractCommandName(node: TSNode): string | undefined {
|
||||
for (let i = 0; i < node.childCount; i++) {
|
||||
const child = node.child(i);
|
||||
if (!child) continue;
|
||||
if (child.type === "command_name") {
|
||||
const text = resolveNodeText(child);
|
||||
return text ? basename(text) : undefined;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// ── Private helpers and config ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* A long or short option carrying its value inline: one or two leading dashes,
|
||||
* a name containing no `=` or whitespace, then `=` and a non-empty value.
|
||||
* Only the first `=` separates, so `--opt=/tmp/a=b` yields `/tmp/a=b`.
|
||||
*/
|
||||
const OPTION_VALUE_PATTERN = /^-{1,2}[^=\s]+=(.+)$/;
|
||||
|
||||
/**
|
||||
* The values embedded in this command's `--opt=value` argument tokens.
|
||||
*
|
||||
* Read straight from the argument nodes rather than from the collected token
|
||||
* list, because a pattern-first command's collector classifies a flag and never
|
||||
* emits it — so `grep --file=/tmp/patterns` would otherwise lose the path.
|
||||
*
|
||||
* This is token *preprocessing*, not classification: the extracted value is
|
||||
* handed to the ordinary shape classifiers and existence probe, so
|
||||
* `--file=/tmp/patterns` reaches the path surfaces while `--format=json`
|
||||
* yields a bare `json` that names nothing and is dropped. Keeping the split
|
||||
* here is what lets the projection see option-embedded paths without per-command
|
||||
* option tables (ADR 0009, #645).
|
||||
*/
|
||||
function collectEmbeddedOptionValues(node: TSNode): string[] {
|
||||
const values: string[] = [];
|
||||
for (let i = 0; i < node.childCount; i++) {
|
||||
const child = node.child(i);
|
||||
if (!child) continue;
|
||||
if (child.type === "command_name" || child.type === "variable_assignment")
|
||||
continue;
|
||||
if (!ARG_NODE_TYPES.has(child.type)) continue;
|
||||
|
||||
const value = OPTION_VALUE_PATTERN.exec(resolveNodeText(child))?.[1];
|
||||
if (value !== undefined) values.push(value);
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
interface PatternCommandConfig {
|
||||
/** Flags that consume the next argument as a non-path value (pattern, separator, etc.) */
|
||||
readonly argConsumingFlags: ReadonlySet<string>;
|
||||
/** Flags that consume the next argument as a file path */
|
||||
readonly fileConsumingFlags: ReadonlySet<string>;
|
||||
/**
|
||||
* Number of leading positional arguments that are patterns/scripts, not paths.
|
||||
* Default: 1 (covers sed, awk, grep, rg).
|
||||
* sd uses 2 (FIND and REPLACE_WITH are both non-path positionals).
|
||||
*/
|
||||
readonly patternPositionals?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Commands whose first N positional arguments are inline patterns/scripts,
|
||||
* not filesystem paths. The map stores per-command flag configuration so
|
||||
* the walker can correctly identify which arguments are consumed by flags
|
||||
* vs. which are positional.
|
||||
*/
|
||||
const PATTERN_FIRST_COMMANDS: ReadonlyMap<string, PatternCommandConfig> =
|
||||
new Map([
|
||||
[
|
||||
"sed",
|
||||
{
|
||||
argConsumingFlags: new Set(["-e", "-i"]),
|
||||
fileConsumingFlags: new Set(["-f"]),
|
||||
},
|
||||
],
|
||||
[
|
||||
"awk",
|
||||
{
|
||||
argConsumingFlags: new Set(["-e", "-F", "-v"]),
|
||||
fileConsumingFlags: new Set(["-f"]),
|
||||
},
|
||||
],
|
||||
[
|
||||
"gawk",
|
||||
{
|
||||
argConsumingFlags: new Set(["-e", "-F", "-v"]),
|
||||
fileConsumingFlags: new Set(["-f"]),
|
||||
},
|
||||
],
|
||||
[
|
||||
"nawk",
|
||||
{
|
||||
argConsumingFlags: new Set(["-e", "-F", "-v"]),
|
||||
fileConsumingFlags: new Set(["-f"]),
|
||||
},
|
||||
],
|
||||
[
|
||||
"grep",
|
||||
{
|
||||
argConsumingFlags: new Set(["-e", "-A", "-B", "-C", "-m"]),
|
||||
fileConsumingFlags: new Set(["-f"]),
|
||||
},
|
||||
],
|
||||
[
|
||||
"egrep",
|
||||
{
|
||||
argConsumingFlags: new Set(["-e", "-A", "-B", "-C", "-m"]),
|
||||
fileConsumingFlags: new Set(["-f"]),
|
||||
},
|
||||
],
|
||||
[
|
||||
"fgrep",
|
||||
{
|
||||
argConsumingFlags: new Set(["-e", "-A", "-B", "-C", "-m"]),
|
||||
fileConsumingFlags: new Set(["-f"]),
|
||||
},
|
||||
],
|
||||
[
|
||||
"rg",
|
||||
{
|
||||
argConsumingFlags: new Set([
|
||||
"-e",
|
||||
"-A",
|
||||
"-B",
|
||||
"-C",
|
||||
"-m",
|
||||
"-g",
|
||||
"-t",
|
||||
"-T",
|
||||
"-j",
|
||||
"-M",
|
||||
"-r",
|
||||
"-E",
|
||||
]),
|
||||
fileConsumingFlags: new Set(["-f"]),
|
||||
},
|
||||
],
|
||||
[
|
||||
"sd",
|
||||
{
|
||||
argConsumingFlags: new Set(["-n", "-f"]),
|
||||
fileConsumingFlags: new Set([]),
|
||||
patternPositionals: 2,
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
/**
|
||||
* Describes what the walker should do when it encounters a flag word inside
|
||||
* a pattern-first command. Using a discriminated union lets the `switch` in
|
||||
* `collectPatternCommandTokens` narrow `nextArgAction` without a non-null
|
||||
* assertion (which would trigger the Biome/ESLint assertion conflict).
|
||||
*/
|
||||
type PatternCommandFlagDirective =
|
||||
| { kind: "end-of-flags" }
|
||||
| { kind: "regular-flag" }
|
||||
| {
|
||||
kind: "consume-arg";
|
||||
nextArgAction: "skip" | "extract";
|
||||
setsExplicitScript: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Classify a flag word from a pattern-first command into a directive that
|
||||
* tells the walker how to handle the flag and its following argument.
|
||||
*/
|
||||
function classifyPatternCommandFlag(
|
||||
text: string,
|
||||
config: PatternCommandConfig,
|
||||
): PatternCommandFlagDirective {
|
||||
if (text === "--") return { kind: "end-of-flags" };
|
||||
if (config.argConsumingFlags.has(text)) {
|
||||
return {
|
||||
kind: "consume-arg",
|
||||
nextArgAction: "skip",
|
||||
setsExplicitScript: text === "-e" || text === "-f",
|
||||
};
|
||||
}
|
||||
if (config.fileConsumingFlags.has(text)) {
|
||||
return {
|
||||
kind: "consume-arg",
|
||||
nextArgAction: "extract",
|
||||
setsExplicitScript: true,
|
||||
};
|
||||
}
|
||||
return { kind: "regular-flag" };
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect path-candidate tokens from a command known to have
|
||||
* pattern/script arguments in leading positional slots.
|
||||
*
|
||||
* Uses position-based skipping: the first N positional arguments
|
||||
* (where N = patternPositionals, default 1) are assumed to be
|
||||
* inline patterns/scripts and are skipped. Remaining positional
|
||||
* arguments are collected as path candidates.
|
||||
*
|
||||
* Flags listed in `argConsumingFlags` consume the next argument
|
||||
* (skipped). Flags in `fileConsumingFlags` consume the next
|
||||
* argument as a file path (collected). The flags `-e` and `-f`
|
||||
* additionally signal that an explicit script was provided via
|
||||
* flag, so no inline positional script is expected.
|
||||
*/
|
||||
function collectPatternCommandTokens(
|
||||
node: TSNode,
|
||||
config: PatternCommandConfig,
|
||||
): string[] {
|
||||
const patternPositionals = config.patternPositionals ?? 1;
|
||||
let hasExplicitScript = false;
|
||||
let positionalsSeen = 0;
|
||||
let nextArgAction: "skip" | "extract" | null = null;
|
||||
let pastEndOfFlags = false;
|
||||
const tokens: string[] = [];
|
||||
|
||||
for (let i = 0; i < node.childCount; i++) {
|
||||
const child = node.child(i);
|
||||
if (!child) continue;
|
||||
|
||||
// Skip command_name and variable_assignment nodes.
|
||||
if (child.type === "command_name" || child.type === "variable_assignment")
|
||||
continue;
|
||||
|
||||
// Only process argument-like nodes; recurse into others
|
||||
// (e.g. command_substitution) for nested commands.
|
||||
if (!ARG_NODE_TYPES.has(child.type)) {
|
||||
tokens.push(...collectPathCandidateTokens(child));
|
||||
continue;
|
||||
}
|
||||
|
||||
const text = resolveNodeText(child);
|
||||
|
||||
// Handle consumed argument from previous flag.
|
||||
if (nextArgAction === "skip") {
|
||||
nextArgAction = null;
|
||||
continue;
|
||||
}
|
||||
if (nextArgAction === "extract") {
|
||||
tokens.push(text);
|
||||
nextArgAction = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Flag detection (only before "--" end-of-flags marker).
|
||||
if (
|
||||
!pastEndOfFlags &&
|
||||
child.type === "word" &&
|
||||
text.startsWith("-") &&
|
||||
text.length > 1
|
||||
) {
|
||||
const directive = classifyPatternCommandFlag(text, config);
|
||||
switch (directive.kind) {
|
||||
case "end-of-flags":
|
||||
pastEndOfFlags = true;
|
||||
break;
|
||||
case "consume-arg":
|
||||
nextArgAction = directive.nextArgAction;
|
||||
if (directive.setsExplicitScript) hasExplicitScript = true;
|
||||
break;
|
||||
case "regular-flag":
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Positional argument.
|
||||
if (!hasExplicitScript && positionalsSeen < patternPositionals) {
|
||||
positionalsSeen++;
|
||||
continue; // Skip: this is an inline pattern/script.
|
||||
}
|
||||
|
||||
// File argument — collect as path candidate.
|
||||
tokens.push(text);
|
||||
}
|
||||
|
||||
return tokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect all argument tokens from a generic (non-pattern-first) command node,
|
||||
* skipping the command name and variable assignments.
|
||||
*/
|
||||
function collectGenericCommandTokens(node: TSNode): string[] {
|
||||
const tokens: string[] = [];
|
||||
let seenCommandName = false;
|
||||
|
||||
for (let i = 0; i < node.childCount; i++) {
|
||||
const child = node.child(i);
|
||||
if (!child) continue;
|
||||
|
||||
if (child.type === "command_name") {
|
||||
seenCommandName = true;
|
||||
continue;
|
||||
}
|
||||
// Skip variable_assignment nodes (FOO=/bar)
|
||||
if (child.type === "variable_assignment") continue;
|
||||
|
||||
// If there was no explicit command_name node, the first word-like
|
||||
// child is the command name itself — skip it.
|
||||
if (!seenCommandName && ARG_NODE_TYPES.has(child.type)) {
|
||||
seenCommandName = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Argument nodes: resolve their text and collect.
|
||||
if (ARG_NODE_TYPES.has(child.type)) {
|
||||
tokens.push(resolveNodeText(child));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Recurse into other children (e.g. command_substitution nested in args)
|
||||
tokens.push(...collectPathCandidateTokens(child));
|
||||
}
|
||||
|
||||
return tokens;
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
/**
|
||||
* Wrapper interpretation for a bash command unit: what kind of wrapper it is,
|
||||
* and — where it can be established — what it actually runs.
|
||||
*
|
||||
* Pure and word-based; the AST walk that produces the words lives in
|
||||
* `command-enumeration.ts`. Both questions live here together deliberately: the
|
||||
* shape that floors a unit to `ask` and the shape that names its inner command
|
||||
* must agree, and two classifiers over the same vocabulary would drift.
|
||||
*/
|
||||
|
||||
/** One word of a command unit: its text, and its offset into the unit's text. */
|
||||
export interface CommandWord {
|
||||
readonly text: string;
|
||||
readonly offset: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Why a command unit's decision is floored to at least `ask`.
|
||||
* `"opaque-payload"` — an inline-shell payload (`bash -c`/`eval`) whose inner
|
||||
* program is not re-parsed (#481).
|
||||
* `"indirection"` — a prefix/exec wrapper (`sudo`/`env`/`xargs`/`find -exec`/…)
|
||||
* whose inner command is a visible argument but is not gated on its own (#490).
|
||||
* The kind selects the audit sentinel; both floor identically.
|
||||
*/
|
||||
export type WrapperKind = "opaque-payload" | "indirection";
|
||||
|
||||
/**
|
||||
* Classify a command unit's words as a floored wrapper, or `undefined` for an
|
||||
* ordinary command. `words[0]` is the command name; a leading
|
||||
* `variable_assignment` prefix is already stripped by the caller. The command
|
||||
* name is matched on its basename, so `/bin/bash -c …` counts.
|
||||
*
|
||||
* `"opaque-payload"`: `eval`, or a shell (`bash`/`sh`/`dash`/`zsh`/`ksh`) with a
|
||||
* `-c` short-flag cluster (`-c`, `-ec`, `-xc`) — the inner program is a quoted
|
||||
* argument the enumerator does not re-parse (#481).
|
||||
*
|
||||
* `"indirection"`: an always-invoking prefix/exec wrapper
|
||||
* ({@link INDIRECTION_WRAPPER_NAMES}), or a search tool
|
||||
* ({@link EXEC_CONDITIONAL_WRAPPERS}, `find`/`fd`) carrying a per-result exec
|
||||
* flag — the inner command is a visible argument that a `<cmd> *` rule would
|
||||
* otherwise never match (#490). A bare `find`/`fd` search runs no subcommand and
|
||||
* is not flagged.
|
||||
*/
|
||||
export function classifyWrapperWords(
|
||||
words: readonly CommandWord[],
|
||||
): WrapperKind | undefined {
|
||||
const commandName = wrapperName(words);
|
||||
if (commandName === undefined) return undefined;
|
||||
const args = words.slice(1).map((word) => word.text);
|
||||
if (commandName === "eval") return "opaque-payload";
|
||||
if (SHELL_WRAPPER_NAMES.has(commandName) && hasShortFlagC(args)) {
|
||||
return "opaque-payload";
|
||||
}
|
||||
if (INDIRECTION_WRAPPER_NAMES.has(commandName)) return "indirection";
|
||||
if (execFlagIndex(commandName, args) !== -1) return "indirection";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// ── Wrapper vocabulary ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The command a wrapper unit actually runs, or `null` when it cannot be
|
||||
* established or adds nothing over the unit itself.
|
||||
*
|
||||
* Display-only (ADR 0011 §3.5, #713): the result is never gated and never
|
||||
* becomes a `BashCommand`, so the wrapper floor is untouched. Because it is
|
||||
* shown on a decision surface, the rule is to fail to `null` rather than to a
|
||||
* guess — an unrecognized option shape yields nothing rather than a remainder
|
||||
* that might name the wrong command.
|
||||
*
|
||||
* Nested wrappers unwrap to the innermost command (`sudo timeout 5 xargs grep
|
||||
* foo` → `grep foo`), bounded by {@link MAX_UNWRAP_DEPTH}.
|
||||
*/
|
||||
export function executedUnitOf(
|
||||
unitText: string,
|
||||
words: readonly CommandWord[],
|
||||
): string | null {
|
||||
let text = unitText;
|
||||
let current = words;
|
||||
|
||||
for (let depth = 0; depth < MAX_UNWRAP_DEPTH; depth++) {
|
||||
const kind = classifyWrapperWords(current);
|
||||
if (kind === undefined) break;
|
||||
|
||||
if (kind === "opaque-payload") {
|
||||
// The payload is an inner *program*, not a slice of this command line, so
|
||||
// it is unquoted and terminal — unwrapping it further would need a parse.
|
||||
return nothingNew(opaquePayload(current), unitText);
|
||||
}
|
||||
|
||||
const start = innerCommandIndex(current);
|
||||
if (start === -1 || start >= current.length) break;
|
||||
const end = execTerminatorIndex(current, start);
|
||||
text = sliceWords(text, current, start, end).trimEnd();
|
||||
current = rebase(current, start, end);
|
||||
}
|
||||
|
||||
return nothingNew(text, unitText);
|
||||
}
|
||||
|
||||
/** How many wrapper layers to unwrap before giving up. */
|
||||
const MAX_UNWRAP_DEPTH = 4;
|
||||
|
||||
/**
|
||||
* The extracted text, or `null` when it establishes nothing new — it is absent
|
||||
* or empty, it still begins with an option (so the inner command was never
|
||||
* reached), or it simply repeats the unit.
|
||||
*/
|
||||
function nothingNew(text: string | null, unitText: string): string | null {
|
||||
if (text === null || text === "" || text === unitText) return null;
|
||||
return text.startsWith("-") ? null : text;
|
||||
}
|
||||
|
||||
/** The inline-shell payload argument, unquoted; `null` when absent. */
|
||||
function opaquePayload(words: readonly CommandWord[]): string | null {
|
||||
const args = words.slice(1);
|
||||
// `eval` takes its program as the first argument (no `-c`, so the index is
|
||||
// -1); a shell takes it after the `-c` cluster.
|
||||
const flagIndex = shortFlagCIndex(args.map((word) => word.text));
|
||||
const payload = args[flagIndex + 1] as CommandWord | undefined;
|
||||
return payload === undefined ? null : unquote(payload.text);
|
||||
}
|
||||
|
||||
/** Strip one matching pair of surrounding quotes. */
|
||||
function unquote(text: string): string {
|
||||
const first = text.at(0);
|
||||
const quoted =
|
||||
(first === "'" || first === '"') &&
|
||||
text.length >= 2 &&
|
||||
text.endsWith(first);
|
||||
return quoted ? text.slice(1, -1) : text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Index of the word beginning the inner command, or `-1` when the wrapper's own
|
||||
* options run out first.
|
||||
*
|
||||
* Skips the wrapper name, environment assignments, options (consuming a
|
||||
* following value for the options in {@link VALUE_TAKING_FLAGS}), and a leading
|
||||
* operand for the wrappers that take one. An exec-conditional wrapper instead
|
||||
* starts immediately after its exec flag.
|
||||
*/
|
||||
function innerCommandIndex(words: readonly CommandWord[]): number {
|
||||
const name = wrapperName(words);
|
||||
if (name === undefined) return -1;
|
||||
|
||||
const argTexts = words.slice(1).map((word) => word.text);
|
||||
const execFlag = execFlagIndex(name, argTexts);
|
||||
if (execFlag !== -1) return execFlag + 2;
|
||||
|
||||
const valueTaking = VALUE_TAKING_FLAGS.get(name) ?? EMPTY_FLAGS;
|
||||
let operandPending = LEADING_OPERAND_WRAPPERS.has(name);
|
||||
let index = 1;
|
||||
|
||||
while (index < words.length) {
|
||||
const word = words[index].text;
|
||||
if (word === "--") return index + 1;
|
||||
if (isEnvironmentAssignment(word)) {
|
||||
index++;
|
||||
continue;
|
||||
}
|
||||
if (word.startsWith("-")) {
|
||||
index += valueTaking.has(word) ? 2 : 1;
|
||||
continue;
|
||||
}
|
||||
if (operandPending) {
|
||||
operandPending = false;
|
||||
index++;
|
||||
continue;
|
||||
}
|
||||
return index;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Index of an exec wrapper's `;`/`+` terminator, or `words.length` — the
|
||||
* terminator belongs to `find`, not to the command it runs.
|
||||
*/
|
||||
function execTerminatorIndex(
|
||||
words: readonly CommandWord[],
|
||||
start: number,
|
||||
): number {
|
||||
const terminator = words.findIndex(
|
||||
(word, index) =>
|
||||
index >= start && EXEC_TERMINATORS.has(word.text.replace(/^\\/, "")),
|
||||
);
|
||||
return terminator === -1 ? words.length : terminator;
|
||||
}
|
||||
|
||||
/** The unit text spanned by `words[start..end)`. */
|
||||
function sliceWords(
|
||||
unitText: string,
|
||||
words: readonly CommandWord[],
|
||||
start: number,
|
||||
end: number,
|
||||
): string {
|
||||
const from = words[start].offset;
|
||||
return end < words.length
|
||||
? unitText.slice(from, words[end].offset)
|
||||
: unitText.slice(from);
|
||||
}
|
||||
|
||||
/** `words[start..end)` with offsets rebased onto the sliced text. */
|
||||
function rebase(
|
||||
words: readonly CommandWord[],
|
||||
start: number,
|
||||
end: number,
|
||||
): CommandWord[] {
|
||||
const origin = words[start].offset;
|
||||
return words
|
||||
.slice(start, end)
|
||||
.map((word) => ({ text: word.text, offset: word.offset - origin }));
|
||||
}
|
||||
|
||||
/** True for a `NAME=value` environment prefix. */
|
||||
function isEnvironmentAssignment(word: string): boolean {
|
||||
return /^[A-Za-z_][A-Za-z0-9_]*=/.test(word);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shell command names whose `-c` flag introduces an opaque inline program.
|
||||
*/
|
||||
const SHELL_WRAPPER_NAMES = new Set(["bash", "sh", "dash", "zsh", "ksh"]);
|
||||
|
||||
/**
|
||||
* Indirection wrappers that always invoke a following command, so the wrapper
|
||||
* (not the inner command) is what a bash rule matches. Floored by command-name
|
||||
* basename alone. Extend this set to cover another always-invoking wrapper.
|
||||
*/
|
||||
const INDIRECTION_WRAPPER_NAMES = new Set([
|
||||
"sudo",
|
||||
"env",
|
||||
"xargs",
|
||||
"time",
|
||||
"nohup",
|
||||
"timeout",
|
||||
"nice",
|
||||
// Exec-capable rewrites and prefix wrappers surveyed in #575: parallelizers
|
||||
// (parallel/rust-parallel/rush), a sudo rewrite (doas), and prefix wrappers
|
||||
// (setsid/stdbuf/watch/flock) that all always invoke a following command.
|
||||
"parallel",
|
||||
"rust-parallel",
|
||||
"rush",
|
||||
"doas",
|
||||
"setsid",
|
||||
"stdbuf",
|
||||
"watch",
|
||||
"flock",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Search tools that invoke a command per result only when an exec flag is
|
||||
* present; a bare search runs no subcommand. Floored only when an argument
|
||||
* exactly matches one of the tool's exec flags. Extend by adding a tool with
|
||||
* its exec-flag set.
|
||||
*/
|
||||
const EXEC_CONDITIONAL_WRAPPERS = new Map<string, ReadonlySet<string>>([
|
||||
["find", new Set(["-exec", "-execdir", "-ok", "-okdir"])],
|
||||
["fd", new Set(["-x", "--exec", "-X", "--exec-batch"])],
|
||||
]);
|
||||
|
||||
/**
|
||||
* Curated per-wrapper options that consume the following word, so skipping a
|
||||
* wrapper's own arguments does not mistake an option's value for the inner
|
||||
* command. Attached forms (`-I{}`, `--user=root`) need no entry — they are one
|
||||
* word. Only the display-side extraction reads this, and a missing or wrong
|
||||
* entry yields `null` (see {@link executedUnitOf}), never a weaker gate.
|
||||
*/
|
||||
const VALUE_TAKING_FLAGS = new Map<string, ReadonlySet<string>>([
|
||||
["sudo", new Set(["-u", "-g", "-p", "-C", "-h", "-U", "-r", "-t"])],
|
||||
["doas", new Set(["-u", "-C"])],
|
||||
["env", new Set(["-u", "-C", "--unset", "--chdir"])],
|
||||
[
|
||||
"xargs",
|
||||
new Set(["-n", "-P", "-I", "-i", "-d", "-E", "-L", "-l", "-s", "-a"]),
|
||||
],
|
||||
["timeout", new Set(["-s", "-k", "--signal", "--kill-after"])],
|
||||
["nice", new Set(["-n", "--adjustment"])],
|
||||
["time", new Set(["-o", "-f", "--output", "--format"])],
|
||||
["stdbuf", new Set(["-i", "-o", "-e", "--input", "--output", "--error"])],
|
||||
["watch", new Set(["-n", "--interval"])],
|
||||
["flock", new Set(["-w", "-E", "--timeout", "--conflict-exit-code"])],
|
||||
]);
|
||||
|
||||
const EMPTY_FLAGS: ReadonlySet<string> = new Set<string>();
|
||||
|
||||
/**
|
||||
* Wrappers whose first bare word is an operand (a duration, a lock file) rather
|
||||
* than the start of the inner command.
|
||||
*/
|
||||
const LEADING_OPERAND_WRAPPERS = new Set(["timeout", "flock"]);
|
||||
|
||||
/** Words ending a `find -exec` clause; they belong to `find`, not its command. */
|
||||
const EXEC_TERMINATORS = new Set([";", "+"]);
|
||||
|
||||
// ── Shared helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
/** The wrapper's command-name basename, or `undefined` for an empty unit. */
|
||||
function wrapperName(words: readonly CommandWord[]): string | undefined {
|
||||
return words.length === 0 ? undefined : basename(words[0].text);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when an argument list has a short-flag cluster containing `c` before any
|
||||
* `--` end-of-options marker (`-c`, `-ec`, `-xc`) — the inline-shell payload
|
||||
* flag for `bash`/`sh`/`dash`/`zsh`/`ksh`.
|
||||
*/
|
||||
function hasShortFlagC(args: readonly string[]): boolean {
|
||||
return shortFlagCIndex(args) !== -1;
|
||||
}
|
||||
|
||||
/** Index within `args` of the `-c` short-flag cluster, or `-1`. */
|
||||
function shortFlagCIndex(args: readonly string[]): number {
|
||||
for (const [index, arg] of args.entries()) {
|
||||
if (arg === "--") return -1;
|
||||
if (arg.startsWith("-") && !arg.startsWith("--") && arg.includes("c")) {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/** Index within `args` of a matched per-result exec flag, or `-1`. */
|
||||
function execFlagIndex(commandName: string, args: readonly string[]): number {
|
||||
const execFlags = EXEC_CONDITIONAL_WRAPPERS.get(commandName);
|
||||
if (!execFlags) return -1;
|
||||
return args.findIndex((arg) => execFlags.has(arg));
|
||||
}
|
||||
|
||||
/** The final path segment of a command name (`/bin/bash` → `bash`). */
|
||||
function basename(name: string): string {
|
||||
const slash = name.lastIndexOf("/");
|
||||
return slash === -1 ? name : name.slice(slash + 1);
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import { stripBashCommentLines } from "#src/bash-arity";
|
||||
import type { PathNormalizer } from "#src/path-normalizer";
|
||||
import { getNonEmptyString, toRecord } from "#src/value-guards";
|
||||
import type { AccessIntent, ResolvedAccessIntent } from "./access-intent";
|
||||
import { createMcpPermissionTargets } from "./mcp-targets";
|
||||
import { PATH_SURFACES } from "./path-surfaces";
|
||||
import { classifyToolKind } from "./tool-kind";
|
||||
|
||||
/**
|
||||
* Build the {@link AccessIntent} an external policy query (the `Symbol.for()`
|
||||
* service and the event-bus RPC) feeds to the resolver from a `(surface, value)`
|
||||
* pair.
|
||||
*
|
||||
* For a path-shaped surface (`path`, `external_directory`, or a path-bearing
|
||||
* tool) carrying a non-empty value, it builds an `AccessPath` and emits an
|
||||
* `access-path` intent, so the resolver matches the lexical aliases ∪ canonical
|
||||
* (symlink-resolved) set — at parity with the gates (#486, #502). Every other
|
||||
* surface, and any value-less surface-level query, keeps the `tool` intent so
|
||||
* the manager's `normalizeInput` `["*"]` fallback is preserved.
|
||||
*/
|
||||
export function buildAccessIntentForSurface(
|
||||
surface: string,
|
||||
value: string | undefined,
|
||||
normalizer: PathNormalizer,
|
||||
agentName: string | undefined,
|
||||
): AccessIntent {
|
||||
const pathValue = getNonEmptyString(value);
|
||||
if (pathValue !== null && PATH_SURFACES.has(surface)) {
|
||||
return {
|
||||
kind: "access-path",
|
||||
surface,
|
||||
path: normalizer.forPath(pathValue),
|
||||
agentName,
|
||||
};
|
||||
}
|
||||
return {
|
||||
kind: "tool",
|
||||
surface,
|
||||
input: buildInputForSurface(surface, value),
|
||||
agentName,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a {@link ResolvedAccessIntent} directly from a forwarded request's
|
||||
* child-fixed match values (ADR 0008 §2), for the forwarded-serving wire
|
||||
* (#597).
|
||||
*
|
||||
* Unlike {@link buildAccessIntentForSurface}, this never touches a
|
||||
* `PathNormalizer` and never rebuilds an `AccessPath` — a path-shaped surface
|
||||
* gets a `path-values` intent carrying `matchValues` as-is (the values the
|
||||
* child already fixed), and every other surface gets a `tool` intent built
|
||||
* from its single portable value. `agentName` is always the requester's
|
||||
* `principal.agentName` (ADR 0008 §3, agent-scoped serving).
|
||||
*/
|
||||
export function buildResolvedIntentFromMatchValues(
|
||||
surface: string,
|
||||
matchValues: readonly string[],
|
||||
agentName: string,
|
||||
): ResolvedAccessIntent {
|
||||
if (PATH_SURFACES.has(surface)) {
|
||||
return {
|
||||
kind: "path-values",
|
||||
surface,
|
||||
values: [...matchValues],
|
||||
agentName,
|
||||
};
|
||||
}
|
||||
return {
|
||||
kind: "tool",
|
||||
surface,
|
||||
input: buildInputForSurface(surface, matchValues[0]),
|
||||
agentName,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Construct a surface-appropriate input object from a raw value string for the
|
||||
* `tool`-intent branch of {@link buildAccessIntentForSurface} (the non-path
|
||||
* surfaces and value-less path queries).
|
||||
*
|
||||
* This is the inverse of `normalizeInput()` — it builds the minimal input
|
||||
* object that the manager expects for a given surface, from a single string
|
||||
* value.
|
||||
*
|
||||
* Note: MCP inputs are complex (server name + tool name derivation). Callers
|
||||
* providing an MCP surface receive a best-effort policy evaluation using the
|
||||
* value as a pre-qualified target string. Pass the fully-qualified target
|
||||
* (e.g. "exa:search" or "exa") directly.
|
||||
*/
|
||||
function buildInputForSurface(
|
||||
surface: string,
|
||||
value: string | undefined,
|
||||
): unknown {
|
||||
const v = value ?? "";
|
||||
if (surface === "bash") return { command: v };
|
||||
if (surface === "skill") return { name: v };
|
||||
if (surface === "external_directory") return { path: v };
|
||||
// MCP and tool surfaces: normalizeInput handles them from the surface alone.
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Surface-normalized representation of a tool invocation used by
|
||||
* `checkPermission()` to feed a single `evaluateFirst()` call.
|
||||
*/
|
||||
export interface NormalizedInput {
|
||||
/** The permission surface for `evaluate()` (e.g. "bash", "mcp", "skill"). */
|
||||
surface: string;
|
||||
/**
|
||||
* Candidate lookup values in priority order (most-specific first).
|
||||
* Most surfaces produce a single-element array; MCP produces a
|
||||
* multi-candidate list derived from the invocation input.
|
||||
*/
|
||||
values: string[];
|
||||
/**
|
||||
* Surface-specific fields forwarded verbatim into `PermissionCheckResult`
|
||||
* (e.g. `{ command }` for bash, `{ target }` for mcp).
|
||||
*/
|
||||
resultExtras: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a raw tool invocation to the surface/values/extras triple needed by
|
||||
* `checkPermission()`.
|
||||
*
|
||||
* Handles bash, skill, mcp, and extension surfaces. Path-bearing tool surfaces
|
||||
* (`path`, `external_directory`, `read`, `write`, `edit`, `grep`, `find`,
|
||||
* `ls`) now route through the access-path gate (#502) and service/RPC builder
|
||||
* (#503) before reaching the manager, so they never arrive here with a real
|
||||
* path value — all fall through to the extension catch-all `["*"]`.
|
||||
*
|
||||
* @param toolName - Normalized (trimmed) tool name from the tool-call event.
|
||||
* @param input - Raw input payload from the tool-call event.
|
||||
* @param configuredMcpServerNames - Ordered list of MCP server names from the
|
||||
* global MCP config, used to derive server-qualified MCP targets.
|
||||
*/
|
||||
export function normalizeInput(
|
||||
toolName: string,
|
||||
input: unknown,
|
||||
configuredMcpServerNames: readonly string[],
|
||||
): NormalizedInput {
|
||||
switch (classifyToolKind(toolName)) {
|
||||
// --- Skill ---
|
||||
case "skill": {
|
||||
const record = toRecord(input);
|
||||
const skillName = record.name;
|
||||
const lookupValue = typeof skillName === "string" ? skillName : "*";
|
||||
return {
|
||||
surface: "skill",
|
||||
values: [lookupValue],
|
||||
resultExtras: {},
|
||||
};
|
||||
}
|
||||
|
||||
// --- Bash ---
|
||||
case "bash": {
|
||||
const record = toRecord(input);
|
||||
const command = typeof record.command === "string" ? record.command : "";
|
||||
// Strip leading shell comment lines so pattern matching operates on the
|
||||
// actual command, not a `# description` prefix agents often prepend.
|
||||
// Fall back to the raw command when stripping leaves nothing, so an
|
||||
// all-comment command still evaluates against its literal text.
|
||||
const matchValue = stripBashCommentLines(command) || command;
|
||||
return {
|
||||
surface: "bash",
|
||||
values: [matchValue],
|
||||
resultExtras: { command },
|
||||
};
|
||||
}
|
||||
|
||||
// --- MCP ---
|
||||
case "mcp": {
|
||||
const mcpTargets = [
|
||||
...createMcpPermissionTargets(input, configuredMcpServerNames),
|
||||
"mcp",
|
||||
];
|
||||
const fallbackTarget = mcpTargets[0] ?? "mcp";
|
||||
return {
|
||||
surface: "mcp",
|
||||
values: mcpTargets,
|
||||
resultExtras: { target: fallbackTarget },
|
||||
};
|
||||
}
|
||||
|
||||
// --- All other surfaces (path-bearing tools and extension tools) ---
|
||||
// Path-bearing tools with a present path never reach here — the gate emits
|
||||
// an access-path intent (#502). Missing-path and extension-tool cases both
|
||||
// collapse to the surface catch-all.
|
||||
case "path":
|
||||
case "extension":
|
||||
return {
|
||||
surface: toolName,
|
||||
values: ["*"],
|
||||
resultExtras: {},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import { getNonEmptyString, toRecord } from "#src/value-guards";
|
||||
|
||||
/**
|
||||
* An ordered accumulator that owns the uniqueness invariant.
|
||||
*
|
||||
* `add` ignores null/empty values and silently skips duplicates (first-insertion
|
||||
* wins). `toArray` returns the ordered result as an independent copy.
|
||||
*/
|
||||
export class McpTargetList {
|
||||
private readonly targets: string[] = [];
|
||||
|
||||
add(value: string | null): void {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
if (!this.targets.includes(value)) {
|
||||
this.targets.push(value);
|
||||
}
|
||||
}
|
||||
|
||||
toArray(): string[] {
|
||||
return [...this.targets];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a qualified MCP tool name of the form `server:tool`.
|
||||
*
|
||||
* Returns `{ server, tool }` when the string contains exactly one colon with
|
||||
* non-empty text on both sides; otherwise returns `null`.
|
||||
*/
|
||||
export function parseQualifiedMcpToolName(
|
||||
value: string,
|
||||
): { server: string; tool: string } | null {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const colonIndex = trimmed.indexOf(":");
|
||||
if (colonIndex <= 0 || colonIndex >= trimmed.length - 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const server = trimmed.slice(0, colonIndex).trim();
|
||||
const tool = trimmed.slice(colonIndex + 1).trim();
|
||||
if (!server || !tool) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { server, tool };
|
||||
}
|
||||
|
||||
function addDerivedMcpServerTargets(
|
||||
toolName: string,
|
||||
configuredServerNames: readonly string[],
|
||||
targets: McpTargetList,
|
||||
): void {
|
||||
const trimmedToolName = toolName.trim();
|
||||
if (!trimmedToolName) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const serverName of configuredServerNames) {
|
||||
const trimmedServerName = serverName.trim();
|
||||
if (!trimmedServerName) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!trimmedToolName.endsWith(`_${trimmedServerName}`)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (trimmedToolName.startsWith(`${trimmedServerName}_`)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
targets.add(`${trimmedServerName}_${trimmedToolName}`);
|
||||
targets.add(`${trimmedServerName}:${trimmedToolName}`);
|
||||
targets.add(trimmedServerName);
|
||||
}
|
||||
}
|
||||
|
||||
function pushMcpToolPermissionTargets(
|
||||
rawReference: string,
|
||||
serverHint: string | null,
|
||||
configuredServerNames: readonly string[],
|
||||
targets: McpTargetList,
|
||||
): void {
|
||||
const qualified = parseQualifiedMcpToolName(rawReference);
|
||||
const resolvedServer = serverHint ?? qualified?.server ?? null;
|
||||
const resolvedTool = qualified?.tool ?? rawReference;
|
||||
|
||||
if (resolvedServer) {
|
||||
targets.add(`${resolvedServer}_${resolvedTool}`);
|
||||
targets.add(`${resolvedServer}:${resolvedTool}`);
|
||||
targets.add(resolvedServer);
|
||||
} else {
|
||||
addDerivedMcpServerTargets(resolvedTool, configuredServerNames, targets);
|
||||
}
|
||||
|
||||
targets.add(resolvedTool);
|
||||
targets.add(rawReference);
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the ordered list of MCP permission-lookup candidates from a raw MCP
|
||||
* tool invocation input.
|
||||
*
|
||||
* Candidates are ordered from most-specific to least-specific so that
|
||||
* `evaluateFirst()` stops at the first non-default match.
|
||||
*/
|
||||
export function createMcpPermissionTargets(
|
||||
input: unknown,
|
||||
configuredServerNames: readonly string[] = [],
|
||||
): string[] {
|
||||
const record = toRecord(input);
|
||||
const tool = getNonEmptyString(record.tool);
|
||||
const server = getNonEmptyString(record.server);
|
||||
const connect = getNonEmptyString(record.connect);
|
||||
const describe = getNonEmptyString(record.describe);
|
||||
const search = getNonEmptyString(record.search);
|
||||
|
||||
const targets = new McpTargetList();
|
||||
|
||||
if (tool) {
|
||||
pushMcpToolPermissionTargets(tool, server, configuredServerNames, targets);
|
||||
targets.add("mcp_call");
|
||||
return targets.toArray();
|
||||
}
|
||||
|
||||
if (connect) {
|
||||
targets.add(`mcp_connect_${connect}`);
|
||||
targets.add(connect);
|
||||
targets.add("mcp_connect");
|
||||
return targets.toArray();
|
||||
}
|
||||
|
||||
if (describe) {
|
||||
pushMcpToolPermissionTargets(
|
||||
describe,
|
||||
server,
|
||||
configuredServerNames,
|
||||
targets,
|
||||
);
|
||||
targets.add("mcp_describe");
|
||||
return targets.toArray();
|
||||
}
|
||||
|
||||
if (search) {
|
||||
if (server) {
|
||||
targets.add(`mcp_server_${server}`);
|
||||
targets.add(server);
|
||||
}
|
||||
|
||||
targets.add(search);
|
||||
targets.add("mcp_search");
|
||||
return targets.toArray();
|
||||
}
|
||||
|
||||
if (server) {
|
||||
targets.add(`mcp_server_${server}`);
|
||||
targets.add(server);
|
||||
targets.add("mcp_list");
|
||||
return targets.toArray();
|
||||
}
|
||||
|
||||
targets.add("mcp_status");
|
||||
return targets.toArray();
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { expandHomePath } from "#src/expand-home";
|
||||
import { canonicalizePath } from "#src/path/canonicalize-path";
|
||||
import type { PathFlavor } from "#src/path/path-flavor";
|
||||
|
||||
/**
|
||||
* Representation derivation backing {@link AccessPath}: turn an accessed path
|
||||
* into the lexical / canonical / policy-value forms the resolver matches
|
||||
* against rules. Pure (no filesystem access except `canonicalizePath`'s
|
||||
* best-effort symlink resolution); the platform's path semantics arrive as an
|
||||
* injected {@link PathFlavor}, never read ambiently.
|
||||
*/
|
||||
export function normalizePathForComparison(
|
||||
pathValue: string,
|
||||
base: string,
|
||||
flavor: PathFlavor,
|
||||
): string {
|
||||
const cleaned = normalizePathPolicyLiteral(pathValue);
|
||||
return cleaned ? flavor.comparable(cleaned, base) : "";
|
||||
}
|
||||
|
||||
export interface PathPolicyValueOptions {
|
||||
/**
|
||||
* Current Pi working directory. When provided, returned values include a
|
||||
* project-relative alias for paths that resolve inside this directory.
|
||||
*/
|
||||
cwd?: string;
|
||||
/**
|
||||
* Directory used to resolve `pathValue` into an absolute policy value.
|
||||
* Defaults to `cwd`. Bash uses this for tokens seen after a literal `cd`.
|
||||
*/
|
||||
resolveBase?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a single path-like lookup value without resolving it against CWD.
|
||||
*
|
||||
* Preserves compatibility with existing relative path rules (`src/*`, `*.env`)
|
||||
* while applying the lexical cleanup {@link normalizePathForComparison} shares:
|
||||
* trim, strip simple wrapping quotes, strip the OpenCode-style leading `@`, and
|
||||
* expand `~` / `$HOME`.
|
||||
*/
|
||||
export function normalizePathPolicyLiteral(pathValue: string): string {
|
||||
const trimmed = pathValue.trim().replace(/^['"]|['"]$/g, "");
|
||||
if (!trimmed) return "";
|
||||
const unprefixed = trimmed.startsWith("@") ? trimmed.slice(1) : trimmed;
|
||||
return expandHomePath(unprefixed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return equivalent lookup values for path-policy matching.
|
||||
*
|
||||
* The first value is the cwd/effective-base normalized absolute path when a
|
||||
* base is available. The later values preserve project-relative and raw
|
||||
* relative forms so existing rules like `src/*` and `*.env` continue to match.
|
||||
*/
|
||||
export function getPathPolicyValues(
|
||||
pathValue: string,
|
||||
options: PathPolicyValueOptions,
|
||||
flavor: PathFlavor,
|
||||
): string[] {
|
||||
const literal = normalizePathPolicyLiteral(pathValue);
|
||||
if (!literal) return [];
|
||||
if (literal === "*") return ["*"];
|
||||
|
||||
return [
|
||||
...new Set([
|
||||
...getAbsolutePathPolicyValues(pathValue, options, flavor),
|
||||
literal,
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
||||
function getAbsolutePathPolicyValues(
|
||||
pathValue: string,
|
||||
options: PathPolicyValueOptions,
|
||||
flavor: PathFlavor,
|
||||
): string[] {
|
||||
const resolveBase = options.resolveBase ?? options.cwd;
|
||||
if (!resolveBase) return [];
|
||||
|
||||
const absolute = normalizePathForComparison(pathValue, resolveBase, flavor);
|
||||
if (!absolute) return [];
|
||||
|
||||
return [
|
||||
absolute,
|
||||
...getCwdRelativePathPolicyValues(absolute, options.cwd, flavor),
|
||||
];
|
||||
}
|
||||
|
||||
function getCwdRelativePathPolicyValues(
|
||||
absolute: string,
|
||||
cwd: string | undefined,
|
||||
flavor: PathFlavor,
|
||||
): string[] {
|
||||
if (!cwd) return [];
|
||||
|
||||
const normalizedCwd = normalizePathForComparison(cwd, cwd, flavor);
|
||||
if (!normalizedCwd) return [];
|
||||
if (absolute !== normalizedCwd && !flavor.isWithin(absolute, normalizedCwd)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const relativeValue = flavor.impl.relative(normalizedCwd, absolute);
|
||||
return relativeValue ? [relativeValue] : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Like {@link normalizePathForComparison} but also resolves symlinks via
|
||||
* `realpathSync` (best-effort). Use this for containment decisions where the
|
||||
* OS-followed path matters, not for pattern matching.
|
||||
*/
|
||||
export function canonicalNormalizePathForComparison(
|
||||
pathValue: string,
|
||||
base: string,
|
||||
flavor: PathFlavor,
|
||||
): string {
|
||||
const lexical = normalizePathForComparison(pathValue, base, flavor);
|
||||
if (!lexical) return "";
|
||||
return flavor.fold(canonicalizePath(lexical, flavor));
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* File tools that only read — never write — the filesystem.
|
||||
* Only these tools are eligible for the Pi infrastructure auto-allow.
|
||||
*/
|
||||
export const READ_ONLY_PATH_BEARING_TOOLS: ReadonlySet<string> = new Set([
|
||||
"read",
|
||||
"find",
|
||||
"grep",
|
||||
"ls",
|
||||
]);
|
||||
|
||||
export const PATH_BEARING_TOOLS = new Set([
|
||||
"read",
|
||||
"write",
|
||||
"edit",
|
||||
"find",
|
||||
"grep",
|
||||
"ls",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Surfaces whose patterns are matched against filesystem paths and therefore
|
||||
* fold case (and separators) on Windows: the path-bearing tools plus the
|
||||
* cross-cutting `path` gate and the `external_directory` boundary gate.
|
||||
*/
|
||||
export const PATH_SURFACES: ReadonlySet<string> = new Set([
|
||||
...PATH_BEARING_TOOLS,
|
||||
"external_directory",
|
||||
"path",
|
||||
]);
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { ToolAccessExtractorLookup } from "#src/tool-access-extractor-registry";
|
||||
import { getNonEmptyString, toRecord } from "#src/value-guards";
|
||||
import { classifyToolKind } from "./tool-kind";
|
||||
|
||||
export function getPathBearingToolPath(
|
||||
toolName: string,
|
||||
input: unknown,
|
||||
): string | null {
|
||||
if (classifyToolKind(toolName) !== "path") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return getNonEmptyString(toRecord(input).path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the filesystem path a tool will access, for the cross-cutting `path`
|
||||
* and `external_directory` gates.
|
||||
*
|
||||
* Unlike {@link getPathBearingToolPath} (built-in tools only), this recognizes
|
||||
* extension and MCP tools so they are no longer exempt from path gating:
|
||||
*
|
||||
* - `bash` → `null` (bash has its own token-based path gates).
|
||||
* - Built-in path-bearing tools → `input.path`.
|
||||
* - `mcp` → `input.arguments.path`.
|
||||
* - Any other tool → a registered {@link ToolAccessExtractor}'s path, else the
|
||||
* default `input.path` convention.
|
||||
*/
|
||||
export function getToolInputPath(
|
||||
toolName: string,
|
||||
input: unknown,
|
||||
extractors?: ToolAccessExtractorLookup,
|
||||
): string | null {
|
||||
const record = toRecord(input);
|
||||
|
||||
switch (classifyToolKind(toolName)) {
|
||||
case "bash":
|
||||
return null;
|
||||
case "path":
|
||||
return getNonEmptyString(record.path);
|
||||
case "mcp":
|
||||
return getNonEmptyString(toRecord(record.arguments).path);
|
||||
case "skill":
|
||||
case "extension": {
|
||||
const custom = extractors?.get(toolName);
|
||||
if (custom) {
|
||||
return getNonEmptyString(custom(record));
|
||||
}
|
||||
return getNonEmptyString(record.path);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import type { ShellToolsConfig } from "#src/config-schema";
|
||||
import { getNonEmptyString, toRecord } from "#src/value-guards";
|
||||
import { PATH_BEARING_TOOLS } from "./path-surfaces";
|
||||
|
||||
/**
|
||||
* What a tool invocation accesses — decided once from the tool name at the
|
||||
* point an invocation enters the system.
|
||||
*
|
||||
* This is the single dispatch point that replaces the scattered
|
||||
* `toolName === "bash"`/`"mcp"` re-derivation across the extraction consumers
|
||||
* (`input-normalizer`, `tool-input-path`, the tool-call gate pipeline, and
|
||||
* `permission-manager`'s source derivation) and the presentation consumers
|
||||
* (`tool-preview-formatter`, `permission-prompts`, the payload builders, and
|
||||
* `deriveDecisionValue`), which dispatch on {@link classifyToolKind} or
|
||||
* {@link isMcpCheck}. Adding a tool kind means editing {@link classifyToolKind}
|
||||
* plus the exhaustive switches the compiler flags — an OCP win over silent
|
||||
* `===` comparisons a new variant sails past (#561).
|
||||
*
|
||||
* The value is plain data (a string union): `tool-kind.ts` imports no
|
||||
* `AccessPath`, so `permission-manager.ts` may consume it without breaching the
|
||||
* string boundary formalized in ADR-0002
|
||||
* (`docs/decisions/0002-path-values-string-boundary.md`).
|
||||
*
|
||||
* - `bash` — its own token-based path gates; extraction product is the command.
|
||||
* - `mcp` — extraction product is the qualified target.
|
||||
* - `skill` — a distinct surface `normalizeInput`/`deriveSource` treat specially.
|
||||
* - `path` — a path-bearing built-in (`read`/`write`/`edit`/`grep`/`find`/`ls`);
|
||||
* extraction product is `input.path`.
|
||||
* - `extension` — every other tool, plus the `external_directory`/`path` special
|
||||
* surfaces that reach `deriveSource` as normalized names.
|
||||
*/
|
||||
export type ToolKind = "bash" | "mcp" | "skill" | "path" | "extension";
|
||||
|
||||
/** Classify a tool name into its {@link ToolKind}. */
|
||||
export function classifyToolKind(toolName: string): ToolKind {
|
||||
const name = toolName.trim();
|
||||
if (name === "bash") return "bash";
|
||||
if (name === "mcp") return "mcp";
|
||||
if (name === "skill") return "skill";
|
||||
if (PATH_BEARING_TOOLS.has(name)) return "path";
|
||||
return "extension";
|
||||
}
|
||||
|
||||
/** A shell invocation's effective command and optional working directory. */
|
||||
export interface ShellInvocation {
|
||||
/** The shell command string to decompose and gate. */
|
||||
command: string;
|
||||
/** The working directory the command runs in, if the tool projects one. */
|
||||
workdir: string | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether a tool invocation carries shell semantics, and if so extract
|
||||
* its command and working directory.
|
||||
*
|
||||
* Native `bash` and any tool recorded in `shellTools` both yield a
|
||||
* {@link ShellInvocation}; every other tool yields `null`. This is the single
|
||||
* dispatch point the bash gate pipeline consults instead of re-deriving
|
||||
* `toolName === "bash"` and reading `input.command`, so an aliased shell tool
|
||||
* (e.g. `@howaboua/pi-codex-conversion`'s `exec_command`) is routed through the
|
||||
* same bash enforcement stack as native `bash` (#574).
|
||||
*
|
||||
* The command and workdir are read through {@link getNonEmptyString} (trimmed,
|
||||
* empty → `""`/`undefined`), matching the pipeline's existing native-bash
|
||||
* extraction. Kept separate from {@link classifyToolKind} because it needs
|
||||
* config (the alias map) and returns a richer product than a {@link ToolKind}
|
||||
* string — `classifyToolKind` stays AccessPath-free and config-free.
|
||||
*/
|
||||
export function resolveShellInvocation(
|
||||
toolName: string,
|
||||
input: unknown,
|
||||
aliases: ShellToolsConfig | undefined,
|
||||
): ShellInvocation | null {
|
||||
const name = toolName.trim();
|
||||
const record = toRecord(input);
|
||||
|
||||
if (name === "bash") {
|
||||
return {
|
||||
command: getNonEmptyString(record.command) ?? "",
|
||||
workdir: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const alias = aliases?.[name];
|
||||
if (alias) {
|
||||
return {
|
||||
command: getNonEmptyString(record[alias.commandArgument]) ?? "",
|
||||
workdir: alias.workdirArgument
|
||||
? (getNonEmptyString(record[alias.workdirArgument]) ?? undefined)
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The resolved-check fields that decide MCP-ness. */
|
||||
interface McpKindFields {
|
||||
toolName: string;
|
||||
source: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a resolved check concerns an MCP call — either the invoked tool is
|
||||
* `mcp`, or the winning rule matched on the `mcp` surface (`source`). The
|
||||
* `source` disjunct is why this cannot reduce to `classifyToolKind(toolName)`:
|
||||
* `deriveSource` can set `source` to `mcp` on a result whose `toolName` is a
|
||||
* server-qualified string.
|
||||
*/
|
||||
export function isMcpCheck(check: McpKindFields): boolean {
|
||||
return check.source === "mcp" || classifyToolKind(check.toolName) === "mcp";
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Minimal session-entry view: the only fields {@link getActiveAgentName}
|
||||
* reads off each entry. Narrowing to this structural slice (rather than the
|
||||
* SDK `SessionEntry` discriminated union) keeps callers and test fixtures free
|
||||
* of the union's nine unrelated variants.
|
||||
*/
|
||||
export interface SessionEntryView {
|
||||
type: string;
|
||||
customType?: string;
|
||||
data?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrow context for {@link getActiveAgentName} — it reads only the session
|
||||
* entries. A full `ExtensionContext` satisfies this structurally.
|
||||
*/
|
||||
export interface ActiveAgentContext {
|
||||
sessionManager: { getEntries(): readonly SessionEntryView[] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Matches the `<active_agent name="...">` tag injected by pi-agent-router
|
||||
* into the system prompt to identify which agent definition is active.
|
||||
*/
|
||||
export const ACTIVE_AGENT_TAG_REGEX =
|
||||
/<active_agent\s+name=["']([^"']+)["'][^>]*>/i;
|
||||
|
||||
export function normalizeAgentName(value: unknown): string | null {
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? trimmed : null;
|
||||
}
|
||||
|
||||
export function getActiveAgentName(ctx: ActiveAgentContext): string | null {
|
||||
const entries = ctx.sessionManager.getEntries();
|
||||
for (let i = entries.length - 1; i >= 0; i--) {
|
||||
const entry = entries[i];
|
||||
if (entry.type !== "custom" || entry.customType !== "active_agent") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const data = entry.data as { name?: unknown } | undefined;
|
||||
const normalizedName = normalizeAgentName(data?.name);
|
||||
if (normalizedName) {
|
||||
return normalizedName;
|
||||
}
|
||||
|
||||
if (data?.name === null) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getActiveAgentNameFromSystemPrompt(
|
||||
systemPrompt: string | undefined,
|
||||
): string | null {
|
||||
if (!systemPrompt) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const match = ACTIVE_AGENT_TAG_REGEX.exec(systemPrompt);
|
||||
if (!match?.[1]) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return normalizeAgentName(match[1]);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Memoize an async factory, but drop a rejected result so the next call
|
||||
* retries.
|
||||
*
|
||||
* On success the resolved promise is cached and shared across all callers (the
|
||||
* factory runs once). On failure the cache is cleared before the rejection is
|
||||
* re-thrown, so a transient init failure does not poison the memo for the
|
||||
* process lifetime — the next call re-invokes the factory.
|
||||
*/
|
||||
export function memoizeAsyncWithRetry<T>(
|
||||
factory: () => Promise<T>,
|
||||
): () => Promise<T> {
|
||||
let cached: Promise<T> | null = null;
|
||||
return () => {
|
||||
cached ??= factory().catch((error: unknown) => {
|
||||
cached = null; // poisoned result cleared → next call re-attempts
|
||||
throw error;
|
||||
});
|
||||
return cached;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,464 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
getActiveAgentName,
|
||||
getActiveAgentNameFromSystemPrompt,
|
||||
} from "#src/active-agent";
|
||||
import {
|
||||
type ForwarderContext,
|
||||
getCwd,
|
||||
getSessionId,
|
||||
} from "#src/authority/forwarder-context";
|
||||
import {
|
||||
cleanupPermissionForwardingLocationIfEmpty,
|
||||
ensurePermissionForwardingLocation,
|
||||
logPermissionForwardingError,
|
||||
logPermissionForwardingWarning,
|
||||
readForwardedPermissionResponse,
|
||||
safeDeleteFile,
|
||||
sleep,
|
||||
writeJsonFileAtomic,
|
||||
} from "#src/authority/forwarding-io";
|
||||
import type { TargetServingLookup } from "#src/authority/forwarding-liveness";
|
||||
import type { PermissionPromptDecision } from "#src/authority/permission-dialog";
|
||||
import {
|
||||
type ForwardedAccessFacts,
|
||||
type ForwardedPermissionRequest,
|
||||
type ForwardedPermissionResponse,
|
||||
type ForwardedPromptDisplay,
|
||||
type ForwardedSessionApproval,
|
||||
PERMISSION_FORWARDING_POLL_INTERVAL_MS,
|
||||
PERMISSION_FORWARDING_SERVING_GRACE_MS,
|
||||
type PermissionForwardingLocation,
|
||||
type PermissionForwardingTarget,
|
||||
resolvePermissionForwardingTarget,
|
||||
SUBAGENT_PARENT_SESSION_ENV_CANDIDATES,
|
||||
} from "#src/authority/permission-forwarding";
|
||||
import type { SubagentSessionRegistry } from "#src/authority/subagent-registry";
|
||||
import { createPermissionRequestId } from "#src/permission-request-id";
|
||||
import { buildUiPrompt } from "#src/permission-ui-prompt";
|
||||
import type { PromptPayload } from "#src/presentation/prompt-payload";
|
||||
import type { DebugReviewLogger } from "#src/session-logger";
|
||||
import { toRecord } from "#src/value-guards";
|
||||
import type { TerminalAuthorizer } from "./authorizer";
|
||||
import type { PromptPermissionDetails } from "./permission-prompter";
|
||||
|
||||
// ── Module-private helpers ────────────────────────────────────────────────
|
||||
|
||||
function getContextSystemPrompt(ctx: ForwarderContext): string | undefined {
|
||||
const getSystemPrompt = toRecord(ctx).getSystemPrompt;
|
||||
if (typeof getSystemPrompt !== "function") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- getSystemPrompt is a Pi SDK accessor returning any
|
||||
const systemPrompt = getSystemPrompt.call(ctx);
|
||||
return typeof systemPrompt === "string" ? systemPrompt : undefined;
|
||||
} catch (error) {
|
||||
// No deps available in this helper — warning silently dropped.
|
||||
logPermissionForwardingWarning(
|
||||
null,
|
||||
"Failed to read context system prompt for forwarded permission metadata",
|
||||
error,
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// ── ParentAuthorizer ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The facts a forwarded request relays unchanged from the child's ask: the
|
||||
* prompt payload, the optional display projection, and the optional
|
||||
* session-approval suggestion.
|
||||
*
|
||||
* Bundled into one object so the two-hop private chain
|
||||
* (`waitForForwardedApproval` → `buildForwardedRequest`) threads a single
|
||||
* relayed value instead of three positional optionals.
|
||||
*/
|
||||
interface ForwardedRequestFacts {
|
||||
/**
|
||||
* The requester's own permission request id, adopted as the forwarded
|
||||
* request's id so one id runs from the child's gate to the serving node's
|
||||
* decision instead of a third being minted here.
|
||||
*/
|
||||
requestId: string;
|
||||
/** The child's complete prompt payload, relayed for the serving node to render. */
|
||||
payload: PromptPayload;
|
||||
display?: ForwardedPromptDisplay;
|
||||
sessionApproval?: ForwardedSessionApproval;
|
||||
/** The child-fixed access facts; the edge completes them into a `ForwardedAccessIntent`. */
|
||||
accessIntent?: ForwardedAccessFacts;
|
||||
}
|
||||
|
||||
/** Constructor config for {@link ParentAuthorizer}. */
|
||||
export interface ParentAuthorizerDeps {
|
||||
forwardingDir: string;
|
||||
/** In-process subagent session registry for forwarding target resolution. */
|
||||
registry?: SubagentSessionRegistry;
|
||||
/** Whether the resolved target is draining its inbox, on whichever channel can say. */
|
||||
serving: TargetServingLookup;
|
||||
/** How long to wait for the target's answer, read live so config edits apply. */
|
||||
getTimeoutMs: () => number;
|
||||
logger: DebugReviewLogger;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deny because no authority ever ruled — the request was never delivered,
|
||||
* never answered, or answered unreadably.
|
||||
*
|
||||
* `confirmationUnavailable` is what keeps this out of the "User denied …"
|
||||
* message (#719): a user who was never asked denied nothing. `denialReason`
|
||||
* names which path gave up, and the gate renders it to the model.
|
||||
*
|
||||
* The provenance record reuses that same string rather than restating it, so
|
||||
* what the model is told and what the log attributes cannot drift (#726).
|
||||
*/
|
||||
function abandon(denialReason: string): PermissionPromptDecision {
|
||||
return {
|
||||
approved: false,
|
||||
state: "denied",
|
||||
confirmationUnavailable: true,
|
||||
denialReason,
|
||||
decidedBy: { kind: "unavailable", reason: denialReason },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Adopt the responder's answer, recording the hop it came through.
|
||||
*
|
||||
* The requester's own terminal entry has to answer two questions, and they are
|
||||
* different: *which session* answered, and *what within it* decided. Nesting
|
||||
* keeps both rather than flattening the responder's source into this node's
|
||||
* record, where it would read as a local decision (#726).
|
||||
*
|
||||
* A responder that sent no usable source yields `decision: null` — the hop is
|
||||
* still a fact, and an older parent is not an error.
|
||||
*/
|
||||
function relayDecision(
|
||||
response: ForwardedPermissionResponse,
|
||||
): PermissionPromptDecision {
|
||||
return {
|
||||
...response,
|
||||
decidedBy: {
|
||||
kind: "forwarded",
|
||||
responderSessionId: response.responderSessionId,
|
||||
decision: response.decidedBy ?? null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Ids this node is willing to use as a request/response filename. */
|
||||
const FILENAME_SAFE_REQUEST_ID = /^[A-Za-z0-9._-]+$/;
|
||||
|
||||
/**
|
||||
* The id to write on the forwarded request: the requester's own, or a fresh
|
||||
* mint when that id could not safely name a file.
|
||||
*
|
||||
* At a relay hop the adopted id came from a request file on disk, which the
|
||||
* tolerant reader validates only as a string — so this is the boundary that
|
||||
* keeps an inbound id from choosing an outbound path.
|
||||
*/
|
||||
function forwardableRequestId(requesterRequestId: string): string {
|
||||
return FILENAME_SAFE_REQUEST_ID.test(requesterRequestId)
|
||||
? requesterRequestId
|
||||
: createPermissionRequestId();
|
||||
}
|
||||
|
||||
/**
|
||||
* Authorizer for a subagent session: escalate the ask up the tree to the
|
||||
* parent's authority.
|
||||
*
|
||||
* Owns the escalation-up role of the forwarded-permission behavior: builds
|
||||
* and persists a request file, then polls for the parent session's
|
||||
* response. `ctx` is bound once at construction — `selectAuthorizer` only
|
||||
* constructs a `ParentAuthorizer` for a context it has already confirmed has
|
||||
* no UI and is a subagent, so `authorize` never re-derives that dispatch
|
||||
* (formerly `ApprovalEscalator.requestApproval`'s `hasUI` / `!isSubagent`
|
||||
* arms, both dead once every caller routes through `selectAuthorizer`).
|
||||
*/
|
||||
export class ParentAuthorizer implements TerminalAuthorizer {
|
||||
private readonly forwardingDir: string;
|
||||
private readonly registry: SubagentSessionRegistry | undefined;
|
||||
private readonly serving: TargetServingLookup;
|
||||
private readonly getTimeoutMs: () => number;
|
||||
private readonly logger: DebugReviewLogger;
|
||||
|
||||
constructor(
|
||||
private readonly ctx: ForwarderContext,
|
||||
deps: ParentAuthorizerDeps,
|
||||
) {
|
||||
this.forwardingDir = deps.forwardingDir;
|
||||
this.registry = deps.registry;
|
||||
this.serving = deps.serving;
|
||||
this.getTimeoutMs = deps.getTimeoutMs;
|
||||
this.logger = deps.logger;
|
||||
}
|
||||
|
||||
authorize(
|
||||
details: PromptPermissionDetails,
|
||||
): Promise<PermissionPromptDecision> {
|
||||
const uiPrompt = buildUiPrompt(details);
|
||||
return this.waitForForwardedApproval(this.ctx, {
|
||||
requestId: details.requestId,
|
||||
payload: details.payload,
|
||||
display: {
|
||||
source: uiPrompt.source,
|
||||
surface: uiPrompt.surface,
|
||||
value: uiPrompt.value,
|
||||
},
|
||||
sessionApproval: details.sessionApproval,
|
||||
accessIntent: details.accessIntent,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Private methods ────────────────────────────────────────────────────
|
||||
|
||||
private async waitForForwardedApproval(
|
||||
ctx: ForwarderContext,
|
||||
facts: ForwardedRequestFacts,
|
||||
): Promise<PermissionPromptDecision> {
|
||||
const requesterSessionId = getSessionId(ctx);
|
||||
const target = resolvePermissionForwardingTarget({
|
||||
hasUI: ctx.hasUI,
|
||||
// Invariant: selectAuthorizer only selects ParentAuthorizer for a
|
||||
// no-UI subagent context, so this is always true — no detection dep
|
||||
// needed to re-derive it here.
|
||||
isSubagent: true,
|
||||
currentSessionId: requesterSessionId,
|
||||
env: process.env,
|
||||
sessionId: requesterSessionId,
|
||||
registry: this.registry,
|
||||
});
|
||||
|
||||
if (!target) {
|
||||
logPermissionForwardingError(
|
||||
this.logger,
|
||||
`Permission forwarding target session could not be resolved. ` +
|
||||
`Checked env vars: ${SUBAGENT_PARENT_SESSION_ENV_CANDIDATES.join(", ")}. ` +
|
||||
`If you are using a subagent extension (nicobailon/pi-subagents, HazAT/pi-interactive-subagents, etc.), ` +
|
||||
`ask its maintainer to set PI_SUBAGENT_PARENT_SESSION in the child process environment ` +
|
||||
`(see https://github.com/gotgenes/pi-permission-system/issues/143).`,
|
||||
);
|
||||
return abandon(
|
||||
"Could not resolve a parent session to forward this permission request to",
|
||||
);
|
||||
}
|
||||
|
||||
const location = ensurePermissionForwardingLocation(
|
||||
this.logger,
|
||||
this.forwardingDir,
|
||||
target.sessionId,
|
||||
);
|
||||
if (!location) {
|
||||
logPermissionForwardingError(
|
||||
this.logger,
|
||||
`Permission forwarding is unavailable because session-scoped directories could not be prepared for '${target.sessionId}'`,
|
||||
);
|
||||
return abandon(
|
||||
`Permission forwarding directories could not be prepared for session '${target.sessionId}'`,
|
||||
);
|
||||
}
|
||||
|
||||
const request = this.buildForwardedRequest(
|
||||
ctx,
|
||||
facts,
|
||||
requesterSessionId,
|
||||
target.sessionId,
|
||||
);
|
||||
const requestPath = join(location.requestsDir, `${request.id}.json`);
|
||||
const responsePath = join(location.responsesDir, `${request.id}.json`);
|
||||
|
||||
this.logger.review("forwarded_permission.request_created", {
|
||||
requestId: request.id,
|
||||
requesterAgentName: request.requesterAgentName,
|
||||
requesterSessionId: request.requesterSessionId,
|
||||
targetSessionId: target.sessionId,
|
||||
requestPath,
|
||||
responsePath,
|
||||
});
|
||||
|
||||
try {
|
||||
writeJsonFileAtomic(this.logger, requestPath, request);
|
||||
} catch (error) {
|
||||
logPermissionForwardingError(
|
||||
this.logger,
|
||||
`Failed to write forwarded permission request '${requestPath}'`,
|
||||
error,
|
||||
);
|
||||
cleanupPermissionForwardingLocationIfEmpty(this.logger, location);
|
||||
return abandon("The forwarded permission request could not be written");
|
||||
}
|
||||
|
||||
return this.pollForForwardedResponse(
|
||||
location,
|
||||
request,
|
||||
requestPath,
|
||||
responsePath,
|
||||
target,
|
||||
);
|
||||
}
|
||||
|
||||
private buildForwardedRequest(
|
||||
ctx: ForwarderContext,
|
||||
facts: ForwardedRequestFacts,
|
||||
requesterSessionId: string,
|
||||
targetSessionId: string,
|
||||
): ForwardedPermissionRequest {
|
||||
const requestId = forwardableRequestId(facts.requestId);
|
||||
const requesterAgentName =
|
||||
getActiveAgentName(ctx) ??
|
||||
getActiveAgentNameFromSystemPrompt(getContextSystemPrompt(ctx)) ??
|
||||
"unknown";
|
||||
// Complete the child-fixed facts into a full ForwardedAccessIntent: the
|
||||
// gate fixed the access facts; the edge stamps the requester identity it
|
||||
// alone knows (cwd + principal). The parent resolves against this intent
|
||||
// and never re-derives the match set (ADR 0008).
|
||||
const accessIntent = facts.accessIntent
|
||||
? {
|
||||
...facts.accessIntent,
|
||||
requesterCwd: getCwd(ctx),
|
||||
principal: {
|
||||
sessionId: requesterSessionId,
|
||||
agentName: requesterAgentName,
|
||||
},
|
||||
}
|
||||
: undefined;
|
||||
return {
|
||||
id: requestId,
|
||||
createdAt: Date.now(),
|
||||
requesterSessionId,
|
||||
targetSessionId,
|
||||
requesterAgentName,
|
||||
payload: facts.payload,
|
||||
...(facts.display
|
||||
? {
|
||||
source: facts.display.source,
|
||||
surface: facts.display.surface,
|
||||
value: facts.display.value,
|
||||
}
|
||||
: {}),
|
||||
...(facts.sessionApproval
|
||||
? { sessionApproval: facts.sessionApproval }
|
||||
: {}),
|
||||
...(accessIntent ? { accessIntent } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
private async pollForForwardedResponse(
|
||||
location: PermissionForwardingLocation,
|
||||
request: ForwardedPermissionRequest,
|
||||
requestPath: string,
|
||||
responsePath: string,
|
||||
target: PermissionForwardingTarget,
|
||||
): Promise<PermissionPromptDecision> {
|
||||
const { id: requestId, requesterAgentName, targetSessionId } = request;
|
||||
const timeoutMs = this.getTimeoutMs();
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let unservedSince: number | null = null;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
if (existsSync(responsePath)) {
|
||||
const response = readForwardedPermissionResponse(
|
||||
this.logger,
|
||||
responsePath,
|
||||
);
|
||||
const relayed = response ? relayDecision(response) : null;
|
||||
this.logger.review("forwarded_permission.response_received", {
|
||||
requestId,
|
||||
approved: response?.approved ?? null,
|
||||
state: response?.state ?? null,
|
||||
denialReason: response?.denialReason ?? null,
|
||||
responderSessionId: response?.responderSessionId ?? null,
|
||||
targetSessionId,
|
||||
responsePath,
|
||||
decidedBy: relayed?.decidedBy,
|
||||
});
|
||||
this.discardRequest(location, requestPath, responsePath);
|
||||
return (
|
||||
relayed ??
|
||||
abandon("The parent session's permission response could not be read")
|
||||
);
|
||||
}
|
||||
|
||||
unservedSince = this.checkServingLiveness(target, unservedSince);
|
||||
if (
|
||||
unservedSince !== null &&
|
||||
Date.now() - unservedSince >= PERMISSION_FORWARDING_SERVING_GRACE_MS
|
||||
) {
|
||||
const observation = this.serving.describe(target);
|
||||
this.logger.review("forwarded_permission.no_serving_session", {
|
||||
requestId,
|
||||
requesterSessionId: request.requesterSessionId,
|
||||
targetSessionId,
|
||||
// Which channel answered, and what it saw: the difference between a
|
||||
// parent that exited, one that was killed, and one polling under a
|
||||
// different session id is the whole diagnosis of a stalled forward.
|
||||
servingChannel: observation.channel,
|
||||
servingState: observation.state,
|
||||
servingSessionIds: observation.servingIds,
|
||||
});
|
||||
this.discardRequest(location, requestPath);
|
||||
return abandon(
|
||||
`Session '${target.sessionId}' is not serving forwarded permission requests`,
|
||||
);
|
||||
}
|
||||
|
||||
await sleep(PERMISSION_FORWARDING_POLL_INTERVAL_MS);
|
||||
}
|
||||
|
||||
logPermissionForwardingWarning(
|
||||
this.logger,
|
||||
`Timed out waiting for forwarded permission response '${responsePath}'`,
|
||||
);
|
||||
this.logger.review("forwarded_permission.response_timed_out", {
|
||||
requestId,
|
||||
requesterAgentName,
|
||||
targetSessionId,
|
||||
responsePath,
|
||||
});
|
||||
this.discardRequest(location, requestPath);
|
||||
return abandon(
|
||||
`Session '${target.sessionId}' did not answer within ${timeoutMs / 1000}s`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Track how long the target has looked unserved, or `null` while it looks fine.
|
||||
*
|
||||
* Which channel can answer for this target is the judge's decision, not this
|
||||
* one's: a target it cannot judge answers `null`, which resets the window
|
||||
* exactly as "serving" does, so an unjudgeable target waits out the timeout.
|
||||
*/
|
||||
private checkServingLiveness(
|
||||
target: PermissionForwardingTarget,
|
||||
unservedSince: number | null,
|
||||
): number | null {
|
||||
return this.serving.isServing(target) === false
|
||||
? (unservedSince ?? Date.now())
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop this exchange's files and, if nothing else is pending, its directories.
|
||||
*
|
||||
* Deleting the request is what makes an abandonment final: a request left
|
||||
* behind would be answered by the parent long after the child gave up.
|
||||
*/
|
||||
private discardRequest(
|
||||
location: PermissionForwardingLocation,
|
||||
requestPath: string,
|
||||
responsePath?: string,
|
||||
): void {
|
||||
if (responsePath) {
|
||||
safeDeleteFile(
|
||||
this.logger,
|
||||
responsePath,
|
||||
"forwarded permission response",
|
||||
);
|
||||
}
|
||||
safeDeleteFile(this.logger, requestPath, "forwarded permission request");
|
||||
cleanupPermissionForwardingLocationIfEmpty(this.logger, location);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { DecisionSource } from "#src/authority/decision-source";
|
||||
import type { AuthorizerLog, PermissionQuery } from "#src/service";
|
||||
import type {
|
||||
AuthorizerVerdict,
|
||||
NamedAuthorizer,
|
||||
TerminalAuthorizer,
|
||||
} from "./authorizer";
|
||||
import {
|
||||
createDeniedPermissionDecision,
|
||||
type PermissionPromptDecision,
|
||||
} from "./permission-dialog";
|
||||
|
||||
/**
|
||||
* Compose the live-authority chain (ADR 0007): try each non-terminal `link`
|
||||
* in order, and on `defer` fall through to the next link, ending at the
|
||||
* context-selected `terminal` that always decides.
|
||||
*
|
||||
* The signature is the type-level terminal-cannot-defer invariant: `links` are
|
||||
* deferring {@link NamedAuthorizer}s while `terminal` is a
|
||||
* {@link TerminalAuthorizer} (returns a full decision), so a deferring link
|
||||
* cannot occupy the terminal slot.
|
||||
*
|
||||
* Each link is handed the session-scoped `query` and the review-log `log` at
|
||||
* `authorize` time (ADR 0007 §3) so it queries the deterministic engine at gate
|
||||
* parity and records its decision trail; the terminal receives neither. With
|
||||
* zero links the composed chain **is** the terminal instance (identity), so
|
||||
* behavior is byte-identical to the pre-chain spine — the empty-links case that
|
||||
* ships until a link registers.
|
||||
*/
|
||||
export function composeAuthorizerChain(
|
||||
links: readonly NamedAuthorizer[],
|
||||
terminal: TerminalAuthorizer,
|
||||
query: PermissionQuery,
|
||||
log: AuthorizerLog,
|
||||
): TerminalAuthorizer {
|
||||
if (links.length === 0) {
|
||||
return terminal;
|
||||
}
|
||||
return {
|
||||
async authorize(details) {
|
||||
for (const link of links) {
|
||||
const verdict = await link.authorize(details, query, log);
|
||||
const decision = decideFromVerdict(link.name, verdict);
|
||||
if (decision) {
|
||||
return decision;
|
||||
}
|
||||
// `defer` \u2014 try the next link.
|
||||
}
|
||||
return terminal.authorize(details);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a link's decisive verdict to a decision; `defer` yields `null`.
|
||||
*
|
||||
* The deciding link is named on the decision, not merely counted among the
|
||||
* consulted set the selection already records: a link ahead of it that
|
||||
* deferred decided nothing and must not be credited (#726).
|
||||
*/
|
||||
function decideFromVerdict(
|
||||
name: string,
|
||||
verdict: AuthorizerVerdict,
|
||||
): PermissionPromptDecision | null {
|
||||
switch (verdict.kind) {
|
||||
case "allow":
|
||||
// A link grant is non-persistent (state `approved`, never
|
||||
// `approved_for_session`), per ADR 0007's off-by-default envelope.
|
||||
return {
|
||||
approved: true,
|
||||
state: "approved",
|
||||
decidedBy: decidedByLink(name, "allow", null),
|
||||
};
|
||||
case "deny":
|
||||
return {
|
||||
...createDeniedPermissionDecision(verdict.reason),
|
||||
decidedBy: decidedByLink(name, "deny", verdict.reason ?? null),
|
||||
};
|
||||
case "defer":
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function decidedByLink(
|
||||
name: string,
|
||||
verdict: "allow" | "deny",
|
||||
reason: string | null,
|
||||
): DecisionSource {
|
||||
return { kind: "authorizer", name, verdict, reason };
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Registry for named live-authority chain links (ADR 0007 §4).
|
||||
*
|
||||
* A downstream extension offers a named `Authorizer` link via
|
||||
* `PermissionsService.registerAuthorizer`; this registry stores the link's
|
||||
* `authorize` callback so composition can bind names to capabilities. One link
|
||||
* per name; duplicate registration throws.
|
||||
*
|
||||
* Registration alone grants no authority — a link decides nothing until the
|
||||
* operator names it in the `authorizerChain` config (the opt-in activation
|
||||
* model). `AuthorizerSelection` owns that config-order resolution; this registry
|
||||
* is storage only.
|
||||
*/
|
||||
|
||||
import type { Authorizer } from "./authorizer";
|
||||
|
||||
/**
|
||||
* Read-only lookup used by chain composition (ISP — exposes only the read side,
|
||||
* not the registration surface).
|
||||
*/
|
||||
export interface AuthorizerLookup {
|
||||
get(name: string): Authorizer["authorize"] | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registration side of the registry (ISP — exposes only the write surface,
|
||||
* mirroring the read-only {@link AuthorizerLookup}).
|
||||
*/
|
||||
export interface AuthorizerRegistrar {
|
||||
register(name: string, authorize: Authorizer["authorize"]): () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persistent registry mapping link names to their `authorize` callbacks.
|
||||
*
|
||||
* Owned by the extension factory (`index.ts`) so it survives across session
|
||||
* activations. Exposed to sibling extensions via
|
||||
* `PermissionsService.registerAuthorizer` and consulted by
|
||||
* `AuthorizerSelection` during chain resolution.
|
||||
*/
|
||||
export class AuthorizerRegistry
|
||||
implements AuthorizerLookup, AuthorizerRegistrar
|
||||
{
|
||||
private readonly links = new Map<string, Authorizer["authorize"]>();
|
||||
|
||||
/**
|
||||
* Register a link under `name`.
|
||||
*
|
||||
* Throws if a link is already registered for that name — keeps resolution
|
||||
* deterministic (a pi-permission-system package priority). Returns a disposer
|
||||
* that removes the link; the disposer is identity-guarded so a stale call
|
||||
* cannot evict a later registration.
|
||||
*/
|
||||
register(name: string, authorize: Authorizer["authorize"]): () => void {
|
||||
if (this.links.has(name)) {
|
||||
throw new Error(`An authorizer is already registered for '${name}'.`);
|
||||
}
|
||||
this.links.set(name, authorize);
|
||||
return () => {
|
||||
if (this.links.get(name) === authorize) {
|
||||
this.links.delete(name);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
get(name: string): Authorizer["authorize"] | undefined {
|
||||
return this.links.get(name);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
||||
import type { PermissionPromptDecision } from "#src/authority/permission-dialog";
|
||||
import type { PermissionQuery } from "#src/service";
|
||||
import {
|
||||
type AuthorizerSelectionDeps,
|
||||
type NamedAuthorizer,
|
||||
type SelectedAuthority,
|
||||
selectAuthorizer,
|
||||
} from "./authorizer";
|
||||
import { composeAuthorizerChain } from "./authorizer-chain";
|
||||
import type { AuthorizerLookup } from "./authorizer-registry";
|
||||
import { encloseInDelegationEnvelope } from "./delegation-envelope";
|
||||
import type {
|
||||
PermissionPrompterApi,
|
||||
PromptPermissionDetails,
|
||||
} from "./permission-prompter";
|
||||
|
||||
/**
|
||||
* The lifecycle slice of the selection owner that PermissionSession drives.
|
||||
*
|
||||
* PermissionSession calls activate/deactivate to keep the selection's stored
|
||||
* context in sync with its own — the same pattern the former
|
||||
* PromptingGatewayLifecycle used.
|
||||
*/
|
||||
export interface AuthorizerSelectionLifecycle {
|
||||
activate(ctx: ExtensionContext): void;
|
||||
deactivate(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The ask-escalation seam `GateRunner` depends on: escalate a single ask to
|
||||
* the session's selected `Authorizer` and return its decision.
|
||||
*
|
||||
* Replaces the two-method `GatePrompter` role (#556). There is no
|
||||
* "can anyone answer" pre-check: absent authority is the `DenyingAuthorizer`,
|
||||
* which answers by denying with a `confirmationUnavailable` marker.
|
||||
*/
|
||||
export interface AskEscalator {
|
||||
escalate(details: PromptPermissionDetails): Promise<PermissionPromptDecision>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Context-owning selection root for the Authorizer spine.
|
||||
*
|
||||
* The rewrite of `PromptingGateway`: owns the stored `ExtensionContext`, runs
|
||||
* `selectAuthorizer` once per activation, and implements `AskEscalator` by
|
||||
* delegating to the selected `Authorizer` via `PermissionPrompter`.
|
||||
*
|
||||
* `selectAuthorizer` encodes the liveness decision in *which* `Authorizer` it
|
||||
* returns (`LocalUserAuthorizer` / `ParentAuthorizer` when authority is
|
||||
* reachable, `DenyingAuthorizer` otherwise), so no separate confirmability
|
||||
* predicate survives (#556 dissolved `canConfirm()`).
|
||||
*/
|
||||
export class AuthorizerSelection
|
||||
implements AskEscalator, AuthorizerSelectionLifecycle
|
||||
{
|
||||
private authority: SelectedAuthority | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly deps: AuthorizerSelectionDeps & {
|
||||
prompter: PermissionPrompterApi;
|
||||
/** The session-scoped query injected into each chain link (ADR 0007 §3). */
|
||||
getPermissionQuery: () => PermissionQuery;
|
||||
/** Read-only lookup of registered links by name. */
|
||||
authorizerRegistry: AuthorizerLookup;
|
||||
/** The operator's configured link names, read live per ask. */
|
||||
getAuthorizerChain: () => string[];
|
||||
},
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Select the live authority for `ctx` and store it. The non-terminal
|
||||
* chain is composed per ask in {@link escalate}, not here: ADR 0007 §4 lets a
|
||||
* link register in a `permissions:ready` handler that may fire after
|
||||
* activation, so link resolution is deferred to the session's first ask.
|
||||
*/
|
||||
activate(ctx: ExtensionContext): void {
|
||||
this.authority = selectAuthorizer(ctx, this.deps);
|
||||
}
|
||||
|
||||
/**
|
||||
* The chain links for this ask.
|
||||
*
|
||||
* A node that adjudicates locally resolves its configured names; a relaying
|
||||
* node resolves none. Its terminal hands the ask to a serving node, which
|
||||
* resolves the request against its own recorded authority and escalates it
|
||||
* through *its* chain over the same child-fixed facts (#635) — so running
|
||||
* links here would adjudicate one ask twice, and a relaying node cannot host
|
||||
* a link in the first place (#699). The delegation is recorded rather than
|
||||
* reported as a fail-safe skip: an absent link is the design here, not the
|
||||
* misconfiguration `authorizer_chain_unregistered_link` exists to surface.
|
||||
*/
|
||||
private linksFor(
|
||||
authority: SelectedAuthority,
|
||||
requestId: string,
|
||||
): NamedAuthorizer[] {
|
||||
const configured = this.deps.getAuthorizerChain();
|
||||
if (configured.length === 0) {
|
||||
return [];
|
||||
}
|
||||
if (!authority.adjudicatesLocally) {
|
||||
this.deps.logger.review("authorizer_chain_delegated", {
|
||||
requestId,
|
||||
links: configured,
|
||||
});
|
||||
return [];
|
||||
}
|
||||
return this.resolveConfiguredLinks(configured, requestId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the operator's `authorizerChain` names to registered links, in
|
||||
* config order (ADR 0007 invariant 1). An unregistered name is skipped with a
|
||||
* warning (invariant 2 — more prompting, never less); each resolved link is
|
||||
* wrapped in the bounded-delegation envelope so an `allow` on an excluded
|
||||
* surface cannot exceed the operator's policy.
|
||||
*
|
||||
* The resolved names are recorded against the ask before any link runs — a
|
||||
* link that defers decides nothing and would otherwise leave no evidence it
|
||||
* was consulted at all, which is what makes "the judge never ran" and "the
|
||||
* judge ran and deferred" indistinguishable in the review log.
|
||||
*/
|
||||
private resolveConfiguredLinks(
|
||||
configured: readonly string[],
|
||||
requestId: string,
|
||||
): NamedAuthorizer[] {
|
||||
const links: NamedAuthorizer[] = [];
|
||||
const resolved: string[] = [];
|
||||
for (const name of configured) {
|
||||
const authorize = this.deps.authorizerRegistry.get(name);
|
||||
if (authorize === undefined) {
|
||||
this.deps.logger.review("authorizer_chain_unregistered_link", {
|
||||
requestId,
|
||||
name,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
resolved.push(name);
|
||||
links.push({ name, authorize: encloseInDelegationEnvelope(authorize) });
|
||||
}
|
||||
if (resolved.length > 0) {
|
||||
this.deps.logger.review("authorizer_chain_resolved", {
|
||||
requestId,
|
||||
links: resolved,
|
||||
});
|
||||
}
|
||||
return links;
|
||||
}
|
||||
|
||||
/** Clear the stored selection. */
|
||||
deactivate(): void {
|
||||
this.authority = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Escalate an ask through the composed chain and return its decision.
|
||||
*
|
||||
* Resolves this ask's links freshly (so a link registered any time before
|
||||
* this first ask is honored) and composes them ahead of the selected
|
||||
* terminal. With zero links — no chain configured, or a relaying node that
|
||||
* delegates adjudication to the serving node — the composed value **is** the
|
||||
* terminal instance, so behavior is identical to a bare terminal escalation.
|
||||
*
|
||||
* Rejects if no terminal has been selected — i.e. before the session was
|
||||
* activated. Implements {@link AskEscalator}.
|
||||
*/
|
||||
escalate(
|
||||
details: PromptPermissionDetails,
|
||||
): Promise<PermissionPromptDecision> {
|
||||
const authority = this.authority;
|
||||
if (authority === null) {
|
||||
return Promise.reject(
|
||||
new Error("escalate called before the session was activated"),
|
||||
);
|
||||
}
|
||||
const chain = composeAuthorizerChain(
|
||||
this.linksFor(authority, details.requestId),
|
||||
authority.terminal,
|
||||
this.deps.getPermissionQuery(),
|
||||
this.deps.logger,
|
||||
);
|
||||
return this.deps.prompter.prompt(chain, details);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
||||
import type { TargetServingLookup } from "#src/authority/forwarding-liveness";
|
||||
import type { PermissionPromptDecision } from "#src/authority/permission-dialog";
|
||||
import type {
|
||||
PromptPreferences,
|
||||
requestPermissionDecision,
|
||||
} from "#src/authority/permission-prompt-component";
|
||||
import type { SubagentSessionRegistry } from "#src/authority/subagent-registry";
|
||||
import type { PermissionEventBus } from "#src/permission-events";
|
||||
import type { AuthorizerLog, PermissionQuery } from "#src/service";
|
||||
import type { DebugReviewLogger } from "#src/session-logger";
|
||||
import { ParentAuthorizer } from "./approval-escalator";
|
||||
import { DenyingAuthorizer } from "./denying-authorizer";
|
||||
import { LocalUserAuthorizer } from "./local-user-authorizer";
|
||||
import type { PromptPermissionDetails } from "./permission-prompter";
|
||||
import type { SubagentDetector } from "./subagent-detection";
|
||||
|
||||
/**
|
||||
* A non-terminal chain link's ruling on an `ask`: decide (`allow`/`deny`) or
|
||||
* pass the ask on to the next link (`defer`). A `deny` carries an optional
|
||||
* teaching `reason` the invoking model sees, so it can self-correct.
|
||||
*/
|
||||
export type AuthorizerVerdict =
|
||||
| { kind: "allow" }
|
||||
| { kind: "deny"; reason?: string }
|
||||
| { kind: "defer" };
|
||||
|
||||
/**
|
||||
* A non-terminal link in the live-authority chain: reviews an `ask` and may
|
||||
* decide it or defer to the next link (ADR 0007). The chain injects a narrow,
|
||||
* session-scoped {@link PermissionQuery} at `authorize` time (§3), so a link
|
||||
* queries the deterministic engine at gate parity rather than reaching for the
|
||||
* cross-extension service via `Symbol.for()`. It also injects an
|
||||
* {@link AuthorizerLog} so a link can record its decision trail to the shared
|
||||
* permission review log (same §3 injection pattern).
|
||||
*/
|
||||
export interface Authorizer {
|
||||
authorize(
|
||||
details: PromptPermissionDetails,
|
||||
query: PermissionQuery,
|
||||
log: AuthorizerLog,
|
||||
): Promise<AuthorizerVerdict>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A resolved chain link together with the operator-configured name it came
|
||||
* from.
|
||||
*
|
||||
* `AuthorizerRegistry` already keys links by name, and `AuthorizerSelection`
|
||||
* has the name in scope when it resolves the operator's `authorizerChain`; the
|
||||
* name is carried through composition so a decision record can say *which*
|
||||
* link decided rather than only which links were consulted.
|
||||
*/
|
||||
export interface NamedAuthorizer extends Authorizer {
|
||||
readonly name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The terminal link: on `ask`, rules on a single request and is told the
|
||||
* decision. Structurally cannot defer — it always returns a full
|
||||
* {@link PermissionPromptDecision}, which is the type-level enforcement of
|
||||
* ADR 0007's terminal-cannot-defer invariant.
|
||||
*
|
||||
* One method, one responsibility. `DenyingAuthorizer` ignores `details`;
|
||||
* `LocalUserAuthorizer` renders `payload` for the human and derives the UI
|
||||
* event from the request facts; `ParentAuthorizer` ships `payload` over the
|
||||
* wire so the serving node renders it under its own budget.
|
||||
*/
|
||||
export interface TerminalAuthorizer {
|
||||
authorize(
|
||||
details: PromptPermissionDetails,
|
||||
): Promise<PermissionPromptDecision>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The node's live-authority selection: who decides this node's asks, and
|
||||
* whether this node adjudicates them with its own chain.
|
||||
*
|
||||
* The chain role is the selection's product, not a discriminator a consumer
|
||||
* re-derives: `selectAuthorizer` tests `hasUI` before `isSubagent`, so a
|
||||
* subagent that has its own UI decides locally, and re-deriving the role from
|
||||
* `detection.isSubagent(ctx)` alone would get that case wrong.
|
||||
*/
|
||||
export interface SelectedAuthority {
|
||||
/** The terminal that decides this node's asks, or relays them upward. */
|
||||
readonly terminal: TerminalAuthorizer;
|
||||
/**
|
||||
* False when the terminal relays the ask to a serving node
|
||||
* (`ParentAuthorizer`): that node resolves the request against its own
|
||||
* recorded authority and escalates it through *its* chain over the same
|
||||
* child-fixed facts (#635), so resolving links here would adjudicate one ask
|
||||
* twice.
|
||||
*/
|
||||
readonly adjudicatesLocally: boolean;
|
||||
}
|
||||
|
||||
/** Construction inputs for {@link selectAuthorizer}. */
|
||||
export interface AuthorizerSelectionDeps {
|
||||
/** Single owner of subagent detection; the ParentAuthorizer-selection predicate. */
|
||||
detection: SubagentDetector;
|
||||
/** Event bus used by `LocalUserAuthorizer` for the `permissions:ui_prompt` broadcast. */
|
||||
events: PermissionEventBus;
|
||||
/** Read live at prompt time; threaded into `LocalUserAuthorizer`. */
|
||||
getPromptPreferences: () => PromptPreferences;
|
||||
/** Injected for testability; production callers pass the real function. */
|
||||
requestPermissionDecision: typeof requestPermissionDecision;
|
||||
/** Forwarding directory `ParentAuthorizer` reads/writes request and response files under. */
|
||||
forwardingDir: string;
|
||||
/** In-process subagent session registry for forwarding target resolution. */
|
||||
registry?: SubagentSessionRegistry;
|
||||
/** Whether a forwarding target is draining its inbox, on whichever channel can say. */
|
||||
serving: TargetServingLookup;
|
||||
/** The forwarding timeout, read live so a config edit applies to the next ask. */
|
||||
getForwardingTimeoutMs: () => number;
|
||||
logger: DebugReviewLogger;
|
||||
}
|
||||
|
||||
/**
|
||||
* Select the live authority for the current context: the single owner of the
|
||||
* three-way `hasUI` / `isSubagent` / deny dispatch, and of the chain role that
|
||||
* dispatch implies.
|
||||
*
|
||||
* Evaluated once per session activation (`AuthorizerSelection.activate`),
|
||||
* replacing the re-derivation of the same predicates across
|
||||
* `PromptingGateway`, `PermissionPrompter`, and `ApprovalEscalator`.
|
||||
*/
|
||||
export function selectAuthorizer(
|
||||
ctx: ExtensionContext,
|
||||
deps: AuthorizerSelectionDeps,
|
||||
): SelectedAuthority {
|
||||
if (ctx.hasUI) {
|
||||
return {
|
||||
terminal: new LocalUserAuthorizer({
|
||||
ui: ctx.ui,
|
||||
mode: ctx.mode,
|
||||
events: deps.events,
|
||||
getPromptPreferences: deps.getPromptPreferences,
|
||||
requestPermissionDecision: deps.requestPermissionDecision,
|
||||
}),
|
||||
adjudicatesLocally: true,
|
||||
};
|
||||
}
|
||||
if (deps.detection.isSubagent(ctx)) {
|
||||
return {
|
||||
terminal: new ParentAuthorizer(ctx, {
|
||||
forwardingDir: deps.forwardingDir,
|
||||
registry: deps.registry,
|
||||
serving: deps.serving,
|
||||
getTimeoutMs: deps.getForwardingTimeoutMs,
|
||||
logger: deps.logger,
|
||||
}),
|
||||
adjudicatesLocally: false,
|
||||
};
|
||||
}
|
||||
return { terminal: new DenyingAuthorizer(), adjudicatesLocally: true };
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Bracketed-paste normalization for the inline permission dialog's reason field.
|
||||
*
|
||||
* A terminal in bracketed-paste mode wraps pasted text in these markers, and
|
||||
* the TUI hands the wrapped chunk to the focused component in a single call.
|
||||
*/
|
||||
|
||||
const PASTE_START = "\u001b[200~";
|
||||
const PASTE_END = "\u001b[201~";
|
||||
const NEWLINE_RUN = /[\r\n]+/g;
|
||||
|
||||
/**
|
||||
* Collapse newline runs inside a bracketed-paste chunk to single spaces.
|
||||
*
|
||||
* The framework line editor deletes newlines outright, which joins the words
|
||||
* on either side of a line break; a reason pasted from a multi-line source
|
||||
* should stay readable in the single-line field. The markers are preserved so
|
||||
* the editor still recognizes the chunk as a paste, and anything that is not
|
||||
* a complete paste chunk is returned unchanged.
|
||||
*/
|
||||
export function collapsePastedNewlines(data: string): string {
|
||||
const start = data.indexOf(PASTE_START);
|
||||
if (start === -1) {
|
||||
return data;
|
||||
}
|
||||
const contentStart = start + PASTE_START.length;
|
||||
const contentEnd = data.indexOf(PASTE_END, contentStart);
|
||||
if (contentEnd === -1) {
|
||||
return data;
|
||||
}
|
||||
const content = data
|
||||
.slice(contentStart, contentEnd)
|
||||
.replace(NEWLINE_RUN, " ");
|
||||
return data.slice(0, contentStart) + content + data.slice(contentEnd);
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* What decided a permission request, recorded at the site that decided it.
|
||||
*
|
||||
* The decision paths are already distinct in the code — a session hit, a yolo
|
||||
* grant, an infrastructure read, a config rule, a chain link, a human at a
|
||||
* dialog, an unreachable authority — and each one knows what it is at the
|
||||
* moment it decides. This is that fact, carried to the record instead of being
|
||||
* discarded and re-guessed from an event name downstream.
|
||||
*
|
||||
* Every variant is **self-contained**: it repeats the detail that made it
|
||||
* decisive rather than leaning on a sibling log column. That duplicates
|
||||
* `surface` and the pattern on a local review line, and it is the only shape
|
||||
* that survives the forwarding hop, where the response file has no such
|
||||
* columns to lean on.
|
||||
*/
|
||||
|
||||
/** Which human-facing surface the operator answered on. */
|
||||
export type UserDecisionSurface = "dialog" | "select";
|
||||
|
||||
export type DecisionSource =
|
||||
/** A human ruled, at the inline dialog or the `select`/`input` fallback. */
|
||||
| { kind: "user"; via: UserDecisionSurface }
|
||||
/** A registered `authorizerChain` link ruled; `name` is the configured name. */
|
||||
| {
|
||||
kind: "authorizer";
|
||||
name: string;
|
||||
verdict: "allow" | "deny";
|
||||
reason: string | null;
|
||||
}
|
||||
/** Recorded authority: a rule in the composed ruleset matched. */
|
||||
| {
|
||||
kind: "rule";
|
||||
surface: string;
|
||||
pattern: string | null;
|
||||
origin: string | null;
|
||||
}
|
||||
/** A session-scoped grant the operator made earlier in this session. */
|
||||
| { kind: "session_approval"; surface: string; pattern: string | null }
|
||||
/**
|
||||
* `yoloMode`. `pattern` preserves the ask's matched rule — including a
|
||||
* synthetic sentinel such as `<opaque-bash-wrapper>` — which is what makes a
|
||||
* yolo grant over a synthesized ask legible.
|
||||
*/
|
||||
| { kind: "yolo"; pattern: string | null }
|
||||
/** A Pi infrastructure read, allowed by containment rather than by a rule. */
|
||||
| { kind: "infrastructure_read" }
|
||||
/**
|
||||
* No authority ever ruled: none was reachable, or the forwarding path gave
|
||||
* up before reaching one. `reason` names which path gave up.
|
||||
*/
|
||||
| { kind: "unavailable"; reason: string }
|
||||
/** A gate threw, and the boundary blocked rather than allowed. */
|
||||
| { kind: "gate_error"; reason: string }
|
||||
/**
|
||||
* Another session decided. Recursive by design: the requesting side records
|
||||
* both that the decider was elsewhere and what, within that session, decided
|
||||
* — which is the distinction an audit of a forwarded ask needs.
|
||||
*
|
||||
* `decision` is `null` when the responder sent none (an older parent).
|
||||
*/
|
||||
| {
|
||||
kind: "forwarded";
|
||||
responderSessionId: string | null;
|
||||
decision: DecisionSource | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* How deep a `forwarded` chain may nest before {@link asDecisionSource} gives
|
||||
* up.
|
||||
*
|
||||
* Forwarding is depth-1 by invariant (child → root) and a relay hop makes it
|
||||
* two, so this is headroom rather than a working limit. It exists because the
|
||||
* value is read off disk: a recursive reader over a file another process wrote
|
||||
* is a stack-overflow surface, and the fail-closed answer is to stop.
|
||||
*/
|
||||
export const MAX_DECISION_SOURCE_DEPTH = 4;
|
||||
|
||||
/**
|
||||
* Narrow an unknown value to a {@link DecisionSource}, or `undefined`.
|
||||
*
|
||||
* Lives beside its type so a new variant updates the guard next door, following
|
||||
* `asPromptPayload` and `isPermissionDecisionState`. All-or-nothing: a
|
||||
* malformed field — at any nesting level — yields `undefined` rather than a
|
||||
* half-parsed record, because a provenance record that names a decider who did
|
||||
* not decide is worse than one that names none.
|
||||
*/
|
||||
export function asDecisionSource(value: unknown): DecisionSource | undefined {
|
||||
return narrowSource(value, MAX_DECISION_SOURCE_DEPTH);
|
||||
}
|
||||
|
||||
function narrowSource(
|
||||
value: unknown,
|
||||
depthBudget: number,
|
||||
): DecisionSource | undefined {
|
||||
const candidate = asObject(value);
|
||||
if (!candidate) return undefined;
|
||||
|
||||
switch (candidate.kind) {
|
||||
case "user":
|
||||
return narrowUser(candidate);
|
||||
case "authorizer":
|
||||
return narrowAuthorizer(candidate);
|
||||
case "rule":
|
||||
return narrowRule(candidate);
|
||||
case "session_approval":
|
||||
return narrowSessionApproval(candidate);
|
||||
case "yolo":
|
||||
return isNullableString(candidate.pattern)
|
||||
? { kind: "yolo", pattern: candidate.pattern }
|
||||
: undefined;
|
||||
case "infrastructure_read":
|
||||
return { kind: "infrastructure_read" };
|
||||
case "unavailable":
|
||||
return typeof candidate.reason === "string"
|
||||
? { kind: "unavailable", reason: candidate.reason }
|
||||
: undefined;
|
||||
case "gate_error":
|
||||
return typeof candidate.reason === "string"
|
||||
? { kind: "gate_error", reason: candidate.reason }
|
||||
: undefined;
|
||||
case "forwarded":
|
||||
return narrowForwarded(candidate, depthBudget);
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function narrowUser(
|
||||
candidate: Record<string, unknown>,
|
||||
): DecisionSource | undefined {
|
||||
const via = USER_DECISION_SURFACES.find((entry) => entry === candidate.via);
|
||||
return via ? { kind: "user", via } : undefined;
|
||||
}
|
||||
|
||||
function narrowAuthorizer(
|
||||
candidate: Record<string, unknown>,
|
||||
): DecisionSource | undefined {
|
||||
const verdict = AUTHORIZER_VERDICTS.find(
|
||||
(entry) => entry === candidate.verdict,
|
||||
);
|
||||
if (
|
||||
!verdict ||
|
||||
typeof candidate.name !== "string" ||
|
||||
!isNullableString(candidate.reason)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
kind: "authorizer",
|
||||
name: candidate.name,
|
||||
verdict,
|
||||
reason: candidate.reason,
|
||||
};
|
||||
}
|
||||
|
||||
function narrowRule(
|
||||
candidate: Record<string, unknown>,
|
||||
): DecisionSource | undefined {
|
||||
if (
|
||||
typeof candidate.surface !== "string" ||
|
||||
!isNullableString(candidate.pattern) ||
|
||||
!isNullableString(candidate.origin)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
kind: "rule",
|
||||
surface: candidate.surface,
|
||||
pattern: candidate.pattern,
|
||||
origin: candidate.origin,
|
||||
};
|
||||
}
|
||||
|
||||
function narrowSessionApproval(
|
||||
candidate: Record<string, unknown>,
|
||||
): DecisionSource | undefined {
|
||||
if (
|
||||
typeof candidate.surface !== "string" ||
|
||||
!isNullableString(candidate.pattern)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
kind: "session_approval",
|
||||
surface: candidate.surface,
|
||||
pattern: candidate.pattern,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The inner decision is narrowed against a decremented budget, so a chain
|
||||
* deeper than {@link MAX_DECISION_SOURCE_DEPTH} is rejected whole rather than
|
||||
* truncated — a truncated chain would silently attribute the decision to the
|
||||
* last frame that fit.
|
||||
*/
|
||||
function narrowForwarded(
|
||||
candidate: Record<string, unknown>,
|
||||
depthBudget: number,
|
||||
): DecisionSource | undefined {
|
||||
if (depthBudget <= 0 || !isNullableString(candidate.responderSessionId)) {
|
||||
return undefined;
|
||||
}
|
||||
if (candidate.decision === null) {
|
||||
return {
|
||||
kind: "forwarded",
|
||||
responderSessionId: candidate.responderSessionId,
|
||||
decision: null,
|
||||
};
|
||||
}
|
||||
const decision = narrowSource(candidate.decision, depthBudget - 1);
|
||||
return decision
|
||||
? {
|
||||
kind: "forwarded",
|
||||
responderSessionId: candidate.responderSessionId,
|
||||
decision,
|
||||
}
|
||||
: undefined;
|
||||
}
|
||||
|
||||
const USER_DECISION_SURFACES = [
|
||||
"dialog",
|
||||
"select",
|
||||
] as const satisfies readonly UserDecisionSurface[];
|
||||
|
||||
const AUTHORIZER_VERDICTS = ["allow", "deny"] as const;
|
||||
|
||||
function asObject(value: unknown): Record<string, unknown> | undefined {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function isNullableString(value: unknown): value is string | null {
|
||||
return value === null || typeof value === "string";
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* The bounded-delegation enforcement checkpoint (ADR 0007 §5).
|
||||
*
|
||||
* The chain owner caps every registered link's verdict so a buggy or over-eager
|
||||
* external judge can never exceed the operator's policy: a link's `allow` on an
|
||||
* excluded surface is downgraded to `defer`, letting the `ask` fall through to
|
||||
* the terminal (a prompt) instead. The checkpoint only ever *tightens* a
|
||||
* verdict — it never turns a `defer`/`deny` into an `allow`.
|
||||
*
|
||||
* The excluded set is the whole `path` surface plus `external_directory`, with
|
||||
* one bundle-maintained exception: the built-in `read` tool may accept a link's
|
||||
* `allow` for an external-directory ask. Mutating tools, bash, extension tools,
|
||||
* and unknown tools remain capped to the terminal human authority. A finer
|
||||
* secret-shaped-`path` exclusion remains deferred; `path` stays fully excluded.
|
||||
*/
|
||||
|
||||
import type { Authorizer } from "./authorizer";
|
||||
import type { PromptPermissionDetails } from "./permission-prompter";
|
||||
|
||||
/** Surfaces on which a link may never grant an `allow` (ADR 0007 §5). */
|
||||
export const DELEGATION_EXCLUDED_SURFACES: ReadonlySet<string> = new Set([
|
||||
"external_directory",
|
||||
"path",
|
||||
]);
|
||||
|
||||
/** Read-only external-directory access explicitly delegable in my-pi. */
|
||||
const DELEGABLE_EXTERNAL_DIRECTORY_TOOLS: ReadonlySet<string> = new Set(["read"]);
|
||||
|
||||
/**
|
||||
* Wrap a link's `authorize` so an `allow` on an excluded surface is capped to
|
||||
* `defer`. All other verdicts, and `allow`s on non-excluded surfaces, pass
|
||||
* through unchanged. `details`, the injected `query`, and the review-log `log`
|
||||
* are forwarded as-is.
|
||||
*/
|
||||
export function encloseInDelegationEnvelope(
|
||||
authorize: Authorizer["authorize"],
|
||||
): Authorizer["authorize"] {
|
||||
return async (details, query, log) => {
|
||||
const verdict = await authorize(details, query, log);
|
||||
if (verdict.kind === "allow" && isExcludedSurface(details)) {
|
||||
return { kind: "defer" };
|
||||
}
|
||||
return verdict;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an allow verdict exceeds the delegation envelope. The gate-computed
|
||||
* surface is authoritative. `path` remains fully excluded; external-directory
|
||||
* access is excluded unless it comes from the built-in `read` tool. Unknown
|
||||
* surfaces fail safe to the terminal authority.
|
||||
*/
|
||||
function isExcludedSurface(details: PromptPermissionDetails): boolean {
|
||||
const surface = details.accessIntent?.surface ?? details.surface ?? undefined;
|
||||
if (surface === undefined) {
|
||||
return true;
|
||||
}
|
||||
if (surface === "external_directory") {
|
||||
return !(
|
||||
details.toolName !== undefined &&
|
||||
DELEGABLE_EXTERNAL_DIRECTORY_TOOLS.has(details.toolName)
|
||||
);
|
||||
}
|
||||
return DELEGATION_EXCLUDED_SURFACES.has(surface);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { PermissionPromptDecision } from "#src/authority/permission-dialog";
|
||||
import type { TerminalAuthorizer } from "./authorizer";
|
||||
|
||||
/** Why this authorizer denies; the provenance record's `reason` (#726). */
|
||||
const NO_AUTHORITY_REASON = "No live authority was reachable for this session";
|
||||
|
||||
/**
|
||||
* Least-privilege Authorizer: no authority is reachable for this session
|
||||
* (no UI, not a subagent), so every ask is denied.
|
||||
*
|
||||
* The denial carries the `confirmationUnavailable` marker so the ask path can
|
||||
* distinguish "nobody could answer" from an interactive user denial when it
|
||||
* derives the review-entry and decision-event resolution.
|
||||
*/
|
||||
export class DenyingAuthorizer implements TerminalAuthorizer {
|
||||
authorize(): Promise<PermissionPromptDecision> {
|
||||
return Promise.resolve({
|
||||
approved: false,
|
||||
state: "denied",
|
||||
confirmationUnavailable: true,
|
||||
decidedBy: { kind: "unavailable", reason: NO_AUTHORITY_REASON },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,453 @@
|
||||
import { join } from "node:path";
|
||||
import type { DecisionSource } from "#src/authority/decision-source";
|
||||
import {
|
||||
type ForwarderContext,
|
||||
getSessionId,
|
||||
} from "#src/authority/forwarder-context";
|
||||
import type { PermissionPromptDecision } from "#src/authority/permission-dialog";
|
||||
import {
|
||||
type ForwardedAccessFacts,
|
||||
type ForwardedAccessIntent,
|
||||
type ForwardedPermissionRequest,
|
||||
type ForwardedPermissionResponse,
|
||||
isForwardedPermissionRequestForSession,
|
||||
type PermissionForwardingLocation,
|
||||
} from "#src/authority/permission-forwarding";
|
||||
import type { SubagentSessionRegistry } from "#src/authority/subagent-registry";
|
||||
import { buildForwardedAskPayload } from "#src/presentation/forwarded-ask-payload";
|
||||
import { SessionApproval } from "#src/session-approval";
|
||||
import type { SessionApprovalRecorder } from "#src/session-approval-recorder";
|
||||
import type { DebugReviewLogger } from "#src/session-logger";
|
||||
import type { PermissionCheckResult } from "#src/types";
|
||||
import type { AskEscalator } from "./authorizer-selection";
|
||||
import {
|
||||
cleanupPermissionForwardingLocationIfEmpty,
|
||||
ensureDirectoryExists,
|
||||
formatUnknownErrorMessage,
|
||||
getExistingPermissionForwardingLocation,
|
||||
listRequestFiles,
|
||||
logPermissionForwardingError,
|
||||
logPermissionForwardingWarning,
|
||||
readForwardedPermissionRequest,
|
||||
safeDeleteFile,
|
||||
writeJsonFileAtomic,
|
||||
} from "./forwarding-io";
|
||||
import type { PromptPermissionDetails } from "./permission-prompter";
|
||||
|
||||
/**
|
||||
* Narrow seam describing what `ForwardingManager` needs from the server: a
|
||||
* single method that drains this session's forwarded-permission inbox.
|
||||
*
|
||||
* Depending on the interface (not the concrete `ForwardedRequestServer`)
|
||||
* keeps the manager's unit tests free of casts — they inject a plain
|
||||
* `{ processInbox: vi.fn() }` mock.
|
||||
*/
|
||||
export interface InboxProcessor {
|
||||
processInbox(ctx: ForwarderContext): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recorded-authority view the serving node resolves a forwarded request
|
||||
* against: answer one {@link ForwardedAccessIntent} query on the serving
|
||||
* session's composed ruleset, agent-scoped to the requester
|
||||
* (`principal.agentName`, ADR 0008 §3) — the child-fixed `matchValues` are
|
||||
* used as-is, never re-derived through this session's `PathNormalizer`/cwd.
|
||||
*
|
||||
* Narrow by design (ISP): the server needs one decision, not the whole
|
||||
* resolver. The composition root satisfies it with
|
||||
* `buildResolvedIntentFromMatchValues` plus `resolver.resolve`, the same
|
||||
* `resolve` entry point `LocalPermissionsService` composes.
|
||||
*/
|
||||
export interface ServingPolicy {
|
||||
resolve(intent: ForwardedAccessIntent): PermissionCheckResult;
|
||||
}
|
||||
|
||||
/** Constructor config for `ForwardedRequestServer`. */
|
||||
export interface ForwardedRequestServerDeps {
|
||||
forwardingDir: string;
|
||||
logger: DebugReviewLogger;
|
||||
/** Recorded-authority resolution for a forwarded `ForwardedAccessIntent`. */
|
||||
policy: ServingPolicy;
|
||||
/** Escalation seam to the serving session's selected `Authorizer` on `ask`. */
|
||||
escalator: AskEscalator;
|
||||
/**
|
||||
* The serving session's `SessionRules`. Records a whole-session grant when a
|
||||
* human approves a forwarded request for the entire serving session.
|
||||
*/
|
||||
recorder: SessionApprovalRecorder;
|
||||
/** In-process subagent registry, read only by the one-hop canary. */
|
||||
registry?: SubagentSessionRegistry;
|
||||
}
|
||||
|
||||
// ── Module-private helpers ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Map a forwarded request onto the escalated ask's details, carrying the
|
||||
* forwarded provenance (requester agent/session + the child's original display
|
||||
* projection) so `LocalUserAuthorizer` emits a non-degraded broadcast (#292),
|
||||
* plus the child-fixed access facts so the serving node's `Authorizer` chain
|
||||
* judges a forwarded ask on the same evidence as a local one (ADR 0008; #635).
|
||||
*
|
||||
* The display `surface` and the fact `surface` are distinct and both belong
|
||||
* here: the former is the child's tool name (what the UI shows), the latter the
|
||||
* gate surface the rule fired on (what the bounded-delegation checkpoint
|
||||
* excludes on).
|
||||
*/
|
||||
function buildForwardedAskDetails(
|
||||
request: ForwardedPermissionRequest,
|
||||
): PromptPermissionDetails {
|
||||
const payload = buildForwardedAskPayload(request);
|
||||
return {
|
||||
requestId: request.id,
|
||||
source: request.source ?? "tool_call",
|
||||
agentName: request.requesterAgentName || null,
|
||||
payload,
|
||||
surface: request.surface ?? null,
|
||||
value: request.value ?? null,
|
||||
forwarding: {
|
||||
requesterAgentName: request.requesterAgentName || null,
|
||||
requesterSessionId: request.requesterSessionId || null,
|
||||
},
|
||||
// Carries the child's suggestion so LocalUserAuthorizer can offer the
|
||||
// whole-session grant scope; absent for a legacy/version-skew request.
|
||||
...(request.sessionApproval
|
||||
? { sessionApproval: request.sessionApproval }
|
||||
: {}),
|
||||
// Absent for a version-skew request that carried no intent — which the
|
||||
// delegation envelope reads as "surface undetermined" and fail-safes to
|
||||
// excluded, so absence must stay absence rather than become `undefined`.
|
||||
...(request.accessIntent
|
||||
? { accessIntent: toAccessFacts(request.accessIntent) }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Project the wire intent down to the child-fixed access facts an `Authorizer`
|
||||
* may see.
|
||||
*
|
||||
* Field-by-field rather than a spread, because this is a disclosure boundary:
|
||||
* `requesterCwd` and `principal` are requester identity for the serving node's
|
||||
* own resolution (ADR 0008 §3) and stay off the ask details. A link that needs
|
||||
* requester identity reads `details.forwarding`. `ForwardedAccessIntent`
|
||||
* extends `ForwardedAccessFacts`, so a spread would type-check while widening
|
||||
* disclosure at runtime; the explicit return type makes any future field on
|
||||
* `ForwardedAccessFacts` a compile error here until it is deliberately
|
||||
* projected or deliberately withheld.
|
||||
*/
|
||||
function toAccessFacts(intent: ForwardedAccessIntent): ForwardedAccessFacts {
|
||||
return {
|
||||
surface: intent.surface,
|
||||
matchValues: intent.matchValues,
|
||||
boundaryValue: intent.boundaryValue,
|
||||
};
|
||||
}
|
||||
|
||||
// ── ForwardedRequestServer ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Owner of the serving-down role of the forwarded-permission behavior:
|
||||
* draining this session's forwarded-permission inbox and answering each
|
||||
* request the same way the session resolves a local action — resolving its
|
||||
* `ForwardedAccessIntent` against recorded authority (`ServingPolicy`), then
|
||||
* escalation to its selected `Authorizer` (`AskEscalator`) on `ask` (ADR
|
||||
* 0008).
|
||||
*/
|
||||
export class ForwardedRequestServer implements InboxProcessor {
|
||||
private readonly forwardingDir: string;
|
||||
private readonly logger: DebugReviewLogger;
|
||||
private readonly policy: ServingPolicy;
|
||||
private readonly escalator: AskEscalator;
|
||||
private readonly recorder: SessionApprovalRecorder;
|
||||
private readonly registry: SubagentSessionRegistry | undefined;
|
||||
|
||||
constructor(deps: ForwardedRequestServerDeps) {
|
||||
this.forwardingDir = deps.forwardingDir;
|
||||
this.logger = deps.logger;
|
||||
this.policy = deps.policy;
|
||||
this.escalator = deps.escalator;
|
||||
this.recorder = deps.recorder;
|
||||
this.registry = deps.registry;
|
||||
}
|
||||
|
||||
/** Drain and respond to this session's forwarded-permission inbox. */
|
||||
async processInbox(ctx: ForwarderContext): Promise<void> {
|
||||
const currentSessionId = getSessionId(ctx);
|
||||
const location = getExistingPermissionForwardingLocation(
|
||||
this.forwardingDir,
|
||||
currentSessionId,
|
||||
);
|
||||
if (!location) {
|
||||
return;
|
||||
}
|
||||
|
||||
const requestFiles = listRequestFiles(this.logger, location.requestsDir);
|
||||
if (requestFiles.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Defensively recreate responses/ before writing any response — a
|
||||
// concurrent cleanup pass may have removed it between the requestsDir
|
||||
// existence check above and the write inside processSingleForwardedRequest
|
||||
// (the ENOENT write loop reported in issue #398).
|
||||
if (
|
||||
!ensureDirectoryExists(
|
||||
this.logger,
|
||||
location.responsesDir,
|
||||
"permission forwarding responses",
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const fileName of requestFiles) {
|
||||
const requestPath = join(location.requestsDir, fileName);
|
||||
const request = readForwardedPermissionRequest(this.logger, requestPath);
|
||||
if (!request) {
|
||||
safeDeleteFile(
|
||||
this.logger,
|
||||
requestPath,
|
||||
`${location.label} forwarded permission request`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
await this.processSingleForwardedRequest(
|
||||
request,
|
||||
location,
|
||||
requestPath,
|
||||
currentSessionId,
|
||||
);
|
||||
}
|
||||
|
||||
cleanupPermissionForwardingLocationIfEmpty(this.logger, location);
|
||||
}
|
||||
|
||||
// ── Private methods ────────────────────────────────────────────────────
|
||||
|
||||
private async processSingleForwardedRequest(
|
||||
request: ForwardedPermissionRequest,
|
||||
location: PermissionForwardingLocation,
|
||||
requestPath: string,
|
||||
currentSessionId: string,
|
||||
): Promise<void> {
|
||||
if (!isForwardedPermissionRequestForSession(request, currentSessionId)) {
|
||||
logPermissionForwardingWarning(
|
||||
this.logger,
|
||||
`Ignoring forwarded permission request '${request.id}' because it targets session '${request.targetSessionId}' instead of '${currentSessionId}'`,
|
||||
);
|
||||
safeDeleteFile(
|
||||
this.logger,
|
||||
requestPath,
|
||||
`${location.label} forwarded permission request`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.warnOnMultiHop(request, currentSessionId);
|
||||
|
||||
const forwardedPermissionLogDetails = {
|
||||
requestId: request.id,
|
||||
source: location.label,
|
||||
requesterAgentName: request.requesterAgentName,
|
||||
requesterSessionId: request.requesterSessionId,
|
||||
targetSessionId: request.targetSessionId,
|
||||
requestPath,
|
||||
};
|
||||
|
||||
const decision = await this.resolveDecision(
|
||||
request,
|
||||
forwardedPermissionLogDetails,
|
||||
);
|
||||
|
||||
this.recordForwardedDecision(
|
||||
request,
|
||||
location,
|
||||
requestPath,
|
||||
currentSessionId,
|
||||
this.applyGrantScope(request, decision, forwardedPermissionLogDetails),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the human's grant-scope choice on a forwarded approval.
|
||||
*
|
||||
* A whole-session grant (`approved_for_serving_session`) records the child's
|
||||
* suggested pattern into this serving node's `SessionRules` — the single
|
||||
* source of truth for the scope — and is then translated to a plain
|
||||
* `approved` so the child records nothing (its next identical action
|
||||
* re-forwards and resolves as recorded authority). Every other decision
|
||||
* passes through unchanged (`approved_for_session` → the child records).
|
||||
*
|
||||
* The translation rewrites the grant's *scope*, never its decider: the human
|
||||
* who chose the wider scope is still the one who decided (#726).
|
||||
*/
|
||||
private applyGrantScope(
|
||||
request: ForwardedPermissionRequest,
|
||||
decision: PermissionPromptDecision,
|
||||
logDetails: Record<string, unknown>,
|
||||
): PermissionPromptDecision {
|
||||
if (decision.state !== "approved_for_serving_session") {
|
||||
return decision;
|
||||
}
|
||||
if (request.sessionApproval) {
|
||||
this.recorder.recordSessionApproval(
|
||||
SessionApproval.multiple(
|
||||
request.sessionApproval.surface,
|
||||
request.sessionApproval.patterns,
|
||||
),
|
||||
);
|
||||
this.logger.review("forwarded_permission.session_recorded", {
|
||||
...logDetails,
|
||||
surface: request.sessionApproval.surface,
|
||||
patterns: request.sessionApproval.patterns,
|
||||
});
|
||||
}
|
||||
return {
|
||||
approved: true,
|
||||
state: "approved",
|
||||
decidedBy: decision.decidedBy,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the served decision: write the response file the child polls for,
|
||||
* log the outcome, and delete the drained request. The symmetric "respond"
|
||||
* half to {@link resolveDecision}'s "decide" half.
|
||||
*/
|
||||
private recordForwardedDecision(
|
||||
request: ForwardedPermissionRequest,
|
||||
location: PermissionForwardingLocation,
|
||||
requestPath: string,
|
||||
currentSessionId: string,
|
||||
decision: PermissionPromptDecision,
|
||||
): void {
|
||||
const responsePath = join(location.responsesDir, `${request.id}.json`);
|
||||
this.logger.review(
|
||||
decision.approved
|
||||
? "forwarded_permission.approved"
|
||||
: "forwarded_permission.denied",
|
||||
{
|
||||
requestId: request.id,
|
||||
source: location.label,
|
||||
requesterAgentName: request.requesterAgentName,
|
||||
requesterSessionId: request.requesterSessionId,
|
||||
targetSessionId: request.targetSessionId,
|
||||
responsePath,
|
||||
resolution: decision.state,
|
||||
denialReason: decision.denialReason ?? null,
|
||||
decidedBy: decision.decidedBy,
|
||||
},
|
||||
);
|
||||
try {
|
||||
writeJsonFileAtomic(this.logger, responsePath, {
|
||||
approved: decision.approved,
|
||||
state: decision.state,
|
||||
denialReason: decision.denialReason,
|
||||
responderSessionId: currentSessionId,
|
||||
respondedAt: Date.now(),
|
||||
// Carried onto the wire so the requester can name what decided inside
|
||||
// this session, not merely that this session answered (#726).
|
||||
decidedBy: decision.decidedBy,
|
||||
} satisfies ForwardedPermissionResponse);
|
||||
} catch (error) {
|
||||
logPermissionForwardingError(
|
||||
this.logger,
|
||||
`Failed to write ${location.label} forwarded permission response '${responsePath}'`,
|
||||
error,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
safeDeleteFile(
|
||||
this.logger,
|
||||
requestPath,
|
||||
`${location.label} forwarded permission request`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the request the same way the session resolves a local action:
|
||||
* recorded authority first (a request carrying an `accessIntent` — the
|
||||
* child-fixed facts, ADR 0008 §2 — resolves against the serving node's
|
||||
* composed ruleset — `allow`, including yolo-rewritten, auto-approves;
|
||||
* `deny` auto-denies), then escalate `ask` (or a request missing
|
||||
* `accessIntent`, the version-skew floor, ADR 0008 §4) to the selected
|
||||
* `Authorizer`.
|
||||
*/
|
||||
private async resolveDecision(
|
||||
request: ForwardedPermissionRequest,
|
||||
logDetails: Record<string, unknown>,
|
||||
): Promise<PermissionPromptDecision> {
|
||||
const check = request.accessIntent
|
||||
? this.policy.resolve(request.accessIntent)
|
||||
: null;
|
||||
|
||||
if (check && check.state !== "ask") {
|
||||
// The rule is carried in full rather than left to the event name: the
|
||||
// response file has no surface, pattern, or origin column for the
|
||||
// requester's record to lean on.
|
||||
const decidedBy: DecisionSource = {
|
||||
kind: "rule",
|
||||
surface: request.accessIntent?.surface ?? check.toolName,
|
||||
pattern: check.matchedPattern ?? null,
|
||||
origin: check.origin,
|
||||
};
|
||||
const approved = check.state === "allow";
|
||||
this.logger.review(
|
||||
approved
|
||||
? "forwarded_permission.auto_approved"
|
||||
: "forwarded_permission.auto_denied",
|
||||
{ ...logDetails, decidedBy },
|
||||
);
|
||||
return approved
|
||||
? { approved: true, state: "approved", decidedBy }
|
||||
: { approved: false, state: "denied", decidedBy };
|
||||
}
|
||||
|
||||
this.logger.review("forwarded_permission.prompted", logDetails);
|
||||
try {
|
||||
return await this.escalator.escalate(buildForwardedAskDetails(request));
|
||||
} catch (error) {
|
||||
const reason = formatUnknownErrorMessage(error);
|
||||
logPermissionForwardingError(
|
||||
this.logger,
|
||||
`Failed to escalate forwarded permission request '${request.id}'`,
|
||||
error,
|
||||
);
|
||||
// Nobody denied this; the escalation broke and the node failed closed.
|
||||
return {
|
||||
approved: false,
|
||||
state: "denied",
|
||||
decidedBy: { kind: "gate_error", reason },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One-hop canary: forwarding is depth-1 (child → root). If the requester is
|
||||
* itself a registered subagent whose parent is not this serving session, the
|
||||
* request came through more than one hop (or was misrouted) — resolution is
|
||||
* still well-defined, so keep serving, but warn loudly so a future
|
||||
* recursion-guard break is visible rather than silent. Unregistered
|
||||
* (external file-based) requesters have no recorded parent and are silent.
|
||||
*/
|
||||
private warnOnMultiHop(
|
||||
request: ForwardedPermissionRequest,
|
||||
currentSessionId: string,
|
||||
): void {
|
||||
const requesterInfo = this.registry?.get(request.requesterSessionId);
|
||||
if (
|
||||
requesterInfo?.parentSessionId &&
|
||||
requesterInfo.parentSessionId !== currentSessionId
|
||||
) {
|
||||
logPermissionForwardingWarning(
|
||||
this.logger,
|
||||
`Forwarded permission request '${request.id}' violates the one-hop ` +
|
||||
`invariant: requester '${request.requesterSessionId}' is a registered ` +
|
||||
`subagent whose parent '${requesterInfo.parentSessionId}' is not this ` +
|
||||
`serving session '${currentSessionId}' (multi-hop or misrouted).`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { SessionEntryView } from "#src/active-agent";
|
||||
import type { PermissionDecisionUi } from "#src/authority/permission-dialog";
|
||||
|
||||
/**
|
||||
* Narrow context the forwarding subsystem reads: the UI gate (`hasUI`), the
|
||||
* dialog UI surface, and the three session-manager readers `getSessionId`
|
||||
* and the `active-agent` helpers use.
|
||||
*
|
||||
* A full `ExtensionContext` satisfies this structurally, so production
|
||||
* callers pass `ctx` unchanged.
|
||||
*/
|
||||
export interface ForwarderContext {
|
||||
hasUI: boolean;
|
||||
ui: PermissionDecisionUi;
|
||||
/** The session's working directory, stamped onto a forwarded request as the requester cwd. */
|
||||
cwd: string;
|
||||
sessionManager: {
|
||||
getSessionId(): string;
|
||||
getSessionDir(): string;
|
||||
getEntries(): readonly SessionEntryView[];
|
||||
};
|
||||
}
|
||||
|
||||
/** Reads the current session cwd off `ctx`. */
|
||||
export function getCwd(ctx: ForwarderContext): string {
|
||||
return ctx.cwd;
|
||||
}
|
||||
|
||||
/** Reads the current session id off `ctx`, falling back to `"unknown"`. */
|
||||
export function getSessionId(ctx: ForwarderContext): string {
|
||||
try {
|
||||
const sessionId = ctx.sessionManager.getSessionId();
|
||||
if (typeof sessionId === "string" && sessionId.trim()) {
|
||||
return sessionId.trim();
|
||||
}
|
||||
} catch {}
|
||||
|
||||
return "unknown";
|
||||
}
|
||||
@@ -0,0 +1,507 @@
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
renameSync,
|
||||
rmdirSync,
|
||||
unlinkSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
|
||||
import { asDecisionSource } from "#src/authority/decision-source";
|
||||
import { isPermissionDecisionState } from "#src/authority/permission-dialog";
|
||||
import {
|
||||
createPermissionForwardingLocation,
|
||||
type ForwardedAccessIntent,
|
||||
type ForwardedPermissionRequest,
|
||||
type ForwardedPermissionResponse,
|
||||
type ForwardedSessionApproval,
|
||||
type PermissionForwardingLocation,
|
||||
} from "#src/authority/permission-forwarding";
|
||||
import {
|
||||
OWNER_ONLY_DIRECTORY_MODE,
|
||||
OWNER_ONLY_FILE_MODE,
|
||||
} from "#src/log-file-permissions";
|
||||
import type { PermissionUiPromptSource } from "#src/permission-events";
|
||||
import { asPromptPayload } from "#src/presentation/prompt-payload";
|
||||
import type { DebugReviewLogger } from "#src/session-logger";
|
||||
|
||||
/** Valid `permissions:ui_prompt` source values, for tolerant request reads. */
|
||||
const UI_PROMPT_SOURCES = [
|
||||
"tool_call",
|
||||
"skill_input",
|
||||
"skill_read",
|
||||
] as const satisfies readonly PermissionUiPromptSource[];
|
||||
|
||||
/** Narrow an unknown value to a valid prompt source, or `undefined`. */
|
||||
function asUiPromptSource(
|
||||
value: unknown,
|
||||
): PermissionUiPromptSource | undefined {
|
||||
return UI_PROMPT_SOURCES.find((source) => source === value);
|
||||
}
|
||||
|
||||
/** Narrow an unknown value to a nullable display string, or `undefined`. */
|
||||
function asNullableDisplayString(value: unknown): string | null | undefined {
|
||||
if (value === null || typeof value === "string") {
|
||||
return value;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrow an unknown value to a `ForwardedSessionApproval`, or `undefined`.
|
||||
*
|
||||
* Tolerant read: the child's session-approval suggestion is optional (absent
|
||||
* on an older child) and only accepted when well-formed — a non-empty surface
|
||||
* and an all-string patterns array.
|
||||
*/
|
||||
function asForwardedSessionApproval(
|
||||
value: unknown,
|
||||
): ForwardedSessionApproval | undefined {
|
||||
if (typeof value !== "object" || value === null) {
|
||||
return undefined;
|
||||
}
|
||||
const candidate = value as Partial<ForwardedSessionApproval>;
|
||||
if (
|
||||
typeof candidate.surface !== "string" ||
|
||||
candidate.surface.length === 0 ||
|
||||
!Array.isArray(candidate.patterns) ||
|
||||
!candidate.patterns.every((pattern) => typeof pattern === "string")
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return { surface: candidate.surface, patterns: [...candidate.patterns] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrow an unknown value to a `ForwardedAccessIntent`, or `undefined`.
|
||||
*
|
||||
* Tolerant read: the child-fixed access intent is optional (absent on an older
|
||||
* child) and only accepted when fully well-formed — a string `surface`, an
|
||||
* all-string `matchValues` array, a `string | null` `boundaryValue`, a string
|
||||
* `requesterCwd`, and a `principal` with string `sessionId`/`agentName`. Any
|
||||
* malformed shape → `undefined`, so the serving node floors to `ask` (Step 3)
|
||||
* rather than resolving against corrupt facts.
|
||||
*/
|
||||
function asForwardedAccessIntent(
|
||||
value: unknown,
|
||||
): ForwardedAccessIntent | undefined {
|
||||
if (typeof value !== "object" || value === null) {
|
||||
return undefined;
|
||||
}
|
||||
const candidate = value as {
|
||||
surface?: unknown;
|
||||
matchValues?: unknown;
|
||||
boundaryValue?: unknown;
|
||||
requesterCwd?: unknown;
|
||||
principal?: unknown;
|
||||
};
|
||||
if (
|
||||
typeof candidate.surface !== "string" ||
|
||||
!Array.isArray(candidate.matchValues) ||
|
||||
!candidate.matchValues.every((entry) => typeof entry === "string") ||
|
||||
!(
|
||||
candidate.boundaryValue === null ||
|
||||
typeof candidate.boundaryValue === "string"
|
||||
) ||
|
||||
typeof candidate.requesterCwd !== "string" ||
|
||||
typeof candidate.principal !== "object" ||
|
||||
candidate.principal === null
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const principal = candidate.principal as {
|
||||
sessionId?: unknown;
|
||||
agentName?: unknown;
|
||||
};
|
||||
if (
|
||||
typeof principal.sessionId !== "string" ||
|
||||
typeof principal.agentName !== "string"
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
surface: candidate.surface,
|
||||
matchValues: [...candidate.matchValues],
|
||||
boundaryValue: candidate.boundaryValue,
|
||||
requesterCwd: candidate.requesterCwd,
|
||||
principal: {
|
||||
sessionId: principal.sessionId,
|
||||
agentName: principal.agentName,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function formatUnknownErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error && error.message) {
|
||||
return error.message;
|
||||
}
|
||||
return String(error);
|
||||
}
|
||||
|
||||
export function isErrnoCode(error: unknown, code: string): boolean {
|
||||
return Boolean(
|
||||
error &&
|
||||
typeof error === "object" &&
|
||||
"code" in error &&
|
||||
(error as { code?: string }).code === code,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a warning to both the review and debug logs.
|
||||
* Pass `null` for `logger` to silently no-op (e.g. in unit tests without IO).
|
||||
*/
|
||||
export function logPermissionForwardingWarning(
|
||||
logger: DebugReviewLogger | null,
|
||||
message: string,
|
||||
error?: unknown,
|
||||
): void {
|
||||
const details =
|
||||
typeof error === "undefined"
|
||||
? { message }
|
||||
: { message, error: formatUnknownErrorMessage(error) };
|
||||
|
||||
logger?.review("permission_forwarding.warning", details);
|
||||
logger?.debug("permission_forwarding.warning", details);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log an error to both the review and debug logs.
|
||||
* Pass `null` for `logger` to silently no-op (e.g. in unit tests without IO).
|
||||
*/
|
||||
export function logPermissionForwardingError(
|
||||
logger: DebugReviewLogger | null,
|
||||
message: string,
|
||||
error?: unknown,
|
||||
): void {
|
||||
const details =
|
||||
typeof error === "undefined"
|
||||
? { message }
|
||||
: { message, error: formatUnknownErrorMessage(error) };
|
||||
|
||||
logger?.review("permission_forwarding.error", details);
|
||||
logger?.debug("permission_forwarding.error", details);
|
||||
}
|
||||
|
||||
export function ensureDirectoryExists(
|
||||
logger: DebugReviewLogger | null,
|
||||
path: string,
|
||||
description: string,
|
||||
): boolean {
|
||||
try {
|
||||
mkdirSync(path, { recursive: true, mode: OWNER_ONLY_DIRECTORY_MODE });
|
||||
return true;
|
||||
} catch (error) {
|
||||
logPermissionForwardingError(
|
||||
logger,
|
||||
`Failed to create ${description} directory '${path}'`,
|
||||
error,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function getPermissionForwardingLocationForSession(
|
||||
forwardingDir: string,
|
||||
sessionId: string,
|
||||
): PermissionForwardingLocation {
|
||||
return createPermissionForwardingLocation(forwardingDir, sessionId);
|
||||
}
|
||||
|
||||
export function ensurePermissionForwardingLocation(
|
||||
logger: DebugReviewLogger | null,
|
||||
forwardingDir: string,
|
||||
sessionId: string,
|
||||
): PermissionForwardingLocation | null {
|
||||
let location: PermissionForwardingLocation;
|
||||
try {
|
||||
location = getPermissionForwardingLocationForSession(
|
||||
forwardingDir,
|
||||
sessionId,
|
||||
);
|
||||
} catch (error) {
|
||||
logPermissionForwardingError(
|
||||
logger,
|
||||
"Failed to resolve permission forwarding location",
|
||||
error,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const sessionRootReady = ensureDirectoryExists(
|
||||
logger,
|
||||
location.sessionRootDir,
|
||||
"permission forwarding session root",
|
||||
);
|
||||
const requestsReady = ensureDirectoryExists(
|
||||
logger,
|
||||
location.requestsDir,
|
||||
"permission forwarding requests",
|
||||
);
|
||||
const responsesReady = ensureDirectoryExists(
|
||||
logger,
|
||||
location.responsesDir,
|
||||
"permission forwarding responses",
|
||||
);
|
||||
|
||||
return sessionRootReady && requestsReady && responsesReady ? location : null;
|
||||
}
|
||||
|
||||
export function getExistingPermissionForwardingLocation(
|
||||
forwardingDir: string,
|
||||
sessionId: string,
|
||||
): PermissionForwardingLocation | null {
|
||||
let location: PermissionForwardingLocation;
|
||||
try {
|
||||
location = getPermissionForwardingLocationForSession(
|
||||
forwardingDir,
|
||||
sessionId,
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
return existsSync(location.requestsDir) ? location : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to remove a directory if it is empty.
|
||||
*
|
||||
* Returns `true` when the directory is absent after the call (successfully
|
||||
* removed, or never existed). Returns `false` when the directory still exists
|
||||
* (non-empty, or a filesystem error prevented removal).
|
||||
*/
|
||||
export function tryRemoveDirectoryIfEmpty(
|
||||
logger: DebugReviewLogger | null,
|
||||
path: string,
|
||||
description: string,
|
||||
): boolean {
|
||||
if (!existsSync(path)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = readdirSync(path);
|
||||
} catch (error) {
|
||||
logPermissionForwardingWarning(
|
||||
logger,
|
||||
`Failed to inspect ${description} directory '${path}'`,
|
||||
error,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (entries.length > 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
rmdirSync(path);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (isErrnoCode(error, "ENOENT")) {
|
||||
return true;
|
||||
}
|
||||
if (isErrnoCode(error, "ENOTEMPTY")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
logPermissionForwardingWarning(
|
||||
logger,
|
||||
`Failed to remove empty ${description} directory '${path}'`,
|
||||
error,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function cleanupPermissionForwardingLocationIfEmpty(
|
||||
logger: DebugReviewLogger | null,
|
||||
location: PermissionForwardingLocation,
|
||||
): void {
|
||||
// Only remove responses/ when requests/ is already gone — removing responses/
|
||||
// while a request is still pending causes the ENOENT write loop (issue #398).
|
||||
const requestsGone = tryRemoveDirectoryIfEmpty(
|
||||
logger,
|
||||
location.requestsDir,
|
||||
`${location.label} permission forwarding requests`,
|
||||
);
|
||||
if (requestsGone) {
|
||||
tryRemoveDirectoryIfEmpty(
|
||||
logger,
|
||||
location.responsesDir,
|
||||
`${location.label} permission forwarding responses`,
|
||||
);
|
||||
}
|
||||
tryRemoveDirectoryIfEmpty(
|
||||
logger,
|
||||
location.sessionRootDir,
|
||||
`${location.label} permission forwarding session root`,
|
||||
);
|
||||
}
|
||||
|
||||
export function safeDeleteFile(
|
||||
logger: DebugReviewLogger | null,
|
||||
filePath: string,
|
||||
description: string,
|
||||
): void {
|
||||
try {
|
||||
unlinkSync(filePath);
|
||||
} catch (error) {
|
||||
if (isErrnoCode(error, "ENOENT")) {
|
||||
return;
|
||||
}
|
||||
|
||||
logPermissionForwardingWarning(
|
||||
logger,
|
||||
`Failed to delete ${description} file '${filePath}'`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function writeJsonFileAtomic(
|
||||
logger: DebugReviewLogger | null,
|
||||
filePath: string,
|
||||
value: unknown,
|
||||
): void {
|
||||
const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
|
||||
|
||||
try {
|
||||
// `rename` preserves the temp file's mode, so setting it here is enough —
|
||||
// a response overwriting an existing file also comes through a fresh temp.
|
||||
writeFileSync(tempPath, JSON.stringify(value), {
|
||||
encoding: "utf-8",
|
||||
mode: OWNER_ONLY_FILE_MODE,
|
||||
});
|
||||
renameSync(tempPath, filePath);
|
||||
} catch (error) {
|
||||
safeDeleteFile(logger, tempPath, "temporary permission-forwarding");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function readForwardedPermissionRequest(
|
||||
logger: DebugReviewLogger | null,
|
||||
filePath: string,
|
||||
): ForwardedPermissionRequest | null {
|
||||
try {
|
||||
const raw = readFileSync(filePath, "utf-8");
|
||||
const parsed = JSON.parse(raw) as Partial<ForwardedPermissionRequest>;
|
||||
if (
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- JSON.parse can return null for the string "null"
|
||||
!parsed ||
|
||||
typeof parsed.id !== "string" ||
|
||||
typeof parsed.createdAt !== "number" ||
|
||||
typeof parsed.requesterSessionId !== "string" ||
|
||||
typeof parsed.targetSessionId !== "string" ||
|
||||
typeof parsed.requesterAgentName !== "string"
|
||||
) {
|
||||
logPermissionForwardingWarning(
|
||||
logger,
|
||||
`Ignoring invalid forwarded permission request format in '${filePath}'`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: parsed.id,
|
||||
createdAt: parsed.createdAt,
|
||||
requesterSessionId: parsed.requesterSessionId,
|
||||
targetSessionId: parsed.targetSessionId,
|
||||
requesterAgentName: parsed.requesterAgentName,
|
||||
// Tolerant read: the payload and display fields are optional and may be
|
||||
// absent (older child) or malformed; reconstruct only the well-formed
|
||||
// ones. An older child's `message` is deliberately not salvaged — a
|
||||
// skewed ask renders from the fields it does carry (ADR 0011 §9).
|
||||
payload: asPromptPayload(parsed.payload),
|
||||
source: asUiPromptSource(parsed.source),
|
||||
surface: asNullableDisplayString(parsed.surface),
|
||||
value: asNullableDisplayString(parsed.value),
|
||||
sessionApproval: asForwardedSessionApproval(parsed.sessionApproval),
|
||||
accessIntent: asForwardedAccessIntent(parsed.accessIntent),
|
||||
};
|
||||
} catch (error) {
|
||||
logPermissionForwardingWarning(
|
||||
logger,
|
||||
`Failed to read forwarded permission request '${filePath}'`,
|
||||
error,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function readForwardedPermissionResponse(
|
||||
logger: DebugReviewLogger | null,
|
||||
filePath: string,
|
||||
): ForwardedPermissionResponse | null {
|
||||
try {
|
||||
const raw = readFileSync(filePath, "utf-8");
|
||||
const parsed = JSON.parse(raw) as Partial<ForwardedPermissionResponse>;
|
||||
if (
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- JSON.parse can return null for the string "null"
|
||||
!parsed ||
|
||||
typeof parsed.approved !== "boolean" ||
|
||||
!isPermissionDecisionState(parsed.state) ||
|
||||
typeof parsed.responderSessionId !== "string"
|
||||
) {
|
||||
logPermissionForwardingWarning(
|
||||
logger,
|
||||
`Ignoring invalid forwarded permission response format in '${filePath}'`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
approved: parsed.approved,
|
||||
state: parsed.state,
|
||||
denialReason:
|
||||
typeof parsed.denialReason === "string"
|
||||
? parsed.denialReason
|
||||
: undefined,
|
||||
responderSessionId: parsed.responderSessionId,
|
||||
respondedAt:
|
||||
typeof parsed.respondedAt === "number"
|
||||
? parsed.respondedAt
|
||||
: Date.now(),
|
||||
// Tolerant like the request's `accessIntent`: an unusable provenance
|
||||
// record is dropped, but the decision itself still has to reach the
|
||||
// requester, so it never rejects the response.
|
||||
decidedBy: asDecisionSource(parsed.decidedBy),
|
||||
};
|
||||
} catch (error) {
|
||||
logPermissionForwardingWarning(
|
||||
logger,
|
||||
`Failed to read forwarded permission response '${filePath}'`,
|
||||
error,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function listRequestFiles(
|
||||
logger: DebugReviewLogger | null,
|
||||
requestsDir: string,
|
||||
): string[] {
|
||||
try {
|
||||
return readdirSync(requestsDir)
|
||||
.filter((name) => name.endsWith(".json"))
|
||||
.sort();
|
||||
} catch (error) {
|
||||
logPermissionForwardingWarning(
|
||||
logger,
|
||||
`Failed to read permission forwarding requests from '${requestsDir}'`,
|
||||
error,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,450 @@
|
||||
/**
|
||||
* forwarding-liveness.ts — Is anyone draining a forwarded-permission inbox?
|
||||
*
|
||||
* The in-process answer already exists: a serving session marks itself in the
|
||||
* process-global `ServingSessionRegistry`, and an in-process child abandons a
|
||||
* target that has looked unmarked for the grace window instead of waiting out
|
||||
* the full forwarding timeout (#719).
|
||||
*
|
||||
* A child spawned as a separate `pi` process shares no `globalThis` with its
|
||||
* parent, so that mark is invisible to it and it keeps waiting the full ten
|
||||
* minutes — every `ask` forwarded to a session that has already exited costs
|
||||
* the whole timeout and ends in a denial nobody made (#735 scenario 1).
|
||||
*
|
||||
* The filesystem is the only channel those two processes share, so the serving
|
||||
* session publishes a heartbeat there: one record per serving session,
|
||||
* refreshed while it polls and withdrawn when it stops.
|
||||
*/
|
||||
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
ensureDirectoryExists,
|
||||
isErrnoCode,
|
||||
logPermissionForwardingError,
|
||||
safeDeleteFile,
|
||||
writeJsonFileAtomic,
|
||||
} from "#src/authority/forwarding-io";
|
||||
import type { PermissionForwardingTarget } from "#src/authority/permission-forwarding";
|
||||
import {
|
||||
encodeSessionIdForPath,
|
||||
PERMISSION_FORWARDING_POLL_INTERVAL_MS,
|
||||
} from "#src/authority/permission-forwarding";
|
||||
import type {
|
||||
ServingAnnouncer,
|
||||
ServingLookup,
|
||||
} from "#src/authority/serving-registry";
|
||||
import type { DebugReviewLogger } from "#src/session-logger";
|
||||
|
||||
/**
|
||||
* How often a serving session rewrites its heartbeat — four poll ticks.
|
||||
*
|
||||
* Longer than the poll interval so `ForwardingManager` can announce on every
|
||||
* tick without four filesystem writes a second, and short enough that a record
|
||||
* deleted underneath its owner reappears well inside the grace window a
|
||||
* forwarding child waits out before abandoning.
|
||||
*/
|
||||
export const SERVING_HEARTBEAT_REFRESH_MS =
|
||||
4 * PERMISSION_FORWARDING_POLL_INTERVAL_MS;
|
||||
|
||||
/**
|
||||
* How long a heartbeat may go unrefreshed before its writer is presumed gone —
|
||||
* five refreshes.
|
||||
*
|
||||
* Generous because a delayed Node timer is not a dead session, and because the
|
||||
* case this threshold exists for (a process that is alive but no longer
|
||||
* polling) is the rare one: an exited session withdraws its record and a killed
|
||||
* one is caught by the recorded pid, neither of which waits for staleness.
|
||||
*/
|
||||
export const SERVING_HEARTBEAT_STALE_MS = 5 * SERVING_HEARTBEAT_REFRESH_MS;
|
||||
|
||||
/** What a serving session publishes while it drains its forwarded-permission inbox. */
|
||||
export interface ServingHeartbeat {
|
||||
sessionId: string;
|
||||
/** The serving process, so a killed session is detectable without waiting out staleness. */
|
||||
pid: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* How a session's heartbeat reads right now.
|
||||
*
|
||||
* Only `"alive"` means someone is draining the inbox. The other three are the
|
||||
* ways a target can be unserved, kept apart because they are the diagnosis a
|
||||
* stalled forward needs: `"absent"` is a session that exited (or never served,
|
||||
* or runs a version that does not publish), `"dead_pid"` one that was killed,
|
||||
* and `"stale"` one whose process survives but stopped polling.
|
||||
*/
|
||||
export type HeartbeatState = "alive" | "absent" | "stale" | "dead_pid";
|
||||
|
||||
/**
|
||||
* Read side of the heartbeat channel, consumed by a forwarding child.
|
||||
*
|
||||
* Separate from the announce seam because the two have no caller in common: a
|
||||
* serving session only publishes, and a forwarding child only reads (ISP).
|
||||
*/
|
||||
export interface HeartbeatReader {
|
||||
read(sessionId: string): HeartbeatState;
|
||||
/** Every session whose record reads as alive, for the abandonment diagnostic. */
|
||||
servingIds(): readonly string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Query-side seam: is the session a forwarding target names being drained?
|
||||
*
|
||||
* Keyed on the target rather than a session id because the answer depends on
|
||||
* how the target was resolved. An in-process child and its parent share a
|
||||
* `globalThis`, so the registry answers for them; an out-of-process pair shares
|
||||
* only the filesystem; and a session that owns the inbox it is forwarding to is
|
||||
* not a case either channel describes.
|
||||
*
|
||||
* Consolidating that into one collaborator is what keeps `ParentAuthorizer`
|
||||
* from holding two lookups and re-deciding which one applies — the decision has
|
||||
* one home, and a third channel would not reach the poll loop.
|
||||
*/
|
||||
export interface TargetServingLookup {
|
||||
/** `true` serving, `false` not serving, `null` when the target carries no signal. */
|
||||
isServing(target: PermissionForwardingTarget): boolean | null;
|
||||
/** What the judge observed, for the review entry a child writes when it gives up. */
|
||||
describe(target: PermissionForwardingTarget): ServingObservation;
|
||||
}
|
||||
|
||||
/** What answered a liveness question, and what it saw. */
|
||||
export interface ServingObservation {
|
||||
channel: "registry" | "heartbeat" | "none";
|
||||
/** The heartbeat state behind a `"heartbeat"` answer; `null` on the other channels. */
|
||||
state: HeartbeatState | null;
|
||||
servingIds: readonly string[];
|
||||
}
|
||||
|
||||
/** Constructor config for {@link ForwardingLivenessJudge}. */
|
||||
export interface ForwardingLivenessJudgeDeps {
|
||||
/** Answers for a target the requester shares a process with. */
|
||||
registry: ServingLookup;
|
||||
/** Answers for a target in another process. */
|
||||
heartbeats: HeartbeatReader;
|
||||
}
|
||||
|
||||
/**
|
||||
* Routes a liveness question to the channel that can answer it.
|
||||
*
|
||||
* The routing key is `PermissionForwardingTarget.source`, which the resolver
|
||||
* already produces — so "in-process" is decided once, where the target is
|
||||
* found, rather than re-derived here (#719).
|
||||
*/
|
||||
export class ForwardingLivenessJudge implements TargetServingLookup {
|
||||
constructor(private readonly deps: ForwardingLivenessJudgeDeps) {}
|
||||
|
||||
isServing(target: PermissionForwardingTarget): boolean | null {
|
||||
switch (target.source) {
|
||||
case "registry":
|
||||
return this.deps.registry.isServing(target.sessionId);
|
||||
case "env":
|
||||
return this.deps.heartbeats.read(target.sessionId) === "alive";
|
||||
case "self":
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
describe(target: PermissionForwardingTarget): ServingObservation {
|
||||
switch (target.source) {
|
||||
case "registry":
|
||||
return {
|
||||
channel: "registry",
|
||||
state: null,
|
||||
servingIds: this.deps.registry.servingIds(),
|
||||
};
|
||||
case "env":
|
||||
return {
|
||||
channel: "heartbeat",
|
||||
state: this.deps.heartbeats.read(target.sessionId),
|
||||
servingIds: this.deps.heartbeats.servingIds(),
|
||||
};
|
||||
case "self":
|
||||
return { channel: "none", state: null, servingIds: [] };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const SERVING_HEARTBEAT_DIRECTORY_NAME = "serving";
|
||||
|
||||
/**
|
||||
* Where serving heartbeats live: beside the `sessions/` tree, never inside it.
|
||||
*
|
||||
* A heartbeat under `sessions/<id>/` would make that session root permanently
|
||||
* non-empty, entangling liveness with the request/response cleanup whose
|
||||
* removal ordering already produced an ENOENT write loop (#398). Kept disjoint,
|
||||
* that logic stays untouched and "who is serving" is a single directory read.
|
||||
*/
|
||||
export function servingHeartbeatDir(forwardingDir: string): string {
|
||||
return join(forwardingDir, SERVING_HEARTBEAT_DIRECTORY_NAME);
|
||||
}
|
||||
|
||||
/** The heartbeat record for `sessionId`, under {@link servingHeartbeatDir}. */
|
||||
export function servingHeartbeatPath(
|
||||
forwardingDir: string,
|
||||
sessionId: string,
|
||||
): string {
|
||||
return join(
|
||||
servingHeartbeatDir(forwardingDir),
|
||||
`${encodeSessionIdForPath(sessionId)}.json`,
|
||||
);
|
||||
}
|
||||
|
||||
/** Constructor config for {@link ServingHeartbeatStore}. */
|
||||
export interface ServingHeartbeatStoreDeps {
|
||||
forwardingDir: string;
|
||||
logger: DebugReviewLogger;
|
||||
/** Injected so the refresh throttle and staleness are testable without sleeping. */
|
||||
now?: () => number;
|
||||
/** The process to record. Injected so a test can publish a pid it controls. */
|
||||
pid?: number;
|
||||
/** Injected so a test can decide which pids are running. */
|
||||
isProcessAlive?: (pid: number) => boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Publishes this session's serving heartbeat to the filesystem.
|
||||
*
|
||||
* Satisfies the same {@link ServingAnnouncer} seam as `ServingSessionRegistry`,
|
||||
* so `ForwardingManager` announces to both channels through one collaborator
|
||||
* and neither knows the other exists.
|
||||
*
|
||||
* `markServing` is idempotent by that seam's contract and internally throttled,
|
||||
* so the caller may announce on every poll tick. Nothing here throws: it runs
|
||||
* from a timer, and a filesystem failure must degrade to the pre-existing
|
||||
* timeout rather than break the poll loop.
|
||||
*/
|
||||
export class ServingHeartbeatStore
|
||||
implements ServingAnnouncer, HeartbeatReader
|
||||
{
|
||||
private readonly forwardingDir: string;
|
||||
private readonly logger: DebugReviewLogger;
|
||||
private readonly now: () => number;
|
||||
private readonly pid: number;
|
||||
private readonly isProcessAlive: (pid: number) => boolean;
|
||||
private published: { sessionId: string; at: number } | null = null;
|
||||
private hasSweptDeadRecords = false;
|
||||
|
||||
constructor(deps: ServingHeartbeatStoreDeps) {
|
||||
this.forwardingDir = deps.forwardingDir;
|
||||
this.logger = deps.logger;
|
||||
this.now = deps.now ?? Date.now;
|
||||
this.pid = deps.pid ?? process.pid;
|
||||
this.isProcessAlive = deps.isProcessAlive ?? isRunningProcess;
|
||||
}
|
||||
|
||||
/** Publish (or refresh) `sessionId`'s heartbeat. Throttled; never throws. */
|
||||
markServing(sessionId: string): void {
|
||||
const at = this.now();
|
||||
if (this.isThrottled(sessionId, at)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const directory = servingHeartbeatDir(this.forwardingDir);
|
||||
if (
|
||||
!ensureDirectoryExists(
|
||||
this.logger,
|
||||
directory,
|
||||
"permission forwarding serving heartbeat",
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.sweepDeadRecordsOnce();
|
||||
|
||||
const heartbeat: ServingHeartbeat = {
|
||||
sessionId,
|
||||
pid: this.pid,
|
||||
updatedAt: at,
|
||||
};
|
||||
try {
|
||||
writeJsonFileAtomic(
|
||||
this.logger,
|
||||
servingHeartbeatPath(this.forwardingDir, sessionId),
|
||||
heartbeat,
|
||||
);
|
||||
} catch (error) {
|
||||
logPermissionForwardingError(
|
||||
this.logger,
|
||||
`Failed to publish the serving heartbeat for session '${sessionId}'`,
|
||||
error,
|
||||
);
|
||||
return;
|
||||
}
|
||||
this.published = { sessionId, at };
|
||||
}
|
||||
|
||||
/** Withdraw `sessionId`'s heartbeat, leaving the directory for its siblings. */
|
||||
clearServing(sessionId: string): void {
|
||||
if (this.published?.sessionId === sessionId) {
|
||||
this.published = null;
|
||||
}
|
||||
safeDeleteFile(
|
||||
this.logger,
|
||||
servingHeartbeatPath(this.forwardingDir, sessionId),
|
||||
"permission forwarding serving heartbeat",
|
||||
);
|
||||
}
|
||||
|
||||
/** How `sessionId`'s heartbeat reads right now. */
|
||||
read(sessionId: string): HeartbeatState {
|
||||
const record = this.readRecord(
|
||||
servingHeartbeatPath(this.forwardingDir, sessionId),
|
||||
);
|
||||
return record === null ? "absent" : this.classify(record);
|
||||
}
|
||||
|
||||
/** Every session whose record reads as alive. */
|
||||
servingIds(): readonly string[] {
|
||||
const ids: string[] = [];
|
||||
for (const { record } of this.listRecords()) {
|
||||
if (record !== null && this.classify(record) === "alive") {
|
||||
ids.push(record.sessionId);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
// ── Private methods ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Delete the records of processes that are provably gone, once per session.
|
||||
*
|
||||
* Without this the directory grows one record per session that was killed
|
||||
* rather than shut down, forever. Bounded to a single directory read at the
|
||||
* first announcement, and safe under pid reuse: a wrongly swept owner
|
||||
* republishes within the refresh window, which is shorter than the grace a
|
||||
* forwarding child waits out.
|
||||
*
|
||||
* Only a dead pid is proof. A record that is merely stale belongs to a
|
||||
* process that still exists, and the reader already reports it as stale
|
||||
* without anyone having to remove it.
|
||||
*/
|
||||
private sweepDeadRecordsOnce(): void {
|
||||
if (this.hasSweptDeadRecords) {
|
||||
return;
|
||||
}
|
||||
this.hasSweptDeadRecords = true;
|
||||
for (const { path, record } of this.listRecords()) {
|
||||
if (record !== null && this.isProcessAlive(record.pid)) {
|
||||
continue;
|
||||
}
|
||||
safeDeleteFile(
|
||||
this.logger,
|
||||
path,
|
||||
"abandoned permission forwarding serving heartbeat",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Every published record, paired with its path; unusable ones read as `null`. */
|
||||
private listRecords(): {
|
||||
path: string;
|
||||
record: ServingHeartbeat | null;
|
||||
}[] {
|
||||
const directory = servingHeartbeatDir(this.forwardingDir);
|
||||
let names: string[];
|
||||
try {
|
||||
names = readdirSync(directory);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
return names
|
||||
.filter((name) => name.endsWith(".json"))
|
||||
.map((name) => {
|
||||
const path = join(directory, name);
|
||||
return { path, record: this.readRecord(path) };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a record, or `null` when it is missing or unusable.
|
||||
*
|
||||
* Silent by design: a forwarding child calls this on every poll tick, so a
|
||||
* warning per unreadable read would flood the review log at four lines a
|
||||
* second. The unusability is already reported once, as the `absent` state on
|
||||
* the abandonment entry.
|
||||
*/
|
||||
private readRecord(path: string): ServingHeartbeat | null {
|
||||
try {
|
||||
return asServingHeartbeat(JSON.parse(readFileSync(path, "utf-8")));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Which of the four states a well-formed record is in. */
|
||||
private classify(record: ServingHeartbeat): HeartbeatState {
|
||||
if (!this.isProcessAlive(record.pid)) {
|
||||
return "dead_pid";
|
||||
}
|
||||
return this.now() - record.updatedAt >= SERVING_HEARTBEAT_STALE_MS
|
||||
? "stale"
|
||||
: "alive";
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the record on disk is recent enough to leave alone.
|
||||
*
|
||||
* Time alone, with no existence probe: an existence check would cost a
|
||||
* syscall on every poll tick to save at most one refresh window, and a record
|
||||
* removed underneath its owner reappears inside the grace window anyway.
|
||||
*/
|
||||
private isThrottled(sessionId: string, at: number): boolean {
|
||||
return (
|
||||
this.published !== null &&
|
||||
this.published.sessionId === sessionId &&
|
||||
at - this.published.at < SERVING_HEARTBEAT_REFRESH_MS
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Module-private helpers ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Narrow a parsed record, or `undefined`.
|
||||
*
|
||||
* `pid` must be a positive integer specifically: `process.kill(0, 0)` addresses
|
||||
* the caller's own process group and `kill(-n)` a foreign one, so a malformed
|
||||
* record must be rejected before it can reach the liveness probe.
|
||||
*/
|
||||
function asServingHeartbeat(value: unknown): ServingHeartbeat | null {
|
||||
if (typeof value !== "object" || value === null) {
|
||||
return null;
|
||||
}
|
||||
const candidate = value as Partial<ServingHeartbeat>;
|
||||
if (
|
||||
typeof candidate.sessionId !== "string" ||
|
||||
candidate.sessionId.length === 0 ||
|
||||
typeof candidate.pid !== "number" ||
|
||||
!Number.isInteger(candidate.pid) ||
|
||||
candidate.pid <= 0 ||
|
||||
typeof candidate.updatedAt !== "number" ||
|
||||
!Number.isFinite(candidate.updatedAt)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
sessionId: candidate.sessionId,
|
||||
pid: candidate.pid,
|
||||
updatedAt: candidate.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `pid` names a running process.
|
||||
*
|
||||
* Signal `0` performs the permission and existence checks without delivering
|
||||
* anything. `EPERM` means the process exists under another user — reported as
|
||||
* alive, the direction that falls back to the timeout rather than abandoning a
|
||||
* request someone may still answer.
|
||||
*/
|
||||
function isRunningProcess(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch (error) {
|
||||
return isErrnoCode(error, "EPERM");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
||||
import type { DebugReviewLogger } from "#src/session-logger";
|
||||
import type { InboxProcessor } from "./forwarded-request-server";
|
||||
import { getSessionId } from "./forwarder-context";
|
||||
import { PERMISSION_FORWARDING_POLL_INTERVAL_MS } from "./permission-forwarding";
|
||||
import type { ServingAnnouncer } from "./serving-registry";
|
||||
import type { SubagentDetector } from "./subagent-detection";
|
||||
|
||||
/**
|
||||
* Narrow interface for the forwarding lifecycle used by `PermissionSession`.
|
||||
* `ForwardingManager` satisfies it; tests can provide a plain object mock.
|
||||
*/
|
||||
export interface ForwardingController {
|
||||
start(ctx: ExtensionContext): void;
|
||||
stop(): void;
|
||||
}
|
||||
|
||||
/** Constructor config for {@link ForwardingManager}. */
|
||||
export interface ForwardingManagerDeps {
|
||||
/** Single owner of subagent detection; gates whether this session may serve. */
|
||||
detection: SubagentDetector;
|
||||
/** Drains this session's forwarded-permission inbox on each tick. */
|
||||
forwarder: InboxProcessor;
|
||||
/** Publishes that this session is draining its inbox, for forwarding children. */
|
||||
serving: ServingAnnouncer;
|
||||
logger: DebugReviewLogger;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encapsulates the forwarded-permission polling lifecycle.
|
||||
*
|
||||
* Owns the timer, current context, and processing-lock state that previously
|
||||
* lived as 3 mutable fields on `ExtensionRuntime`. Call `start(ctx)` on each
|
||||
* session event that may activate forwarding; call `stop()` on session
|
||||
* shutdown.
|
||||
*
|
||||
* While polling, it publishes the session id it polls to the `ServingAnnouncer`
|
||||
* so a forwarding child can tell that someone is draining the inbox it wrote
|
||||
* into — and the review log records that id, so a child forwarding to a
|
||||
* *different* id is visible as a one-line diff against its
|
||||
* `forwarded_permission.request_created` entry (#719).
|
||||
*/
|
||||
export class ForwardingManager {
|
||||
private timer: NodeJS.Timeout | null = null;
|
||||
private context: ExtensionContext | null = null;
|
||||
private processing = false;
|
||||
private servingSessionId: string | null = null;
|
||||
|
||||
constructor(private readonly deps: ForwardingManagerDeps) {}
|
||||
|
||||
/**
|
||||
* Start polling if `ctx` has UI and is not a subagent execution context.
|
||||
* No-op (timer stays running) if already polling — updates the stored
|
||||
* context so the next tick uses the latest session.
|
||||
* Stops any existing poll when the context does not qualify for forwarding.
|
||||
*/
|
||||
start(ctx: ExtensionContext): void {
|
||||
if (!ctx.hasUI || this.deps.detection.isSubagent(ctx)) {
|
||||
this.stop();
|
||||
return;
|
||||
}
|
||||
this.context = ctx;
|
||||
this.announceServing(getSessionId(ctx));
|
||||
if (this.timer) {
|
||||
return;
|
||||
}
|
||||
this.timer = setInterval(() => {
|
||||
// Ahead of the processing guard: a session whose human is deliberating at
|
||||
// a forwarded dialog holds `processInbox` open for as long as they take,
|
||||
// and it is serving throughout. Refreshing behind the guard would let its
|
||||
// announcement decay exactly when it is most demonstrably alive, and
|
||||
// every other forwarding child would give up on it.
|
||||
this.refreshServing();
|
||||
if (!this.context || this.processing) {
|
||||
return;
|
||||
}
|
||||
this.processing = true;
|
||||
void this.deps.forwarder.processInbox(this.context).finally(() => {
|
||||
this.processing = false;
|
||||
});
|
||||
}, PERMISSION_FORWARDING_POLL_INTERVAL_MS);
|
||||
}
|
||||
|
||||
/** Stop polling and clear all internal state. */
|
||||
stop(): void {
|
||||
if (this.timer) {
|
||||
clearInterval(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
this.withdrawServing();
|
||||
this.context = null;
|
||||
this.processing = false;
|
||||
}
|
||||
|
||||
// ── Private methods ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Publish `sessionId` as the served session, replacing any previous one.
|
||||
*
|
||||
* A no-op when the id is unchanged, since `start` runs on every
|
||||
* `before_agent_start`, `input`, and `tool_call` — the announcement must not
|
||||
* cost a log line per turn.
|
||||
*/
|
||||
private announceServing(sessionId: string): void {
|
||||
if (this.servingSessionId === sessionId) {
|
||||
return;
|
||||
}
|
||||
this.withdrawServing();
|
||||
this.servingSessionId = sessionId;
|
||||
this.deps.serving.markServing(sessionId);
|
||||
this.deps.logger.review("forwarded_permission.serving_started", {
|
||||
sessionId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-announce the served session, keeping a decayable channel current.
|
||||
*
|
||||
* Separate from {@link announceServing} because that one detects a change to
|
||||
* write its log line, and this one deliberately writes none — four review
|
||||
* entries a second would drown the log the announcement exists to make
|
||||
* readable.
|
||||
*/
|
||||
private refreshServing(): void {
|
||||
if (this.servingSessionId === null) {
|
||||
return;
|
||||
}
|
||||
this.deps.serving.markServing(this.servingSessionId);
|
||||
}
|
||||
|
||||
/** Withdraw the published session, if any. */
|
||||
private withdrawServing(): void {
|
||||
const sessionId = this.servingSessionId;
|
||||
if (sessionId === null) {
|
||||
return;
|
||||
}
|
||||
this.servingSessionId = null;
|
||||
this.deps.serving.clearServing(sessionId);
|
||||
this.deps.logger.review("forwarded_permission.serving_stopped", {
|
||||
sessionId,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
||||
import type {
|
||||
PermissionPromptDecision,
|
||||
RequestPermissionOptions,
|
||||
} from "#src/authority/permission-dialog";
|
||||
import type {
|
||||
PermissionPromptUi,
|
||||
PromptPreferences,
|
||||
requestPermissionDecision,
|
||||
} from "#src/authority/permission-prompt-component";
|
||||
import { buildForwardedScopeLabels } from "#src/pattern-suggest";
|
||||
import {
|
||||
emitUiPromptEvent,
|
||||
type PermissionEventBus,
|
||||
} from "#src/permission-events";
|
||||
import { buildUiPrompt } from "#src/permission-ui-prompt";
|
||||
import type { TerminalAuthorizer } from "./authorizer";
|
||||
import type { PromptPermissionDetails } from "./permission-prompter";
|
||||
|
||||
/** Dependencies required by {@link LocalUserAuthorizer}. */
|
||||
export interface LocalUserAuthorizerDeps {
|
||||
/** The active session's UI surface (select/input plus the inline `custom` dialog). */
|
||||
ui: PermissionPromptUi;
|
||||
/** The session run mode; the dispatcher renders the inline dialog only in `"tui"`. */
|
||||
mode: ExtensionContext["mode"];
|
||||
/** Event bus used for the `permissions:ui_prompt` broadcast. */
|
||||
events: PermissionEventBus;
|
||||
/** Read live at prompt time so a settings-modal toggle takes effect on the next prompt. */
|
||||
getPromptPreferences: () => PromptPreferences;
|
||||
/** Injected for testability; production callers pass the real function. */
|
||||
requestPermissionDecision: typeof requestPermissionDecision;
|
||||
}
|
||||
|
||||
/**
|
||||
* Authorizer for a session with an active UI: prompt the human here.
|
||||
*
|
||||
* Emits the `permissions:ui_prompt` broadcast (moved here from
|
||||
* `PermissionPrompter`'s `ctx.hasUI` arm) before showing the dialog, so
|
||||
* observers know a decision is imminent. This is the single emit site: a
|
||||
* forwarded ask carries its provenance on `details.forwarding`, which this
|
||||
* class renders (populated `forwarding` context + "(Subagent)" title) so the
|
||||
* broadcast stays non-degraded (#292) without a second emission path.
|
||||
*/
|
||||
export class LocalUserAuthorizer implements TerminalAuthorizer {
|
||||
constructor(private readonly deps: LocalUserAuthorizerDeps) {}
|
||||
|
||||
authorize(
|
||||
details: PromptPermissionDetails,
|
||||
): Promise<PermissionPromptDecision> {
|
||||
const uiPrompt = buildUiPrompt(details);
|
||||
emitUiPromptEvent(this.deps.events, uiPrompt);
|
||||
return this.deps.requestPermissionDecision(
|
||||
{
|
||||
mode: this.deps.mode,
|
||||
ui: this.deps.ui,
|
||||
...this.deps.getPromptPreferences(),
|
||||
},
|
||||
details.forwarding
|
||||
? "Permission Required (Subagent)"
|
||||
: "Permission Required",
|
||||
details.payload,
|
||||
buildRequestOptions(details),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A forwarded ask carrying a session-approval suggestion offers the scope
|
||||
* choice (subagent vs whole session); any other ask keeps its single
|
||||
* "for this session" option (custom label when the gate supplied one).
|
||||
*/
|
||||
function buildRequestOptions(
|
||||
details: PromptPermissionDetails,
|
||||
): RequestPermissionOptions | undefined {
|
||||
const pattern = details.sessionApproval?.patterns[0];
|
||||
if (details.forwarding && details.sessionApproval && pattern) {
|
||||
return {
|
||||
sessionScope: buildForwardedScopeLabels(
|
||||
details.forwarding.requesterAgentName,
|
||||
details.sessionApproval.surface,
|
||||
pattern,
|
||||
),
|
||||
};
|
||||
}
|
||||
return details.sessionLabel
|
||||
? { sessionLabel: details.sessionLabel }
|
||||
: undefined;
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import type { DecisionSource } from "#src/authority/decision-source";
|
||||
|
||||
export type PermissionDecisionState =
|
||||
| "approved"
|
||||
| "approved_for_session"
|
||||
| "approved_for_serving_session"
|
||||
| "denied"
|
||||
| "denied_with_reason";
|
||||
|
||||
export type PermissionPromptDecision = {
|
||||
approved: boolean;
|
||||
state: PermissionDecisionState;
|
||||
denialReason?: string;
|
||||
/**
|
||||
* True when the decision was made automatically by yolo mode rather than
|
||||
* by an interactive user prompt. Used by handlers to emit "auto_approved"
|
||||
* rather than "user_approved" in the permissions:decision broadcast.
|
||||
*/
|
||||
autoApproved?: true;
|
||||
/**
|
||||
* True when no human ever ruled on this ask: either no live authority was
|
||||
* reachable at all (`DenyingAuthorizer`, a no-UI non-subagent session) or the
|
||||
* forwarding path gave up before reaching one (`ParentAuthorizer` — target
|
||||
* unresolvable, request undeliverable, target not serving, or no answer
|
||||
* within the timeout). Consumed by deriveResolution (the decision-event
|
||||
* resolution), the gate (block reason), and PermissionPrompter (review-entry
|
||||
* resolution) to emit "confirmation_unavailable" rather than a plain user
|
||||
* denial — a user who was never asked denied nothing (#719).
|
||||
*/
|
||||
confirmationUnavailable?: true;
|
||||
/**
|
||||
* What decided this request, stamped by the site that decided it.
|
||||
*
|
||||
* Required: every decision names its decider, and the type is what
|
||||
* guarantees it rather than a convention each producer has to remember — the
|
||||
* same discipline `PromptPermissionDetails.payload` carries (#726).
|
||||
*/
|
||||
decidedBy: DecisionSource;
|
||||
};
|
||||
|
||||
/**
|
||||
* A decision before its decider is known.
|
||||
*
|
||||
* The inner producers — the dialog's decision model, the `select`/`input`
|
||||
* fallback, the verdict mapper — state the outcome; which decider to attribute
|
||||
* it to is settled one layer up, at the site that chose the producer. The same
|
||||
* shape `GateBypass.decision` uses for the request id: a producer emits only
|
||||
* what it knows.
|
||||
*/
|
||||
export type UnattributedDecision = Omit<PermissionPromptDecision, "decidedBy">;
|
||||
|
||||
export interface PermissionDecisionUi {
|
||||
select(title: string, options: string[]): Promise<string | undefined>;
|
||||
input(title: string, placeholder?: string): Promise<string | undefined>;
|
||||
}
|
||||
|
||||
const APPROVE_OPTION = "Yes";
|
||||
const APPROVE_FOR_SESSION_OPTION = "Yes, for this session";
|
||||
const DENY_OPTION = "No";
|
||||
const DENY_WITH_REASON_OPTION = "No, provide reason";
|
||||
|
||||
export function normalizePermissionDenialReason(
|
||||
value: unknown,
|
||||
): string | undefined {
|
||||
if (typeof value !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : undefined;
|
||||
}
|
||||
|
||||
export function createDeniedPermissionDecision(
|
||||
denialReason?: string,
|
||||
): UnattributedDecision {
|
||||
const normalizedReason = normalizePermissionDenialReason(denialReason);
|
||||
return normalizedReason
|
||||
? {
|
||||
approved: false,
|
||||
state: "denied_with_reason",
|
||||
denialReason: normalizedReason,
|
||||
}
|
||||
: {
|
||||
approved: false,
|
||||
state: "denied",
|
||||
};
|
||||
}
|
||||
|
||||
export function isPermissionDecisionState(
|
||||
value: unknown,
|
||||
): value is PermissionDecisionState {
|
||||
return (
|
||||
value === "approved" ||
|
||||
value === "approved_for_session" ||
|
||||
value === "approved_for_serving_session" ||
|
||||
value === "denied" ||
|
||||
value === "denied_with_reason"
|
||||
);
|
||||
}
|
||||
|
||||
export interface RequestPermissionOptions {
|
||||
/** Override the "for this session" option label (e.g. to show the suggested pattern). */
|
||||
sessionLabel?: string;
|
||||
/**
|
||||
* Forwarded asks only: when set, choosing the "for this session" option opens
|
||||
* a second select asking whether the grant applies to the requesting subagent
|
||||
* only (the least-privilege default) or the whole serving session.
|
||||
*/
|
||||
sessionScope?: {
|
||||
subagentLabel: string;
|
||||
servingSessionLabel: string;
|
||||
};
|
||||
}
|
||||
|
||||
export async function requestPermissionDecisionFromUi(
|
||||
ui: PermissionDecisionUi,
|
||||
title: string,
|
||||
message: string,
|
||||
options?: RequestPermissionOptions,
|
||||
): Promise<UnattributedDecision> {
|
||||
const sessionOption = options?.sessionLabel ?? APPROVE_FOR_SESSION_OPTION;
|
||||
const decisionOptions = [
|
||||
APPROVE_OPTION,
|
||||
sessionOption,
|
||||
DENY_OPTION,
|
||||
DENY_WITH_REASON_OPTION,
|
||||
] as const;
|
||||
|
||||
const selected = await ui.select(`${title}\n${message}`, [
|
||||
...decisionOptions,
|
||||
]);
|
||||
|
||||
if (selected === APPROVE_OPTION) {
|
||||
return {
|
||||
approved: true,
|
||||
state: "approved",
|
||||
};
|
||||
}
|
||||
|
||||
if (selected === sessionOption) {
|
||||
if (options?.sessionScope) {
|
||||
const scope = await ui.select(`${title}\nApply this session grant to:`, [
|
||||
options.sessionScope.subagentLabel,
|
||||
options.sessionScope.servingSessionLabel,
|
||||
]);
|
||||
return {
|
||||
approved: true,
|
||||
// A cancelled scope select (undefined) falls back to the
|
||||
// least-privilege subagent scope.
|
||||
state:
|
||||
scope === options.sessionScope.servingSessionLabel
|
||||
? "approved_for_serving_session"
|
||||
: "approved_for_session",
|
||||
};
|
||||
}
|
||||
return {
|
||||
approved: true,
|
||||
state: "approved_for_session",
|
||||
};
|
||||
}
|
||||
|
||||
if (selected === DENY_WITH_REASON_OPTION) {
|
||||
const denialReason = normalizePermissionDenialReason(
|
||||
await ui.input(
|
||||
`${title}\nShare why this request was denied (optional).`,
|
||||
"Reason shown back to the agent",
|
||||
),
|
||||
);
|
||||
|
||||
return createDeniedPermissionDecision(denialReason);
|
||||
}
|
||||
|
||||
return createDeniedPermissionDecision();
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
import { join } from "node:path";
|
||||
import type { DecisionSource } from "#src/authority/decision-source";
|
||||
import type { PermissionUiPromptSource } from "#src/permission-events";
|
||||
import type { PromptPayload } from "#src/presentation/prompt-payload";
|
||||
import type { PermissionDecisionState } from "./permission-dialog";
|
||||
import type { SubagentSessionRegistry } from "./subagent-registry";
|
||||
|
||||
export const PERMISSION_FORWARDING_POLL_INTERVAL_MS = 250;
|
||||
export const PERMISSION_FORWARDING_TIMEOUT_MS = 10 * 60 * 1000;
|
||||
/**
|
||||
* How long an in-process forwarding target may go unserved before the child
|
||||
* gives up on it — eight poll ticks.
|
||||
*
|
||||
* A window rather than a single check because `ForwardingManager` withdraws and
|
||||
* re-announces across a session switch, and a request that arrives inside that
|
||||
* gap is about to be picked up. Not configurable: the operator-facing knob is
|
||||
* the overall timeout, and this only decides how fast a hopeless wait ends.
|
||||
*/
|
||||
export const PERMISSION_FORWARDING_SERVING_GRACE_MS =
|
||||
8 * PERMISSION_FORWARDING_POLL_INTERVAL_MS;
|
||||
export const SUBAGENT_ENV_HINT_KEYS = [
|
||||
// pi-agent-router (original)
|
||||
"PI_IS_SUBAGENT",
|
||||
"PI_SUBAGENT_SESSION_ID",
|
||||
"PI_AGENT_ROUTER_SUBAGENT",
|
||||
// nicobailon/pi-subagents
|
||||
"PI_SUBAGENT_CHILD",
|
||||
"PI_SUBAGENT_RUN_ID",
|
||||
"PI_SUBAGENT_CHILD_AGENT",
|
||||
"PI_SUBAGENT_DEPTH",
|
||||
// HazAT/pi-interactive-subagents
|
||||
"PI_SUBAGENT_NAME",
|
||||
"PI_SUBAGENT_ID",
|
||||
"PI_SUBAGENT_SESSION",
|
||||
"PI_SUBAGENT_ACTIVITY_FILE",
|
||||
] as const;
|
||||
/** Ordered list of env var names to check for the parent session ID. First match wins. */
|
||||
export const SUBAGENT_PARENT_SESSION_ENV_CANDIDATES: readonly string[] = [
|
||||
// pi-agent-router (original)
|
||||
"PI_AGENT_ROUTER_PARENT_SESSION_ID",
|
||||
// Shared convention for CLI-based subagent extensions
|
||||
// (nicobailon/pi-subagents, HazAT/pi-interactive-subagents, etc.)
|
||||
"PI_SUBAGENT_PARENT_SESSION",
|
||||
] as const;
|
||||
|
||||
/** @deprecated Use SUBAGENT_PARENT_SESSION_ENV_CANDIDATES */
|
||||
export const SUBAGENT_PARENT_SESSION_ENV_KEY =
|
||||
SUBAGENT_PARENT_SESSION_ENV_CANDIDATES[0];
|
||||
|
||||
const SESSION_FORWARDING_ROOT_DIRECTORY_NAME = "sessions";
|
||||
const SESSION_FORWARDING_REQUESTS_DIRECTORY_NAME = "requests";
|
||||
const SESSION_FORWARDING_RESPONSES_DIRECTORY_NAME = "responses";
|
||||
|
||||
/**
|
||||
* Display fields relayed from a forwarding child to the parent UI so the parent
|
||||
* can emit a non-degraded `permissions:ui_prompt` event.
|
||||
*
|
||||
* Carried separately from the prompt payload because the parent reconstructs
|
||||
* the original event from the escalated ask's details (`buildUiPrompt`), not
|
||||
* from the payload's own facts.
|
||||
*/
|
||||
export interface ForwardedPromptDisplay {
|
||||
source: PermissionUiPromptSource;
|
||||
surface: string | null;
|
||||
value: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The child's session-approval suggestion, relayed to the serving node so a
|
||||
* human who grants "the whole session" records the same pattern the child
|
||||
* would have recorded locally.
|
||||
*
|
||||
* A plain data shape (not the `SessionApproval` value object) so it serializes
|
||||
* onto the forwarded request; the serving node rebuilds a `SessionApproval`
|
||||
* from it via `SessionApproval.multiple`.
|
||||
*/
|
||||
export interface ForwardedSessionApproval {
|
||||
surface: string;
|
||||
patterns: readonly string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The child-fixed facts a gate emits: the surface it evaluated and the match
|
||||
* set it computed. `requesterCwd` and `principal` are stamped at the escalation
|
||||
* edge (`ParentAuthorizer`), so a gate carries only what it alone can produce.
|
||||
*
|
||||
* Strings only — an `AccessPath` never crosses onto the wire
|
||||
* (`docs/decisions/0002-path-values-string-boundary.md`).
|
||||
*/
|
||||
export interface ForwardedAccessFacts {
|
||||
/** Gate surface: `"path"`, `"external_directory"`, `"bash"`, a tool name, or a skill name. */
|
||||
surface: string;
|
||||
/**
|
||||
* The child-fixed match set. Path surface: `AccessPath.matchValues()`
|
||||
* (absolute ∪ cwd-relative ∪ canonical), computed at the child. Non-path
|
||||
* surface: the already-portable single value as a one-element array.
|
||||
*/
|
||||
matchValues: string[];
|
||||
/** `AccessPath.boundaryValue()` (canonical) for a path surface; `null` for a non-path surface. */
|
||||
boundaryValue: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The forwarded-wire access intent (ADR 0008 §2): the child-fixed access facts
|
||||
* plus the requester identity the escalation edge stamps.
|
||||
*
|
||||
* The serving node resolves against this intent directly (Step 3, [#597]),
|
||||
* using `matchValues` as-is — it never re-derives a path through its own
|
||||
* `PathNormalizer`/cwd. See
|
||||
* `docs/decisions/0008-cross-session-access-intent.md`.
|
||||
*/
|
||||
export interface ForwardedAccessIntent extends ForwardedAccessFacts {
|
||||
/** The requester's cwd, for provenance/disclosure — never for parent re-derivation. */
|
||||
requesterCwd: string;
|
||||
/** Who is requesting. */
|
||||
principal: {
|
||||
sessionId: string;
|
||||
agentName: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type ForwardedPermissionRequest = {
|
||||
id: string;
|
||||
createdAt: number;
|
||||
requesterSessionId: string;
|
||||
targetSessionId: string;
|
||||
requesterAgentName: string;
|
||||
/**
|
||||
* The child's complete prompt payload (ADR 0011 §2), so the serving node
|
||||
* renders the child's own facts under the *parent's* budget rather than
|
||||
* relaying a sentence the child assembled under its own configuration.
|
||||
*
|
||||
* Optional for version-skew tolerance: an older child omits it, and the
|
||||
* serving node renders from the display fields it does carry (ADR 0011 §9).
|
||||
*/
|
||||
payload?: PromptPayload;
|
||||
/**
|
||||
* Original prompt display fields, persisted so the parent emits a
|
||||
* non-degraded event. Optional for version-skew tolerance: a parent on a
|
||||
* newer version may read a request written by an older child during an
|
||||
* upgrade, in which case the reader defaults `source` to `"tool_call"`.
|
||||
*/
|
||||
source?: PermissionUiPromptSource;
|
||||
surface?: string | null;
|
||||
value?: string | null;
|
||||
/**
|
||||
* The child's session-approval suggestion. Present when the child computed a
|
||||
* "for this session" pattern for the ask; lets the serving node record a
|
||||
* whole-session grant. Optional for version-skew tolerance (an older child
|
||||
* omits it, and the serving dialog then offers no scope choice).
|
||||
*/
|
||||
sessionApproval?: ForwardedSessionApproval;
|
||||
/**
|
||||
* The child-fixed access intent (ADR 0008 §2). Optional for version-skew
|
||||
* tolerance: an older child omits it, and the serving node floors to `ask`
|
||||
* (Step 3). Present on a current child's request for every gate surface.
|
||||
*/
|
||||
accessIntent?: ForwardedAccessIntent;
|
||||
};
|
||||
|
||||
export type ForwardedPermissionResponse = {
|
||||
approved: boolean;
|
||||
state: PermissionDecisionState;
|
||||
denialReason?: string;
|
||||
responderSessionId: string;
|
||||
respondedAt: number;
|
||||
/**
|
||||
* What decided, inside the responding session (#726).
|
||||
*
|
||||
* `responderSessionId` names *where* the decision was made; this names
|
||||
* *what* made it, which is the difference between a human at the parent's
|
||||
* dialog and the parent's policy answering on their behalf.
|
||||
*
|
||||
* Optional for version-skew tolerance: an older responder omits it, and the
|
||||
* requester records the hop with a `null` inner decision rather than
|
||||
* rejecting the answer.
|
||||
*/
|
||||
decidedBy?: DecisionSource;
|
||||
};
|
||||
|
||||
export type PermissionForwardingLocation = {
|
||||
sessionId: string;
|
||||
sessionRootDir: string;
|
||||
requestsDir: string;
|
||||
responsesDir: string;
|
||||
label: "primary";
|
||||
};
|
||||
|
||||
export function normalizePermissionForwardingSessionId(
|
||||
value: unknown,
|
||||
): string | null {
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || trimmed.toLowerCase() === "unknown") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a session id safe to name a path segment.
|
||||
*
|
||||
* Exported because the forwarding tree has two layouts keyed by session id —
|
||||
* `sessions/<id>/` and the serving-heartbeat records beside it — and a second
|
||||
* encoding would be a silent way for the two to disagree about which file
|
||||
* belongs to which session.
|
||||
*/
|
||||
export function encodeSessionIdForPath(sessionId: string): string {
|
||||
return encodeURIComponent(sessionId);
|
||||
}
|
||||
|
||||
export function createPermissionForwardingLocation(
|
||||
forwardingRootDir: string,
|
||||
sessionId: string,
|
||||
): PermissionForwardingLocation {
|
||||
const normalizedSessionId = normalizePermissionForwardingSessionId(sessionId);
|
||||
if (!normalizedSessionId) {
|
||||
throw new Error(
|
||||
"Permission forwarding session id must be a non-empty string.",
|
||||
);
|
||||
}
|
||||
|
||||
const sessionRootDir = join(
|
||||
forwardingRootDir,
|
||||
SESSION_FORWARDING_ROOT_DIRECTORY_NAME,
|
||||
encodeSessionIdForPath(normalizedSessionId),
|
||||
);
|
||||
|
||||
return {
|
||||
sessionId: normalizedSessionId,
|
||||
sessionRootDir,
|
||||
requestsDir: join(
|
||||
sessionRootDir,
|
||||
SESSION_FORWARDING_REQUESTS_DIRECTORY_NAME,
|
||||
),
|
||||
responsesDir: join(
|
||||
sessionRootDir,
|
||||
SESSION_FORWARDING_RESPONSES_DIRECTORY_NAME,
|
||||
),
|
||||
label: "primary",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* How a forwarding target was resolved.
|
||||
*
|
||||
* `"registry"` is the load-bearing value: it means the requester is an
|
||||
* **in-process** child of `sessionId`, so the two share a `globalThis` and the
|
||||
* requester may consult the serving-session registry to decide whether anyone
|
||||
* is draining its inbox. `"env"` means the target lives in another process,
|
||||
* where that signal is unavailable; `"self"` is the UI host owning its own
|
||||
* forwarding location.
|
||||
*/
|
||||
export type PermissionForwardingTargetSource = "self" | "registry" | "env";
|
||||
|
||||
/** The resolved forwarding target together with how it was found. */
|
||||
export interface PermissionForwardingTarget {
|
||||
sessionId: string;
|
||||
source: PermissionForwardingTargetSource;
|
||||
}
|
||||
|
||||
export function resolvePermissionForwardingTarget(options: {
|
||||
hasUI: boolean;
|
||||
isSubagent: boolean;
|
||||
currentSessionId?: string | null;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
/** Child session id for registry lookup. */
|
||||
sessionId?: string;
|
||||
/** In-process subagent session registry (checked before env vars). */
|
||||
registry?: SubagentSessionRegistry;
|
||||
}): PermissionForwardingTarget | null {
|
||||
if (options.hasUI) {
|
||||
const own = normalizePermissionForwardingSessionId(
|
||||
options.currentSessionId,
|
||||
);
|
||||
return own === null ? null : { sessionId: own, source: "self" };
|
||||
}
|
||||
|
||||
if (!options.isSubagent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 1. Registry — in-process subagents register parentSessionId explicitly.
|
||||
if (options.registry && options.sessionId) {
|
||||
const entry = options.registry.get(options.sessionId);
|
||||
const resolved = normalizePermissionForwardingSessionId(
|
||||
entry?.parentSessionId,
|
||||
);
|
||||
if (resolved) return { sessionId: resolved, source: "registry" };
|
||||
}
|
||||
|
||||
// 2. Env vars — process-based subagent extensions.
|
||||
const env = options.env ?? process.env;
|
||||
for (const key of SUBAGENT_PARENT_SESSION_ENV_CANDIDATES) {
|
||||
const resolved = normalizePermissionForwardingSessionId(env[key]);
|
||||
if (resolved) return { sessionId: resolved, source: "env" };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function isForwardedPermissionRequestForSession(
|
||||
request: Pick<ForwardedPermissionRequest, "targetSessionId">,
|
||||
sessionId: string | null | undefined,
|
||||
): boolean {
|
||||
const normalizedRequestSessionId = normalizePermissionForwardingSessionId(
|
||||
request.targetSessionId,
|
||||
);
|
||||
const normalizedSessionId = normalizePermissionForwardingSessionId(sessionId);
|
||||
return (
|
||||
normalizedRequestSessionId !== null &&
|
||||
normalizedRequestSessionId === normalizedSessionId
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,409 @@
|
||||
import type {
|
||||
ExtensionContext,
|
||||
ExtensionUIContext,
|
||||
KeybindingsManager,
|
||||
} from "@earendil-works/pi-coding-agent";
|
||||
import { type Component, Input, matchesKey } from "@earendil-works/pi-tui";
|
||||
import { collapsePastedNewlines } from "#src/authority/bracketed-paste";
|
||||
import type {
|
||||
DecisionSource,
|
||||
UserDecisionSurface,
|
||||
} from "#src/authority/decision-source";
|
||||
import {
|
||||
type PermissionPromptDecision,
|
||||
type RequestPermissionOptions,
|
||||
requestPermissionDecisionFromUi,
|
||||
type UnattributedDecision,
|
||||
} from "#src/authority/permission-dialog";
|
||||
import {
|
||||
initialPromptState,
|
||||
type PromptEvent,
|
||||
type PromptKey,
|
||||
type PromptModelConfig,
|
||||
type PromptViewState,
|
||||
reducePrompt,
|
||||
} from "#src/authority/permission-prompt-decision";
|
||||
import {
|
||||
completeViewBudget,
|
||||
type DialogView,
|
||||
type RenderBudget,
|
||||
renderPromptDialog,
|
||||
} from "#src/presentation/dialog-renderer";
|
||||
import { fitLinesToWidth } from "#src/presentation/line-fitting";
|
||||
import type { PromptPayload } from "#src/presentation/prompt-payload";
|
||||
|
||||
/**
|
||||
* Inline `ctx.ui.custom` permission dialog for TUI sessions.
|
||||
*
|
||||
* All interaction logic lives in the pure {@link reducePrompt} model; this
|
||||
* module is the thin adapter that renders the model's state to lines, maps raw
|
||||
* keystrokes to {@link PromptEvent}s, and resolves the `ctx.ui.custom` promise
|
||||
* with the committed {@link PermissionPromptDecision}. The component renders
|
||||
* inline (never as an overlay).
|
||||
*/
|
||||
|
||||
/** The subset of the session UI surface the inline dialog needs. */
|
||||
export type PermissionPromptUi = Pick<
|
||||
ExtensionUIContext,
|
||||
"select" | "input" | "custom" | "getToolsExpanded" | "setToolsExpanded"
|
||||
>;
|
||||
|
||||
/** The keybindings surface the dialog consults; only `matches` is read (ISP). */
|
||||
type PromptKeybindings = Pick<KeybindingsManager, "matches">;
|
||||
|
||||
/** The resolved presentation context selected once per activation. */
|
||||
export interface PermissionPromptView extends PromptPreferences {
|
||||
mode: ExtensionContext["mode"];
|
||||
ui: PermissionPromptUi;
|
||||
}
|
||||
|
||||
/** Live prompt-behavior preferences read at prompt time (see `doublePressToConfirm`). */
|
||||
export interface PromptPreferences {
|
||||
doublePressToConfirm: boolean;
|
||||
/** How much room a render has; the terminal width is added per frame. */
|
||||
budget: RenderBudget;
|
||||
}
|
||||
|
||||
/**
|
||||
* Route a permission ask to the inline keybind dialog in TUI mode, or the
|
||||
* `select()`/`input()` flow otherwise (RPC / frontend — the #519 constraint).
|
||||
*
|
||||
* The single entry the `LocalUserAuthorizer` calls; keeps the mode dispatch in
|
||||
* one place so the fallback and the inline component never both render.
|
||||
*
|
||||
* It is therefore also the one place that knows which surface the human
|
||||
* answered on, so it is where the decision is attributed to that surface
|
||||
* (#726). Having the dialog model and the fallback each name themselves would
|
||||
* be two sites that must agree with this branch.
|
||||
*/
|
||||
export async function requestPermissionDecision(
|
||||
view: PermissionPromptView,
|
||||
title: string,
|
||||
payload: PromptPayload,
|
||||
options?: RequestPermissionOptions,
|
||||
): Promise<PermissionPromptDecision> {
|
||||
if (view.mode === "tui") {
|
||||
return attributeToHuman(
|
||||
await presentInlinePermissionPrompt(view, title, payload, options),
|
||||
"dialog",
|
||||
);
|
||||
}
|
||||
// The fallback renders once and cannot re-render, so it neither paints nor
|
||||
// offers an expansion; it substitutes a nominal width for the terminal size
|
||||
// it is never told, and the host's own select wraps from there.
|
||||
const rendered = renderPromptDialog(payload, {
|
||||
...view.budget,
|
||||
width: FALLBACK_RENDER_WIDTH,
|
||||
});
|
||||
return attributeToHuman(
|
||||
await requestPermissionDecisionFromUi(
|
||||
view.ui,
|
||||
title,
|
||||
rendered.lines.join("\n"),
|
||||
options,
|
||||
),
|
||||
"select",
|
||||
);
|
||||
}
|
||||
|
||||
function attributeToHuman(
|
||||
decision: UnattributedDecision,
|
||||
via: UserDecisionSurface,
|
||||
): PermissionPromptDecision {
|
||||
const decidedBy: DecisionSource = { kind: "user", via };
|
||||
return { ...decision, decidedBy };
|
||||
}
|
||||
|
||||
/** The width the `select`/`input` fallback renders against. */
|
||||
const FALLBACK_RENDER_WIDTH = 80;
|
||||
|
||||
/** Minimal theme surface the dialog uses; satisfied by the real SDK theme. */
|
||||
interface PromptTheme {
|
||||
fg(color: string, text: string): string;
|
||||
}
|
||||
|
||||
const DEFAULT_SESSION_LABEL = "Yes, for this session";
|
||||
|
||||
const OPTION_LABELS: Record<PromptKey, string> = {
|
||||
y: "Yes",
|
||||
s: DEFAULT_SESSION_LABEL,
|
||||
n: "No",
|
||||
r: "No, provide reason",
|
||||
};
|
||||
|
||||
const OPTION_ORDER: readonly PromptKey[] = ["y", "s", "n", "r"];
|
||||
|
||||
export function presentInlinePermissionPrompt(
|
||||
view: PermissionPromptView,
|
||||
title: string,
|
||||
payload: PromptPayload,
|
||||
options?: RequestPermissionOptions,
|
||||
): Promise<UnattributedDecision> {
|
||||
const config: PromptModelConfig = {
|
||||
doublePressToConfirm: view.doublePressToConfirm,
|
||||
sessionLabel: options?.sessionLabel ?? DEFAULT_SESSION_LABEL,
|
||||
sessionScope: options?.sessionScope,
|
||||
};
|
||||
return view.ui.custom<UnattributedDecision>(
|
||||
(tui, theme, keybindings, done) =>
|
||||
new PermissionPromptComponent(
|
||||
theme,
|
||||
config,
|
||||
title,
|
||||
payload,
|
||||
view.budget,
|
||||
(data) => handleToolsExpandAction(data, keybindings, view.ui),
|
||||
() => {
|
||||
tui.requestRender();
|
||||
},
|
||||
done,
|
||||
),
|
||||
{ overlay: false },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Forward Pi's tool-expansion action while the dialog holds keyboard focus.
|
||||
*
|
||||
* A focused `ctx.ui.custom` component consumes every keystroke, so `Ctrl+O`
|
||||
* would otherwise be dead for the duration of an ask — exactly when the user
|
||||
* most needs to see the full pending tool invocation. Returns `true` when the
|
||||
* keystroke was the action (and was handled), so the caller stops before
|
||||
* mapping it to a {@link PromptEvent}; expansion is a display concern and must
|
||||
* never reach the decision model.
|
||||
*
|
||||
* Deliberately does not request a render: `setToolsExpanded` re-renders the
|
||||
* host itself, and the dialog's own lines are unaffected by tool expansion.
|
||||
*/
|
||||
function handleToolsExpandAction(
|
||||
data: string,
|
||||
keybindings: PromptKeybindings,
|
||||
ui: PermissionPromptUi,
|
||||
): boolean {
|
||||
if (!keybindings.matches(data, "app.tools.expand")) {
|
||||
return false;
|
||||
}
|
||||
ui.setToolsExpanded(!ui.getToolsExpanded());
|
||||
return true;
|
||||
}
|
||||
|
||||
class PermissionPromptComponent implements Component {
|
||||
private state: PromptViewState;
|
||||
/** The denial-reason line editor, rebuilt each time the step is entered. */
|
||||
private reason: Input;
|
||||
/** Whether the operator asked to see the complete request (ADR 0011 §4). */
|
||||
private expanded = false;
|
||||
|
||||
constructor(
|
||||
private readonly theme: PromptTheme,
|
||||
private readonly config: PromptModelConfig,
|
||||
private readonly title: string,
|
||||
private readonly payload: PromptPayload,
|
||||
private readonly budget: RenderBudget,
|
||||
private readonly handleAppAction: (data: string) => boolean,
|
||||
private readonly requestRender: () => void,
|
||||
private readonly done: (decision: UnattributedDecision) => void,
|
||||
) {
|
||||
this.state = initialPromptState(config);
|
||||
this.reason = this.createReasonEditor();
|
||||
}
|
||||
|
||||
/**
|
||||
* A fresh editor per visit to the reason step.
|
||||
*
|
||||
* The framework editor carries an undo stack and a kill ring, so reusing one
|
||||
* instance would let a reason the operator backed out of be restored into a
|
||||
* later ask.
|
||||
*/
|
||||
private createReasonEditor(): Input {
|
||||
const editor = new Input();
|
||||
// Emits pi-tui's zero-width cursor marker, which positions the hardware
|
||||
// cursor for IME composition.
|
||||
editor.focused = true;
|
||||
editor.onSubmit = (draft) => {
|
||||
this.apply({ type: "submitReason", draft });
|
||||
};
|
||||
editor.onEscape = () => {
|
||||
this.apply({ type: "cancel" });
|
||||
};
|
||||
return editor;
|
||||
}
|
||||
|
||||
invalidate(): void {
|
||||
// No cached rendering state to clear.
|
||||
}
|
||||
|
||||
render(width: number): string[] {
|
||||
return fitLinesToWidth(this.renderStep(width), width);
|
||||
}
|
||||
|
||||
private renderStep(width: number): string[] {
|
||||
switch (this.state.step) {
|
||||
case "decision":
|
||||
return this.renderDecision(width);
|
||||
case "reason":
|
||||
return this.renderReason(width);
|
||||
case "scope":
|
||||
return this.renderScope();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The ask itself, bounded to the budget at this frame's width.
|
||||
*
|
||||
* Rendered per frame rather than once, because the row budget is a function
|
||||
* of the width the host gives us, which a resize changes.
|
||||
*/
|
||||
private renderAsk(width: number): DialogView {
|
||||
return renderPromptDialog(
|
||||
this.payload,
|
||||
this.expanded ? completeViewBudget(width) : { ...this.budget, width },
|
||||
(text) => this.theme.fg("warning", text),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The key hints, naming the expansion only when it would do something.
|
||||
*
|
||||
* An affordance advertised when there is nothing to expand is noise; one
|
||||
* left unadvertised when the render dropped something is a decision made
|
||||
* without the evidence.
|
||||
*/
|
||||
private hint(view: DialogView): string {
|
||||
const keys = [
|
||||
"↑/↓ move",
|
||||
"enter confirm",
|
||||
"esc deny",
|
||||
"press a letter, then again to confirm",
|
||||
];
|
||||
if (this.expanded) {
|
||||
keys.push("ctrl+o collapse");
|
||||
} else if (view.elided) {
|
||||
keys.push("ctrl+o full request");
|
||||
}
|
||||
return this.theme.fg("muted", keys.join(" · "));
|
||||
}
|
||||
|
||||
handleInput(data: string): void {
|
||||
if (this.state.step === "reason") {
|
||||
this.handleReasonInput(data);
|
||||
return;
|
||||
}
|
||||
if (this.handleAppAction(data)) {
|
||||
// One "expand" for the operator: the host expands its pending tool call
|
||||
// and the dialog expands its own render, on the same keystroke.
|
||||
this.expanded = !this.expanded;
|
||||
this.requestRender();
|
||||
return;
|
||||
}
|
||||
const event = this.toEvent(data);
|
||||
if (event) {
|
||||
this.apply(event);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hand the keystroke to the framework line editor.
|
||||
*
|
||||
* Delegating is what makes the field accept a paste: a paste arrives as one
|
||||
* multi-character chunk wrapped in bracketed-paste markers, which the editor
|
||||
* understands and a per-character reader cannot. Submit and cancel come back
|
||||
* through the editor's callbacks, so the decision model still owns them.
|
||||
*/
|
||||
private handleReasonInput(data: string): void {
|
||||
this.reason.handleInput(collapsePastedNewlines(data));
|
||||
// The editor mutates its own buffer silently; only the dialog can repaint.
|
||||
this.requestRender();
|
||||
}
|
||||
|
||||
private toEvent(data: string): PromptEvent | undefined {
|
||||
if (matchesKey(data, "up") || matchesKey(data, "k")) {
|
||||
return { type: "nav", direction: "up" };
|
||||
}
|
||||
if (matchesKey(data, "down") || matchesKey(data, "j")) {
|
||||
return { type: "nav", direction: "down" };
|
||||
}
|
||||
if (matchesKey(data, "enter")) {
|
||||
return { type: "confirm" };
|
||||
}
|
||||
if (matchesKey(data, "escape")) {
|
||||
return { type: "cancel" };
|
||||
}
|
||||
if (this.state.step === "decision") {
|
||||
const key = OPTION_ORDER.find((option) => matchesKey(data, option));
|
||||
if (key) {
|
||||
return { type: "hotkey", key };
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private apply(event: PromptEvent): void {
|
||||
const outcome = reducePrompt(this.config, this.state, event);
|
||||
if (outcome.kind === "decision") {
|
||||
this.done(outcome.decision);
|
||||
return;
|
||||
}
|
||||
if (outcome.state.step === "reason" && this.state.step !== "reason") {
|
||||
this.reason = this.createReasonEditor();
|
||||
}
|
||||
this.state = outcome.state;
|
||||
this.requestRender();
|
||||
}
|
||||
|
||||
private renderDecision(width: number): string[] {
|
||||
const ask = this.renderAsk(width);
|
||||
const lines = [this.theme.fg("accent", this.title), ...ask.lines, ""];
|
||||
for (const key of OPTION_ORDER) {
|
||||
const label = key === "s" ? this.config.sessionLabel : OPTION_LABELS[key];
|
||||
const selected = this.state.highlightedKey === key;
|
||||
const marker = selected ? "▶" : " ";
|
||||
const row = `${marker} (${key}) ${label}`;
|
||||
lines.push(selected ? this.theme.fg("accent", row) : row);
|
||||
}
|
||||
lines.push("");
|
||||
lines.push(this.state.hint || this.hint(ask));
|
||||
return lines;
|
||||
}
|
||||
|
||||
private renderReason(width: number): string[] {
|
||||
const lines = [
|
||||
this.theme.fg("accent", this.title),
|
||||
...this.renderAsk(width).lines,
|
||||
"",
|
||||
"Reason (required):",
|
||||
// Exactly one row, whatever its length: the editor scrolls horizontally.
|
||||
...this.reason.render(width),
|
||||
];
|
||||
if (this.state.reasonError) {
|
||||
lines.push(this.theme.fg("error", this.state.reasonError));
|
||||
}
|
||||
lines.push("");
|
||||
lines.push(this.theme.fg("muted", "enter submit · esc back"));
|
||||
return lines;
|
||||
}
|
||||
|
||||
private renderScope(): string[] {
|
||||
const scope = this.config.sessionScope;
|
||||
const subagentLabel = scope?.subagentLabel ?? "This subagent only";
|
||||
const servingLabel = scope?.servingSessionLabel ?? "The whole session";
|
||||
const rows: Array<{ label: string; serving: boolean }> = [
|
||||
{ label: subagentLabel, serving: false },
|
||||
{ label: servingLabel, serving: true },
|
||||
];
|
||||
const lines = [
|
||||
this.theme.fg("accent", this.title),
|
||||
"Apply this session grant to:",
|
||||
"",
|
||||
];
|
||||
for (const row of rows) {
|
||||
const selected = this.state.scopeServing === row.serving;
|
||||
const marker = selected ? "▶" : " ";
|
||||
const text = `${marker} ${row.label}`;
|
||||
lines.push(selected ? this.theme.fg("accent", text) : text);
|
||||
}
|
||||
lines.push("");
|
||||
lines.push(this.theme.fg("muted", "↑/↓ move · enter confirm · esc back"));
|
||||
return lines;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
import {
|
||||
createDeniedPermissionDecision,
|
||||
normalizePermissionDenialReason,
|
||||
type RequestPermissionOptions,
|
||||
type UnattributedDecision,
|
||||
} from "#src/authority/permission-dialog";
|
||||
|
||||
/**
|
||||
* Pure decision model for the inline keybind permission dialog.
|
||||
*
|
||||
* The interaction logic — which hotkey produces which decision, double-press
|
||||
* arming, step transitions, and reason validation — lives here with no SDK or
|
||||
* TUI imports, so it is unit-testable directly. The `ctx.ui.custom` component
|
||||
* ({@link file://./permission-prompt-component.ts}) is a thin adapter that
|
||||
* forwards keystrokes to {@link reducePrompt} and renders the returned state.
|
||||
*/
|
||||
|
||||
/** The four decision hotkeys, in display order. */
|
||||
export type PromptKey = "y" | "s" | "n" | "r";
|
||||
|
||||
/** Which sub-view the dialog is showing. */
|
||||
export type PromptStep = "decision" | "reason" | "scope";
|
||||
|
||||
const OPTION_ORDER: readonly PromptKey[] = ["y", "s", "n", "r"];
|
||||
|
||||
const OPTION_VERBS: Record<PromptKey, string> = {
|
||||
y: "approve",
|
||||
s: "approve for this session",
|
||||
n: "deny",
|
||||
r: "deny with a reason",
|
||||
};
|
||||
|
||||
/** Static configuration for a single prompt presentation. */
|
||||
export interface PromptModelConfig {
|
||||
/** When true, a letter hotkey arms first and commits only on a second press. */
|
||||
doublePressToConfirm: boolean;
|
||||
/** Label shown beside the approve-for-session option. */
|
||||
sessionLabel: string;
|
||||
/**
|
||||
* Forwarded asks only: when set, confirming `s` opens a second step choosing
|
||||
* whether the grant applies to the requesting subagent only (least-privilege
|
||||
* default) or the whole serving session.
|
||||
*/
|
||||
sessionScope?: NonNullable<RequestPermissionOptions["sessionScope"]>;
|
||||
}
|
||||
|
||||
/** The re-render view state the component draws from. */
|
||||
export interface PromptViewState {
|
||||
step: PromptStep;
|
||||
highlightedKey: PromptKey;
|
||||
/** Set only while awaiting the confirming second press of a hotkey. */
|
||||
armedKey?: PromptKey;
|
||||
/** "Press y again to approve." while armed; empty otherwise. */
|
||||
hint: string;
|
||||
/** Set when an empty reason submit is rejected. */
|
||||
reasonError?: string;
|
||||
/** Scope step: false = subagent-only (default), true = whole serving session. */
|
||||
scopeServing: boolean;
|
||||
}
|
||||
|
||||
/** An input event the reducer understands. */
|
||||
export type PromptEvent =
|
||||
| { type: "nav"; direction: "up" | "down" }
|
||||
| { type: "hotkey"; key: PromptKey }
|
||||
| { type: "confirm" }
|
||||
| { type: "cancel" }
|
||||
| { type: "submitReason"; draft: string };
|
||||
|
||||
/** Either a re-render or a terminal decision. */
|
||||
export type PromptOutcome =
|
||||
| { kind: "render"; state: PromptViewState }
|
||||
| { kind: "decision"; decision: UnattributedDecision };
|
||||
|
||||
export function initialPromptState(
|
||||
_config: PromptModelConfig,
|
||||
): PromptViewState {
|
||||
return {
|
||||
step: "decision",
|
||||
highlightedKey: "y",
|
||||
armedKey: undefined,
|
||||
hint: "",
|
||||
reasonError: undefined,
|
||||
scopeServing: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Advance the dialog by one input event, returning either the next view state
|
||||
* to render or the committed {@link UnattributedDecision}.
|
||||
*
|
||||
* The model states the outcome and not the decider: which human surface this
|
||||
* is gets attributed by the dispatcher that chose to render this dialog, so
|
||||
* the two cannot disagree about the surface.
|
||||
*/
|
||||
export function reducePrompt(
|
||||
config: PromptModelConfig,
|
||||
state: PromptViewState,
|
||||
event: PromptEvent,
|
||||
): PromptOutcome {
|
||||
switch (state.step) {
|
||||
case "decision":
|
||||
return reduceDecisionStep(config, state, event);
|
||||
case "reason":
|
||||
return reduceReasonStep(state, event);
|
||||
case "scope":
|
||||
return reduceScopeStep(state, event);
|
||||
}
|
||||
}
|
||||
|
||||
function reduceDecisionStep(
|
||||
config: PromptModelConfig,
|
||||
state: PromptViewState,
|
||||
event: PromptEvent,
|
||||
): PromptOutcome {
|
||||
switch (event.type) {
|
||||
case "nav":
|
||||
return render({
|
||||
...state,
|
||||
highlightedKey: shiftKey(state.highlightedKey, event.direction),
|
||||
armedKey: undefined,
|
||||
hint: "",
|
||||
});
|
||||
case "hotkey":
|
||||
return pressHotkey(config, state, event.key);
|
||||
case "confirm":
|
||||
return commit(config, state, state.highlightedKey);
|
||||
case "cancel":
|
||||
return { kind: "decision", decision: createDeniedPermissionDecision() };
|
||||
case "submitReason":
|
||||
return render(state);
|
||||
}
|
||||
}
|
||||
|
||||
function pressHotkey(
|
||||
config: PromptModelConfig,
|
||||
state: PromptViewState,
|
||||
key: PromptKey,
|
||||
): PromptOutcome {
|
||||
if (!config.doublePressToConfirm || state.armedKey === key) {
|
||||
return commit(config, state, key);
|
||||
}
|
||||
return render({
|
||||
...state,
|
||||
highlightedKey: key,
|
||||
armedKey: key,
|
||||
hint: `Press ${key} again to ${OPTION_VERBS[key]}.`,
|
||||
});
|
||||
}
|
||||
|
||||
function commit(
|
||||
config: PromptModelConfig,
|
||||
state: PromptViewState,
|
||||
key: PromptKey,
|
||||
): PromptOutcome {
|
||||
switch (key) {
|
||||
case "y":
|
||||
return {
|
||||
kind: "decision",
|
||||
decision: { approved: true, state: "approved" },
|
||||
};
|
||||
case "n":
|
||||
return { kind: "decision", decision: createDeniedPermissionDecision() };
|
||||
case "r":
|
||||
return render({
|
||||
...state,
|
||||
step: "reason",
|
||||
highlightedKey: "r",
|
||||
armedKey: undefined,
|
||||
hint: "",
|
||||
reasonError: undefined,
|
||||
});
|
||||
case "s":
|
||||
if (config.sessionScope) {
|
||||
return render({
|
||||
...state,
|
||||
step: "scope",
|
||||
highlightedKey: "s",
|
||||
armedKey: undefined,
|
||||
hint: "",
|
||||
scopeServing: false,
|
||||
});
|
||||
}
|
||||
return {
|
||||
kind: "decision",
|
||||
decision: { approved: true, state: "approved_for_session" },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function reduceReasonStep(
|
||||
state: PromptViewState,
|
||||
event: PromptEvent,
|
||||
): PromptOutcome {
|
||||
if (event.type === "cancel") {
|
||||
return render({
|
||||
...state,
|
||||
step: "decision",
|
||||
armedKey: undefined,
|
||||
hint: "",
|
||||
reasonError: undefined,
|
||||
});
|
||||
}
|
||||
if (event.type === "submitReason") {
|
||||
const reason = normalizePermissionDenialReason(event.draft);
|
||||
if (reason === undefined) {
|
||||
return render({
|
||||
...state,
|
||||
reasonError: "A reason is required.",
|
||||
});
|
||||
}
|
||||
return {
|
||||
kind: "decision",
|
||||
decision: createDeniedPermissionDecision(reason),
|
||||
};
|
||||
}
|
||||
return render(state);
|
||||
}
|
||||
|
||||
function reduceScopeStep(
|
||||
state: PromptViewState,
|
||||
event: PromptEvent,
|
||||
): PromptOutcome {
|
||||
switch (event.type) {
|
||||
case "nav":
|
||||
return render({ ...state, scopeServing: event.direction === "down" });
|
||||
case "confirm":
|
||||
return {
|
||||
kind: "decision",
|
||||
decision: {
|
||||
approved: true,
|
||||
state: state.scopeServing
|
||||
? "approved_for_serving_session"
|
||||
: "approved_for_session",
|
||||
},
|
||||
};
|
||||
case "cancel":
|
||||
return render({
|
||||
...state,
|
||||
step: "decision",
|
||||
armedKey: undefined,
|
||||
hint: "",
|
||||
});
|
||||
default:
|
||||
return render(state);
|
||||
}
|
||||
}
|
||||
|
||||
function shiftKey(current: PromptKey, direction: "up" | "down"): PromptKey {
|
||||
const index = OPTION_ORDER.indexOf(current);
|
||||
const delta = direction === "down" ? 1 : -1;
|
||||
const next = (index + delta + OPTION_ORDER.length) % OPTION_ORDER.length;
|
||||
return OPTION_ORDER[next] ?? current;
|
||||
}
|
||||
|
||||
function render(state: PromptViewState): PromptOutcome {
|
||||
return { kind: "render", state };
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import type { DecisionSource } from "#src/authority/decision-source";
|
||||
import type { PermissionPromptDecision } from "#src/authority/permission-dialog";
|
||||
import type {
|
||||
ForwardedAccessFacts,
|
||||
ForwardedSessionApproval,
|
||||
} from "#src/authority/permission-forwarding";
|
||||
import type { PromptPayload } from "#src/presentation/prompt-payload";
|
||||
import { renderReviewLogFacts } from "#src/presentation/review-log-renderer";
|
||||
import type { ReviewLogger } from "#src/session-logger";
|
||||
import type { TerminalAuthorizer } from "./authorizer";
|
||||
|
||||
export type PermissionReviewSource = "tool_call" | "skill_input" | "skill_read";
|
||||
|
||||
/**
|
||||
* Provenance of a forwarded ask: who is really asking, one hop below.
|
||||
*
|
||||
* Present on {@link PromptPermissionDetails} only when the ask was forwarded
|
||||
* from a subagent. Structurally identical to the event's `ForwardedPromptContext`
|
||||
* so the details flow straight into `buildUiPrompt`, but declared here to keep
|
||||
* the prompter layer free of an events-module import.
|
||||
*/
|
||||
export interface ForwardedAskProvenance {
|
||||
requesterAgentName: string | null;
|
||||
requesterSessionId: string | null;
|
||||
}
|
||||
|
||||
/** Details passed when prompting the user for a permission decision. */
|
||||
export interface PromptPermissionDetails {
|
||||
requestId: string;
|
||||
source: PermissionReviewSource;
|
||||
agentName: string | null;
|
||||
/**
|
||||
* The complete structured description of this ask (ADR 0011 §2).
|
||||
*
|
||||
* Required: every ask carries one, and the type is what guarantees it rather
|
||||
* than a convention each gate has to remember. Every consumer — the dialog,
|
||||
* the wire, the broadcast, the review log, the agent-facing denial text — is
|
||||
* a render over it, so no two of them can disagree.
|
||||
*/
|
||||
payload: PromptPayload;
|
||||
toolCallId?: string;
|
||||
toolName?: string;
|
||||
skillName?: string;
|
||||
path?: string;
|
||||
command?: string;
|
||||
target?: string;
|
||||
toolInputPreview?: string;
|
||||
/** Override label for the "for this session" dialog option. */
|
||||
sessionLabel?: string;
|
||||
/** Explicit display-surface override (a forwarded ask carries the child's original). */
|
||||
surface?: string | null;
|
||||
/** Explicit display-value override (a forwarded ask carries the child's original). */
|
||||
value?: string | null;
|
||||
/** Present iff this ask was forwarded from a subagent; drives the non-degraded broadcast + "(Subagent)" title. */
|
||||
forwarding?: ForwardedAskProvenance;
|
||||
/**
|
||||
* The session-approval suggestion for this ask. On the child's escalation it
|
||||
* rides into the forwarded request; on the serving node it lets the dialog
|
||||
* offer a whole-session grant scope. Absent when the gate computed no
|
||||
* suggestion.
|
||||
*/
|
||||
sessionApproval?: ForwardedSessionApproval;
|
||||
/**
|
||||
* The child-fixed access facts the raising gate computed (surface + match
|
||||
* set). Rides through the runner to the escalation edge, which completes
|
||||
* them into a `ForwardedAccessIntent` by stamping `requesterCwd` and
|
||||
* `principal`. On a serving node these facts are projected back off the
|
||||
* forwarded request, so a forwarded ask reaches the `Authorizer` chain with
|
||||
* the same evidence as a local one; only a version-skew request that carried
|
||||
* no intent leaves this absent.
|
||||
*/
|
||||
accessIntent?: ForwardedAccessFacts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrow seam onto {@link PermissionPrompter}.
|
||||
*
|
||||
* Kept separate from the concrete class so consumers (e.g. `AuthorizerSelection`)
|
||||
* can inject a plain `{ prompt: vi.fn() }` mock in tests — a private field on
|
||||
* the concrete class would create a nominal brand that a structural mock
|
||||
* cannot satisfy without a cast.
|
||||
*/
|
||||
export interface PermissionPrompterApi {
|
||||
prompt(
|
||||
authorizer: TerminalAuthorizer,
|
||||
details: PromptPermissionDetails,
|
||||
): Promise<PermissionPromptDecision>;
|
||||
}
|
||||
|
||||
/** Dependencies required by {@link PermissionPrompter}. */
|
||||
export interface PermissionPrompterDeps {
|
||||
/** Write structured entries to the permission review log. */
|
||||
logger: ReviewLogger;
|
||||
}
|
||||
|
||||
/**
|
||||
* Brackets the ask-path flow with review-log entries and delegates the
|
||||
* live decision to the selected {@link TerminalAuthorizer}:
|
||||
* 1. Review-log "waiting" entry.
|
||||
* 2. `authorizer.authorize(details)`.
|
||||
* 3. Review-log "approved" / "denied" entry.
|
||||
*
|
||||
* The UI/forwarding branching this class previously owned now lives on the
|
||||
* individual `Authorizer` implementations (`LocalUserAuthorizer`,
|
||||
* `ParentAuthorizer`, `DenyingAuthorizer`) — this class no longer threads
|
||||
* `ExtensionContext` per call.
|
||||
*
|
||||
* Yolo-mode auto-approval happens upstream: at the composition stage
|
||||
* (`PermissionManager.check`'s `rewriteAsksToYolo`) for a rule-driven ask, and
|
||||
* at `GateRunner`'s auto-approve fast path (`resolveYoloGrant`) for an ask
|
||||
* synthesized after resolution, which no rule rewrite can reach (#712) — an
|
||||
* `ask` never reaches this class under yolo, so it has no yolo-mode knowledge.
|
||||
*/
|
||||
export class PermissionPrompter implements PermissionPrompterApi {
|
||||
constructor(private readonly deps: PermissionPrompterDeps) {}
|
||||
|
||||
async prompt(
|
||||
authorizer: TerminalAuthorizer,
|
||||
details: PromptPermissionDetails,
|
||||
): Promise<PermissionPromptDecision> {
|
||||
this.writeReviewEntry("permission_request.waiting", details);
|
||||
|
||||
const decision = await authorizer.authorize(details);
|
||||
|
||||
this.writeReviewEntry(
|
||||
decision.approved
|
||||
? "permission_request.approved"
|
||||
: "permission_request.denied",
|
||||
{
|
||||
...details,
|
||||
resolution: decision.confirmationUnavailable
|
||||
? "confirmation_unavailable"
|
||||
: decision.state,
|
||||
denialReason: decision.denialReason,
|
||||
decidedBy: decision.decidedBy,
|
||||
},
|
||||
);
|
||||
|
||||
return decision;
|
||||
}
|
||||
|
||||
// ── Private helpers ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The `waiting` entry carries no `decidedBy` — nothing has decided yet, and
|
||||
* a `null` there would read as "decided by nobody" rather than "not yet".
|
||||
*/
|
||||
private writeReviewEntry(
|
||||
event: string,
|
||||
details: PromptPermissionDetails & {
|
||||
resolution?: string;
|
||||
denialReason?: string;
|
||||
decidedBy?: DecisionSource;
|
||||
},
|
||||
): void {
|
||||
this.deps.logger.review(event, {
|
||||
...(details.decidedBy ? { decidedBy: details.decidedBy } : {}),
|
||||
requestId: details.requestId,
|
||||
source: details.source,
|
||||
agentName: details.agentName,
|
||||
...renderReviewLogFacts(details.payload),
|
||||
toolCallId: details.toolCallId ?? null,
|
||||
toolName: details.toolName ?? null,
|
||||
skillName: details.skillName ?? null,
|
||||
path: details.path ?? null,
|
||||
command: details.command ?? null,
|
||||
target: details.target ?? null,
|
||||
toolInputPreview: details.toolInputPreview ?? null,
|
||||
resolution: details.resolution ?? null,
|
||||
denialReason: details.denialReason ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* serving-registry.ts — Which sessions are draining a forwarded-permission inbox.
|
||||
*
|
||||
* A session with a UI that is not itself a subagent polls its own
|
||||
* `<forwardingDir>/sessions/<id>/requests/` directory (see `ForwardingManager`)
|
||||
* and answers whatever a child forwards to it. Nothing else in the process can
|
||||
* observe that, so a child whose parent is *not* polling has no way to tell
|
||||
* "a human is being asked" from "nobody is home", and waits out the full
|
||||
* forwarding timeout before denying (#719).
|
||||
*
|
||||
* This registry publishes that fact: the polling session marks itself while it
|
||||
* polls, and a forwarding child checks whether its resolved target is marked.
|
||||
*
|
||||
* The single instance is stored on `globalThis` (via `Symbol.for()`) for the
|
||||
* same reason `SubagentSessionRegistry` is: each session's `ResourceLoader`
|
||||
* creates its own jiti instance and its own event bus, so the parent's
|
||||
* permission-system instance and an in-process child's instance share no
|
||||
* module state — only process globals. See `getServingSessionRegistry()`.
|
||||
*
|
||||
* The signal is meaningful only for an **in-process** child (one that resolved
|
||||
* its target through `SubagentSessionRegistry`, i.e. a forwarding target with
|
||||
* `source: "registry"`). A child in another process shares no `globalThis` with
|
||||
* its parent and must not read anything into an absent mark.
|
||||
*/
|
||||
|
||||
/** Process-global key for the shared registry slot. Exported for test teardown. */
|
||||
export const SERVING_SESSION_REGISTRY_KEY = Symbol.for(
|
||||
"@gotgenes/pi-permission-system:serving-registry",
|
||||
);
|
||||
|
||||
/**
|
||||
* Announce-side seam: the polling session marks and clears itself.
|
||||
*
|
||||
* `ForwardingManager` depends on this rather than the concrete registry so it
|
||||
* neither reads the store nor gains a query it has no business making (ISP).
|
||||
*/
|
||||
export interface ServingAnnouncer {
|
||||
/**
|
||||
* Record that `sessionId` is polling its inbox.
|
||||
*
|
||||
* Idempotent, and called on every poll tick rather than once per session: an
|
||||
* announcement that can decay (the filesystem heartbeat) has to be kept
|
||||
* current, and one that cannot (this registry) costs a set insertion to say
|
||||
* so again.
|
||||
*/
|
||||
markServing(sessionId: string): void;
|
||||
clearServing(sessionId: string): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fan an announcement out to every channel a serving session publishes on.
|
||||
*
|
||||
* A session announces to the process-global registry (for its in-process
|
||||
* children) and to the filesystem (for children in other processes). Composing
|
||||
* them keeps `ForwardingManager` holding one collaborator, so adding or
|
||||
* removing a channel never reaches the poll loop.
|
||||
*/
|
||||
export function composeServingAnnouncers(
|
||||
...announcers: readonly ServingAnnouncer[]
|
||||
): ServingAnnouncer {
|
||||
return {
|
||||
markServing(sessionId: string): void {
|
||||
for (const announcer of announcers) {
|
||||
announcer.markServing(sessionId);
|
||||
}
|
||||
},
|
||||
clearServing(sessionId: string): void {
|
||||
for (const announcer of announcers) {
|
||||
announcer.clearServing(sessionId);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Query-side seam: a forwarding child asks whether its target is draining.
|
||||
*
|
||||
* `servingIds()` exists for the diagnostic review entry a child writes when it
|
||||
* abandons an unserved request — the mismatch between the id it forwarded to
|
||||
* and the ids actually being served is the whole diagnosis.
|
||||
*/
|
||||
export interface ServingLookup {
|
||||
isServing(sessionId: string): boolean;
|
||||
servingIds(): readonly string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Registry of sessions currently draining a forwarded-permission inbox.
|
||||
*
|
||||
* A process-global singleton — obtain it via {@link getServingSessionRegistry},
|
||||
* never `new` (see that accessor for why). Written exclusively by the owning
|
||||
* session's `ForwardingManager`, keyed by that session's own id, so one
|
||||
* session's shutdown cannot clear another's mark.
|
||||
*
|
||||
* A mark left behind by a session that died without `session_shutdown` makes a
|
||||
* child wait out the full timeout instead of abandoning early — the same
|
||||
* behavior as before this signal existed, which is the safe direction to fail.
|
||||
*/
|
||||
export class ServingSessionRegistry implements ServingAnnouncer, ServingLookup {
|
||||
private readonly serving = new Set<string>();
|
||||
|
||||
/** Record that `sessionId` is polling its inbox. Idempotent. */
|
||||
markServing(sessionId: string): void {
|
||||
this.serving.add(sessionId);
|
||||
}
|
||||
|
||||
/** Record that `sessionId` has stopped polling. No-op if unmarked. */
|
||||
clearServing(sessionId: string): void {
|
||||
this.serving.delete(sessionId);
|
||||
}
|
||||
|
||||
/** Return `true` when `sessionId` is currently polling its inbox. */
|
||||
isServing(sessionId: string): boolean {
|
||||
return this.serving.has(sessionId);
|
||||
}
|
||||
|
||||
/** Every currently-serving session id, for diagnostics. */
|
||||
servingIds(): readonly string[] {
|
||||
return [...this.serving];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the process-global ServingSessionRegistry, creating it on first call.
|
||||
*
|
||||
* Intentionally has no teardown hook: a child's `session_shutdown` must not be
|
||||
* able to wipe the parent's mark. Entries are added and removed exclusively by
|
||||
* the owning session's `ForwardingManager`.
|
||||
*/
|
||||
export function getServingSessionRegistry(): ServingSessionRegistry {
|
||||
const store = globalThis as Record<symbol, unknown>;
|
||||
const existing = store[SERVING_SESSION_REGISTRY_KEY] as
|
||||
| ServingSessionRegistry
|
||||
| undefined;
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
const registry = new ServingSessionRegistry();
|
||||
store[SERVING_SESSION_REGISTRY_KEY] = registry;
|
||||
return registry;
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { SUBAGENT_ENV_HINT_KEYS } from "#src/authority/permission-forwarding";
|
||||
import type { SubagentSessionRegistry } from "#src/authority/subagent-registry";
|
||||
import type { PathFlavor } from "#src/path/path-flavor";
|
||||
|
||||
/**
|
||||
* Narrow context for subagent detection — the only session-manager readers
|
||||
* {@link isSubagentExecutionContext} and {@link isRegisteredSubagentChild}
|
||||
* consume. A full `ExtensionContext` satisfies this structurally.
|
||||
*/
|
||||
export interface SubagentDetectionContext {
|
||||
sessionManager: {
|
||||
getSessionId(): string;
|
||||
getSessionDir(): string;
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeFilesystemPath(
|
||||
pathValue: string,
|
||||
flavor: PathFlavor,
|
||||
): string {
|
||||
return flavor.fold(flavor.impl.normalize(pathValue));
|
||||
}
|
||||
|
||||
/**
|
||||
* Return `true` when `ctx` belongs to an in-process subagent child registered
|
||||
* in `registry` by its session id.
|
||||
*
|
||||
* This is the only signal that identifies an **in-process** child (one sharing
|
||||
* the parent's `globalThis`); env-hint and filesystem heuristics identify
|
||||
* **process-based** subagents instead. The composition root uses this to decide
|
||||
* whether the instance owns the process-global service slot — a registered
|
||||
* child must not publish over its parent.
|
||||
*/
|
||||
export function isRegisteredSubagentChild(
|
||||
ctx: SubagentDetectionContext,
|
||||
registry: SubagentSessionRegistry,
|
||||
): boolean {
|
||||
try {
|
||||
const sessionId = ctx.sessionManager.getSessionId();
|
||||
if (!sessionId) {
|
||||
return false;
|
||||
}
|
||||
return registry.has(sessionId);
|
||||
} catch {
|
||||
// getSessionId() unavailable — treat as not-a-registered-child.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function isSubagentExecutionContext(
|
||||
ctx: SubagentDetectionContext,
|
||||
subagentSessionsDir: string,
|
||||
flavor: PathFlavor,
|
||||
registry?: SubagentSessionRegistry,
|
||||
): boolean {
|
||||
// 1. Explicit registry — in-process subagent extensions register by child
|
||||
// session id before bindExtensions(); checked first so it takes priority
|
||||
// over heuristics. Each concurrent sibling has a unique session id, so
|
||||
// one sibling's disposed event cannot affect another's registration.
|
||||
if (registry && isRegisteredSubagentChild(ctx, registry)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const sessionDir = ctx.sessionManager.getSessionDir();
|
||||
|
||||
// 2. Env vars — process-based subagent extensions (nicobailon/pi-subagents,
|
||||
// HazAT/pi-interactive-subagents, pi-agent-router, etc.).
|
||||
for (const key of SUBAGENT_ENV_HINT_KEYS) {
|
||||
const value = process.env[key];
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Filesystem path — fallback heuristic for extensions that store sessions
|
||||
// under a known subagent root directory.
|
||||
if (!sessionDir) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const normalizedSessionDir = normalizeFilesystemPath(sessionDir, flavor);
|
||||
const normalizedSubagentRoot = normalizeFilesystemPath(
|
||||
subagentSessionsDir,
|
||||
flavor,
|
||||
);
|
||||
return flavor.isWithin(normalizedSessionDir, normalizedSubagentRoot);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import {
|
||||
isRegisteredSubagentChild,
|
||||
isSubagentExecutionContext,
|
||||
type SubagentDetectionContext,
|
||||
} from "#src/authority/subagent-context";
|
||||
import type { SubagentSessionRegistry } from "#src/authority/subagent-registry";
|
||||
import type { PathFlavor } from "#src/path/path-flavor";
|
||||
|
||||
/**
|
||||
* Narrow seam for the ask-path consumers: "is the current session a subagent?"
|
||||
*
|
||||
* `selectAuthorizer`/`AuthorizerSelection` and `ForwardingManager` depend on
|
||||
* this single-method view so their unit tests inject a one-field fake without
|
||||
* casts. It is the Authorizer-selection predicate the Phase 9 spine consumes.
|
||||
*/
|
||||
export interface SubagentDetector {
|
||||
isSubagent(ctx: SubagentDetectionContext): boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrow seam for the service-publication guard (#302): "is the current
|
||||
* session a registered in-process child?"
|
||||
*
|
||||
* `PermissionServiceLifecycle` depends on this single-method view so a
|
||||
* registered child never publishes over its parent's process-global slot.
|
||||
*/
|
||||
export interface RegisteredChildDetector {
|
||||
isRegisteredChild(ctx: SubagentDetectionContext): boolean;
|
||||
}
|
||||
|
||||
/** Composition-root inputs for {@link SubagentDetection}. */
|
||||
export interface SubagentDetectionDeps {
|
||||
subagentSessionsDir: string;
|
||||
flavor: PathFlavor;
|
||||
registry?: SubagentSessionRegistry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single owner of subagent detection.
|
||||
*
|
||||
* Constructed once in the composition root with the detection inputs
|
||||
* (`subagentSessionsDir`, `flavor`, `registry`) and shared across every
|
||||
* consumer, replacing the dep triple those consumers previously threaded
|
||||
* individually. Delegates to the pure detection functions in
|
||||
* {@link ./subagent-context}, holding only the deps.
|
||||
*/
|
||||
export class SubagentDetection
|
||||
implements SubagentDetector, RegisteredChildDetector
|
||||
{
|
||||
constructor(private readonly deps: SubagentDetectionDeps) {}
|
||||
|
||||
isSubagent(ctx: SubagentDetectionContext): boolean {
|
||||
return isSubagentExecutionContext(
|
||||
ctx,
|
||||
this.deps.subagentSessionsDir,
|
||||
this.deps.flavor,
|
||||
this.deps.registry,
|
||||
);
|
||||
}
|
||||
|
||||
isRegisteredChild(ctx: SubagentDetectionContext): boolean {
|
||||
return this.deps.registry
|
||||
? isRegisteredSubagentChild(ctx, this.deps.registry)
|
||||
: false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* subagent-lifecycle-events.ts — Subscribe to @gotgenes/pi-subagents' child
|
||||
* lifecycle events and keep the SubagentSessionRegistry in sync.
|
||||
*
|
||||
* @gotgenes/pi-subagents publishes its child-execution lifecycle on the Pi
|
||||
* event bus (ADR 0002): it no longer calls this package's service directly.
|
||||
* We register the child on `session-created` and unregister it on `disposed`.
|
||||
*
|
||||
* The channel names and payload shapes are declared independently here (the two
|
||||
* packages must not depend on each other under jiti) and MUST match the
|
||||
* publisher in `@gotgenes/pi-subagents` (`src/lifecycle/child-lifecycle.ts`).
|
||||
*
|
||||
* The `session-created` handler MUST stay synchronous: the core emits it on the
|
||||
* same synchronous call stack immediately before `bindExtensions()`, and the
|
||||
* event bus dispatches listeners synchronously, so a synchronous handler lands
|
||||
* the registry entry before binding proceeds. Introducing an `await` before
|
||||
* `registry.register(...)` would break the pre-bind ordering.
|
||||
*/
|
||||
|
||||
import type { SubagentSessionRegistry } from "./subagent-registry";
|
||||
|
||||
/** Emitted by the core after session creation, before `bindExtensions()`. */
|
||||
export const SUBAGENT_CHILD_SESSION_CREATED = "subagents:child:session-created";
|
||||
|
||||
/** Emitted by the core in the run's `finally` (success and error). */
|
||||
export const SUBAGENT_CHILD_DISPOSED = "subagents:child:disposed";
|
||||
|
||||
/** Minimal event-bus surface this module needs (subscribe only). */
|
||||
interface LifecycleEventBus {
|
||||
on(channel: string, handler: (data: unknown) => void): () => void;
|
||||
}
|
||||
|
||||
/** Fields read from the `session-created` payload (ISP). */
|
||||
interface ChildSessionCreatedEvent {
|
||||
/** Child session id — the registry key. Must match the publisher. */
|
||||
sessionId: string;
|
||||
parentSessionId?: string;
|
||||
}
|
||||
|
||||
/** Fields read from the `disposed` payload (ISP). */
|
||||
interface ChildDisposedEvent {
|
||||
/** Child session id — the registry key. Must match the publisher. */
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to the subagent child lifecycle.
|
||||
*
|
||||
* @returns an unsubscribe that detaches both handlers (call during
|
||||
* `session_shutdown`).
|
||||
*/
|
||||
export function subscribeSubagentLifecycle(
|
||||
events: LifecycleEventBus,
|
||||
registry: SubagentSessionRegistry,
|
||||
): () => void {
|
||||
const unsubCreated = events.on(SUBAGENT_CHILD_SESSION_CREATED, (data) => {
|
||||
const event = data as ChildSessionCreatedEvent;
|
||||
registry.register(event.sessionId, {
|
||||
parentSessionId: event.parentSessionId,
|
||||
});
|
||||
});
|
||||
|
||||
const unsubDisposed = events.on(SUBAGENT_CHILD_DISPOSED, (data) => {
|
||||
const event = data as ChildDisposedEvent;
|
||||
registry.unregister(event.sessionId);
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubCreated();
|
||||
unsubDisposed();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* subagent-registry.ts — In-process subagent session registry.
|
||||
*
|
||||
* In-process subagent extensions (e.g. `@gotgenes/pi-subagents`) register
|
||||
* each child session here before calling `bindExtensions()` so that
|
||||
* `isSubagentExecutionContext()` and permission-forwarding target resolution
|
||||
* can detect them without relying on environment variables or filesystem
|
||||
* heuristics.
|
||||
*
|
||||
* The registry is keyed by the child's **session id**, which is unique per
|
||||
* child and available to both producer (via `sessionManager.getSessionId()`
|
||||
* after `newSession()` in `create-subagent-session.ts`) and consumer (via
|
||||
* `ctx.sessionManager.getSessionId()`). Two concurrent siblings of the same
|
||||
* parent therefore occupy distinct keys, so one sibling's `disposed` event
|
||||
* cannot evict the entry the others depend on.
|
||||
*
|
||||
* The single registry instance is stored on `globalThis` (via `Symbol.for()`)
|
||||
* so that the parent's permission-system instance (which registers children
|
||||
* on the parent's event bus) and each child's separate jiti instance (which
|
||||
* reads the registry to detect itself and resolve its forwarding target) share
|
||||
* one store across per-session event buses. See `getSubagentSessionRegistry()`.
|
||||
*
|
||||
* When a future code path needs the child's agent name, read it from
|
||||
* `tcc.agentName` (resolved from the `<active_agent>` system-prompt tag) —
|
||||
* not from this registry.
|
||||
*/
|
||||
|
||||
/** Process-global key for the shared registry slot. */
|
||||
const SUBAGENT_SESSION_REGISTRY_KEY = Symbol.for(
|
||||
"@gotgenes/pi-permission-system:subagent-registry",
|
||||
);
|
||||
|
||||
/**
|
||||
* Return the process-global SubagentSessionRegistry, creating it on first call.
|
||||
*
|
||||
* Backed by `globalThis` + `Symbol.for()` so the parent's permission-system
|
||||
* instance (which registers children on the parent event bus) and each child's
|
||||
* separate jiti instance (which reads the registry to detect itself and resolve
|
||||
* its forwarding target) share one store across per-session event buses.
|
||||
*
|
||||
* Intentionally has no shutdown/unpublish hook — a child's `session_shutdown`
|
||||
* must not be able to wipe the parent's registrations. Entries are added and
|
||||
* removed exclusively by the parent's `subagents:child:session-created` /
|
||||
* `subagents:child:disposed` subscription.
|
||||
*/
|
||||
export function getSubagentSessionRegistry(): SubagentSessionRegistry {
|
||||
const store = globalThis as Record<symbol, unknown>;
|
||||
const existing = store[SUBAGENT_SESSION_REGISTRY_KEY] as
|
||||
| SubagentSessionRegistry
|
||||
| undefined;
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
const registry = new SubagentSessionRegistry();
|
||||
store[SUBAGENT_SESSION_REGISTRY_KEY] = registry;
|
||||
return registry;
|
||||
}
|
||||
|
||||
/** Signal stored per registered in-process subagent session. */
|
||||
export interface SubagentSessionInfo {
|
||||
/** Parent session ID for permission forwarding. Omit when unknown. */
|
||||
parentSessionId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registry of active in-process subagent sessions.
|
||||
*
|
||||
* A process-global singleton — obtain it via `getSubagentSessionRegistry()`,
|
||||
* never `new` (see that accessor for why). Written exclusively by
|
||||
* `subscribeSubagentLifecycle` via the `subagents:child:session-created` /
|
||||
* `subagents:child:disposed` event subscription (ADR 0002 — the core
|
||||
* publishes, consumers observe).
|
||||
*
|
||||
* Keyed by child session id. Each concurrent child of the same parent receives
|
||||
* a unique session id from `sessionManager.newSession()`, so siblings occupy
|
||||
* distinct keys and one sibling's `disposed` cannot evict another's entry.
|
||||
*/
|
||||
export class SubagentSessionRegistry {
|
||||
private readonly sessions = new Map<string, SubagentSessionInfo>();
|
||||
|
||||
/**
|
||||
* Register an in-process subagent session.
|
||||
*
|
||||
* If a previous entry exists for `sessionId`, it is overwritten
|
||||
* (last-write-wins; single-writer expected per key).
|
||||
*/
|
||||
register(sessionId: string, info: SubagentSessionInfo): void {
|
||||
this.sessions.set(sessionId, info);
|
||||
}
|
||||
|
||||
/** Remove a previously registered session. No-op if the key is absent. */
|
||||
unregister(sessionId: string): void {
|
||||
this.sessions.delete(sessionId);
|
||||
}
|
||||
|
||||
/** Return the registered info for `sessionId`, or `undefined` if absent. */
|
||||
get(sessionId: string): SubagentSessionInfo | undefined {
|
||||
return this.sessions.get(sessionId);
|
||||
}
|
||||
|
||||
/** Return `true` when `sessionId` has a registered entry. */
|
||||
has(sessionId: string): boolean {
|
||||
return this.sessions.has(sessionId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { parseBashCommandsSync } from "#src/access-intent/bash/sync-commands";
|
||||
import { resolveBashCommandCheck } from "#src/handlers/gates/bash-command";
|
||||
import type { ScopedPermissionResolver } from "#src/permission-resolver";
|
||||
import type { PermissionCheckResult } from "#src/types";
|
||||
|
||||
/**
|
||||
* Resolve an advisory bash query at the gate's decomposed fidelity.
|
||||
*
|
||||
* When the tree-sitter parser is warm, the command is decomposed into its
|
||||
* command-pattern units and routed through the same shared orchestrator the
|
||||
* enforcement gate uses (`resolveBashCommandCheck`) — so a chained/nested
|
||||
* command returns the most-restrictive decision (`deny > ask > allow`) and
|
||||
* inherits the opaque-wrapper floor (#481) and the fail-closed
|
||||
* `<unparseable-bash-command>` sentinel (#452), at parity with the gate.
|
||||
*
|
||||
* In the pre-warm window (`parseBashCommandsSync` returns `null`) it falls back
|
||||
* to the pre-#309 whole-string match, so the advisory answer is never *weaker*
|
||||
* than before — only strengthened once warm.
|
||||
*
|
||||
* Synchronous, preserving `PermissionsService.checkPermission`'s sync contract:
|
||||
* the only async step (parser init) happens earlier, at `before_agent_start`.
|
||||
*/
|
||||
export function resolveBashAdvisoryCheck(
|
||||
command: string,
|
||||
agentName: string | undefined,
|
||||
resolver: ScopedPermissionResolver,
|
||||
): PermissionCheckResult {
|
||||
const commands = parseBashCommandsSync(command);
|
||||
if (commands === null) {
|
||||
return resolver.resolve({
|
||||
kind: "tool",
|
||||
surface: "bash",
|
||||
input: { command },
|
||||
agentName,
|
||||
});
|
||||
}
|
||||
return resolveBashCommandCheck(command, commands, agentName, resolver);
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* Curated arity dictionary for common CLI commands.
|
||||
*
|
||||
* Keys are lowercase, space-joined command prefixes.
|
||||
* Values are the total token count that defines the "human-understandable
|
||||
* subcommand" — i.e. how many tokens to include in a session-approval pattern.
|
||||
*
|
||||
* Multi-level entries (e.g. "npm run": 3) take precedence over shorter entries
|
||||
* ("npm": 2) because `prefix()` uses longest-match-wins.
|
||||
*
|
||||
* Exported for testability.
|
||||
*/
|
||||
export const ARITY: Record<string, number> = {
|
||||
// Version control
|
||||
git: 2,
|
||||
hg: 2,
|
||||
svn: 2,
|
||||
|
||||
// Node.js package managers
|
||||
npm: 2,
|
||||
"npm run": 3,
|
||||
"npm exec": 3,
|
||||
npx: 2,
|
||||
pnpm: 2,
|
||||
"pnpm run": 3,
|
||||
"pnpm exec": 3,
|
||||
"pnpm dlx": 3,
|
||||
yarn: 2,
|
||||
"yarn run": 3,
|
||||
bun: 2,
|
||||
"bun run": 3,
|
||||
"bun add": 2,
|
||||
"bun x": 3,
|
||||
|
||||
// Runtimes
|
||||
deno: 2,
|
||||
"deno run": 3,
|
||||
"deno task": 3,
|
||||
"deno compile": 3,
|
||||
|
||||
// Python
|
||||
pip: 2,
|
||||
pip3: 2,
|
||||
uv: 2,
|
||||
"uv run": 3,
|
||||
"uv pip": 3,
|
||||
|
||||
// Rust
|
||||
cargo: 2,
|
||||
|
||||
// Go
|
||||
go: 2,
|
||||
"go run": 3,
|
||||
|
||||
// Ruby
|
||||
bundle: 2,
|
||||
"bundle exec": 3,
|
||||
|
||||
// Docker / container
|
||||
docker: 2,
|
||||
"docker compose": 3,
|
||||
"docker container": 3,
|
||||
"docker image": 3,
|
||||
"docker network": 3,
|
||||
"docker volume": 3,
|
||||
podman: 2,
|
||||
"podman compose": 3,
|
||||
|
||||
// Kubernetes
|
||||
kubectl: 2,
|
||||
helm: 2,
|
||||
|
||||
// Cloud CLIs
|
||||
aws: 3,
|
||||
az: 3,
|
||||
gcloud: 3,
|
||||
gh: 2,
|
||||
"gh pr": 3,
|
||||
"gh issue": 3,
|
||||
"gh repo": 3,
|
||||
fly: 2,
|
||||
vercel: 2,
|
||||
wrangler: 2,
|
||||
|
||||
// Build tools
|
||||
make: 1,
|
||||
bazel: 2,
|
||||
|
||||
// Infrastructure
|
||||
terraform: 2,
|
||||
tofu: 2,
|
||||
pulumi: 2,
|
||||
|
||||
// System service management
|
||||
systemctl: 2,
|
||||
service: 2,
|
||||
|
||||
// Shell file-ops — args are paths/targets, not subcommands
|
||||
ls: 1,
|
||||
ll: 1,
|
||||
la: 1,
|
||||
cat: 1,
|
||||
less: 1,
|
||||
more: 1,
|
||||
head: 1,
|
||||
tail: 1,
|
||||
grep: 1,
|
||||
rg: 1,
|
||||
ag: 1,
|
||||
find: 1,
|
||||
touch: 1,
|
||||
mkdir: 1,
|
||||
rm: 1,
|
||||
cp: 1,
|
||||
mv: 1,
|
||||
ln: 1,
|
||||
chmod: 1,
|
||||
chown: 1,
|
||||
du: 1,
|
||||
df: 1,
|
||||
echo: 1,
|
||||
printf: 1,
|
||||
diff: 1,
|
||||
patch: 1,
|
||||
wc: 1,
|
||||
sort: 1,
|
||||
uniq: 1,
|
||||
awk: 1,
|
||||
sed: 1,
|
||||
tar: 1,
|
||||
zip: 1,
|
||||
unzip: 1,
|
||||
|
||||
// Network
|
||||
curl: 1,
|
||||
wget: 1,
|
||||
ssh: 1,
|
||||
scp: 1,
|
||||
rsync: 1,
|
||||
ping: 1,
|
||||
|
||||
// Process management
|
||||
kill: 1,
|
||||
killall: 1,
|
||||
pkill: 1,
|
||||
|
||||
// Package managers (system)
|
||||
brew: 2,
|
||||
apt: 2,
|
||||
"apt-get": 2,
|
||||
yum: 2,
|
||||
dnf: 2,
|
||||
};
|
||||
|
||||
/**
|
||||
* Return the semantically meaningful prefix tokens for a tokenized command.
|
||||
*
|
||||
* Performs a longest-match-wins lookup against the `ARITY` dictionary:
|
||||
* iterates from the longest possible prefix down to a single token, returning
|
||||
* the first (longest) match. Lookup is case-insensitive; the returned tokens
|
||||
* preserve their original casing.
|
||||
*
|
||||
* When no entry matches, defaults to arity 1 (first token only).
|
||||
* When the resolved arity exceeds the available tokens, it is clamped.
|
||||
*
|
||||
* @param tokens - The command split by whitespace (e.g. `["git", "checkout", "main"]`).
|
||||
* @returns The prefix tokens defining the meaningful subcommand.
|
||||
*/
|
||||
export function prefix(tokens: string[]): string[] {
|
||||
if (tokens.length === 0) return [];
|
||||
|
||||
for (let n = tokens.length; n >= 1; n--) {
|
||||
const key = tokens
|
||||
.slice(0, n)
|
||||
.map((t) => t.toLowerCase())
|
||||
.join(" ");
|
||||
const arity = ARITY[key];
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- ARITY record type hides that a key may be absent at runtime
|
||||
if (arity !== undefined) {
|
||||
return tokens.slice(0, Math.min(arity, tokens.length));
|
||||
}
|
||||
}
|
||||
|
||||
// Unknown command — default arity 1.
|
||||
return [tokens[0]];
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove shell comment lines from a bash command string.
|
||||
*
|
||||
* A comment line is one whose first non-whitespace character is `#`. Agents
|
||||
* frequently prepend descriptive comments before the real command
|
||||
* (e.g. `"# Check debug logs\nfind ..."`); such prefixes defeat wildcard
|
||||
* pattern matching and session-approval suggestions, which tokenize the
|
||||
* leading text. Stripping comment lines lets matching operate on the actual
|
||||
* command.
|
||||
*
|
||||
* The original command is never returned: when every line is a comment (or
|
||||
* the input is blank) an empty string is returned, and each caller applies
|
||||
* its own fallback.
|
||||
*
|
||||
* @param command - Raw bash command, possibly multi-line.
|
||||
* @returns The command with comment lines removed and surrounding whitespace
|
||||
* trimmed, or an empty string when nothing meaningful remains.
|
||||
*/
|
||||
export function stripBashCommentLines(command: string): string {
|
||||
const lines = command.split("\n");
|
||||
const meaningful = lines.filter((line) => !/^\s*#/.test(line));
|
||||
return meaningful.join("\n").trim();
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Built-in tool input formatters registered through the public seam at startup.
|
||||
*
|
||||
* Each formatter here dogfoods `ToolInputFormatterRegistry.register` — it goes
|
||||
* through exactly the same path a third-party extension would use.
|
||||
*/
|
||||
|
||||
import type {
|
||||
ToolInputFormatter,
|
||||
ToolInputFormatterRegistry,
|
||||
} from "./tool-input-formatter-registry";
|
||||
import { truncateInlineText } from "./tool-input-preview";
|
||||
import { toRecord } from "./value-guards";
|
||||
|
||||
/** Maximum total length of the generated argument summary (before "with " prefix). */
|
||||
const MCP_ARGS_SUMMARY_MAX_LENGTH = 160;
|
||||
|
||||
/** Maximum length of a single string argument value (before quoting). */
|
||||
const MCP_ARG_VALUE_MAX_LENGTH = 60;
|
||||
|
||||
/**
|
||||
* Render a single MCP argument value as a compact, readable fragment.
|
||||
*
|
||||
* - Strings: quoted and truncated.
|
||||
* - Numbers / booleans: plain string conversion.
|
||||
* - Arrays: `[N items]`.
|
||||
* - Objects: `{…}`.
|
||||
* - Everything else: plain string conversion.
|
||||
*/
|
||||
function renderArgValue(value: unknown): string {
|
||||
if (typeof value === "string") {
|
||||
return `"${truncateInlineText(value, MCP_ARG_VALUE_MAX_LENGTH)}"`;
|
||||
}
|
||||
if (typeof value === "number" || typeof value === "boolean") {
|
||||
return String(value);
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return `[${value.length} items]`;
|
||||
}
|
||||
if (typeof value === "object" && value !== null) {
|
||||
return "{…}";
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format an MCP tool call's `arguments` payload as a human-readable summary.
|
||||
*
|
||||
* Returns `undefined` when `arguments` is absent or empty — the MCP ask-prompt
|
||||
* is then left unchanged (no suffix appended).
|
||||
*
|
||||
* Intended to be registered as the `"mcp"` formatter via
|
||||
* `registerBuiltinToolInputFormatters`.
|
||||
*/
|
||||
export const formatMcpInputForPrompt: ToolInputFormatter = (
|
||||
input: Record<string, unknown>,
|
||||
): string | undefined => {
|
||||
const args = toRecord(input.arguments);
|
||||
const entries = Object.entries(args);
|
||||
if (entries.length === 0) return undefined;
|
||||
|
||||
const parts = entries.map(
|
||||
([key, value]) => `${key}: ${renderArgValue(value)}`,
|
||||
);
|
||||
const summary = truncateInlineText(
|
||||
parts.join(", "),
|
||||
MCP_ARGS_SUMMARY_MAX_LENGTH,
|
||||
);
|
||||
return `with ${summary}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Register all built-in tool input formatters into `registry`.
|
||||
*
|
||||
* Called once from the extension factory (`index.ts`) immediately after the
|
||||
* registry is constructed, before any third-party registration can occur.
|
||||
*/
|
||||
export function registerBuiltinToolInputFormatters(
|
||||
registry: ToolInputFormatterRegistry,
|
||||
): void {
|
||||
registry.register("mcp", formatMcpInputForPrompt);
|
||||
}
|
||||
@@ -0,0 +1,469 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { normalize } from "node:path";
|
||||
import type { ZodError } from "zod";
|
||||
import {
|
||||
getGlobalConfigPath,
|
||||
getLegacyExtensionConfigPath,
|
||||
getLegacyGlobalPolicyPath,
|
||||
getLegacyProjectPolicyPath,
|
||||
getProjectConfigPath,
|
||||
} from "./config-paths";
|
||||
import {
|
||||
type ShellToolsConfig,
|
||||
type UnifiedPermissionConfig,
|
||||
unifiedConfigSchema,
|
||||
} from "./config-schema";
|
||||
import { mergeFlatPermissions } from "./permission-merge";
|
||||
import type { FlatPermissionConfig, PatternValue } from "./types";
|
||||
import { isDenyWithReason, isPermissionState } from "./types";
|
||||
|
||||
// The unified config shape is derived from the zod schema (config-schema.ts,
|
||||
// the single source of truth) and re-exported so existing importers keep their
|
||||
// import path. All fields are optional so partial configs merge before
|
||||
// defaults are applied downstream.
|
||||
export type { ShellToolsConfig, UnifiedPermissionConfig };
|
||||
|
||||
export interface UnifiedConfigLoadResult {
|
||||
config: UnifiedPermissionConfig;
|
||||
issues: string[];
|
||||
}
|
||||
|
||||
export function stripJsonComments(input: string): string {
|
||||
let output = "";
|
||||
let i = 0;
|
||||
while (i < input.length) {
|
||||
const char = input[i];
|
||||
const next = input[i + 1] ?? "";
|
||||
|
||||
if (char === "/" && next === "/") {
|
||||
const seg = consumeLineComment(input, i);
|
||||
output += seg.output;
|
||||
i = seg.nextIndex;
|
||||
continue;
|
||||
}
|
||||
if (char === "/" && next === "*") {
|
||||
const seg = consumeBlockComment(input, i);
|
||||
output += seg.output;
|
||||
i = seg.nextIndex;
|
||||
continue;
|
||||
}
|
||||
if (char === '"' || char === "'") {
|
||||
const seg = consumeString(input, i);
|
||||
output += seg.output;
|
||||
i = seg.nextIndex;
|
||||
continue;
|
||||
}
|
||||
|
||||
output += char;
|
||||
i++;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
/** A consumed run of source: the text to emit and the index to resume scanning. */
|
||||
interface ScanSegment {
|
||||
output: string;
|
||||
nextIndex: number;
|
||||
}
|
||||
|
||||
/** Consume a `//` line comment starting at `start`; drop the body, keep the newline. */
|
||||
function consumeLineComment(input: string, start: number): ScanSegment {
|
||||
const newlineIndex = input.indexOf("\n", start);
|
||||
if (newlineIndex === -1) return { output: "", nextIndex: input.length };
|
||||
return { output: "\n", nextIndex: newlineIndex + 1 };
|
||||
}
|
||||
|
||||
/** Consume a block comment starting at `start`; drop it entirely. */
|
||||
function consumeBlockComment(input: string, start: number): ScanSegment {
|
||||
const closeIndex = input.indexOf("*/", start + 2);
|
||||
if (closeIndex === -1) return { output: "", nextIndex: input.length };
|
||||
return { output: "", nextIndex: closeIndex + 2 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Consume a string literal starting at the opening quote at `start`.
|
||||
* Honors backslash escapes so an escaped quote does not close the literal.
|
||||
* Emits the opening quote, body, and closing quote verbatim.
|
||||
*/
|
||||
function consumeString(input: string, start: number): ScanSegment {
|
||||
const quote = input[start];
|
||||
let output = quote;
|
||||
let i = start + 1;
|
||||
let escaping = false;
|
||||
while (i < input.length) {
|
||||
const char = input[i];
|
||||
output += char;
|
||||
i++;
|
||||
if (escaping) {
|
||||
escaping = false;
|
||||
continue;
|
||||
}
|
||||
if (char === "\\") {
|
||||
escaping = true;
|
||||
continue;
|
||||
}
|
||||
if (char === quote) break;
|
||||
}
|
||||
return { output, nextIndex: i };
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a raw `permission` value from parsed JSON into a FlatPermissionConfig.
|
||||
* Accepts PermissionState strings and DenyWithReason objects inside pattern
|
||||
* maps. Drops non-object top-level values, invalid PermissionState strings, and
|
||||
* invalid action values inside object maps.
|
||||
*/
|
||||
export function normalizeFlatPermissionValue(
|
||||
value: unknown,
|
||||
): FlatPermissionConfig | undefined {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
const normalized: FlatPermissionConfig = {};
|
||||
let hasAny = false;
|
||||
|
||||
for (const [key, val] of Object.entries(record)) {
|
||||
if (typeof val === "string") {
|
||||
if (isPermissionState(val)) {
|
||||
normalized[key] = val;
|
||||
hasAny = true;
|
||||
}
|
||||
} else if (typeof val === "object" && val !== null && !Array.isArray(val)) {
|
||||
const map: Record<string, PatternValue> = {};
|
||||
let mapHasAny = false;
|
||||
for (const [pattern, action] of Object.entries(
|
||||
val as Record<string, unknown>,
|
||||
)) {
|
||||
if (isDenyWithReason(action)) {
|
||||
map[pattern] = action;
|
||||
mapHasAny = true;
|
||||
} else if (isPermissionState(action)) {
|
||||
map[pattern] = action;
|
||||
mapHasAny = true;
|
||||
}
|
||||
}
|
||||
if (mapHasAny) {
|
||||
normalized[key] = map;
|
||||
hasAny = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return hasAny ? normalized : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate raw parsed JSON against the config schema (the single source of
|
||||
* truth in `config-schema.ts`).
|
||||
*
|
||||
* On success the typed config is returned. On failure the whole scope config is
|
||||
* rejected — fail-closed: an empty config contributes no rules, so missing
|
||||
* surfaces fall through to the universal `ask` default rather than `allow` —
|
||||
* and every schema violation is reported as a clear, actionable issue.
|
||||
*/
|
||||
export function validateUnifiedConfig(
|
||||
parsed: unknown,
|
||||
): UnifiedConfigLoadResult {
|
||||
const result = unifiedConfigSchema.safeParse(parsed);
|
||||
if (result.success) {
|
||||
return { config: result.data, issues: [] };
|
||||
}
|
||||
return { config: {}, issues: formatConfigIssues(result.error) };
|
||||
}
|
||||
|
||||
/** Render each schema violation as a clear, path-qualified message. */
|
||||
function formatConfigIssues(error: ZodError): string[] {
|
||||
const messages: string[] = [];
|
||||
for (const issue of error.issues) {
|
||||
if (issue.code === "unrecognized_keys") {
|
||||
for (const key of issue.keys) {
|
||||
messages.push(`Unrecognized config key '${key}'.`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const location =
|
||||
issue.path.length > 0 ? issue.path.map(String).join(".") : "(root)";
|
||||
messages.push(`Invalid config value at '${location}': ${issue.message}`);
|
||||
}
|
||||
return messages;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge two unified configs.
|
||||
* - `permission` is deep-shallow merged (surface-level object maps are shallow-merged).
|
||||
* - Scalar fields (debugLog, permissionReviewLog, yoloMode) are replaced when
|
||||
* present in the override.
|
||||
* - Array fields (piInfrastructureReadPaths) replace the base when present in
|
||||
* the override (override-wins, same as scalars).
|
||||
*/
|
||||
// Scalar knobs merged by override-replaces-base; keep in sync with
|
||||
// PermissionSystemExtensionConfig booleans (debugLog, permissionReviewLog,
|
||||
// yoloMode, doublePressToConfirm).
|
||||
export function mergeUnifiedConfigs(
|
||||
base: UnifiedPermissionConfig,
|
||||
override: UnifiedPermissionConfig,
|
||||
): UnifiedPermissionConfig {
|
||||
const merged: UnifiedPermissionConfig = {};
|
||||
|
||||
// Boolean scalars: override replaces base when defined
|
||||
for (const key of [
|
||||
"debugLog",
|
||||
"permissionReviewLog",
|
||||
"yoloMode",
|
||||
"doublePressToConfirm",
|
||||
] as const) {
|
||||
const value = override[key] ?? base[key];
|
||||
if (value !== undefined) {
|
||||
merged[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
// Number scalars: override replaces base when defined
|
||||
for (const key of [
|
||||
"forwardingTimeoutMs",
|
||||
"promptMaxRows",
|
||||
"promptFieldMaxWidth",
|
||||
"reviewLogFieldMaxWidth",
|
||||
"toolInputPreviewMaxLength",
|
||||
"toolTextSummaryMaxLength",
|
||||
] as const) {
|
||||
const value = override[key] ?? base[key];
|
||||
if (value !== undefined) {
|
||||
merged[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
// Array fields: override replaces base when defined
|
||||
for (const key of ["piInfrastructureReadPaths", "authorizerChain"] as const) {
|
||||
const value = override[key] ?? base[key];
|
||||
if (value !== undefined) {
|
||||
merged[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
// shellTools: shallow-merge by tool name so a project entry overrides a
|
||||
// colliding tool's alias but never drops a global entry (a dropped alias is
|
||||
// a silent enforcement regression).
|
||||
const baseShell = base.shellTools;
|
||||
const overrideShell = override.shellTools;
|
||||
if (baseShell && overrideShell) {
|
||||
merged.shellTools = { ...baseShell, ...overrideShell };
|
||||
} else if (baseShell) {
|
||||
merged.shellTools = baseShell;
|
||||
} else if (overrideShell) {
|
||||
merged.shellTools = overrideShell;
|
||||
}
|
||||
|
||||
// Permission: deep-shallow merge
|
||||
const basePerm = base.permission;
|
||||
const overridePerm = override.permission;
|
||||
if (basePerm && overridePerm) {
|
||||
merged.permission = mergeFlatPermissions(basePerm, overridePerm);
|
||||
} else if (basePerm) {
|
||||
merged.permission = basePerm;
|
||||
} else if (overridePerm) {
|
||||
merged.permission = overridePerm;
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
export interface MergedConfigResult {
|
||||
global: UnifiedPermissionConfig;
|
||||
project: UnifiedPermissionConfig;
|
||||
merged: UnifiedPermissionConfig;
|
||||
issues: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Load global and project configs from the new layout, detect legacy files,
|
||||
* merge everything, and collect issues.
|
||||
*
|
||||
* Merge order:
|
||||
* 1. Legacy global policy (if present) — lowest precedence
|
||||
* 2. Legacy extension runtime config (if present and path differs from new global)
|
||||
* 3. New global config
|
||||
* 4. Legacy project policy (if present)
|
||||
* 5. New project config — highest precedence
|
||||
*
|
||||
* Legacy files are detected and warned about. Their content is parsed with the
|
||||
* flat-format parser — legacy-format keys (defaultPolicy, tools, bash, etc.)
|
||||
* are not translated and contribute no permission rules.
|
||||
*
|
||||
* When `options.includeProjectScope` is `false`, the project-scope steps (4 and
|
||||
* 5) are skipped entirely — neither the legacy project policy nor the new
|
||||
* project config is read or merged. This gates project-local config on project
|
||||
* trust: an untrusted repository cannot loosen the operator's global policy
|
||||
* (#644). It defaults to `true`, preserving the trusted / caller-agnostic path.
|
||||
*/
|
||||
export function loadAndMergeConfigs(
|
||||
agentDir: string,
|
||||
cwd: string,
|
||||
extensionRoot: string,
|
||||
options: { includeProjectScope?: boolean } = {},
|
||||
): MergedConfigResult {
|
||||
const includeProjectScope = options.includeProjectScope !== false;
|
||||
const allIssues: string[] = [];
|
||||
|
||||
const newGlobalPath = getGlobalConfigPath(agentDir);
|
||||
const newProjectPath = getProjectConfigPath(cwd);
|
||||
const legacyGlobalPolicyPath = getLegacyGlobalPolicyPath(agentDir);
|
||||
const legacyProjectPolicyPath = getLegacyProjectPolicyPath(cwd);
|
||||
const legacyExtConfigPath = getLegacyExtensionConfigPath(extensionRoot);
|
||||
|
||||
// Start with empty
|
||||
let merged: UnifiedPermissionConfig = {};
|
||||
|
||||
// 1. Legacy global policy
|
||||
if (existsSync(legacyGlobalPolicyPath)) {
|
||||
const legacy = loadUnifiedConfig(legacyGlobalPolicyPath);
|
||||
allIssues.push(
|
||||
`Legacy global policy found at '${legacyGlobalPolicyPath}'. ` +
|
||||
`Move it to '${newGlobalPath}':\n` +
|
||||
` mv '${legacyGlobalPolicyPath}' '${newGlobalPath}'`,
|
||||
);
|
||||
// Legacy files are migrated away; the move-it guidance above is the
|
||||
// actionable signal, so strict-validation issues for them are suppressed.
|
||||
merged = mergeUnifiedConfigs(merged, legacy.config);
|
||||
}
|
||||
|
||||
// 2. Legacy extension runtime config (only if different from new global path)
|
||||
const normalizedLegacyExt = normalize(legacyExtConfigPath);
|
||||
const normalizedNewGlobal = normalize(newGlobalPath);
|
||||
if (
|
||||
normalizedLegacyExt !== normalizedNewGlobal &&
|
||||
existsSync(legacyExtConfigPath)
|
||||
) {
|
||||
const legacy = loadUnifiedConfig(legacyExtConfigPath);
|
||||
allIssues.push(
|
||||
`Legacy extension config found at '${legacyExtConfigPath}'. ` +
|
||||
`Move runtime settings to '${newGlobalPath}':\n` +
|
||||
` mv '${legacyExtConfigPath}' '${newGlobalPath}'`,
|
||||
);
|
||||
// See above: legacy-file validation issues are suppressed.
|
||||
merged = mergeUnifiedConfigs(merged, legacy.config);
|
||||
}
|
||||
|
||||
// 3. New global config
|
||||
const globalResult = loadUnifiedConfig(newGlobalPath);
|
||||
allIssues.push(...globalResult.issues);
|
||||
const globalConfig = globalResult.config;
|
||||
merged = mergeUnifiedConfigs(merged, globalConfig);
|
||||
|
||||
// 4. Legacy project policy — skipped when the project scope is withheld.
|
||||
if (includeProjectScope && existsSync(legacyProjectPolicyPath)) {
|
||||
const legacy = loadUnifiedConfig(legacyProjectPolicyPath);
|
||||
allIssues.push(
|
||||
`Legacy project policy found at '${legacyProjectPolicyPath}'. ` +
|
||||
`Move it to '${newProjectPath}':\n` +
|
||||
` mv '${legacyProjectPolicyPath}' '${newProjectPath}'`,
|
||||
);
|
||||
// See above: legacy-file validation issues are suppressed.
|
||||
merged = mergeUnifiedConfigs(merged, legacy.config);
|
||||
}
|
||||
|
||||
// 5. New project config — skipped when the project scope is withheld, so an
|
||||
// untrusted project contributes nothing and `project` reports empty.
|
||||
const projectResult = includeProjectScope
|
||||
? loadUnifiedConfig(newProjectPath)
|
||||
: { config: {}, issues: [] };
|
||||
allIssues.push(...projectResult.issues);
|
||||
const projectConfig = projectResult.config;
|
||||
merged = mergeUnifiedConfigs(merged, projectConfig);
|
||||
|
||||
const bashFallbackIssue = detectPermissiveBashFallback(merged.permission);
|
||||
if (bashFallbackIssue) allIssues.push(bashFallbackIssue);
|
||||
|
||||
const deprecatedCapsIssue = detectDeprecatedPreviewCaps(merged);
|
||||
if (deprecatedCapsIssue) allIssues.push(deprecatedCapsIssue);
|
||||
|
||||
return {
|
||||
global: globalConfig,
|
||||
project: projectConfig,
|
||||
merged,
|
||||
issues: allIssues,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect the config footgun where a permissive top-level `*: allow` leaves the
|
||||
* bash surface ungated, so every bash command silently inherits `allow`.
|
||||
*
|
||||
* Returns one warning string when `permission["*"] === "allow"` and the `bash`
|
||||
* surface neither is a bare string (shorthand for `{ "*": … }`) nor an object
|
||||
* map with an explicit `"*"` key. Returns `undefined` otherwise. The detector
|
||||
* is pure: it takes the merged permission map and returns a message; the caller
|
||||
* owns pushing it onto the issue list.
|
||||
*/
|
||||
export function detectPermissiveBashFallback(
|
||||
permission: FlatPermissionConfig | undefined,
|
||||
): string | undefined {
|
||||
if (permission?.["*"] !== "allow") return undefined;
|
||||
|
||||
// The Record index signature reports an absent surface as the value type, not
|
||||
// `undefined`; read through a Partial view so the absent-bash guard is honest
|
||||
// (an unguarded Object.hasOwn(undefined, …) would throw at runtime).
|
||||
const surfaces: Partial<FlatPermissionConfig> = permission;
|
||||
const bash = surfaces.bash;
|
||||
// A bare string surface is shorthand for `{ "*": action }` — explicitly gated.
|
||||
if (typeof bash === "string") return undefined;
|
||||
// An object map with an explicit `"*"` key is explicitly gated.
|
||||
if (bash && Object.hasOwn(bash, "*")) return undefined;
|
||||
|
||||
return (
|
||||
"Permission config sets a permissive top-level '*': 'allow' with no 'bash' '*' policy, " +
|
||||
"so bash commands silently inherit 'allow'. Set an explicit 'bash' policy " +
|
||||
'(e.g. "bash": { "*": "ask" }) to gate bash commands.'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect a config still setting one of the two superseded tool-preview caps.
|
||||
*
|
||||
* `toolInputPreviewMaxLength` and `toolTextSummaryMaxLength` bounded one
|
||||
* preview inside a prompt, never the prompt itself, which is why they never
|
||||
* bounded it; `promptMaxRows` and `promptFieldMaxWidth` supersede them
|
||||
* (ADR 0011 §5). Both stay valid in the schema so an existing config is not
|
||||
* rejected fail-closed — they are simply no longer read.
|
||||
*
|
||||
* Pure, following `detectPermissiveBashFallback`: it takes the merged config
|
||||
* and returns a message; the caller owns pushing it onto the issue list.
|
||||
*/
|
||||
export function detectDeprecatedPreviewCaps(
|
||||
config: UnifiedPermissionConfig,
|
||||
): string | undefined {
|
||||
const set = (
|
||||
["toolInputPreviewMaxLength", "toolTextSummaryMaxLength"] as const
|
||||
).filter((key) => config[key] !== undefined);
|
||||
if (set.length === 0) return undefined;
|
||||
|
||||
return (
|
||||
`Permission config sets ${set.map((key) => `'${key}'`).join(" and ")}, ` +
|
||||
"which is deprecated and ignored. The prompt is bounded by " +
|
||||
"'promptMaxRows' and 'promptFieldMaxWidth' instead; remove the setting."
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load and normalize a unified config file.
|
||||
* Returns an empty config with no issues if the file does not exist.
|
||||
* Returns an empty config with an issue if the file cannot be parsed.
|
||||
*/
|
||||
export function loadUnifiedConfig(path: string): UnifiedConfigLoadResult {
|
||||
if (!existsSync(path)) {
|
||||
return { config: {}, issues: [] };
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = readFileSync(path, "utf-8");
|
||||
const parsed = JSON.parse(stripJsonComments(raw)) as unknown;
|
||||
return validateUnifiedConfig(parsed);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
config: {},
|
||||
issues: [`Failed to read config at '${path}': ${message}`],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
import {
|
||||
type ExtensionAPI,
|
||||
type ExtensionCommandContext,
|
||||
getSettingsListTheme,
|
||||
} from "@earendil-works/pi-coding-agent";
|
||||
import { type SettingItem, SettingsList } from "@earendil-works/pi-tui";
|
||||
|
||||
import type { CommandConfigStore } from "./config-store";
|
||||
import {
|
||||
DEFAULT_EXTENSION_CONFIG,
|
||||
type PermissionSystemExtensionConfig,
|
||||
} from "./extension-config";
|
||||
import type { Ruleset } from "./rule";
|
||||
|
||||
interface PermissionSystemConfigController {
|
||||
config: CommandConfigStore;
|
||||
/** Precomputed global config file path. */
|
||||
configPath: string;
|
||||
/** Returns the composed config-layer ruleset for the active agent scope. */
|
||||
getActiveAgentConfigRules(): Ruleset;
|
||||
}
|
||||
|
||||
const ON_OFF = ["on", "off"];
|
||||
const COMMAND_ARGUMENTS = [
|
||||
{
|
||||
value: "show",
|
||||
label: "Show active settings",
|
||||
description: "Display the current permission-system config summary",
|
||||
},
|
||||
{
|
||||
value: "path",
|
||||
label: "Show config path",
|
||||
description: "Display the config.json path used by pi-permission-system",
|
||||
},
|
||||
{
|
||||
value: "reset",
|
||||
label: "Reset defaults",
|
||||
description: "Restore default yolo/logging settings and persist them",
|
||||
},
|
||||
{
|
||||
value: "help",
|
||||
label: "Show help",
|
||||
description: "Display command usage",
|
||||
},
|
||||
] as const;
|
||||
const USAGE_TEXT =
|
||||
"Usage: /permission-system [show|path|reset|help] (or run /permission-system with no args to open settings modal)";
|
||||
|
||||
function cloneDefaultConfig(): PermissionSystemExtensionConfig {
|
||||
return {
|
||||
debugLog: DEFAULT_EXTENSION_CONFIG.debugLog,
|
||||
permissionReviewLog: DEFAULT_EXTENSION_CONFIG.permissionReviewLog,
|
||||
yoloMode: DEFAULT_EXTENSION_CONFIG.yoloMode,
|
||||
doublePressToConfirm: DEFAULT_EXTENSION_CONFIG.doublePressToConfirm,
|
||||
};
|
||||
}
|
||||
|
||||
function toOnOff(value: boolean): string {
|
||||
return value ? "on" : "off";
|
||||
}
|
||||
|
||||
function formatRulesSummary(rules: Ruleset): string {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- origin may be absent despite its type
|
||||
const configRules = rules.filter((r) => r.layer === "config" && r.origin);
|
||||
if (configRules.length === 0) return "";
|
||||
const formatted = configRules
|
||||
.map((r) => {
|
||||
const key =
|
||||
r.pattern === "*" ? r.surface : `${r.surface}["${r.pattern}"]`;
|
||||
return `${key}=${r.action} (${r.origin})`;
|
||||
})
|
||||
.join(", ");
|
||||
return `\n rules: ${formatted}`;
|
||||
}
|
||||
|
||||
function summarizeConfig(
|
||||
config: PermissionSystemExtensionConfig,
|
||||
rules?: Ruleset,
|
||||
): string {
|
||||
const knobs = [
|
||||
`yoloMode=${toOnOff(config.yoloMode)}`,
|
||||
`permissionReviewLog=${toOnOff(config.permissionReviewLog)}`,
|
||||
`debugLog=${toOnOff(config.debugLog)}`,
|
||||
].join(", ");
|
||||
const rulesSuffix = rules ? formatRulesSummary(rules) : "";
|
||||
return `${knobs}${rulesSuffix}`;
|
||||
}
|
||||
|
||||
function buildSettingItems(
|
||||
config: PermissionSystemExtensionConfig,
|
||||
): SettingItem[] {
|
||||
return [
|
||||
{
|
||||
id: "yoloMode",
|
||||
label: "YOLO mode",
|
||||
description:
|
||||
"Auto-approve ask-state permission checks, including subagent approval forwarding",
|
||||
currentValue: toOnOff(config.yoloMode),
|
||||
values: ON_OFF,
|
||||
},
|
||||
{
|
||||
id: "permissionReviewLog",
|
||||
label: "Permission review log",
|
||||
description:
|
||||
"Write permission request and decision audit events to the extension logs directory",
|
||||
currentValue: toOnOff(config.permissionReviewLog),
|
||||
values: ON_OFF,
|
||||
},
|
||||
{
|
||||
id: "debugLog",
|
||||
label: "Debug logging",
|
||||
description:
|
||||
"Write verbose permission-system diagnostics to the extension logs directory",
|
||||
currentValue: toOnOff(config.debugLog),
|
||||
values: ON_OFF,
|
||||
},
|
||||
{
|
||||
id: "doublePressToConfirm",
|
||||
label: "Double-press to confirm",
|
||||
description:
|
||||
"Require a confirming second press of a decision hotkey in the inline TUI permission dialog",
|
||||
currentValue: toOnOff(config.doublePressToConfirm),
|
||||
values: ON_OFF,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function applySetting(
|
||||
config: PermissionSystemExtensionConfig,
|
||||
id: string,
|
||||
value: string,
|
||||
): PermissionSystemExtensionConfig {
|
||||
switch (id) {
|
||||
case "yoloMode":
|
||||
return { ...config, yoloMode: value === "on" };
|
||||
case "permissionReviewLog":
|
||||
return { ...config, permissionReviewLog: value === "on" };
|
||||
case "debugLog":
|
||||
return { ...config, debugLog: value === "on" };
|
||||
case "doublePressToConfirm":
|
||||
return { ...config, doublePressToConfirm: value === "on" };
|
||||
default:
|
||||
return config;
|
||||
}
|
||||
}
|
||||
|
||||
function syncSettingValues(
|
||||
settingsList: SettingsList,
|
||||
config: PermissionSystemExtensionConfig,
|
||||
): void {
|
||||
settingsList.updateValue("yoloMode", toOnOff(config.yoloMode));
|
||||
settingsList.updateValue(
|
||||
"permissionReviewLog",
|
||||
toOnOff(config.permissionReviewLog),
|
||||
);
|
||||
settingsList.updateValue("debugLog", toOnOff(config.debugLog));
|
||||
settingsList.updateValue(
|
||||
"doublePressToConfirm",
|
||||
toOnOff(config.doublePressToConfirm),
|
||||
);
|
||||
}
|
||||
|
||||
function getArgumentCompletions(
|
||||
argumentPrefix: string,
|
||||
): Array<{ value: string; label: string; description: string }> | null {
|
||||
const normalized = argumentPrefix.trim().toLowerCase();
|
||||
if (normalized.includes(" ")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const filtered = COMMAND_ARGUMENTS.filter((item) =>
|
||||
item.value.startsWith(normalized),
|
||||
);
|
||||
return filtered.length > 0 ? [...filtered] : null;
|
||||
}
|
||||
|
||||
async function openSettingsModal(
|
||||
ctx: ExtensionCommandContext,
|
||||
controller: PermissionSystemConfigController,
|
||||
): Promise<void> {
|
||||
const overlayOptions = {
|
||||
anchor: "center" as const,
|
||||
width: 82,
|
||||
maxHeight: "85%" as const,
|
||||
margin: 1,
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-invalid-void-type -- ctx.ui.custom<void> is valid; rule does not allow void in generic fn call type args
|
||||
await ctx.ui.custom<void>(
|
||||
(_tui, _theme, _keybindings, done) => {
|
||||
let current = controller.config.current();
|
||||
const settingsList = new SettingsList(
|
||||
buildSettingItems(current),
|
||||
10,
|
||||
getSettingsListTheme(),
|
||||
(id, newValue) => {
|
||||
current = applySetting(current, id, newValue);
|
||||
controller.config.save(current, ctx);
|
||||
current = controller.config.current();
|
||||
syncSettingValues(settingsList, current);
|
||||
},
|
||||
() => done(),
|
||||
);
|
||||
|
||||
return settingsList;
|
||||
},
|
||||
{ overlay: true, overlayOptions },
|
||||
);
|
||||
}
|
||||
|
||||
function handleArgs(
|
||||
args: string,
|
||||
ctx: ExtensionCommandContext,
|
||||
controller: PermissionSystemConfigController,
|
||||
): boolean {
|
||||
const normalized = args.trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (normalized === "show") {
|
||||
const rules = controller.getActiveAgentConfigRules();
|
||||
ctx.ui.notify(
|
||||
`permission-system: ${summarizeConfig(controller.config.current(), rules)}`,
|
||||
"info",
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (normalized === "path") {
|
||||
ctx.ui.notify(`permission-system config: ${controller.configPath}`, "info");
|
||||
return true;
|
||||
}
|
||||
|
||||
if (normalized === "reset") {
|
||||
controller.config.save(cloneDefaultConfig(), ctx);
|
||||
ctx.ui.notify("Permission system settings reset to defaults.", "info");
|
||||
return true;
|
||||
}
|
||||
|
||||
if (normalized === "help") {
|
||||
ctx.ui.notify(USAGE_TEXT, "info");
|
||||
return true;
|
||||
}
|
||||
|
||||
ctx.ui.notify(USAGE_TEXT, "warning");
|
||||
return true;
|
||||
}
|
||||
|
||||
export function registerPermissionSystemCommand(
|
||||
pi: ExtensionAPI,
|
||||
controller: PermissionSystemConfigController,
|
||||
): void {
|
||||
pi.registerCommand("permission-system", {
|
||||
description:
|
||||
"Configure pi-permission-system logging and yolo-mode behavior",
|
||||
getArgumentCompletions,
|
||||
handler: async (args, ctx) => {
|
||||
if (handleArgs(args, ctx, controller)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ctx.hasUI) {
|
||||
ctx.ui.notify(
|
||||
"/permission-system requires interactive TUI mode.",
|
||||
"warning",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await openSettingsModal(ctx, controller);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { join } from "node:path";
|
||||
|
||||
const EXTENSION_ID = "pi-permission-system";
|
||||
|
||||
export const DEBUG_LOG_FILENAME = `${EXTENSION_ID}-debug.jsonl`;
|
||||
export const REVIEW_LOG_FILENAME = `${EXTENSION_ID}-permission-review.jsonl`;
|
||||
|
||||
export function getGlobalConfigDir(agentDir: string): string {
|
||||
return join(agentDir, "extensions", EXTENSION_ID);
|
||||
}
|
||||
|
||||
export function getGlobalConfigPath(agentDir: string): string {
|
||||
return join(getGlobalConfigDir(agentDir), "config.json");
|
||||
}
|
||||
|
||||
export function getGlobalLogsDir(agentDir: string): string {
|
||||
return join(getGlobalConfigDir(agentDir), "logs");
|
||||
}
|
||||
|
||||
export function getProjectConfigPath(cwd: string): string {
|
||||
return join(cwd, ".pi", "extensions", EXTENSION_ID, "config.json");
|
||||
}
|
||||
|
||||
/**
|
||||
* Directory holding project-scoped custom agent definition files.
|
||||
*
|
||||
* `<cwd>/.pi/agents` is a Pi platform convention, also encoded by
|
||||
* `@gotgenes/pi-subagents`' `loadCustomAgents` (`config/custom-agents.ts`).
|
||||
* The two packages encode it independently — pi-permission-system has no
|
||||
* dependency on pi-subagents (ADR-0002) — so this is this package's
|
||||
* authoritative copy.
|
||||
*/
|
||||
export function getProjectAgentsDir(cwd: string): string {
|
||||
return join(cwd, ".pi", "agents");
|
||||
}
|
||||
|
||||
export function getLegacyGlobalPolicyPath(agentDir: string): string {
|
||||
return join(agentDir, "pi-permissions.jsonc");
|
||||
}
|
||||
|
||||
export function getLegacyProjectPolicyPath(cwd: string): string {
|
||||
return join(cwd, ".pi", "agent", "pi-permissions.jsonc");
|
||||
}
|
||||
|
||||
export function getLegacyExtensionConfigPath(extensionRoot: string): string {
|
||||
return join(extensionRoot, "config.json");
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { ResolvedPolicyPaths } from "./permission-manager";
|
||||
|
||||
export interface ResolvedConfigLogEntry {
|
||||
globalConfigPath: string;
|
||||
globalConfigExists: boolean;
|
||||
projectConfigPath: string | null;
|
||||
projectConfigExists: boolean;
|
||||
agentsDir: string;
|
||||
agentsDirExists: boolean;
|
||||
projectAgentsDir: string | null;
|
||||
projectAgentsDirExists: boolean;
|
||||
legacyGlobalPolicyDetected: boolean;
|
||||
legacyProjectPolicyDetected: boolean;
|
||||
legacyExtensionConfigDetected: boolean;
|
||||
}
|
||||
|
||||
export interface BuildResolvedConfigLogEntryOptions {
|
||||
policyPaths: ResolvedPolicyPaths;
|
||||
legacyGlobalPolicyDetected?: boolean;
|
||||
legacyProjectPolicyDetected?: boolean;
|
||||
legacyExtensionConfigDetected?: boolean;
|
||||
}
|
||||
|
||||
export function buildResolvedConfigLogEntry(
|
||||
options: BuildResolvedConfigLogEntryOptions,
|
||||
): ResolvedConfigLogEntry {
|
||||
return {
|
||||
...options.policyPaths,
|
||||
legacyGlobalPolicyDetected: options.legacyGlobalPolicyDetected ?? false,
|
||||
legacyProjectPolicyDetected: options.legacyProjectPolicyDetected ?? false,
|
||||
legacyExtensionConfigDetected:
|
||||
options.legacyExtensionConfigDetected ?? false,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* Single source of truth for the permission-system config file shape.
|
||||
*
|
||||
* These composable zod schemas drive two consumers:
|
||||
* 1. Runtime validation in the config-file loader (`config-loader.ts`).
|
||||
* 2. The published JSON Schema (`schemas/permissions.schema.json`), derived by
|
||||
* `buildPermissionsJsonSchema()` and regenerated via `pnpm run gen:schema`.
|
||||
*
|
||||
* Edit the schemas here — never the generated JSON by hand. A parity test
|
||||
* (`config-schema.test.ts`) fails if the committed JSON drifts from this source.
|
||||
*/
|
||||
|
||||
/** Canonical hosted location of the generated JSON Schema (monorepo raw path). */
|
||||
export const PERMISSIONS_SCHEMA_URL =
|
||||
"https://raw.githubusercontent.com/gotgenes/pi-packages/main/packages/pi-permission-system/schemas/permissions.schema.json";
|
||||
|
||||
const permissionStateSchema = z
|
||||
.union([
|
||||
z.literal("allow").meta({
|
||||
description: "Permit the action silently with no user interaction.",
|
||||
}),
|
||||
z.literal("deny").meta({
|
||||
description:
|
||||
"Block the action with an error message. The agent is told not to retry.",
|
||||
}),
|
||||
z.literal("ask").meta({
|
||||
description:
|
||||
"Prompt the user for confirmation via the interactive UI before proceeding.",
|
||||
}),
|
||||
])
|
||||
.meta({
|
||||
id: "permissionState",
|
||||
description:
|
||||
"A permission decision: allow (permit silently), deny (block with error), or ask (prompt the user for confirmation).",
|
||||
});
|
||||
|
||||
const denyWithReasonSchema = z
|
||||
.strictObject({
|
||||
action: z.literal("deny").meta({
|
||||
description: 'The permission decision — must be "deny".',
|
||||
}),
|
||||
reason: z.string().max(500).optional().meta({
|
||||
description:
|
||||
"Optional reason shown to the agent when this action is denied.",
|
||||
}),
|
||||
})
|
||||
.meta({
|
||||
id: "denyWithReason",
|
||||
description:
|
||||
"Deny with an optional custom reason shown to the agent when the action is blocked.",
|
||||
});
|
||||
|
||||
const patternValueSchema = z.union([
|
||||
permissionStateSchema,
|
||||
denyWithReasonSchema,
|
||||
]);
|
||||
|
||||
const permissionMapSchema = z
|
||||
.record(
|
||||
z.string().min(1).meta({
|
||||
description:
|
||||
"A non-empty pattern string. Use * for wildcard matching. Prefix with ~/ or $HOME/ for home-relative paths.",
|
||||
}),
|
||||
patternValueSchema,
|
||||
)
|
||||
.meta({
|
||||
id: "permissionMap",
|
||||
description:
|
||||
"A map of wildcard patterns to permission states. Last matching pattern wins.",
|
||||
markdownDescription:
|
||||
"A map of wildcard patterns to permission states.\n\nUse `*` for wildcard matching. When multiple patterns match, the **last matching rule wins** — put broad catch-alls first and specific overrides after them.\n\nPattern keys support home directory expansion:\n- `~/path` or `$HOME/path` — expanded to the OS home directory at match time.\n- `~` or `$HOME` alone — expands to the home directory itself.\n\nThe stored pattern is always shown in logs and approval dialogs as written (e.g. `~/dev/*`).",
|
||||
});
|
||||
|
||||
const permissionSchema = z
|
||||
.record(
|
||||
z.string().min(1).meta({
|
||||
description: "A surface name or the universal fallback key '*'.",
|
||||
}),
|
||||
z.union([permissionStateSchema, permissionMapSchema]),
|
||||
)
|
||||
.meta({
|
||||
description:
|
||||
"Flat permission policy. Each key is a surface name; values are a PermissionState string (catch-all) or a pattern→action map.",
|
||||
markdownDescription:
|
||||
'Flat permission policy.\n\nEach top-level key is a surface name:\n- `"*"` — universal fallback (replaces `defaultPolicy.tools` from the legacy format)\n- Tool names (`read`, `write`, `bash`, `mcp`, `skill`, `external_directory`, `path`, etc.)\n\nA **string** value is shorthand for `{ "*": action }` (surface-level catch-all).\nAn **object** value maps wildcard patterns to actions — last matching pattern wins.\n\nFor built-in file tools (`read`, `write`, `edit`, `find`, `grep`, `ls`), patterns are matched against the file path from `input.path`. For example, `"read": { "*": "allow", "*.env": "deny" }` allows reads but denies `.env` files.\n\nWhen Pi\'s current working directory is known, relative path inputs also match their cwd-normalized absolute form, so `src/App.jsx` can match both `src/*` and `/workspace/project/*`. Bash path tokens use the effective directory after literal `cd` commands for this matching; non-literal `cd "$DIR"` style commands remain conservative.\n\nThe `path` surface is a cross-cutting gate that applies to **all** file access: Pi tools, bash commands, MCP calls (via `input.arguments.path`), and extension tools (via `input.path` or a registered access extractor). A `path` deny cannot be overridden by a per-tool allow. Use it to protect sensitive files (`.env`, `~/.ssh/*`) from all path-aware tools at once.\n\nThe `external_directory` surface gates access **outside** the working directory. Give it a pattern map to allow specific outside-CWD directories without opening all external access — e.g. `"external_directory": { "*": "ask", "~/.cargo/registry/*": "allow" }` to silence repeated prompts on a local cache. The trailing `*` is greedy and crosses subdirectory boundaries; a bare `~/.cargo/registry` matches only the directory entry itself. Because layers compose with most-restrictive-wins, a `path` allow cannot loosen an `external_directory: ask` boundary — allow outside-CWD directories here, not on `path`.\n\n**Merge order (lowest → highest precedence):** global → project → per-agent frontmatter.',
|
||||
examples: [
|
||||
{
|
||||
"*": "ask",
|
||||
path: {
|
||||
"*": "allow",
|
||||
"*.env": "deny",
|
||||
"*.env.*": "deny",
|
||||
"*.env.example": "allow",
|
||||
},
|
||||
read: "allow",
|
||||
write: "deny",
|
||||
edit: "deny",
|
||||
bash: {
|
||||
"*": "ask",
|
||||
"git *": "ask",
|
||||
"git status": "allow",
|
||||
"git diff": "allow",
|
||||
},
|
||||
mcp: { "*": "ask", mcp_status: "allow", "exa:*": "allow" },
|
||||
skill: { "*": "ask", librarian: "allow" },
|
||||
external_directory: { "*": "ask", "~/.cargo/registry/*": "allow" },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const shellToolAliasSchema = z
|
||||
.strictObject({
|
||||
commandArgument: z.string().min(1).meta({
|
||||
description:
|
||||
"The name of the tool's input argument holding the shell command string (e.g. 'cmd').",
|
||||
}),
|
||||
workdirArgument: z.string().min(1).optional().meta({
|
||||
description:
|
||||
"Optional name of the tool's input argument holding the working directory (e.g. 'workdir').",
|
||||
}),
|
||||
})
|
||||
.meta({
|
||||
description:
|
||||
"Maps one shell-aliased tool to the input arguments holding its command and (optionally) its working directory.",
|
||||
});
|
||||
|
||||
const shellToolsSchema = z
|
||||
.record(
|
||||
z.string().min(1).meta({
|
||||
description: "A non-bash tool name that carries shell semantics.",
|
||||
}),
|
||||
shellToolAliasSchema,
|
||||
)
|
||||
.meta({
|
||||
description:
|
||||
"Maps non-bash tool names that carry shell semantics to the input arguments holding their command and working directory.",
|
||||
markdownDescription:
|
||||
'Records which non-`bash` tools carry shell semantics, mapping each tool name to the input argument holding its command (and optionally its working directory).\n\nUse this when an extension replaces the native `bash` tool under a different name — e.g. `@howaboua/pi-codex-conversion` registers `exec_command` with a `cmd` argument and an optional `workdir`. Recording the alias lets the permission system gate that tool through the same bash enforcement stack as native `bash` (command decomposition, wrapper flooring, path/external-directory token gates, and `bash:` rules).\n\nExample:\n\n```json\n"shellTools": {\n "exec_command": { "commandArgument": "cmd", "workdirArgument": "workdir" }\n}\n```\n\n**Merge order:** shallow-merge by tool name across global → project. A project entry overrides a specific tool\'s mapping on key collision but never drops a global entry.',
|
||||
examples: [
|
||||
{
|
||||
exec_command: { commandArgument: "cmd", workdirArgument: "workdir" },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
/**
|
||||
* The on-disk config file shape.
|
||||
*
|
||||
* Every field is optional so partial global/project configs merge before the
|
||||
* runtime defaults are applied downstream (`normalizePermissionSystemConfig`).
|
||||
* No `.default()` lives here — injecting defaults at parse time would break the
|
||||
* global-vs-project override semantics. `strictObject` makes unknown top-level
|
||||
* keys an error, so editors flag typos and the runtime loader rejects them.
|
||||
*/
|
||||
export const unifiedConfigSchema = z
|
||||
.strictObject({
|
||||
$schema: z.string().optional().meta({
|
||||
description: "JSON Schema URI for editor autocomplete and validation.",
|
||||
}),
|
||||
debugLog: z.boolean().optional().meta({
|
||||
description:
|
||||
"Write verbose permission-system diagnostics to the extension logs directory.",
|
||||
markdownDescription:
|
||||
"Write verbose permission-system diagnostics to `logs/pi-permission-system-debug.jsonl` under the extension config directory.",
|
||||
default: false,
|
||||
}),
|
||||
permissionReviewLog: z.boolean().optional().meta({
|
||||
description:
|
||||
"Write permission request and decision audit events to the extension logs directory.",
|
||||
markdownDescription:
|
||||
"Write permission request and decision audit events to `logs/pi-permission-system-permission-review.jsonl` under the extension config directory.",
|
||||
default: true,
|
||||
}),
|
||||
yoloMode: z.boolean().optional().meta({
|
||||
description:
|
||||
"Auto-approve ask-state permission checks, including subagent approval forwarding.",
|
||||
markdownDescription:
|
||||
"Auto-approve `ask`-state permission checks, including subagent approval forwarding.\n\n⚠️ **Use with caution** — this disables all interactive confirmation prompts.",
|
||||
default: false,
|
||||
}),
|
||||
doublePressToConfirm: z.boolean().optional().meta({
|
||||
description:
|
||||
"Require a confirming second press of a decision hotkey in the inline permission dialog. Applies to TUI sessions only.",
|
||||
markdownDescription:
|
||||
"Require a confirming second press of a decision hotkey (`y`/`s`/`n`/`r`) in the inline permission dialog before it commits — the first press arms the action and shows a `Press y again to approve.` hint.\n\nApplies to interactive **TUI** sessions only; the non-TUI (RPC/frontend) prompt keeps its single-select flow. Set to `false` to commit decisions on the first hotkey press.",
|
||||
default: true,
|
||||
}),
|
||||
forwardingTimeoutMs: z.number().int().min(1).optional().meta({
|
||||
description:
|
||||
"How long a subagent waits for the parent session to answer a forwarded permission request, in milliseconds. Omit to use the default (600000, ten minutes).",
|
||||
markdownDescription:
|
||||
"How long a subagent waits for the parent session to answer a forwarded permission request, in milliseconds.\n\nOmit to use the default (`600000`, ten minutes). A child whose in-process parent is not draining its inbox at all gives up in a couple of seconds regardless of this value, so lower it only to bound how long you are willing to leave an *unanswered* prompt pending.",
|
||||
default: 600000,
|
||||
}),
|
||||
promptMaxRows: z.number().int().min(1).optional().meta({
|
||||
description:
|
||||
"Maximum rows a permission prompt renders before eliding its evidence. Omit to use the default (24).",
|
||||
markdownDescription:
|
||||
"Maximum rows a permission prompt renders before eliding its evidence.\n\nOmit to use the default (24). The request's own facts — the requesting agent, the tool, the matched rule, the decision-relevant value — are never elided by this budget; what gives way is the supporting evidence, and `Ctrl+O` expands the prompt to the complete request.",
|
||||
default: 24,
|
||||
}),
|
||||
promptFieldMaxWidth: z.number().int().min(1).optional().meta({
|
||||
description:
|
||||
"Maximum characters of any one field shown in a permission prompt. Omit to use the default (400).",
|
||||
markdownDescription:
|
||||
"Maximum characters of any one field shown in a permission prompt.\n\nOmit to use the default (400). This is what bounds a single pathological field — a long here-string command, say — that would otherwise fill the prompt through wrapping. A shortened field is marked with an ellipsis, and `Ctrl+O` shows it in full.",
|
||||
default: 400,
|
||||
}),
|
||||
reviewLogFieldMaxWidth: z.number().int().min(1).optional().meta({
|
||||
description:
|
||||
"Maximum characters of any one value written to the permission review log. Omit to use the default (1000).",
|
||||
markdownDescription:
|
||||
"Maximum characters of any one value written to the permission review log.\n\nOmit to use the default (1000). Every string the review log writes is narrowed to this width and marked with an ellipsis, so the log's growth is a decision you make rather than a side effect of how long a command happened to be. Raise it to keep longer values \u2014 a bash command exceeding the width is stored shortened.\n\nThis is a length bound, not redaction: it never inspects a value to decide what to hide. Key-name masking is unchanged and applies independently.",
|
||||
default: 1000,
|
||||
}),
|
||||
toolInputPreviewMaxLength: z.number().int().min(1).optional().meta({
|
||||
deprecated: true,
|
||||
description:
|
||||
"Deprecated and ignored. Superseded by promptMaxRows and promptFieldMaxWidth, which bound the whole prompt rather than one preview. Still accepted so an existing config is not rejected; remove it.",
|
||||
markdownDescription:
|
||||
"**Deprecated and ignored.** Superseded by `promptMaxRows` and `promptFieldMaxWidth`, which bound the whole permission prompt rather than one preview inside it.\n\nStill accepted so an existing config is not rejected fail-closed, but the value no longer takes effect. Remove it.",
|
||||
}),
|
||||
toolTextSummaryMaxLength: z.number().int().min(1).optional().meta({
|
||||
deprecated: true,
|
||||
description:
|
||||
"Deprecated and ignored. Superseded by promptMaxRows and promptFieldMaxWidth, which bound the whole prompt rather than one summary. Still accepted so an existing config is not rejected; remove it.",
|
||||
markdownDescription:
|
||||
"**Deprecated and ignored.** Superseded by `promptMaxRows` and `promptFieldMaxWidth`, which bound the whole permission prompt rather than one summary inside it.\n\nStill accepted so an existing config is not rejected fail-closed, but the value no longer takes effect. Remove it.",
|
||||
}),
|
||||
piInfrastructureReadPaths: z.array(z.string().min(1)).optional().meta({
|
||||
description:
|
||||
"Additional directories to auto-allow for reads as Pi infrastructure, bypassing the external_directory gate. Supports ~ expansion and wildcard patterns (* and ?).",
|
||||
markdownDescription:
|
||||
"Additional directories to auto-allow for reads as Pi infrastructure, bypassing the `external_directory` gate.\n\nThe extension auto-discovers the global node_modules root (walks up from the extension's install path; falls back to `npm root -g` from a dev checkout), Pi's own install directory (via the coding-agent `getPackageDir()` API), `agentDir`, `agentDir/git`, and project-local `.pi/npm/` and `.pi/git/`. Add entries here for edge cases where auto-discovery is insufficient (e.g. custom `npmCommand` pointing to pnpm).\n\nSupports `~`/`$HOME` expansion. Entries may be plain directory prefixes or wildcard patterns using `*` (matches any characters, including `/`) and `?` (matches exactly one character). `**` and `*` are equivalent — both cross directory boundaries.\n\nOn Windows, matching is case-insensitive and tolerant of either path separator.",
|
||||
default: [],
|
||||
}),
|
||||
authorizerChain: z.array(z.string().min(1)).optional().meta({
|
||||
description:
|
||||
"Ordered names of registered live-authority chain links to consult before the terminal authorizer. Config order (not registration order) fixes the chain order; an unregistered name is skipped fail-safe (more prompting, never less); a link decides nothing until it is named here.",
|
||||
markdownDescription:
|
||||
"Ordered names of registered **live-authority chain links** (e.g. a model judge) to consult before the terminal authorizer (the human, or the subagent-forwarding / headless-deny fallback).\n\nA link reviews an `ask` and returns `allow` / `deny` (with an optional teaching reason) / `defer` to the next link. Three invariants govern the chain:\n\n- **Config order wins.** The order here \u2014 not the order extensions register in \u2014 fixes the security-relevant chain order.\n- **Fail-safe skip.** A name with no registered link is skipped with a warning; the `ask` still reaches the terminal (more prompting, never less).\n- **Opt-in activation.** Installing a judge extension grants it no authority; a link decides nothing until you name it here.\n\nThe chain owner caps every verdict with a bounded-delegation checkpoint: a link's `allow` on an excluded surface (`external_directory` or `path`) is downgraded to `defer`, so a link cannot exceed your policy.\n\nDefaults to an empty list (no links).",
|
||||
default: [],
|
||||
}),
|
||||
permission: permissionSchema.optional(),
|
||||
shellTools: shellToolsSchema.optional(),
|
||||
})
|
||||
.meta({
|
||||
title: "PI Permission System Configuration",
|
||||
description:
|
||||
"Unified config file combining runtime knobs and flat permission policy for pi-permission-system.",
|
||||
markdownDescription:
|
||||
"Unified config file combining runtime knobs and flat permission policy for [pi-permission-system](https://github.com/gotgenes/pi-packages/tree/main/packages/pi-permission-system).\n\nPlace at `~/.pi/agent/extensions/pi-permission-system/config.json` (global) or `<project>/.pi/extensions/pi-permission-system/config.json` (project).",
|
||||
});
|
||||
|
||||
/** A permission decision. */
|
||||
export type PermissionState = z.infer<typeof permissionStateSchema>;
|
||||
|
||||
/** A deny action with an optional custom reason. */
|
||||
export type DenyWithReason = z.infer<typeof denyWithReasonSchema>;
|
||||
|
||||
/** A pattern value: a PermissionState string OR a DenyWithReason object. */
|
||||
export type PatternValue = z.infer<typeof patternValueSchema>;
|
||||
|
||||
/** The on-disk permission shape inside the `"permission"` key. */
|
||||
export type FlatPermissionConfig = z.infer<typeof permissionSchema>;
|
||||
|
||||
/** The `shellTools` map: tool name → shell-alias argument mapping. */
|
||||
export type ShellToolsConfig = z.infer<typeof shellToolsSchema>;
|
||||
|
||||
/** The raw config file shape after validation (all fields optional). */
|
||||
export type UnifiedPermissionConfig = z.infer<typeof unifiedConfigSchema>;
|
||||
|
||||
/**
|
||||
* Derive the published JSON Schema (Draft 2020-12) from the zod source.
|
||||
*
|
||||
* The three id-tagged sub-schemas (`permissionState`, `permissionMap`,
|
||||
* `denyWithReason`) become `$defs` referenced by `$ref`; everything else
|
||||
* inlines. The root `$id` is set to the canonical monorepo URL.
|
||||
*/
|
||||
export function buildPermissionsJsonSchema(): Record<string, unknown> {
|
||||
const { $schema, ...rest } = z.toJSONSchema(unifiedConfigSchema, {
|
||||
target: "draft-2020-12",
|
||||
});
|
||||
return { $schema, $id: PERMISSIONS_SCHEMA_URL, ...rest };
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
renameSync,
|
||||
unlinkSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { dirname, normalize } from "node:path";
|
||||
import type {
|
||||
ExtensionCommandContext,
|
||||
ExtensionContext,
|
||||
} from "@earendil-works/pi-coding-agent";
|
||||
|
||||
import { loadAndMergeConfigs, loadUnifiedConfig } from "./config-loader";
|
||||
import {
|
||||
getGlobalConfigPath,
|
||||
getLegacyExtensionConfigPath,
|
||||
getLegacyGlobalPolicyPath,
|
||||
getLegacyProjectPolicyPath,
|
||||
} from "./config-paths";
|
||||
import { buildResolvedConfigLogEntry } from "./config-reporter";
|
||||
import {
|
||||
DEFAULT_EXTENSION_CONFIG,
|
||||
EXTENSION_ROOT,
|
||||
normalizePermissionSystemConfig,
|
||||
type PermissionSystemExtensionConfig,
|
||||
} from "./extension-config";
|
||||
import type { ResolvedPolicyPaths } from "./policy-loader";
|
||||
import type { DebugReviewLogger } from "./session-logger";
|
||||
import { syncPermissionSystemStatus } from "./status";
|
||||
|
||||
/** Read-only view of the current config — for consumers that only read. */
|
||||
export interface ConfigReader {
|
||||
current(): PermissionSystemExtensionConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrow subset of `ConfigStore` that `PermissionSession` depends on.
|
||||
*
|
||||
* Using an interface rather than the concrete class avoids private-member
|
||||
* coupling between the class and test doubles.
|
||||
*/
|
||||
export interface SessionConfigStore extends ConfigReader {
|
||||
refresh(ctx: ExtensionContext | undefined, projectTrusted: boolean): void;
|
||||
logResolvedPaths(cwd?: string): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrow subset of `ConfigStore` for the `/permission-system` command.
|
||||
*
|
||||
* Using an interface rather than the concrete class avoids private-member
|
||||
* coupling between the class and test doubles.
|
||||
*/
|
||||
export interface CommandConfigStore extends ConfigReader {
|
||||
save(
|
||||
next: PermissionSystemExtensionConfig,
|
||||
ctx: ExtensionCommandContext,
|
||||
): void;
|
||||
}
|
||||
|
||||
/** Narrow view of the manager's resolved policy paths (for `logResolvedPaths`). */
|
||||
export interface ResolvedPolicyPathProvider {
|
||||
getResolvedPolicyPaths(): ResolvedPolicyPaths;
|
||||
}
|
||||
|
||||
export interface ConfigStoreDeps {
|
||||
agentDir: string;
|
||||
policyPaths: ResolvedPolicyPathProvider;
|
||||
logger: DebugReviewLogger;
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns the mutable extension config and the operations that read/write it.
|
||||
*
|
||||
* Replaces the three `(runtime, …)` config free functions
|
||||
* (`refreshExtensionConfig`, `saveExtensionConfig`, `logResolvedConfigPaths`)
|
||||
* with methods that privately own `config` and `lastConfigWarning`.
|
||||
*
|
||||
* Implements {@link ConfigReader} so consumers that only read the current config
|
||||
* can depend on the narrow interface rather than the full class.
|
||||
*/
|
||||
export class ConfigStore implements SessionConfigStore, CommandConfigStore {
|
||||
private config: PermissionSystemExtensionConfig;
|
||||
private lastConfigWarning: string | null = null;
|
||||
|
||||
constructor(private readonly deps: ConfigStoreDeps) {
|
||||
this.config = { ...DEFAULT_EXTENSION_CONFIG };
|
||||
}
|
||||
|
||||
/** Return the current extension config. */
|
||||
current(): PermissionSystemExtensionConfig {
|
||||
return this.config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reload merged config from disk.
|
||||
*
|
||||
* If `ctx` is provided, uses it to derive the cwd and sync UI status.
|
||||
* When `projectTrusted` is `false`, the project scope is withheld so an
|
||||
* untrusted repository's runtime config (`yoloMode`, `permissionReviewLog`,
|
||||
* …) cannot loosen the operator's global config (#644).
|
||||
*/
|
||||
refresh(ctx: ExtensionContext | undefined, projectTrusted: boolean): void {
|
||||
const cwd = ctx?.cwd ?? null;
|
||||
const mergeResult = loadAndMergeConfigs(
|
||||
this.deps.agentDir,
|
||||
cwd ?? "",
|
||||
EXTENSION_ROOT,
|
||||
{ includeProjectScope: projectTrusted },
|
||||
);
|
||||
const runtimeConfig = normalizePermissionSystemConfig(mergeResult.merged);
|
||||
this.config = runtimeConfig;
|
||||
|
||||
if (ctx?.hasUI) {
|
||||
syncPermissionSystemStatus(ctx, runtimeConfig);
|
||||
}
|
||||
|
||||
const warning =
|
||||
mergeResult.issues.length > 0 ? mergeResult.issues.join("\n") : undefined;
|
||||
|
||||
if (warning && warning !== this.lastConfigWarning) {
|
||||
this.lastConfigWarning = warning;
|
||||
ctx?.ui.notify(warning, "warning");
|
||||
} else if (!warning) {
|
||||
this.lastConfigWarning = null;
|
||||
}
|
||||
|
||||
this.deps.logger.debug("config.loaded", {
|
||||
warning: warning ?? null,
|
||||
debugLog: runtimeConfig.debugLog,
|
||||
permissionReviewLog: runtimeConfig.permissionReviewLog,
|
||||
yoloMode: runtimeConfig.yoloMode,
|
||||
projectTrusted,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Save updated runtime knobs to the global config file, then update
|
||||
* the current config and sync UI status.
|
||||
*
|
||||
* Equivalent to `saveExtensionConfig(runtime, next, ctx)`.
|
||||
*/
|
||||
// Called via the CommandConfigStore interface from config-modal.ts — fallow cannot trace through interfaces.
|
||||
// fallow-ignore-next-line unused-class-member
|
||||
save(
|
||||
next: PermissionSystemExtensionConfig,
|
||||
ctx: ExtensionCommandContext,
|
||||
): void {
|
||||
const normalized = normalizePermissionSystemConfig(next);
|
||||
const globalPath = getGlobalConfigPath(this.deps.agentDir);
|
||||
|
||||
const existing = loadUnifiedConfig(globalPath);
|
||||
const merged = {
|
||||
...existing.config,
|
||||
debugLog: normalized.debugLog,
|
||||
permissionReviewLog: normalized.permissionReviewLog,
|
||||
yoloMode: normalized.yoloMode,
|
||||
};
|
||||
|
||||
const tmpPath = `${globalPath}.tmp`;
|
||||
try {
|
||||
mkdirSync(dirname(globalPath), { recursive: true });
|
||||
writeFileSync(tmpPath, `${JSON.stringify(merged, null, 2)}\n`, "utf-8");
|
||||
renameSync(tmpPath, globalPath);
|
||||
} catch (error) {
|
||||
try {
|
||||
if (existsSync(tmpPath)) {
|
||||
unlinkSync(tmpPath);
|
||||
}
|
||||
} catch {
|
||||
// Ignore cleanup failures.
|
||||
}
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
ctx.ui.notify(
|
||||
`Failed to save permission-system config at '${globalPath}': ${message}`,
|
||||
"error",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.config = normalized;
|
||||
syncPermissionSystemStatus(ctx, normalized);
|
||||
this.lastConfigWarning = null;
|
||||
|
||||
this.deps.logger.debug("config.saved", {
|
||||
debugLog: normalized.debugLog,
|
||||
permissionReviewLog: normalized.permissionReviewLog,
|
||||
yoloMode: normalized.yoloMode,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the resolved config path set to the review and debug logs.
|
||||
*
|
||||
* Equivalent to `logResolvedConfigPaths(runtime)`.
|
||||
*/
|
||||
logResolvedPaths(cwd?: string): void {
|
||||
const policyPaths = this.deps.policyPaths.getResolvedPolicyPaths();
|
||||
const { agentDir } = this.deps;
|
||||
const legacyGlobalPolicyDetected = existsSync(
|
||||
getLegacyGlobalPolicyPath(agentDir),
|
||||
);
|
||||
const legacyProjectPolicyDetected = cwd
|
||||
? existsSync(getLegacyProjectPolicyPath(cwd))
|
||||
: false;
|
||||
const legacyExtConfigPath = getLegacyExtensionConfigPath(EXTENSION_ROOT);
|
||||
const newGlobalPath = getGlobalConfigPath(agentDir);
|
||||
const legacyExtensionConfigDetected =
|
||||
normalize(legacyExtConfigPath) !== normalize(newGlobalPath) &&
|
||||
existsSync(legacyExtConfigPath);
|
||||
const entry = buildResolvedConfigLogEntry({
|
||||
policyPaths,
|
||||
legacyGlobalPolicyDetected,
|
||||
legacyProjectPolicyDetected,
|
||||
legacyExtensionConfigDetected,
|
||||
});
|
||||
this.deps.logger.review(
|
||||
"config.resolved",
|
||||
entry as unknown as Record<string, unknown>,
|
||||
);
|
||||
this.deps.logger.debug(
|
||||
"config.resolved",
|
||||
entry as unknown as Record<string, unknown>,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Records the per-call terminal decision so an evaluated-and-allowed call is
|
||||
* distinguishable from a never-evaluated one. The fail-closed boundary owns the
|
||||
* recorder and calls exactly one of `recordDecision` / `recordError` per call.
|
||||
*/
|
||||
export interface DecisionRecorder {
|
||||
/** Record a terminal allow/block decision (also bumps the tool-call count). */
|
||||
recordDecision(action: "allow" | "block"): void;
|
||||
/** Record a gate error that blocked fail-closed (also bumps the count). */
|
||||
recordError(): void;
|
||||
}
|
||||
|
||||
/** Narrow logging surface the summary needs: a debug line and a warning. */
|
||||
export interface AuditLogger {
|
||||
debug(event: string, details?: Record<string, unknown>): void;
|
||||
warn(message: string): void;
|
||||
}
|
||||
|
||||
/** Narrow surface the session-shutdown handler depends on. */
|
||||
export interface DecisionSummaryWriter {
|
||||
writeSummary(logger: AuditLogger): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* In-process, per-session decision counters.
|
||||
*
|
||||
* The boundary produces exactly one terminal decision per tool call, so
|
||||
* `toolCalls` must always equal `allowed + blocked + errors`. `writeSummary`
|
||||
* emits the counters on `session_shutdown` and flags any mismatch as a cheap
|
||||
* structural self-check — a mismatch means a code path re-opened a silent
|
||||
* (never-recorded) exit.
|
||||
*/
|
||||
export class DecisionAudit implements DecisionRecorder {
|
||||
private toolCalls = 0;
|
||||
private allowed = 0;
|
||||
private blocked = 0;
|
||||
private errors = 0;
|
||||
|
||||
recordDecision(action: "allow" | "block"): void {
|
||||
this.toolCalls++;
|
||||
if (action === "allow") {
|
||||
this.allowed++;
|
||||
} else {
|
||||
this.blocked++;
|
||||
}
|
||||
}
|
||||
|
||||
recordError(): void {
|
||||
this.toolCalls++;
|
||||
this.errors++;
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit one `permission.session_summary` debug line with the counters. When
|
||||
* `toolCalls !== allowed + blocked + errors`, also emit a warning — the
|
||||
* invariant violation means a tool call resolved without a recorded terminal
|
||||
* decision (a re-opened silent path).
|
||||
*/
|
||||
writeSummary(logger: AuditLogger): void {
|
||||
const counts = {
|
||||
toolCalls: this.toolCalls,
|
||||
allowed: this.allowed,
|
||||
blocked: this.blocked,
|
||||
errors: this.errors,
|
||||
};
|
||||
logger.debug("permission.session_summary", counts);
|
||||
if (this.toolCalls !== this.allowed + this.blocked + this.errors) {
|
||||
logger.warn(
|
||||
`[pi-permission-system] decision audit invariant violated: ${this.toolCalls} tool calls != ` +
|
||||
`${this.allowed} allowed + ${this.blocked} blocked + ${this.errors} errors. ` +
|
||||
"A tool call resolved without a recorded terminal decision.",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import {
|
||||
emitDecisionEvent,
|
||||
type PermissionDecisionEvent,
|
||||
type PermissionEventBus,
|
||||
} from "./permission-events";
|
||||
import type { SessionLogger } from "./session-logger";
|
||||
|
||||
/**
|
||||
* Reports a permission gate's outcome to the review log and the decision
|
||||
* channel. Groups the two side effects that always travel together:
|
||||
* writing a structured review-log entry and broadcasting a decision event.
|
||||
*/
|
||||
export interface DecisionReporter {
|
||||
writeReviewLog(event: string, details: Record<string, unknown>): void;
|
||||
emitDecision(event: PermissionDecisionEvent): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns the `SessionLogger` and the event bus so neither the handler nor
|
||||
* the runner has to reach through the session to its logger or close over
|
||||
* the event bus directly.
|
||||
*
|
||||
* Built once in `PermissionGateHandler`'s constructor; shared between
|
||||
* `handleToolCall` (gate runner + bypass branch) and `handleInput`.
|
||||
*
|
||||
* Answers "who owns the event bus" — the reporter does, not the session.
|
||||
*/
|
||||
export class GateDecisionReporter implements DecisionReporter {
|
||||
constructor(
|
||||
private readonly logger: SessionLogger,
|
||||
private readonly events: PermissionEventBus,
|
||||
) {}
|
||||
|
||||
writeReviewLog(event: string, details: Record<string, unknown>): void {
|
||||
this.logger.review(event, details);
|
||||
}
|
||||
|
||||
emitDecision(event: PermissionDecisionEvent): void {
|
||||
emitDecisionEvent(this.events, event);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
/**
|
||||
* The spellings of the home directory this package resolves, in every pattern
|
||||
* and path literal.
|
||||
*
|
||||
* `$HOME` and `${HOME}` are the two spellings of the same shell variable and
|
||||
* must stay interchangeable: a rule keyed on one form has to match a path
|
||||
* written in the other, and the bash path projection classifies a token by the
|
||||
* shape it has *after* this expansion (#694).
|
||||
*/
|
||||
const HOME_PREFIXES = ["~", "$HOME", "${HOME}"] as const;
|
||||
|
||||
/**
|
||||
* Expand a home-directory prefix in a pattern or path value to the OS home
|
||||
* directory.
|
||||
*
|
||||
* A prefix is recognized only when it stands alone or is followed by a path
|
||||
* separator, so a longer name (`~username`, `$HOMEDIR`, `${HOMEDIR}`) and a
|
||||
* braced parameter expansion carrying an operator (`${HOME:-/tmp}`,
|
||||
* `${HOME%/*}`) are both left untouched.
|
||||
*
|
||||
* Supported forms, for each prefix in {@link HOME_PREFIXES}:
|
||||
* - `<prefix>` → `homedir()`
|
||||
* - `<prefix>/path` → `homedir()/path`
|
||||
* - `<prefix>\path` → `homedir()\path` (Windows)
|
||||
*
|
||||
* All other patterns are returned unchanged.
|
||||
*/
|
||||
export function expandHomePath(pattern: string): string {
|
||||
for (const prefix of HOME_PREFIXES) {
|
||||
if (pattern === prefix) return homedir();
|
||||
if (!pattern.startsWith(prefix)) continue;
|
||||
|
||||
const rest = pattern.slice(prefix.length);
|
||||
if (rest.startsWith("/") || rest.startsWith("\\")) {
|
||||
return join(homedir(), rest.slice(1));
|
||||
}
|
||||
}
|
||||
return pattern;
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type {
|
||||
ShellToolsConfig,
|
||||
UnifiedPermissionConfig,
|
||||
} from "./config-loader";
|
||||
import {
|
||||
OWNER_ONLY_DIRECTORY_MODE,
|
||||
restrictExistingPathToOwner,
|
||||
} from "./log-file-permissions";
|
||||
|
||||
export const EXTENSION_ID = "pi-permission-system";
|
||||
|
||||
export interface PermissionSystemExtensionConfig {
|
||||
debugLog: boolean;
|
||||
permissionReviewLog: boolean;
|
||||
yoloMode: boolean;
|
||||
/** Require a confirming second press of a decision hotkey in the inline TUI dialog. Defaults to true. */
|
||||
doublePressToConfirm: boolean;
|
||||
/** Additional directories to auto-allow for reads as Pi infrastructure. */
|
||||
piInfrastructureReadPaths?: string[];
|
||||
/** How long a subagent waits for the parent's answer to a forwarded ask, in ms. Defaults to 600000. */
|
||||
forwardingTimeoutMs?: number;
|
||||
/** Max rows a permission prompt renders before eliding its evidence. Defaults to 24. */
|
||||
promptMaxRows?: number;
|
||||
/** Max characters of any one field shown in a permission prompt. Defaults to 400. */
|
||||
promptFieldMaxWidth?: number;
|
||||
/** Max characters of any one value written to the permission review log. Defaults to 1000. */
|
||||
reviewLogFieldMaxWidth?: number;
|
||||
/** Non-bash tools that carry shell semantics, keyed by tool name. */
|
||||
shellTools?: ShellToolsConfig;
|
||||
/** Ordered names of registered live-authority chain links to consult before the terminal authorizer. */
|
||||
authorizerChain?: string[];
|
||||
}
|
||||
|
||||
export const DEFAULT_EXTENSION_CONFIG: PermissionSystemExtensionConfig = {
|
||||
debugLog: false,
|
||||
permissionReviewLog: true,
|
||||
yoloMode: false,
|
||||
doublePressToConfirm: true,
|
||||
};
|
||||
|
||||
function resolveExtensionRoot(moduleUrl = import.meta.url): string {
|
||||
return join(dirname(fileURLToPath(moduleUrl)), "..");
|
||||
}
|
||||
|
||||
export const EXTENSION_ROOT = resolveExtensionRoot();
|
||||
|
||||
const PERMISSION_POLICY_KEYS: ReadonlySet<string> = new Set([
|
||||
"defaultPolicy",
|
||||
"tools",
|
||||
"bash",
|
||||
"mcp",
|
||||
"skills",
|
||||
"special",
|
||||
"external_directory",
|
||||
]);
|
||||
|
||||
export function detectMisplacedPermissionKeys(
|
||||
raw: Record<string, unknown>,
|
||||
): string[] {
|
||||
return Object.keys(raw).filter((key) => PERMISSION_POLICY_KEYS.has(key));
|
||||
}
|
||||
|
||||
export function normalizePermissionSystemConfig(
|
||||
raw: UnifiedPermissionConfig,
|
||||
): PermissionSystemExtensionConfig {
|
||||
const result: PermissionSystemExtensionConfig = {
|
||||
debugLog: raw.debugLog === true,
|
||||
permissionReviewLog: raw.permissionReviewLog !== false,
|
||||
yoloMode: raw.yoloMode === true,
|
||||
doublePressToConfirm: raw.doublePressToConfirm !== false,
|
||||
};
|
||||
if (raw.piInfrastructureReadPaths !== undefined) {
|
||||
result.piInfrastructureReadPaths = raw.piInfrastructureReadPaths;
|
||||
}
|
||||
if (raw.forwardingTimeoutMs !== undefined) {
|
||||
result.forwardingTimeoutMs = raw.forwardingTimeoutMs;
|
||||
}
|
||||
if (raw.promptMaxRows !== undefined) {
|
||||
result.promptMaxRows = raw.promptMaxRows;
|
||||
}
|
||||
if (raw.promptFieldMaxWidth !== undefined) {
|
||||
result.promptFieldMaxWidth = raw.promptFieldMaxWidth;
|
||||
}
|
||||
if (raw.reviewLogFieldMaxWidth !== undefined) {
|
||||
result.reviewLogFieldMaxWidth = raw.reviewLogFieldMaxWidth;
|
||||
}
|
||||
// `toolInputPreviewMaxLength` / `toolTextSummaryMaxLength` are deliberately
|
||||
// absent: the schema and the merge still accept them so the deprecation
|
||||
// detector can see an operator's setting, but no runtime consumer may read
|
||||
// one (ADR 0011 §5, #745).
|
||||
if (raw.shellTools !== undefined) {
|
||||
result.shellTools = raw.shellTools;
|
||||
}
|
||||
if (raw.authorizerChain !== undefined) {
|
||||
result.authorizerChain = raw.authorizerChain;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function isYoloModeEnabled(
|
||||
config: PermissionSystemExtensionConfig,
|
||||
): boolean {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-conversion -- typed as boolean but may be undefined at runtime (untyped callers); Boolean() guards against that
|
||||
return Boolean(config.yoloMode);
|
||||
}
|
||||
|
||||
export function ensurePermissionSystemLogsDirectory(
|
||||
logsDir: string,
|
||||
): string | undefined {
|
||||
try {
|
||||
// `recursive` applies the mode to every directory this creates, so a fresh
|
||||
// install also gets an owner-only extension config dir. Directories that
|
||||
// already exist are untouched by `mkdirSync`, hence the explicit tighten.
|
||||
mkdirSync(logsDir, { recursive: true, mode: OWNER_ONLY_DIRECTORY_MODE });
|
||||
restrictExistingPathToOwner(logsDir, OWNER_ONLY_DIRECTORY_MODE);
|
||||
return undefined;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return `Failed to create permission-system log directory '${logsDir}': ${message}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { join } from "node:path";
|
||||
import { getGlobalLogsDir } from "./config-paths";
|
||||
import { discoverGlobalNodeModulesRoot } from "./node-modules-discovery";
|
||||
|
||||
/**
|
||||
* Immutable path constants derived from `agentDir` at construction time.
|
||||
*
|
||||
* Computed once at startup in `computeExtensionPaths()` and embedded into
|
||||
* `ExtensionRuntime`. Later refactorings (#129 PermissionSession, #130
|
||||
* handler classes) consume this as a single dep instead of individual fields.
|
||||
*/
|
||||
export interface ExtensionPaths {
|
||||
readonly agentDir: string;
|
||||
readonly sessionsDir: string;
|
||||
readonly subagentSessionsDir: string;
|
||||
readonly forwardingDir: string;
|
||||
readonly globalLogsDir: string;
|
||||
/**
|
||||
* Static Pi infrastructure directories used for external-directory
|
||||
* read auto-allow. Computed once from `agentDir`,
|
||||
* `discoverGlobalNodeModulesRoot()`, and (when provided) Pi's own
|
||||
* install directory (`getPackageDir()`). Config-based extras
|
||||
* (`piInfrastructureReadPaths`) are read from `runtime.config` at
|
||||
* call time in the handler so they pick up config reloads.
|
||||
*/
|
||||
readonly piInfrastructureDirs: readonly string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute all immutable path constants from `agentDir`.
|
||||
*
|
||||
* Calls `discoverGlobalNodeModulesRoot()` internally so the result is
|
||||
* self-contained. Call this once at extension startup, not at module scope.
|
||||
*
|
||||
* `piPackageDir` is Pi's own install directory (from the coding-agent
|
||||
* `getPackageDir()` API, resolved at the composition root). When provided it is
|
||||
* auto-allowed for read-only tools so the agent can read Pi's bundled docs and
|
||||
* examples regardless of install layout. It is strictly narrower than the
|
||||
* discovered global `node_modules` root already included here.
|
||||
*/
|
||||
export function computeExtensionPaths(
|
||||
agentDir: string,
|
||||
piPackageDir?: string,
|
||||
): ExtensionPaths {
|
||||
const sessionsDir = join(agentDir, "sessions");
|
||||
const subagentSessionsDir = join(agentDir, "subagent-sessions");
|
||||
const forwardingDir = join(sessionsDir, "permission-forwarding");
|
||||
const globalLogsDir = getGlobalLogsDir(agentDir);
|
||||
|
||||
const globalNodeModulesRoot = discoverGlobalNodeModulesRoot();
|
||||
const piInfrastructureDirs: string[] = [
|
||||
agentDir,
|
||||
join(agentDir, "git"),
|
||||
...(globalNodeModulesRoot ? [globalNodeModulesRoot] : []),
|
||||
...(piPackageDir ? [piPackageDir] : []),
|
||||
];
|
||||
|
||||
return {
|
||||
agentDir,
|
||||
sessionsDir,
|
||||
subagentSessionsDir,
|
||||
forwardingDir,
|
||||
globalLogsDir,
|
||||
piInfrastructureDirs,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import type {
|
||||
BeforeAgentStartEventResult,
|
||||
ExtensionContext,
|
||||
} from "@earendil-works/pi-coding-agent";
|
||||
import type { PermissionResolver } from "#src/permission-resolver";
|
||||
import type { PermissionSession } from "#src/permission-session";
|
||||
import { resolveSkillPromptEntries } from "#src/skill-prompt-sanitizer";
|
||||
import { sanitizeAvailableToolsSection } from "#src/system-prompt-sanitizer";
|
||||
import { getToolNameFromValue, type ToolRegistry } from "#src/tool-registry";
|
||||
import type { PermissionState } from "#src/types";
|
||||
|
||||
/** Minimal subset of BeforeAgentStartEvent used by this handler. */
|
||||
interface BeforeAgentStartPayload {
|
||||
systemPrompt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure helper: returns true when the tool should be exposed to the agent.
|
||||
* Checks the tool-level permission (not command-level) so that a blanket
|
||||
* `bash: deny` hides the tool entirely before any invocation is attempted.
|
||||
*/
|
||||
export function shouldExposeTool(
|
||||
toolName: string,
|
||||
agentName: string | null,
|
||||
getToolPermission: (toolName: string, agentName?: string) => PermissionState,
|
||||
): boolean {
|
||||
const toolPermission = getToolPermission(toolName, agentName ?? undefined);
|
||||
return toolPermission !== "deny";
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the `before_agent_start` event: tool filtering + prompt sanitization.
|
||||
*
|
||||
* Recomputes the active tool set and the returned system-prompt override on
|
||||
* every fire (no memoization): the override must be returned each turn so that
|
||||
* skill filtering is reapplied and the wire prompt stays byte-stable, rather
|
||||
* than letting Pi reset to its skill-unfiltered base prompt on a cache hit.
|
||||
*
|
||||
* Constructor deps:
|
||||
* - `session` — encapsulates all mutable session state and lifecycle operations
|
||||
* - `resolver` — owns permission-query surface: `getToolPermission`, skill check
|
||||
* - `toolRegistry` — Pi tool API subset (getActive + setActive)
|
||||
* - `warmParser` — warms the tree-sitter parser so the synchronous advisory
|
||||
* bash path can decompose at gate parity; `before_agent_start` precedes any
|
||||
* tool call, so triggering it here closes the pre-warm window (#309)
|
||||
*/
|
||||
export class AgentPrepHandler {
|
||||
constructor(
|
||||
private readonly session: PermissionSession,
|
||||
private readonly resolver: PermissionResolver,
|
||||
private readonly toolRegistry: ToolRegistry,
|
||||
private readonly warmParser: () => void,
|
||||
) {}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/require-await
|
||||
async handle(
|
||||
event: BeforeAgentStartPayload,
|
||||
ctx: ExtensionContext,
|
||||
): Promise<BeforeAgentStartEventResult> {
|
||||
// Fire-and-forget: warming is idempotent and best-effort, so it never
|
||||
// delays agent start. A bash advisory query before it completes falls back
|
||||
// to whole-string matching.
|
||||
this.warmParser();
|
||||
this.session.activate(ctx);
|
||||
// Gate the mid-session runtime-config refresh on project trust too, so an
|
||||
// untrusted project cannot slip its runtime config (e.g. `yoloMode`) in
|
||||
// right before agent start after session_start withheld it (#644). The
|
||||
// session_start handler already warned; do not re-warn on every start.
|
||||
this.session.refreshConfig(ctx, ctx.isProjectTrusted());
|
||||
|
||||
const agentName = this.session.resolveAgentName(ctx, event.systemPrompt);
|
||||
const activeTools = this.toolRegistry.getActive();
|
||||
const allowedTools: string[] = [];
|
||||
|
||||
for (const tool of activeTools) {
|
||||
const toolName = getToolNameFromValue(tool);
|
||||
if (!toolName) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
shouldExposeTool(toolName, agentName, (t, a) =>
|
||||
this.resolver.getToolPermission(t, a),
|
||||
)
|
||||
) {
|
||||
allowedTools.push(toolName);
|
||||
}
|
||||
}
|
||||
|
||||
this.toolRegistry.setActive(allowedTools);
|
||||
|
||||
const toolPromptResult = sanitizeAvailableToolsSection(
|
||||
event.systemPrompt,
|
||||
allowedTools,
|
||||
);
|
||||
const skillPromptResult = resolveSkillPromptEntries(
|
||||
toolPromptResult.prompt,
|
||||
this.resolver,
|
||||
agentName,
|
||||
this.session.getPathNormalizer(),
|
||||
);
|
||||
this.session.setActiveSkillEntries(skillPromptResult.entries);
|
||||
return skillPromptResult.prompt !== event.systemPrompt
|
||||
? { systemPrompt: skillPromptResult.prompt }
|
||||
: {};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import type {
|
||||
BashCommand,
|
||||
WrapperKind,
|
||||
} from "#src/access-intent/bash/command-enumeration";
|
||||
import { pickMostRestrictive } from "#src/handlers/gates/candidate-check";
|
||||
import type { ScopedPermissionResolver } from "#src/permission-resolver";
|
||||
import type { PermissionCheckResult } from "#src/types";
|
||||
|
||||
/**
|
||||
* Resolve the bash command-pattern decision for a (possibly chained) command.
|
||||
*
|
||||
* A bash invocation may be a shell program with several commands joined by
|
||||
* `&&`, `||`, `;`, `|`, `&`, or newlines. Matching the whole string against the
|
||||
* bash patterns lets a denied command ride through on an allowed leading one
|
||||
* (issue #301). Instead, the caller supplies the program's command units (from
|
||||
* the shared `BashProgram.commands()` parse) — including those nested inside
|
||||
* substitutions and subshells (#306); each is evaluated on the `bash` surface
|
||||
* and the most restrictive result wins (`deny > ask > allow`).
|
||||
*
|
||||
* The selected result carries the offending sub-command in `command`, its rule
|
||||
* in `matchedPattern`, and the offending command's execution context in
|
||||
* `commandContext` (set only for a nested command), so the prompt,
|
||||
* session-approval suggestion, and decision event scope to that command.
|
||||
*
|
||||
* A wrapper unit (flagged with a `wrapperKind` by the enumerator) hides or
|
||||
* indirects the command that should be gated, so an `allow` is floored up to a
|
||||
* synthetic `ask` — the `<opaque-bash-wrapper>` pattern for an inline-shell
|
||||
* payload (`bash -c`/`eval`, #481) or `<indirection-bash-wrapper>` for a
|
||||
* prefix/exec wrapper (`sudo`/`env`/`xargs`/`find -exec`/…, #490) — to keep it
|
||||
* from riding a permissive rule; an explicit `deny`/`ask` on the wrapper is left
|
||||
* untouched (`deny > ask > allow`).
|
||||
*
|
||||
* When `commands` is empty there are two cases. A trivially-empty command (an
|
||||
* empty, whitespace-only, or comment-only line) has genuinely nothing to gate,
|
||||
* so the whole `command` is resolved as before. A non-empty command that parsed
|
||||
* to zero command units (a parse anomaly or an opaque program) fails closed to
|
||||
* a synthetic `ask` so a permissive top-level `*` cannot silently allow an
|
||||
* unparseable command (e.g. `cd /repo && git push` riding a top-level allow on
|
||||
* the empty-parse path) — #452. The whole command is still resolved first so an
|
||||
* explicit `deny` covering it denies outright rather than being masked into an
|
||||
* approvable prompt (#712).
|
||||
*
|
||||
* Pure and synchronous: the (async, tree-sitter) parse happens once in the
|
||||
* handler, which passes the decomposed `commands` here.
|
||||
*/
|
||||
/**
|
||||
* The synthetic `matchedPattern` recorded when a wrapper unit's `allow` is
|
||||
* floored to `ask`, keyed by the wrapper kind that caused the floor.
|
||||
*/
|
||||
const WRAPPER_SENTINEL: Record<WrapperKind, string> = {
|
||||
"opaque-payload": "<opaque-bash-wrapper>",
|
||||
indirection: "<indirection-bash-wrapper>",
|
||||
};
|
||||
|
||||
export function resolveBashCommandCheck(
|
||||
command: string,
|
||||
commands: BashCommand[],
|
||||
agentName: string | undefined,
|
||||
resolver: ScopedPermissionResolver,
|
||||
): PermissionCheckResult {
|
||||
if (commands.length === 0) {
|
||||
if (isTriviallyEmptyCommand(command)) {
|
||||
return resolveWholeCommand(command, agentName, resolver);
|
||||
}
|
||||
const whole = resolveWholeCommand(command, agentName, resolver);
|
||||
if (whole.state === "deny") {
|
||||
return whole;
|
||||
}
|
||||
return {
|
||||
state: "ask",
|
||||
toolName: "bash",
|
||||
source: "bash",
|
||||
origin: "builtin",
|
||||
command,
|
||||
matchedPattern: "<unparseable-bash-command>",
|
||||
};
|
||||
}
|
||||
|
||||
const results = commands.map((cmd) => {
|
||||
const base = resolver.resolve({
|
||||
kind: "tool",
|
||||
surface: "bash",
|
||||
input: { command: cmd.text },
|
||||
agentName,
|
||||
});
|
||||
const floored =
|
||||
cmd.wrapperKind && base.state === "allow"
|
||||
? {
|
||||
...base,
|
||||
state: "ask" as const,
|
||||
matchedPattern: WRAPPER_SENTINEL[cmd.wrapperKind],
|
||||
}
|
||||
: base;
|
||||
const result = cmd.context
|
||||
? { ...floored, commandContext: cmd.context }
|
||||
: floored;
|
||||
return cmd.executedUnit === undefined
|
||||
? result
|
||||
: { ...result, executedUnit: cmd.executedUnit };
|
||||
});
|
||||
return (
|
||||
pickMostRestrictive(results) ??
|
||||
resolveWholeCommand(command, agentName, resolver)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a command has genuinely nothing to gate: it is empty,
|
||||
* whitespace-only, or contains only comment lines (every non-blank line starts
|
||||
* with `#`). Such a command yields zero command units legitimately, so the
|
||||
* whole-string resolve is safe rather than a parse anomaly.
|
||||
*/
|
||||
function isTriviallyEmptyCommand(command: string): boolean {
|
||||
const lines = command
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0);
|
||||
return lines.every((line) => line.startsWith("#"));
|
||||
}
|
||||
|
||||
/** Resolve the whole command string as a single unit on the `bash` surface. */
|
||||
function resolveWholeCommand(
|
||||
command: string,
|
||||
agentName: string | undefined,
|
||||
resolver: ScopedPermissionResolver,
|
||||
): PermissionCheckResult {
|
||||
return resolver.resolve({
|
||||
kind: "tool",
|
||||
surface: "bash",
|
||||
input: { command },
|
||||
agentName,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import type { BashProgram } from "#src/access-intent/bash/program";
|
||||
import type { ScopedPermissionResolver } from "#src/permission-resolver";
|
||||
import { buildBashExternalDirectoryAskPayload } from "#src/presentation/path-ask-payload";
|
||||
import { SessionApproval } from "#src/session-approval";
|
||||
import { deriveApprovalPattern } from "#src/session-rules";
|
||||
import type { GateResult } from "./descriptor";
|
||||
import { selectUncoveredExternalPaths } from "./external-directory-policy";
|
||||
import { accessFactsFromPath } from "./helpers";
|
||||
import type { ToolCallContext } from "./types";
|
||||
|
||||
/**
|
||||
* Build a pure descriptor for the bash external-directory permission gate.
|
||||
*
|
||||
* Reads the external paths from the injected `BashProgram` and checks whether
|
||||
* any reference directories outside the working directory. Returns `null` when the gate
|
||||
* does not apply (not a shell invocation, no command, or no external paths found).
|
||||
* Returns a `GateBypass` when all paths are allowed (by config or session rule).
|
||||
* Returns a `GateDescriptor` with multi-pattern sessionApproval for uncovered paths.
|
||||
*
|
||||
* The shell command (native `bash` or an aliased shell tool) is read from the
|
||||
* injected `BashProgram`, which owns the source text it was parsed from, so
|
||||
* this gate does not re-derive the input field name (#574).
|
||||
*/
|
||||
export function describeBashExternalDirectoryGate(
|
||||
tcc: ToolCallContext,
|
||||
bashProgram: BashProgram | null,
|
||||
resolver: ScopedPermissionResolver,
|
||||
): GateResult {
|
||||
if (!bashProgram) return null;
|
||||
const command = bashProgram.commandText();
|
||||
|
||||
const externalPaths = bashProgram.externalPaths();
|
||||
if (externalPaths.length === 0) return null;
|
||||
|
||||
// Resolve every external path on the external_directory surface and keep the
|
||||
// ones not already allowed (config-level allows suppress the prompt just as
|
||||
// session-level allows do); the shared helper single-sources the #418 alias
|
||||
// matching and the worst-uncovered selection.
|
||||
const { uncovered: uncoveredEntries, worstCheck } =
|
||||
selectUncoveredExternalPaths(
|
||||
externalPaths,
|
||||
resolver,
|
||||
tcc.agentName ?? undefined,
|
||||
);
|
||||
const uncoveredPaths = uncoveredEntries.map(({ path }) => path.value());
|
||||
|
||||
if (uncoveredPaths.length === 0) {
|
||||
return {
|
||||
action: "allow",
|
||||
// A whole-command bypass covers every external path at once, and each
|
||||
// may have matched a different session pattern -- so the surface is one
|
||||
// value and the pattern is not. The entry's `externalPaths` lists what
|
||||
// was covered.
|
||||
decidedBy: {
|
||||
kind: "session_approval",
|
||||
surface: "external_directory",
|
||||
pattern: null,
|
||||
},
|
||||
log: {
|
||||
event: "permission_request.session_approved",
|
||||
details: {
|
||||
source: "tool_call",
|
||||
toolCallId: tcc.toolCallId,
|
||||
toolName: tcc.toolName,
|
||||
agentName: tcc.agentName,
|
||||
command,
|
||||
externalPaths: externalPaths.map((p) => p.value()),
|
||||
resolution: "session_approved",
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// After the early bypass, at least one path is uncovered, so worstCheck is
|
||||
// defined; the fallback keeps TypeScript happy across the early return. A
|
||||
// config-level "deny" is preserved (not downgraded to the catch-all "ask").
|
||||
const preCheck = worstCheck ?? uncoveredEntries[0].check;
|
||||
// The AccessPath the decision was made against — its facts ride the wire.
|
||||
const worstEntry =
|
||||
uncoveredEntries.find(({ check }) => check === preCheck) ??
|
||||
uncoveredEntries[0];
|
||||
|
||||
const disclosures = uncoveredEntries.map(({ path }) => ({
|
||||
path: path.value(),
|
||||
resolvedPath: path.resolvedAlias(),
|
||||
}));
|
||||
|
||||
const payload = buildBashExternalDirectoryAskPayload({
|
||||
command,
|
||||
externalPaths: disclosures,
|
||||
cwd: tcc.cwd,
|
||||
agentName: tcc.agentName,
|
||||
toolName: tcc.toolName,
|
||||
matchedPattern: preCheck.matchedPattern,
|
||||
});
|
||||
|
||||
const patterns = uncoveredPaths.map((p) => deriveApprovalPattern(p));
|
||||
|
||||
return {
|
||||
surface: "external_directory",
|
||||
input: {},
|
||||
payload,
|
||||
sessionApproval: SessionApproval.multiple("external_directory", patterns),
|
||||
promptDetails: {
|
||||
source: "tool_call",
|
||||
agentName: tcc.agentName,
|
||||
toolCallId: tcc.toolCallId,
|
||||
toolName: tcc.toolName,
|
||||
command,
|
||||
accessIntent: accessFactsFromPath("external_directory", worstEntry.path),
|
||||
},
|
||||
logContext: {
|
||||
source: "tool_call",
|
||||
toolCallId: tcc.toolCallId,
|
||||
toolName: tcc.toolName,
|
||||
agentName: tcc.agentName,
|
||||
command,
|
||||
externalPaths: uncoveredPaths,
|
||||
},
|
||||
decision: {
|
||||
surface: "external_directory",
|
||||
value: command,
|
||||
},
|
||||
preCheck,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { BashProgram } from "#src/access-intent/bash/program";
|
||||
import type { PathNormalizer } from "#src/path-normalizer";
|
||||
|
||||
/**
|
||||
* Extract paths from a bash command that resolve outside CWD.
|
||||
*
|
||||
* Thin facade over {@link BashProgram.externalPaths}; parses the command
|
||||
* through the injected {@link PathNormalizer} (platform + cwd baked in) and
|
||||
* returns the cd-aware external paths in their lexical (as-typed) string form.
|
||||
* See `BashProgram` for the parsing and resolution semantics.
|
||||
*
|
||||
* Returns `string[]` (not `AccessPath[]`) so the large projection-correctness
|
||||
* test suite in `bash-external-directory.test.ts` can assert path values
|
||||
* without migrating to the `AccessPath` accessors.
|
||||
*/
|
||||
export async function extractExternalPathsFromBashCommand(
|
||||
command: string,
|
||||
normalizer: PathNormalizer,
|
||||
): Promise<string[]> {
|
||||
return (await BashProgram.parse(command, normalizer))
|
||||
.externalPaths()
|
||||
.map((p) => p.value());
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import type { AccessPath } from "#src/access-intent/access-path";
|
||||
import type { BashProgram } from "#src/access-intent/bash/program";
|
||||
import type { ScopedPermissionResolver } from "#src/permission-resolver";
|
||||
import { buildPathAskPayload } from "#src/presentation/path-ask-payload";
|
||||
import { SessionApproval } from "#src/session-approval";
|
||||
import { deriveApprovalPattern } from "#src/session-rules";
|
||||
import type { PermissionCheckResult } from "#src/types";
|
||||
import { pickMostRestrictive } from "./candidate-check";
|
||||
import type { GateResult } from "./descriptor";
|
||||
import { accessFactsFromPath } from "./helpers";
|
||||
import type { ToolCallContext } from "./types";
|
||||
|
||||
/**
|
||||
* Build a pure descriptor for the cross-cutting path permission gate (bash).
|
||||
*
|
||||
* Reads path-rule candidates from the injected `BashProgram` (the broader
|
||||
* `path`-rule filter, accepting dot-files and relative paths). Each candidate
|
||||
* pairs the raw token with cd-aware policy values; the gate evaluates those
|
||||
* values against the `path` permission surface and returns the most
|
||||
* restrictive result, while prompts, logs, and session approvals use the raw
|
||||
* token.
|
||||
*
|
||||
* Returns `null` when the gate does not apply (not a shell invocation, no
|
||||
* command, no tokens extracted, or all tokens evaluate to `allow`).
|
||||
* Returns a `GateBypass` when all tokens are session-covered.
|
||||
* Returns a `GateDescriptor` for the most restrictive token needing a check.
|
||||
*
|
||||
* The shell command (native `bash` or an aliased shell tool) is read from the
|
||||
* injected `BashProgram`, which owns the source text it was parsed from, so
|
||||
* this gate does not re-derive the input field name (#574).
|
||||
*/
|
||||
export function describeBashPathGate(
|
||||
tcc: ToolCallContext,
|
||||
bashProgram: BashProgram | null,
|
||||
resolver: ScopedPermissionResolver,
|
||||
): GateResult {
|
||||
if (!bashProgram) return null;
|
||||
const command = bashProgram.commandText();
|
||||
|
||||
const candidates = bashProgram.pathRuleCandidates();
|
||||
if (candidates.length === 0) return null;
|
||||
const tokens = candidates.map(({ token }) => token);
|
||||
|
||||
// Tokens whose resolved state needs a check (deny/ask), paired with the raw
|
||||
// token (prompt/decision display) and its `AccessPath` (whose `value()` is
|
||||
// the lexical absolute path the approval pattern is derived from).
|
||||
const uncovered: Array<{
|
||||
token: string;
|
||||
path: AccessPath;
|
||||
check: PermissionCheckResult;
|
||||
}> = [];
|
||||
let allSessionCovered = true;
|
||||
|
||||
for (const { token, path } of candidates) {
|
||||
const check = resolver.resolve({
|
||||
kind: "access-path",
|
||||
surface: "path",
|
||||
path,
|
||||
agentName: tcc.agentName ?? undefined,
|
||||
});
|
||||
|
||||
// No explicit path rule matched — only the universal default fired.
|
||||
// Treat this token as unrestricted to preserve backward compatibility
|
||||
// for configs without a "path" key (#58).
|
||||
if (check.matchedPattern === undefined && check.source !== "session") {
|
||||
allSessionCovered = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (check.source !== "session") {
|
||||
allSessionCovered = false;
|
||||
}
|
||||
|
||||
if (check.state === "deny") {
|
||||
uncovered.push({ token, path, check });
|
||||
break; // Short-circuit on deny.
|
||||
}
|
||||
if (check.state === "ask") {
|
||||
uncovered.push({ token, path, check });
|
||||
}
|
||||
}
|
||||
|
||||
// All tokens are session-covered — bypass.
|
||||
if (allSessionCovered) {
|
||||
return {
|
||||
action: "allow",
|
||||
// Every token was covered, each possibly by a different session pattern
|
||||
// -- the surface is one value and the pattern is not. The entry's
|
||||
// `tokens` lists what was covered.
|
||||
decidedBy: {
|
||||
kind: "session_approval",
|
||||
surface: "path",
|
||||
pattern: null,
|
||||
},
|
||||
log: {
|
||||
event: "permission_request.session_approved",
|
||||
details: {
|
||||
source: "tool_call",
|
||||
toolCallId: tcc.toolCallId,
|
||||
toolName: tcc.toolName,
|
||||
agentName: tcc.agentName,
|
||||
command,
|
||||
tokens,
|
||||
resolution: "session_approved",
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Pick the most restrictive (deny > ask > allow, first-wins) uncovered token.
|
||||
const worstCheck = pickMostRestrictive(uncovered.map(({ check }) => check));
|
||||
const worstEntry = worstCheck
|
||||
? uncovered.find(({ check }) => check === worstCheck)
|
||||
: undefined;
|
||||
const worstToken = worstEntry?.token ?? null;
|
||||
|
||||
// All tokens evaluate to allow — no restriction.
|
||||
if (!worstCheck || !worstToken || !worstEntry) return null;
|
||||
|
||||
// Derive the pattern from the lexical absolute form (the cd-aware resolved
|
||||
// path), so it matches the values a later call produces. For an unknown base
|
||||
// (`forLiteral`) `value()` is the raw token.
|
||||
const pattern = deriveApprovalPattern(worstEntry.path.value());
|
||||
const payload = buildPathAskPayload({
|
||||
toolName: tcc.toolName,
|
||||
pathValue: worstToken,
|
||||
agentName: tcc.agentName,
|
||||
matchedPattern: worstCheck.matchedPattern,
|
||||
});
|
||||
|
||||
return {
|
||||
surface: "path",
|
||||
input: { path: worstToken },
|
||||
payload,
|
||||
sessionApproval: SessionApproval.single("path", pattern),
|
||||
promptDetails: {
|
||||
source: "tool_call",
|
||||
agentName: tcc.agentName,
|
||||
toolCallId: tcc.toolCallId,
|
||||
toolName: tcc.toolName,
|
||||
command,
|
||||
accessIntent: accessFactsFromPath("path", worstEntry.path),
|
||||
},
|
||||
logContext: {
|
||||
source: "tool_call",
|
||||
toolCallId: tcc.toolCallId,
|
||||
toolName: tcc.toolName,
|
||||
agentName: tcc.agentName,
|
||||
command,
|
||||
path: worstToken,
|
||||
},
|
||||
decision: {
|
||||
surface: "path",
|
||||
value: worstToken,
|
||||
},
|
||||
preCheck: worstCheck,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { PermissionCheckResult, PermissionState } from "#src/types";
|
||||
|
||||
/** Restrictiveness ordering: deny is the most restrictive, allow the least. */
|
||||
const RESTRICTIVENESS: Record<PermissionState, number> = {
|
||||
allow: 0,
|
||||
ask: 1,
|
||||
deny: 2,
|
||||
};
|
||||
|
||||
/**
|
||||
* Select the most restrictive permission result from a list (deny > ask > allow).
|
||||
*
|
||||
* The first occurrence wins on ties, so a caller passing results in candidate
|
||||
* order receives the earliest worst case. Returns `undefined` for an empty list.
|
||||
*
|
||||
* Shared by the bash gates (path, external-directory) to combine the per-candidate
|
||||
* `checkPermission` results their tree-sitter token extraction produces.
|
||||
*/
|
||||
export function pickMostRestrictive(
|
||||
results: readonly PermissionCheckResult[],
|
||||
): PermissionCheckResult | undefined {
|
||||
let worst: PermissionCheckResult | undefined;
|
||||
for (const result of results) {
|
||||
if (
|
||||
worst === undefined ||
|
||||
RESTRICTIVENESS[result.state] > RESTRICTIVENESS[worst.state]
|
||||
) {
|
||||
worst = result;
|
||||
}
|
||||
}
|
||||
return worst;
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import type { DecisionSource } from "#src/authority/decision-source";
|
||||
import type { PromptPermissionDetails } from "#src/authority/permission-prompter";
|
||||
import type { PermissionDecisionEvent } from "#src/permission-events";
|
||||
import type { PromptPayload } from "#src/presentation/prompt-payload";
|
||||
import type { SessionApproval } from "#src/session-approval";
|
||||
import type { PermissionCheckResult, PermissionState } from "#src/types";
|
||||
|
||||
// ── Descriptor types ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Pure output of a gate function — describes what to check and how to present it.
|
||||
*
|
||||
* The gate runner (`runGateCheck`) uses this descriptor to execute the
|
||||
* mechanical check→log→emit→approve cycle without the gate needing to know
|
||||
* about logging, event emission, or session-rule recording.
|
||||
*/
|
||||
export interface GateDescriptor {
|
||||
/** Permission surface to check (e.g. "bash", "external_directory", "skill"). */
|
||||
surface: string;
|
||||
/** Input passed to checkPermission. */
|
||||
input: unknown;
|
||||
/**
|
||||
* The complete structured description of this ask (ADR 0011 §2).
|
||||
*
|
||||
* The descriptor's one presentation fact: every render over it — the dialog,
|
||||
* the agent-facing denial text, the review log — reads this and nothing
|
||||
* else, so a gate states its facts once.
|
||||
*/
|
||||
payload: PromptPayload;
|
||||
/**
|
||||
* Session-approval suggestion for the "for this session" option.
|
||||
* Wraps either a single pattern or multiple patterns behind a unified
|
||||
* interface — the runner never needs to know which case applies.
|
||||
*/
|
||||
sessionApproval?: SessionApproval;
|
||||
/**
|
||||
* Details passed to the interactive permission prompt.
|
||||
*
|
||||
* The runner stamps both `requestId` (which it mints) and `payload` (which
|
||||
* the descriptor owns), so neither is a gate's to supply twice.
|
||||
*/
|
||||
promptDetails: Omit<PromptPermissionDetails, "requestId" | "payload">;
|
||||
/** Extra context fields written to the review log alongside gate outcomes. */
|
||||
logContext: Record<string, unknown>;
|
||||
/** Surface and value for the decision event (may differ from the check surface). */
|
||||
decision: {
|
||||
surface: string;
|
||||
value: string;
|
||||
};
|
||||
/**
|
||||
* When set, the gate has already resolved the permission state
|
||||
* (e.g. from a skill entry match). The runner uses this directly
|
||||
* instead of calling checkPermission.
|
||||
*/
|
||||
preResolved?: {
|
||||
state: PermissionState;
|
||||
};
|
||||
/**
|
||||
* When set, the runner uses this pre-computed check result directly
|
||||
* instead of calling checkPermission. Used when the orchestrator has
|
||||
* already performed the check (e.g. to build messages from the result).
|
||||
*/
|
||||
preCheck?: PermissionCheckResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* A decision event's facts, before the runner stamps the request id it minted.
|
||||
*
|
||||
* A gate knows what was decided but not which request it was deciding — the id
|
||||
* is minted in `GateRunner.run`. Producing this type rather than the full event
|
||||
* is what routes every emit through the runner's single stamping site.
|
||||
*/
|
||||
export type DecisionEventFacts = Omit<PermissionDecisionEvent, "requestId">;
|
||||
|
||||
/**
|
||||
* Early allow result — gate has determined the action without needing the runner.
|
||||
*
|
||||
* Used for cases like Pi infrastructure read bypass where the gate short-circuits
|
||||
* with a deterministic allow before reaching the permission check.
|
||||
*/
|
||||
export interface GateBypass {
|
||||
action: "allow";
|
||||
/**
|
||||
* What decided this short-circuit.
|
||||
*
|
||||
* The gate that bypasses *is* the decider, so it states its own provenance
|
||||
* and the runner relays it onto the log entry rather than inferring one from
|
||||
* the event name (#726). Required, so a bypass added later cannot omit it.
|
||||
*/
|
||||
decidedBy: DecisionSource;
|
||||
/** Optional review log entry to emit. */
|
||||
log?: { event: string; details: Record<string, unknown> };
|
||||
/** Optional decision event to emit. */
|
||||
decision?: DecisionEventFacts;
|
||||
}
|
||||
|
||||
/** Union of possible gate function return values. */
|
||||
export type GateResult = GateDescriptor | GateBypass | null;
|
||||
|
||||
// ── Type guard helpers ─────────────────────────────────────────────────────
|
||||
|
||||
/** Check whether a GateResult is a GateBypass (early allow). */
|
||||
export function isGateBypass(result: GateResult): result is GateBypass {
|
||||
return result !== null && "action" in result;
|
||||
}
|
||||
|
||||
/** Check whether a GateResult is a GateDescriptor (needs runner). */
|
||||
export function isGateDescriptor(result: GateResult): result is GateDescriptor {
|
||||
return result !== null && !("action" in result);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { AccessPath } from "#src/access-intent/access-path";
|
||||
import type { ScopedPermissionResolver } from "#src/permission-resolver";
|
||||
import type { PermissionCheckResult } from "#src/types";
|
||||
import { pickMostRestrictive } from "./candidate-check";
|
||||
|
||||
/** An external path whose resolved `external_directory` state is not "allow". */
|
||||
export interface UncoveredExternalPath {
|
||||
path: AccessPath;
|
||||
check: PermissionCheckResult;
|
||||
}
|
||||
|
||||
/** The uncovered external paths plus the most restrictive check among them. */
|
||||
export interface UncoveredExternalPaths {
|
||||
uncovered: UncoveredExternalPath[];
|
||||
/** Worst check among uncovered paths; `undefined` only when none are uncovered. */
|
||||
worstCheck: PermissionCheckResult | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve one external path's policy on the `external_directory` surface.
|
||||
*
|
||||
* Emits an `access-path` {@link AccessIntent}; the resolver unwraps it via
|
||||
* {@link AccessPath.matchValues} so a config pattern on either the typed or
|
||||
* symlink-resolved alias applies (#418). This is the single source for the
|
||||
* external-directory resolve that the two external-directory gates previously
|
||||
* duplicated.
|
||||
*/
|
||||
export function resolveExternalDirectoryPolicy(
|
||||
path: AccessPath,
|
||||
resolver: ScopedPermissionResolver,
|
||||
agentName: string | undefined,
|
||||
): PermissionCheckResult {
|
||||
return resolver.resolve({
|
||||
kind: "access-path",
|
||||
surface: "external_directory",
|
||||
path,
|
||||
agentName,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a set of external paths and select those not already allowed.
|
||||
*
|
||||
* Each path is resolved via {@link resolveExternalDirectoryPolicy}; entries
|
||||
* whose state is not "allow" are collected (filtering on state, not source, so
|
||||
* config-level allow rules suppress the prompt just as session-level allow
|
||||
* rules do), and the most restrictive uncovered check is returned so a config
|
||||
* "deny" is not downgraded to the catch-all "ask".
|
||||
*/
|
||||
export function selectUncoveredExternalPaths(
|
||||
paths: readonly AccessPath[],
|
||||
resolver: ScopedPermissionResolver,
|
||||
agentName: string | undefined,
|
||||
): UncoveredExternalPaths {
|
||||
const uncovered: UncoveredExternalPath[] = [];
|
||||
for (const path of paths) {
|
||||
const check = resolveExternalDirectoryPolicy(path, resolver, agentName);
|
||||
if (check.state !== "allow") {
|
||||
uncovered.push({ path, check });
|
||||
}
|
||||
}
|
||||
return {
|
||||
uncovered,
|
||||
worstCheck: pickMostRestrictive(uncovered.map(({ check }) => check)),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { getToolInputPath } from "#src/access-intent/tool-input-path";
|
||||
import type { PathNormalizer } from "#src/path-normalizer";
|
||||
import type { ScopedPermissionResolver } from "#src/permission-resolver";
|
||||
import { buildExternalDirectoryAskPayload } from "#src/presentation/path-ask-payload";
|
||||
import { SessionApproval } from "#src/session-approval";
|
||||
import { deriveApprovalPattern } from "#src/session-rules";
|
||||
import type { ToolAccessExtractorLookup } from "#src/tool-access-extractor-registry";
|
||||
import type { GateResult } from "./descriptor";
|
||||
import { resolveExternalDirectoryPolicy } from "./external-directory-policy";
|
||||
import { accessFactsFromPath } from "./helpers";
|
||||
import type { ToolCallContext } from "./types";
|
||||
|
||||
/**
|
||||
* Build a pure descriptor for the external-directory permission gate.
|
||||
*
|
||||
* Returns `null` when the gate does not apply (no CWD, tool is not
|
||||
* path-bearing, or path is inside the working directory).
|
||||
* Returns a `GateBypass` for Pi infrastructure reads.
|
||||
* Returns a `GateDescriptor` for external paths needing a permission check.
|
||||
*/
|
||||
export function describeExternalDirectoryGate(
|
||||
tcc: ToolCallContext,
|
||||
infraDirs: string[],
|
||||
resolver: ScopedPermissionResolver,
|
||||
normalizer: PathNormalizer,
|
||||
extractors?: ToolAccessExtractorLookup,
|
||||
): GateResult {
|
||||
const externalDirectoryPath = getToolInputPath(
|
||||
tcc.toolName,
|
||||
tcc.input,
|
||||
extractors,
|
||||
);
|
||||
if (!externalDirectoryPath) return null;
|
||||
|
||||
if (!normalizer.isOutsideWorkingDirectory(externalDirectoryPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// The boundary decision (above) and the infrastructure-read containment
|
||||
// check (below) use the canonical, symlink-resolved path; pattern matching
|
||||
// uses the typed and resolved aliases (#418).
|
||||
const accessPath = normalizer.forPath(externalDirectoryPath);
|
||||
|
||||
// ── Pi infrastructure read bypass ──────────────────────────────────────
|
||||
if (normalizer.isInfrastructureRead(tcc.toolName, accessPath, infraDirs)) {
|
||||
return {
|
||||
action: "allow",
|
||||
// Containment allowed this, not a rule the operator wrote.
|
||||
decidedBy: { kind: "infrastructure_read" },
|
||||
log: {
|
||||
event: "permission_request.infrastructure_auto_allowed",
|
||||
details: {
|
||||
source: "tool_call",
|
||||
toolCallId: tcc.toolCallId,
|
||||
toolName: tcc.toolName,
|
||||
agentName: tcc.agentName,
|
||||
path: externalDirectoryPath,
|
||||
},
|
||||
},
|
||||
decision: {
|
||||
surface: tcc.toolName,
|
||||
value: externalDirectoryPath,
|
||||
result: "allow",
|
||||
resolution: "infrastructure_auto_allowed",
|
||||
origin: null,
|
||||
agentName: tcc.agentName ?? null,
|
||||
matchedPattern: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ── Build descriptor for permission check ───────────────────────────────
|
||||
const resolvedAlias = accessPath.resolvedAlias();
|
||||
|
||||
// The runner consumes this preCheck and skips its own resolve.
|
||||
const preCheck = resolveExternalDirectoryPolicy(
|
||||
accessPath,
|
||||
resolver,
|
||||
tcc.agentName ?? undefined,
|
||||
);
|
||||
const pattern = deriveApprovalPattern(accessPath.value());
|
||||
|
||||
const payload = buildExternalDirectoryAskPayload({
|
||||
toolName: tcc.toolName,
|
||||
pathValue: externalDirectoryPath,
|
||||
resolvedPath: resolvedAlias,
|
||||
cwd: tcc.cwd,
|
||||
agentName: tcc.agentName,
|
||||
matchedPattern: preCheck.matchedPattern,
|
||||
});
|
||||
|
||||
return {
|
||||
surface: "external_directory",
|
||||
input: {},
|
||||
preCheck,
|
||||
payload,
|
||||
sessionApproval: SessionApproval.single("external_directory", pattern),
|
||||
promptDetails: {
|
||||
source: "tool_call",
|
||||
agentName: tcc.agentName,
|
||||
toolCallId: tcc.toolCallId,
|
||||
toolName: tcc.toolName,
|
||||
path: externalDirectoryPath,
|
||||
accessIntent: accessFactsFromPath("external_directory", accessPath),
|
||||
},
|
||||
logContext: {
|
||||
source: "tool_call",
|
||||
toolCallId: tcc.toolCallId,
|
||||
toolName: tcc.toolName,
|
||||
agentName: tcc.agentName,
|
||||
path: externalDirectoryPath,
|
||||
},
|
||||
decision: {
|
||||
surface: "external_directory",
|
||||
value: externalDirectoryPath,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import type { AccessPath } from "#src/access-intent/access-path";
|
||||
import { classifyToolKind } from "#src/access-intent/tool-kind";
|
||||
import type { ForwardedAccessFacts } from "#src/authority/permission-forwarding";
|
||||
import type { PermissionDecisionResolution } from "#src/permission-events";
|
||||
import type { PermissionCheckResult } from "#src/types";
|
||||
import type { DecisionEventFacts } from "./descriptor";
|
||||
|
||||
/**
|
||||
* Build the child-fixed access facts for a path-shaped gate from its
|
||||
* `AccessPath`.
|
||||
*
|
||||
* Converts the `AccessPath` to strings at the point of emission (ADR-0002: an
|
||||
* `AccessPath` never crosses onto the wire), carrying the lexical ∪ canonical
|
||||
* match set. An empty `boundaryValue()` (a literal-only path) becomes `null`,
|
||||
* so the wire distinguishes "no canonical form" cleanly.
|
||||
*/
|
||||
export function accessFactsFromPath(
|
||||
surface: string,
|
||||
path: AccessPath,
|
||||
): ForwardedAccessFacts {
|
||||
return {
|
||||
surface,
|
||||
matchValues: path.matchValues(),
|
||||
boundaryValue: path.boundaryValue() || null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the child-fixed access facts for a non-path gate (bash command, MCP
|
||||
* target, skill name, plain tool) from its already-portable single value.
|
||||
*/
|
||||
export function accessFactsFromValue(
|
||||
surface: string,
|
||||
value: string,
|
||||
): ForwardedAccessFacts {
|
||||
return { surface, matchValues: [value], boundaryValue: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the human-readable value for a decision event from a check result.
|
||||
* Bash → extracted command; MCP → qualified target;
|
||||
* path-bearing tools → file path; others → tool name.
|
||||
*/
|
||||
export function deriveDecisionValue(
|
||||
toolName: string,
|
||||
check: Pick<PermissionCheckResult, "command" | "target">,
|
||||
path?: string,
|
||||
): string {
|
||||
switch (classifyToolKind(toolName)) {
|
||||
case "bash":
|
||||
return check.command ?? toolName;
|
||||
case "mcp":
|
||||
return check.target ?? toolName;
|
||||
case "path":
|
||||
case "skill":
|
||||
case "extension":
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- || intentional: an empty path falls through to toolName (the original `if (path)` truthiness)
|
||||
return path || toolName;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a decision event's facts from the gate's inputs.
|
||||
*
|
||||
* Centralises the `origin / agentName / matchedPattern ?? null` normalization
|
||||
* that is otherwise duplicated across the session-hit path and the gate-result
|
||||
* path in `runGateCheck`. The request id is stamped by the runner, which is
|
||||
* where it was minted.
|
||||
*/
|
||||
export function buildDecisionEvent(
|
||||
decision: { surface: string; value: string },
|
||||
check: Pick<PermissionCheckResult, "origin" | "matchedPattern">,
|
||||
agentName: string | null,
|
||||
result: "allow" | "deny",
|
||||
resolution: PermissionDecisionResolution,
|
||||
): DecisionEventFacts {
|
||||
return {
|
||||
surface: decision.surface,
|
||||
value: decision.value,
|
||||
result,
|
||||
resolution,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- ?? null normalises undefined to null for the log record
|
||||
origin: check.origin ?? null,
|
||||
agentName: agentName ?? null,
|
||||
matchedPattern: check.matchedPattern ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Map the gate outcome back to a PermissionDecisionResolution.
|
||||
*
|
||||
* @param state - The permission state passed to the gate.
|
||||
* @param action - The gate's resulting action ("allow" | "block").
|
||||
* @param hasSession - True when the gate result carries a sessionApproval
|
||||
* (indicates the user chose "for this session").
|
||||
* @param confirmationUnavailable - True when the denial came from the
|
||||
* DenyingAuthorizer (no live authority was reachable).
|
||||
*/
|
||||
export function deriveResolution(
|
||||
state: "allow" | "deny" | "ask",
|
||||
action: "allow" | "block",
|
||||
hasSession: boolean,
|
||||
confirmationUnavailable: boolean,
|
||||
autoApproved = false,
|
||||
): PermissionDecisionResolution {
|
||||
if (state === "allow") return autoApproved ? "auto_approved" : "policy_allow";
|
||||
if (state === "deny") return "policy_deny";
|
||||
// state === "ask"
|
||||
if (action === "allow") {
|
||||
if (autoApproved) return "auto_approved";
|
||||
return hasSession ? "user_approved_for_session" : "user_approved";
|
||||
}
|
||||
return confirmationUnavailable ? "confirmation_unavailable" : "user_denied";
|
||||
}
|
||||
|
||||
/**
|
||||
* The standing yolo grant covering a gate's resolved check, or `null` when
|
||||
* yolo does not answer it.
|
||||
*
|
||||
* yolo is primarily recorded authority: `rewriteAsksToYolo` turns every `ask`
|
||||
* rule into an `allow` tagged `origin: "yolo"` at composition (#526), and the
|
||||
* first arm recognizes that grant. The second arm covers an `ask` synthesized
|
||||
* *after* resolution — the bash wrapper floor (#481, #490) and the fail-closed
|
||||
* `<unparseable-bash-command>` sentinel (#452) — which the ruleset rewrite
|
||||
* cannot reach because the floor is a property of a parsed command unit, not of
|
||||
* a pattern (#712). The synthetic `matchedPattern` is preserved so the review
|
||||
* log still shows why the ask was raised, while `origin: "yolo"` records why it
|
||||
* was granted.
|
||||
*
|
||||
* A `deny` matches neither arm, so an explicit deny survives yolo.
|
||||
*/
|
||||
export function resolveYoloGrant(
|
||||
check: PermissionCheckResult,
|
||||
yoloEnabled: boolean,
|
||||
): PermissionCheckResult | null {
|
||||
if (check.state === "allow" && check.origin === "yolo") {
|
||||
return check;
|
||||
}
|
||||
if (check.state === "ask" && yoloEnabled) {
|
||||
return { ...check, state: "allow", origin: "yolo" };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { getToolInputPath } from "#src/access-intent/tool-input-path";
|
||||
import type { PathNormalizer } from "#src/path-normalizer";
|
||||
import type { ScopedPermissionResolver } from "#src/permission-resolver";
|
||||
import { buildPathAskPayload } from "#src/presentation/path-ask-payload";
|
||||
import { SessionApproval } from "#src/session-approval";
|
||||
import { deriveApprovalPattern } from "#src/session-rules";
|
||||
import type { ToolAccessExtractorLookup } from "#src/tool-access-extractor-registry";
|
||||
import type { GateDescriptor, GateResult } from "./descriptor";
|
||||
import { accessFactsFromPath } from "./helpers";
|
||||
import type { ToolCallContext } from "./types";
|
||||
|
||||
/**
|
||||
* Build a pure descriptor for the cross-cutting path permission gate (tools).
|
||||
*
|
||||
* Returns `null` when the gate does not apply (tool is not path-bearing,
|
||||
* no extractable path, the `path` surface evaluates to `allow`, or no
|
||||
* explicit `path` rule matched — i.e. only the universal default fired).
|
||||
* Returns a `GateDescriptor` when the path matches a `deny` or `ask` rule.
|
||||
*/
|
||||
export function describePathGate(
|
||||
tcc: ToolCallContext,
|
||||
resolver: ScopedPermissionResolver,
|
||||
normalizer: PathNormalizer,
|
||||
extractors?: ToolAccessExtractorLookup,
|
||||
): GateResult {
|
||||
const filePath = getToolInputPath(tcc.toolName, tcc.input, extractors);
|
||||
if (!filePath) return null;
|
||||
|
||||
// Emit an access-path intent so the resolver matches the lexical aliases
|
||||
// *and* the canonical (symlink-resolved) form, the same set
|
||||
// `external_directory` matches (#418, #486).
|
||||
const accessPath = normalizer.forPath(filePath);
|
||||
const check = resolver.resolve({
|
||||
kind: "access-path",
|
||||
surface: "path",
|
||||
path: accessPath,
|
||||
agentName: tcc.agentName ?? undefined,
|
||||
});
|
||||
|
||||
if (check.state === "allow") return null;
|
||||
|
||||
// No explicit path rule matched — only the universal default fired.
|
||||
// Skip the gate to preserve backward compatibility: configs without a
|
||||
// "path" key should not trigger path-level prompts (#58).
|
||||
if (check.matchedPattern === undefined) return null;
|
||||
|
||||
// Derive the approval pattern from the lexical absolute form so it matches
|
||||
// the policy values a later call produces.
|
||||
const pattern = deriveApprovalPattern(accessPath.value());
|
||||
|
||||
const payload = buildPathAskPayload({
|
||||
toolName: tcc.toolName,
|
||||
pathValue: filePath,
|
||||
agentName: tcc.agentName,
|
||||
matchedPattern: check.matchedPattern,
|
||||
});
|
||||
|
||||
const descriptor: GateDescriptor = {
|
||||
surface: "path",
|
||||
input: { path: filePath },
|
||||
payload,
|
||||
sessionApproval: SessionApproval.single("path", pattern),
|
||||
promptDetails: {
|
||||
source: "tool_call",
|
||||
agentName: tcc.agentName,
|
||||
toolCallId: tcc.toolCallId,
|
||||
toolName: tcc.toolName,
|
||||
path: filePath,
|
||||
accessIntent: accessFactsFromPath("path", accessPath),
|
||||
},
|
||||
logContext: {
|
||||
source: "tool_call",
|
||||
toolCallId: tcc.toolCallId,
|
||||
toolName: tcc.toolName,
|
||||
agentName: tcc.agentName,
|
||||
path: filePath,
|
||||
},
|
||||
decision: {
|
||||
surface: "path",
|
||||
value: filePath,
|
||||
},
|
||||
preCheck: check,
|
||||
};
|
||||
|
||||
return descriptor;
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
import type { AskEscalator } from "#src/authority/authorizer-selection";
|
||||
import type { PermissionPromptDecision } from "#src/authority/permission-dialog";
|
||||
import type { DecisionReporter } from "#src/decision-reporter";
|
||||
import { applyPermissionGate } from "#src/permission-gate";
|
||||
import { createPermissionRequestId } from "#src/permission-request-id";
|
||||
import type { ScopedPermissionResolver } from "#src/permission-resolver";
|
||||
import {
|
||||
renderPolicyDenial,
|
||||
renderUnavailableDenial,
|
||||
renderUserDenial,
|
||||
} from "#src/presentation/agent-renderer";
|
||||
import { renderReviewLogFacts } from "#src/presentation/review-log-renderer";
|
||||
import type { SessionApprovalRecorder } from "#src/session-approval-recorder";
|
||||
import type { PermissionCheckResult } from "#src/types";
|
||||
import type {
|
||||
DecisionEventFacts,
|
||||
GateDescriptor,
|
||||
GateResult,
|
||||
} from "./descriptor";
|
||||
import { isGateBypass } from "./descriptor";
|
||||
import {
|
||||
buildDecisionEvent,
|
||||
deriveResolution,
|
||||
resolveYoloGrant,
|
||||
} from "./helpers";
|
||||
import type { GateOutcome } from "./types";
|
||||
|
||||
// ── GateRunner class ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Executes permission gate checks for a single gate result (null, bypass, or
|
||||
* descriptor).
|
||||
*
|
||||
* Constructed once per handler with its four role collaborators and reused
|
||||
* for every gate in a tool-call pipeline. The `run` method absorbs the null /
|
||||
* bypass / descriptor dispatch that previously lived as an anonymous closure
|
||||
* in `PermissionGateHandler.handleToolCall`.
|
||||
*/
|
||||
export class GateRunner {
|
||||
constructor(
|
||||
private readonly resolver: ScopedPermissionResolver,
|
||||
private readonly recorder: SessionApprovalRecorder,
|
||||
private readonly prompter: AskEscalator,
|
||||
private readonly reporter: DecisionReporter,
|
||||
/**
|
||||
* Live yolo reader, read per gate so a mid-session config change takes
|
||||
* effect — the same closure `PermissionManager` receives.
|
||||
*/
|
||||
private readonly isYoloEnabled: () => boolean,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Execute a gate: null → allow; bypass → log/emit side effects then allow;
|
||||
* descriptor → full check→log→emit→approve cycle.
|
||||
*
|
||||
* The request id is minted here, before the branch, so a request that never
|
||||
* prompts is identified exactly as one that does.
|
||||
*/
|
||||
async run(gate: GateResult, agentName: string | null): Promise<GateOutcome> {
|
||||
if (!gate) {
|
||||
return { action: "allow" };
|
||||
}
|
||||
const requestId = createPermissionRequestId();
|
||||
if (isGateBypass(gate)) {
|
||||
if (gate.log) {
|
||||
this.reporter.writeReviewLog(gate.log.event, {
|
||||
...gate.log.details,
|
||||
requestId,
|
||||
decidedBy: gate.decidedBy,
|
||||
});
|
||||
}
|
||||
if (gate.decision) {
|
||||
this.emitDecision(requestId, gate.decision);
|
||||
}
|
||||
return { action: "allow" };
|
||||
}
|
||||
return this.runDescriptor(gate, agentName, requestId);
|
||||
}
|
||||
|
||||
// ── Private helpers ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The one place a decision event acquires its request id, so no emit path
|
||||
* can be added that forgets it.
|
||||
*/
|
||||
private emitDecision(requestId: string, facts: DecisionEventFacts): void {
|
||||
this.reporter.emitDecision({ requestId, ...facts });
|
||||
}
|
||||
|
||||
private async runDescriptor(
|
||||
descriptor: GateDescriptor,
|
||||
agentName: string | null,
|
||||
requestId: string,
|
||||
): Promise<GateOutcome> {
|
||||
// 1. Resolve permission state — pre-check, pre-resolved, or via resolver
|
||||
let check: PermissionCheckResult;
|
||||
if (descriptor.preCheck) {
|
||||
check = descriptor.preCheck;
|
||||
} else if (descriptor.preResolved) {
|
||||
check = {
|
||||
state: descriptor.preResolved.state,
|
||||
toolName: descriptor.surface,
|
||||
source: "tool",
|
||||
origin: "builtin",
|
||||
};
|
||||
} else {
|
||||
check = this.resolver.resolve({
|
||||
kind: "tool",
|
||||
surface: descriptor.surface,
|
||||
input: descriptor.input,
|
||||
agentName: agentName ?? undefined,
|
||||
});
|
||||
}
|
||||
|
||||
// The fields every review-log write for this gate shares, whatever the
|
||||
// resolution — built once so a field added here reaches all of them. The
|
||||
// payload's request facts are stamped here rather than by each gate, for
|
||||
// the same reason `requestId` is: a gate cannot forget what it never
|
||||
// supplies (ADR 0011 §6).
|
||||
const logContext = {
|
||||
...descriptor.logContext,
|
||||
...renderReviewLogFacts(descriptor.payload),
|
||||
agentName,
|
||||
requestId,
|
||||
};
|
||||
|
||||
// Each resolution below states its own decider. The provenance is built
|
||||
// at the branch that decides rather than merged into `logContext`: that
|
||||
// context holds what every resolution of this gate shares, and who decided
|
||||
// is by definition not shared (#726).
|
||||
|
||||
// 2. Session-hit fast path
|
||||
if (check.source === "session") {
|
||||
this.reporter.writeReviewLog("permission_request.session_approved", {
|
||||
...logContext,
|
||||
resolution: "session_approved",
|
||||
sessionApprovalPattern: check.matchedPattern,
|
||||
decidedBy: {
|
||||
kind: "session_approval",
|
||||
surface: descriptor.surface,
|
||||
pattern: check.matchedPattern ?? null,
|
||||
},
|
||||
});
|
||||
this.emitDecision(
|
||||
requestId,
|
||||
buildDecisionEvent(
|
||||
descriptor.decision,
|
||||
check,
|
||||
agentName,
|
||||
"allow",
|
||||
"session_approved",
|
||||
),
|
||||
);
|
||||
return { action: "allow" };
|
||||
}
|
||||
|
||||
// 2b. Yolo fast-path — the composition-stage ask→allow rewrite (origin
|
||||
// "yolo" on the matched rule, #526) or, under yolo, an ask synthesized
|
||||
// after resolution (#712). Auto-approve without prompting, preserving the
|
||||
// single auto_approved review entry + decision event so log parity holds.
|
||||
const yoloGrant = resolveYoloGrant(check, this.isYoloEnabled());
|
||||
if (yoloGrant) {
|
||||
this.reporter.writeReviewLog("permission_request.auto_approved", {
|
||||
...logContext,
|
||||
resolution: "auto_approved",
|
||||
// The pattern that raised the ask, sentinel included: "yolo allowed
|
||||
// it" alone does not say why it was asked in the first place.
|
||||
decidedBy: { kind: "yolo", pattern: check.matchedPattern ?? null },
|
||||
});
|
||||
this.emitDecision(
|
||||
requestId,
|
||||
buildDecisionEvent(
|
||||
descriptor.decision,
|
||||
yoloGrant,
|
||||
agentName,
|
||||
"allow",
|
||||
deriveResolution(yoloGrant.state, "allow", false, false, true),
|
||||
),
|
||||
);
|
||||
return { action: "allow" };
|
||||
}
|
||||
|
||||
// 3. Apply the deny/ask/allow gate — always escalate on ask; the selected
|
||||
// Authorizer answers (the DenyingAuthorizer by denying with a marker).
|
||||
|
||||
// The agent-facing renders of this ask. The rule reason is the operator's
|
||||
// deny-with-reason text, which lives on the resolved check rather than the
|
||||
// payload: no human render wants it, because a deny never prompts.
|
||||
const { payload } = descriptor;
|
||||
const messages = {
|
||||
denyReason: renderPolicyDenial(payload, check.reason ?? null),
|
||||
unavailableReason: (decision: PermissionPromptDecision) =>
|
||||
renderUnavailableDenial(payload, decision.denialReason ?? null),
|
||||
userDeniedReason: (decision: PermissionPromptDecision) =>
|
||||
renderUserDenial(payload, decision.denialReason ?? null),
|
||||
};
|
||||
|
||||
let autoApproved = false;
|
||||
let confirmationUnavailable = false;
|
||||
const gateResult = await applyPermissionGate({
|
||||
state: check.state,
|
||||
sessionApproval: descriptor.sessionApproval?.toGateApproval(),
|
||||
promptForApproval: async () => {
|
||||
const decision = await this.prompter.escalate({
|
||||
requestId,
|
||||
payload,
|
||||
...descriptor.promptDetails,
|
||||
...(descriptor.sessionApproval
|
||||
? { sessionApproval: descriptor.sessionApproval.toForwardedData() }
|
||||
: {}),
|
||||
});
|
||||
autoApproved = decision.autoApproved === true;
|
||||
confirmationUnavailable = decision.confirmationUnavailable === true;
|
||||
return decision;
|
||||
},
|
||||
writeLog: (event, details) =>
|
||||
this.reporter.writeReviewLog(event, details),
|
||||
logContext,
|
||||
decidedByRule: {
|
||||
kind: "rule",
|
||||
surface: descriptor.surface,
|
||||
pattern: check.matchedPattern ?? null,
|
||||
origin: check.origin,
|
||||
},
|
||||
messages,
|
||||
});
|
||||
|
||||
// 4. Determine whether session approval was granted
|
||||
const hasSessionApproval =
|
||||
gateResult.action === "allow" && gateResult.sessionApproval !== undefined;
|
||||
|
||||
// 5. Emit decision event
|
||||
this.emitDecision(
|
||||
requestId,
|
||||
buildDecisionEvent(
|
||||
descriptor.decision,
|
||||
check,
|
||||
agentName,
|
||||
gateResult.action === "allow" ? "allow" : "deny",
|
||||
deriveResolution(
|
||||
check.state,
|
||||
gateResult.action,
|
||||
hasSessionApproval,
|
||||
confirmationUnavailable,
|
||||
autoApproved,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// 6. Record session approval — tell the store; it owns the per-pattern loop
|
||||
// hasSessionApproval already implies gateResult.action === "allow"
|
||||
if (hasSessionApproval && descriptor.sessionApproval) {
|
||||
this.recorder.recordSessionApproval(descriptor.sessionApproval);
|
||||
}
|
||||
|
||||
if (gateResult.action === "block") {
|
||||
return { action: "block", reason: gateResult.reason };
|
||||
}
|
||||
|
||||
return { action: "allow" };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { PermissionCheckResult } from "#src/types";
|
||||
import type { GateRunner } from "./runner";
|
||||
import { describeSkillInputGate } from "./skill-input";
|
||||
import type { GateOutcome } from "./types";
|
||||
|
||||
// ── Interfaces ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Narrow interface the pipeline needs from its session-side dependency.
|
||||
*
|
||||
* A raw `checkPermission` (no session rules) — preserves the skill-input
|
||||
* semantics established in #326 where the skill-input gate intentionally
|
||||
* bypasses session-rule resolution.
|
||||
*
|
||||
* `PermissionSession` satisfies this structurally at the construction call
|
||||
* site; no `implements` clause is needed and would create a layer-inversion
|
||||
* import from the domain module into the handler layer.
|
||||
*/
|
||||
export interface SkillInputGateInputs {
|
||||
checkPermission(
|
||||
surface: string,
|
||||
input: unknown,
|
||||
agentName?: string,
|
||||
): PermissionCheckResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrow UI seam: warn the user when a skill is denied.
|
||||
*
|
||||
* The handler builds this per-event from `ctx`, encapsulating the `hasUI`
|
||||
* guard so the pipeline never touches `ExtensionContext` directly
|
||||
* (Tell-Don't-Ask: the pipeline tells the notifier to warn; the notifier
|
||||
* decides whether a UI is present).
|
||||
*/
|
||||
export interface GateNotifier {
|
||||
warn(message: string): void;
|
||||
}
|
||||
|
||||
// ── Pipeline ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Owns the skill-input gate assembly: raw permission pre-check, deny notify,
|
||||
* `describeSkillInputGate` descriptor, and `runner.run(...)`.
|
||||
*
|
||||
* Constructed once in the composition root and injected into
|
||||
* `PermissionGateHandler`, mirroring `ToolCallGatePipeline` for the `input`
|
||||
* path.
|
||||
*
|
||||
* `evaluate` is not `async` because it has no `await` of its own — it returns
|
||||
* `runner.run(...)` directly (`@typescript-eslint/require-await` would reject
|
||||
* an `async` body with no `await`).
|
||||
*/
|
||||
export class SkillInputGatePipeline {
|
||||
constructor(private readonly inputs: SkillInputGateInputs) {}
|
||||
|
||||
evaluate(
|
||||
skillName: string,
|
||||
agentName: string | null,
|
||||
notifier: GateNotifier,
|
||||
runner: GateRunner,
|
||||
): Promise<GateOutcome> {
|
||||
const check = this.inputs.checkPermission(
|
||||
"skill",
|
||||
{ name: skillName },
|
||||
agentName ?? undefined,
|
||||
);
|
||||
if (check.state === "deny") {
|
||||
notifier.warn(formatSkillDenyNotice(skillName, agentName));
|
||||
}
|
||||
return runner.run(
|
||||
describeSkillInputGate(skillName, agentName, check),
|
||||
agentName,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Format the deny warning shown in the UI when a skill is blocked.
|
||||
*
|
||||
* Intentionally untagged (no `[pi-permission-system]` prefix) — this is a
|
||||
* UI notify distinct from the agent-facing deny reasons the runner routes
|
||||
* through `renderPolicyDenial`.
|
||||
*/
|
||||
export function formatSkillDenyNotice(
|
||||
skillName: string,
|
||||
agentName: string | null,
|
||||
): string {
|
||||
return agentName
|
||||
? `Skill '${skillName}' is not permitted for agent '${agentName}'.`
|
||||
: `Skill '${skillName}' is not permitted by the current skill policy.`;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { buildSkillAskPayload } from "#src/presentation/skill-ask-payload";
|
||||
import type { PermissionCheckResult } from "#src/types";
|
||||
import type { GateDescriptor } from "./descriptor";
|
||||
import { accessFactsFromValue } from "./helpers";
|
||||
|
||||
/**
|
||||
* Build a pure descriptor for the skill-input permission gate.
|
||||
*
|
||||
* Takes the pre-computed check result so the gate can reuse the result the
|
||||
* caller already obtained (e.g. to conditionally emit a deny warning) without
|
||||
* re-running the check inside the runner.
|
||||
*/
|
||||
export function describeSkillInputGate(
|
||||
skillName: string,
|
||||
agentName: string | null,
|
||||
preCheck: PermissionCheckResult,
|
||||
): GateDescriptor {
|
||||
const payload = buildSkillAskPayload(skillName, agentName);
|
||||
return {
|
||||
surface: "skill",
|
||||
input: { name: skillName },
|
||||
preCheck,
|
||||
payload,
|
||||
promptDetails: {
|
||||
source: "skill_input",
|
||||
agentName,
|
||||
skillName,
|
||||
accessIntent: accessFactsFromValue("skill", skillName),
|
||||
},
|
||||
logContext: {
|
||||
source: "skill_input",
|
||||
skillName,
|
||||
agentName,
|
||||
},
|
||||
decision: {
|
||||
surface: "skill",
|
||||
value: skillName,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { PathNormalizer } from "#src/path-normalizer";
|
||||
import { buildSkillPathAskPayload } from "#src/presentation/skill-ask-payload";
|
||||
import type { SkillPromptEntry } from "#src/skill-prompt-sanitizer";
|
||||
import { findSkillPathMatch } from "#src/skill-prompt-sanitizer";
|
||||
import { toRecord } from "#src/value-guards";
|
||||
import type { GateDescriptor } from "./descriptor";
|
||||
import { accessFactsFromValue } from "./helpers";
|
||||
import type { ToolCallContext } from "./types";
|
||||
|
||||
/**
|
||||
* Build a pure descriptor for the skill-read permission gate.
|
||||
*
|
||||
* Returns `null` when the gate does not apply (tool is not `read`, no active
|
||||
* skill entries, or the read path does not match any skill).
|
||||
* Returns a GateDescriptor with preResolved state from the matched skill entry.
|
||||
*/
|
||||
export function describeSkillReadGate(
|
||||
tcc: ToolCallContext,
|
||||
normalizer: PathNormalizer,
|
||||
getActiveSkillEntries: () => SkillPromptEntry[],
|
||||
): GateDescriptor | null {
|
||||
const activeSkillEntries = getActiveSkillEntries();
|
||||
|
||||
if (tcc.toolName !== "read" || activeSkillEntries.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const inputRecord = toRecord(tcc.input);
|
||||
const path = typeof inputRecord.path === "string" ? inputRecord.path : "";
|
||||
if (!path) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedReadPath = normalizer.comparableValue(path);
|
||||
const matchedSkill = findSkillPathMatch(
|
||||
normalizedReadPath,
|
||||
activeSkillEntries,
|
||||
normalizer,
|
||||
);
|
||||
|
||||
if (!matchedSkill) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const payload = buildSkillPathAskPayload(matchedSkill, path, tcc.agentName);
|
||||
|
||||
return {
|
||||
surface: "skill",
|
||||
input: { name: matchedSkill.name },
|
||||
payload,
|
||||
promptDetails: {
|
||||
source: "skill_read",
|
||||
agentName: tcc.agentName,
|
||||
toolCallId: tcc.toolCallId,
|
||||
toolName: tcc.toolName,
|
||||
skillName: matchedSkill.name,
|
||||
path,
|
||||
accessIntent: accessFactsFromValue("skill", matchedSkill.name),
|
||||
},
|
||||
logContext: {
|
||||
source: "skill_read",
|
||||
toolCallId: tcc.toolCallId,
|
||||
skillName: matchedSkill.name,
|
||||
agentName: tcc.agentName,
|
||||
path,
|
||||
},
|
||||
decision: {
|
||||
surface: "skill",
|
||||
value: matchedSkill.name,
|
||||
},
|
||||
preResolved: {
|
||||
state: matchedSkill.state,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
import type { AccessPath } from "#src/access-intent/access-path";
|
||||
import { BashProgram } from "#src/access-intent/bash/program";
|
||||
import { getPathBearingToolPath } from "#src/access-intent/tool-input-path";
|
||||
import {
|
||||
resolveShellInvocation,
|
||||
type ShellInvocation,
|
||||
} from "#src/access-intent/tool-kind";
|
||||
import type { ShellToolsConfig } from "#src/config-schema";
|
||||
import type { PathNormalizer } from "#src/path-normalizer";
|
||||
import type { ScopedPermissionResolver } from "#src/permission-resolver";
|
||||
import type { SkillPromptEntry } from "#src/skill-prompt-sanitizer";
|
||||
import type { ToolAccessExtractorLookup } from "#src/tool-access-extractor-registry";
|
||||
import type { ToolInputFormatterLookup } from "#src/tool-input-formatter-registry";
|
||||
import {
|
||||
ToolPreviewFormatter,
|
||||
type ToolPreviewFormatterOptions,
|
||||
} from "#src/tool-preview-formatter";
|
||||
import type { PermissionCheckResult } from "#src/types";
|
||||
import { resolveBashCommandCheck } from "./bash-command";
|
||||
import { describeBashExternalDirectoryGate } from "./bash-external-directory";
|
||||
import { describeBashPathGate } from "./bash-path";
|
||||
import type { GateResult } from "./descriptor";
|
||||
import { describeExternalDirectoryGate } from "./external-directory";
|
||||
import { describePathGate } from "./path";
|
||||
import type { GateRunner } from "./runner";
|
||||
import { describeSkillReadGate } from "./skill-read";
|
||||
import { describeToolGate } from "./tool";
|
||||
import type { GateOutcome, ToolCallContext } from "./types";
|
||||
|
||||
/**
|
||||
* Narrow interface the pipeline needs from its session-side dependency.
|
||||
*
|
||||
* The three query methods needed to assemble gate inputs.
|
||||
* The resolver is injected separately as a constructor parameter.
|
||||
*
|
||||
* `PermissionSession` satisfies this structurally at the construction call
|
||||
* site; no `implements` clause is needed and would create a layer-inversion
|
||||
* import from the domain module into the handler layer.
|
||||
*/
|
||||
export interface ToolCallGateInputs {
|
||||
/** Active skill prompt entries for the skill-read gate. */
|
||||
getActiveSkillEntries(): SkillPromptEntry[];
|
||||
/** Combined infrastructure read directories (static + config-derived). */
|
||||
getInfrastructureReadDirs(): string[];
|
||||
/** Resolved tool-preview formatter options from the current config. */
|
||||
getToolPreviewLimits(): ToolPreviewFormatterOptions;
|
||||
/** The session's path normalizer (platform + cwd baked in). */
|
||||
getPathNormalizer(): PathNormalizer;
|
||||
/**
|
||||
* The configured shell-tool aliases (`shellTools`), or `undefined` when none
|
||||
* are set. Consulted by {@link resolveShellInvocation} so an aliased shell
|
||||
* tool is gated through the bash stack at parity with native `bash` (#574).
|
||||
*/
|
||||
getShellToolAliases(): ShellToolsConfig | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns the ordered tool-call gate-producer assembly and the run loop.
|
||||
*
|
||||
* Constructed once in the composition root and injected into
|
||||
* `PermissionGateHandler`. `evaluate(tcc, runner)` encapsulates:
|
||||
* - bash-command extraction and single `BashProgram.parse` (#308)
|
||||
* - `ToolPreviewFormatter` construction from `getToolPreviewLimits()`
|
||||
* - infrastructure-dir list from `getInfrastructureReadDirs()`
|
||||
* - all six gate producers in their prescribed order
|
||||
* - the run loop that returns the first block outcome, or allow
|
||||
*/
|
||||
export class ToolCallGatePipeline {
|
||||
constructor(
|
||||
private readonly resolver: ScopedPermissionResolver,
|
||||
private readonly inputs: ToolCallGateInputs,
|
||||
private readonly customFormatters?: ToolInputFormatterLookup,
|
||||
private readonly customExtractors?: ToolAccessExtractorLookup,
|
||||
) {}
|
||||
|
||||
async evaluate(
|
||||
tcc: ToolCallContext,
|
||||
runner: GateRunner,
|
||||
): Promise<GateOutcome> {
|
||||
// Resolve the shell invocation once: native `bash` and any tool recorded in
|
||||
// `shellTools` both yield a command (+ optional workdir); every other tool
|
||||
// yields null (#574). The three bash gates then share the single BashProgram
|
||||
// parsed from that command instead of each re-parsing (#308).
|
||||
const shell = resolveShellInvocation(
|
||||
tcc.toolName,
|
||||
tcc.input,
|
||||
this.inputs.getShellToolAliases(),
|
||||
);
|
||||
const normalizer = this.inputs.getPathNormalizer();
|
||||
const bashProgram = shell?.command
|
||||
? await BashProgram.parse(shell.command, normalizer, {
|
||||
workdir: shell.workdir,
|
||||
})
|
||||
: null;
|
||||
|
||||
const formatter = new ToolPreviewFormatter(
|
||||
this.inputs.getToolPreviewLimits(),
|
||||
this.customFormatters,
|
||||
);
|
||||
|
||||
const infraDirs = this.inputs.getInfrastructureReadDirs();
|
||||
|
||||
const gateProducers: Array<() => GateResult | Promise<GateResult>> = [
|
||||
() =>
|
||||
describeSkillReadGate(tcc, normalizer, () =>
|
||||
this.inputs.getActiveSkillEntries(),
|
||||
),
|
||||
() =>
|
||||
describePathGate(tcc, this.resolver, normalizer, this.customExtractors),
|
||||
() =>
|
||||
describeExternalDirectoryGate(
|
||||
tcc,
|
||||
infraDirs,
|
||||
this.resolver,
|
||||
normalizer,
|
||||
this.customExtractors,
|
||||
),
|
||||
() => describeBashExternalDirectoryGate(tcc, bashProgram, this.resolver),
|
||||
() => describeBashPathGate(tcc, bashProgram, this.resolver),
|
||||
() => {
|
||||
const { toolCheck, accessPath } = this.resolvePerToolCheck(
|
||||
tcc,
|
||||
shell,
|
||||
bashProgram,
|
||||
normalizer,
|
||||
);
|
||||
const toolDescriptor = describeToolGate(
|
||||
tcc,
|
||||
toolCheck,
|
||||
formatter,
|
||||
accessPath,
|
||||
shell,
|
||||
);
|
||||
toolDescriptor.preCheck = toolCheck;
|
||||
return toolDescriptor;
|
||||
},
|
||||
];
|
||||
|
||||
for (const produce of gateProducers) {
|
||||
const outcome = await runner.run(await produce(), tcc.agentName);
|
||||
if (outcome.action === "block") {
|
||||
return outcome;
|
||||
}
|
||||
}
|
||||
|
||||
return { action: "allow" };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the per-tool gate's check, choosing the intent by tool shape:
|
||||
* bash chains its sub-commands; a path-bearing tool with a path emits an
|
||||
* `access-path` intent (so the per-tool surface matches lexical ∪ canonical,
|
||||
* #502); every other tool (and a path-bearing tool with no path) keeps the
|
||||
* raw `tool` intent the manager normalizes.
|
||||
*
|
||||
* Returns the `AccessPath` alongside the check so `describeToolGate` derives
|
||||
* the session-approval value from `accessPath.value()`.
|
||||
*/
|
||||
private resolvePerToolCheck(
|
||||
tcc: ToolCallContext,
|
||||
shell: ShellInvocation | null,
|
||||
bashProgram: BashProgram | null,
|
||||
normalizer: PathNormalizer,
|
||||
): { toolCheck: PermissionCheckResult; accessPath?: AccessPath } {
|
||||
if (shell) {
|
||||
if (bashProgram) {
|
||||
return {
|
||||
toolCheck: resolveBashCommandCheck(
|
||||
bashProgram.commandText(),
|
||||
bashProgram.commands(),
|
||||
tcc.agentName ?? undefined,
|
||||
this.resolver,
|
||||
),
|
||||
};
|
||||
}
|
||||
// A shell invocation whose command did not parse (e.g. empty) still
|
||||
// resolves on the `bash` surface, so an aliased tool never falls through
|
||||
// to its own extension-tool surface.
|
||||
return {
|
||||
toolCheck: this.resolver.resolve({
|
||||
kind: "tool",
|
||||
surface: "bash",
|
||||
input: { command: shell.command },
|
||||
agentName: tcc.agentName ?? undefined,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const filePath = getPathBearingToolPath(tcc.toolName, tcc.input);
|
||||
if (filePath !== null) {
|
||||
const accessPath = normalizer.forPath(filePath);
|
||||
return {
|
||||
accessPath,
|
||||
toolCheck: this.resolver.resolve({
|
||||
kind: "access-path",
|
||||
surface: tcc.toolName,
|
||||
path: accessPath,
|
||||
agentName: tcc.agentName ?? undefined,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
toolCheck: this.resolver.resolve({
|
||||
kind: "tool",
|
||||
surface: tcc.toolName,
|
||||
input: tcc.input,
|
||||
agentName: tcc.agentName ?? undefined,
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import type { AccessPath } from "#src/access-intent/access-path";
|
||||
import { PATH_BEARING_TOOLS } from "#src/access-intent/path-surfaces";
|
||||
import { getPathBearingToolPath } from "#src/access-intent/tool-input-path";
|
||||
import {
|
||||
classifyToolKind,
|
||||
type ShellInvocation,
|
||||
} from "#src/access-intent/tool-kind";
|
||||
import { suggestSessionPattern } from "#src/pattern-suggest";
|
||||
import { buildToolAskPayload } from "#src/presentation/tool-ask-payload";
|
||||
import { SessionApproval } from "#src/session-approval";
|
||||
import type { ToolPreviewFormatter } from "#src/tool-preview-formatter";
|
||||
import type { PermissionCheckResult } from "#src/types";
|
||||
import type { GateDescriptor } from "./descriptor";
|
||||
import {
|
||||
accessFactsFromPath,
|
||||
accessFactsFromValue,
|
||||
deriveDecisionValue,
|
||||
} from "./helpers";
|
||||
import type { ToolCallContext } from "./types";
|
||||
|
||||
/**
|
||||
* Derive the value used for session-approval pattern suggestions.
|
||||
*
|
||||
* Bash → command string; MCP → qualified target;
|
||||
* path-bearing tools → the `AccessPath`'s lexical absolute form (`value()`),
|
||||
* so the suggested pattern matches the policy values a later call produces;
|
||||
* others (or a path-bearing tool with no path) → catch-all wildcard.
|
||||
*/
|
||||
function deriveSuggestionValue(
|
||||
toolName: string,
|
||||
check: PermissionCheckResult,
|
||||
accessPath?: AccessPath,
|
||||
): string {
|
||||
switch (classifyToolKind(toolName)) {
|
||||
case "bash":
|
||||
return check.command ?? "";
|
||||
case "mcp":
|
||||
return check.target ?? "mcp";
|
||||
default:
|
||||
return accessPath ? accessPath.value() : "*";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a pure descriptor for the normal tool permission gate.
|
||||
*
|
||||
* Takes a pre-computed PermissionCheckResult (from checkPermission) and
|
||||
* returns a GateDescriptor that the runner can execute. No side effects.
|
||||
*/
|
||||
export function describeToolGate(
|
||||
tcc: ToolCallContext,
|
||||
check: PermissionCheckResult,
|
||||
formatter: ToolPreviewFormatter,
|
||||
accessPath?: AccessPath,
|
||||
shell?: ShellInvocation | null,
|
||||
): GateDescriptor {
|
||||
// A shell invocation (native `bash` or an aliased shell tool) is gated on the
|
||||
// `bash` surface — its session rule, decision value, and suggestion are
|
||||
// bash-shaped — while the invoked tool name is preserved in the prompt and
|
||||
// review log so a user sees which tool actually ran (#574).
|
||||
const gateSurface = shell ? "bash" : tcc.toolName;
|
||||
|
||||
const permissionLogContext = formatter.getPermissionLogContext(
|
||||
check,
|
||||
tcc.input,
|
||||
PATH_BEARING_TOOLS,
|
||||
);
|
||||
|
||||
// Compute session approval suggestion for the "for this session" option.
|
||||
const suggestion = suggestSessionPattern(
|
||||
gateSurface,
|
||||
deriveSuggestionValue(gateSurface, check, accessPath),
|
||||
);
|
||||
|
||||
const payload = buildToolAskPayload({
|
||||
check,
|
||||
agentName: tcc.agentName,
|
||||
surface: gateSurface,
|
||||
invokedToolName: tcc.toolName,
|
||||
input: tcc.input,
|
||||
formatter,
|
||||
});
|
||||
|
||||
const decisionValue = deriveDecisionValue(
|
||||
gateSurface,
|
||||
check,
|
||||
getPathBearingToolPath(tcc.toolName, tcc.input) ?? undefined,
|
||||
);
|
||||
|
||||
// A path-bearing tool carries the AccessPath's alias set; every other surface
|
||||
// (bash command, MCP target, plain tool) carries its already-portable value.
|
||||
const accessIntent = accessPath
|
||||
? accessFactsFromPath(gateSurface, accessPath)
|
||||
: accessFactsFromValue(gateSurface, decisionValue);
|
||||
|
||||
return {
|
||||
surface: gateSurface,
|
||||
input: tcc.input,
|
||||
payload,
|
||||
sessionApproval: SessionApproval.single(
|
||||
suggestion.surface,
|
||||
suggestion.pattern,
|
||||
),
|
||||
promptDetails: {
|
||||
source: "tool_call",
|
||||
agentName: tcc.agentName,
|
||||
toolCallId: tcc.toolCallId,
|
||||
toolName: tcc.toolName,
|
||||
sessionLabel: suggestion.label,
|
||||
accessIntent,
|
||||
...permissionLogContext,
|
||||
},
|
||||
logContext: {
|
||||
source: "tool_call",
|
||||
toolCallId: tcc.toolCallId,
|
||||
toolName: tcc.toolName,
|
||||
...permissionLogContext,
|
||||
},
|
||||
decision: {
|
||||
surface: gateSurface,
|
||||
value: decisionValue,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
/** Outcome of a single permission gate evaluation. */
|
||||
export type GateOutcome =
|
||||
| { action: "allow" }
|
||||
| { action: "block"; reason: string };
|
||||
|
||||
/** Pre-validated context shared across all gates. */
|
||||
export interface ToolCallContext {
|
||||
toolName: string;
|
||||
agentName: string | null;
|
||||
input: unknown;
|
||||
toolCallId: string;
|
||||
cwd: string;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { AgentPrepHandler } from "./before-agent-start";
|
||||
export { SessionLifecycleHandler } from "./lifecycle";
|
||||
export { PermissionGateHandler } from "./permission-gate-handler";
|
||||
@@ -0,0 +1,127 @@
|
||||
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
||||
|
||||
import type { DecisionSummaryWriter } from "#src/decision-audit";
|
||||
import type { PermissionResolver } from "#src/permission-resolver";
|
||||
import type { PermissionSession } from "#src/permission-session";
|
||||
import type { ServiceLifecycle } from "#src/service-lifecycle";
|
||||
import type { SessionLogger } from "#src/session-logger";
|
||||
import { PERMISSION_SYSTEM_STATUS_KEY } from "#src/status";
|
||||
|
||||
/** Minimal subset of SessionStartEvent used by this handler. */
|
||||
interface SessionStartPayload {
|
||||
reason: string;
|
||||
}
|
||||
|
||||
/** Minimal subset of ResourcesDiscoverEvent used by this handler. */
|
||||
interface ResourcesDiscoverPayload {
|
||||
reason: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shown when project config is skipped because the project is untrusted, so the
|
||||
* reduced-scope state is never silent (#644). Exported for assertion in tests.
|
||||
*/
|
||||
export const UNTRUSTED_PROJECT_MESSAGE =
|
||||
"pi-permission-system: project is not trusted — skipping project-scoped " +
|
||||
"permission configuration. Only global policy applies. Grant project trust " +
|
||||
"to load this project's permission rules.";
|
||||
|
||||
/**
|
||||
* Handles session lifecycle events: start, reload, and shutdown.
|
||||
*
|
||||
* Constructor deps:
|
||||
* - `session` — encapsulates all mutable session state and lifecycle operations
|
||||
* - `resolver` — owns permission-query surface: `getConfigIssues`
|
||||
* - `serviceLifecycle` — owns the process-global service publication;
|
||||
* `activate` publishes (skipped for registered subagent children) and emits
|
||||
* the ready event; `teardown` unsubscribes all session listeners and unpublishes
|
||||
* - `logger` — injected directly; replaces the former `session.logger` reach-through
|
||||
* - `audit` — per-session decision counters; its summary is written on shutdown
|
||||
*/
|
||||
export class SessionLifecycleHandler {
|
||||
constructor(
|
||||
private readonly session: PermissionSession,
|
||||
private readonly resolver: PermissionResolver,
|
||||
private readonly serviceLifecycle: ServiceLifecycle,
|
||||
private readonly logger: SessionLogger,
|
||||
private readonly audit: DecisionSummaryWriter,
|
||||
) {}
|
||||
|
||||
handleSessionStart(
|
||||
event: SessionStartPayload,
|
||||
ctx: ExtensionContext,
|
||||
): Promise<void> {
|
||||
const projectTrusted = ctx.isProjectTrusted();
|
||||
this.session.refreshConfig(ctx, projectTrusted);
|
||||
this.session.resetForNewSession(ctx, projectTrusted);
|
||||
this.session.logResolvedConfigPaths();
|
||||
if (!projectTrusted) {
|
||||
this.warnProjectUntrusted(ctx, "session_start");
|
||||
}
|
||||
|
||||
const agentName = this.session.resolveAgentName(ctx);
|
||||
const policyIssues = this.resolver.getConfigIssues(agentName ?? undefined);
|
||||
for (const issue of policyIssues) {
|
||||
this.logger.warn(issue);
|
||||
}
|
||||
|
||||
if (event.reason === "reload") {
|
||||
this.logger.debug("lifecycle.reload", {
|
||||
triggeredBy: "session_start",
|
||||
reason: event.reason,
|
||||
cwd: ctx.cwd,
|
||||
});
|
||||
}
|
||||
|
||||
// Publish the process-global service now that a ctx (and therefore the
|
||||
// session id) is available, so an in-process subagent child can be
|
||||
// identified and excluded. Emitting ready here keeps the
|
||||
// service-resolvable-when-ready ordering contract.
|
||||
this.serviceLifecycle.activate(ctx);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
handleResourcesDiscover(
|
||||
event: ResourcesDiscoverPayload,
|
||||
ctx: ExtensionContext,
|
||||
): Promise<void> {
|
||||
if (event.reason !== "reload") {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
const projectTrusted = ctx.isProjectTrusted();
|
||||
this.session.reload(projectTrusted);
|
||||
if (!projectTrusted) {
|
||||
this.warnProjectUntrusted(ctx, "resources_discover");
|
||||
}
|
||||
this.logger.debug("lifecycle.reload", {
|
||||
triggeredBy: "resources_discover",
|
||||
reason: event.reason,
|
||||
cwd: this.session.getRuntimeContext()?.cwd ?? null,
|
||||
});
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
/**
|
||||
* Record the project-trust skip in the review log and surface a loud warning
|
||||
* to the user, so the reduced (global-only) scope is never silent (#644).
|
||||
*/
|
||||
private warnProjectUntrusted(
|
||||
ctx: ExtensionContext,
|
||||
phase: "session_start" | "resources_discover",
|
||||
): void {
|
||||
this.logger.review("project_trust.skipped", { cwd: ctx.cwd, phase });
|
||||
this.logger.warn(UNTRUSTED_PROJECT_MESSAGE);
|
||||
}
|
||||
|
||||
handleSessionShutdown(): Promise<void> {
|
||||
const ctx = this.session.getRuntimeContext();
|
||||
if (ctx) {
|
||||
ctx.ui.setStatus(PERMISSION_SYSTEM_STATUS_KEY, undefined);
|
||||
}
|
||||
this.audit.writeSummary(this.logger);
|
||||
this.session.shutdown();
|
||||
this.serviceLifecycle.teardown();
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import type {
|
||||
ExtensionContext,
|
||||
InputEventResult,
|
||||
} from "@earendil-works/pi-coding-agent";
|
||||
import {
|
||||
formatMissingToolNameReason,
|
||||
formatUnknownToolReason,
|
||||
} from "#src/permission-prompts";
|
||||
import type { PermissionSession } from "#src/permission-session";
|
||||
import {
|
||||
checkRequestedToolRegistration,
|
||||
getToolNameFromValue,
|
||||
type ToolRegistry,
|
||||
} from "#src/tool-registry";
|
||||
import { toRecord } from "#src/value-guards";
|
||||
import type { GateRunner } from "./gates/runner";
|
||||
import type {
|
||||
GateNotifier,
|
||||
SkillInputGatePipeline,
|
||||
} from "./gates/skill-input-gate-pipeline";
|
||||
import type { ToolCallGatePipeline } from "./gates/tool-call-gate-pipeline";
|
||||
import type { GateOutcome, ToolCallContext } from "./gates/types";
|
||||
|
||||
/** Minimal subset of InputEvent used by handleInput. */
|
||||
interface InputPayload {
|
||||
text: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles permission gate events: tool_call and input.
|
||||
*
|
||||
* Constructor deps:
|
||||
* - `session` — state/lifecycle owner: bind per-event context, resolve agent name
|
||||
* - `toolRegistry` — Pi tool API subset (getAll + setActive)
|
||||
* - `pipeline` — owns tool-call gate-producer assembly and the run loop
|
||||
* - `skillInputPipeline` — owns skill-input gate assembly (pre-check, notify, run)
|
||||
* - `runner` — pre-built gate runner (constructed in the composition root)
|
||||
*/
|
||||
export class PermissionGateHandler {
|
||||
constructor(
|
||||
private readonly session: PermissionSession,
|
||||
private readonly toolRegistry: ToolRegistry,
|
||||
private readonly pipeline: ToolCallGatePipeline,
|
||||
private readonly skillInputPipeline: SkillInputGatePipeline,
|
||||
private readonly runner: GateRunner,
|
||||
) {}
|
||||
|
||||
async handleToolCall(
|
||||
event: unknown,
|
||||
ctx: ExtensionContext,
|
||||
): Promise<GateOutcome> {
|
||||
this.session.activate(ctx);
|
||||
|
||||
const validation = validateRequestedTool(event, this.toolRegistry.getAll());
|
||||
if (validation.status === "block") {
|
||||
return { action: "block", reason: validation.reason };
|
||||
}
|
||||
const toolName = validation.toolName;
|
||||
|
||||
const agentName = this.session.resolveAgentName(ctx);
|
||||
|
||||
const input = getEventInput(event);
|
||||
const toolCallId =
|
||||
typeof (event as Record<string, unknown>).toolCallId === "string"
|
||||
? ((event as Record<string, unknown>).toolCallId as string)
|
||||
: "";
|
||||
|
||||
const tcc: ToolCallContext = {
|
||||
toolName,
|
||||
agentName,
|
||||
input,
|
||||
toolCallId,
|
||||
cwd: ctx.cwd,
|
||||
};
|
||||
|
||||
return await this.pipeline.evaluate(tcc, this.runner);
|
||||
}
|
||||
|
||||
async handleInput(
|
||||
event: InputPayload,
|
||||
ctx: ExtensionContext,
|
||||
): Promise<InputEventResult> {
|
||||
this.session.activate(ctx);
|
||||
|
||||
const skillName = extractSkillNameFromInput(event.text);
|
||||
if (!skillName) {
|
||||
return { action: "continue" };
|
||||
}
|
||||
|
||||
const agentName = this.session.resolveAgentName(ctx);
|
||||
const notifier: GateNotifier = {
|
||||
warn: (message) => {
|
||||
if (ctx.hasUI) {
|
||||
ctx.ui.notify(message, "warning");
|
||||
}
|
||||
},
|
||||
};
|
||||
const outcome = await this.skillInputPipeline.evaluate(
|
||||
skillName,
|
||||
agentName,
|
||||
notifier,
|
||||
this.runner,
|
||||
);
|
||||
return outcome.action === "block"
|
||||
? { action: "handled" }
|
||||
: { action: "continue" };
|
||||
}
|
||||
}
|
||||
|
||||
// ── Pure helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
/** Discriminated result of validating a tool-call event's name and registration. */
|
||||
export type RequestedToolValidation =
|
||||
| { status: "ok"; toolName: string }
|
||||
| { status: "block"; reason: string };
|
||||
|
||||
/**
|
||||
* Validate the tool name from a raw event against the registered tool list.
|
||||
*
|
||||
* Composes `getToolNameFromValue` + `checkRequestedToolRegistration` + the
|
||||
* two reason formatters and returns a discriminated result so `handleToolCall`
|
||||
* reads as a straight validate → proceed path without nested early-returns.
|
||||
*
|
||||
* Returns the **raw** tool name (not the normalised form) so that
|
||||
* `ToolCallContext.toolName` stays identical to the pre-extraction behaviour.
|
||||
*/
|
||||
export function validateRequestedTool(
|
||||
event: unknown,
|
||||
availableTools: readonly unknown[],
|
||||
): RequestedToolValidation {
|
||||
const toolName = getToolNameFromValue(event);
|
||||
if (!toolName) {
|
||||
return { status: "block", reason: formatMissingToolNameReason() };
|
||||
}
|
||||
const check = checkRequestedToolRegistration(toolName, availableTools);
|
||||
if (check.status === "missing-tool-name") {
|
||||
return { status: "block", reason: formatMissingToolNameReason() };
|
||||
}
|
||||
if (check.status === "unregistered") {
|
||||
return {
|
||||
status: "block",
|
||||
reason: formatUnknownToolReason(
|
||||
check.requestedToolName,
|
||||
check.availableToolNames,
|
||||
),
|
||||
};
|
||||
}
|
||||
return { status: "ok", toolName };
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the tool input from an event, checking both `input` and `arguments`
|
||||
* fields (different Pi SDK versions use different names).
|
||||
*/
|
||||
export function getEventInput(event: unknown): unknown {
|
||||
const record = toRecord(event);
|
||||
|
||||
if (record.input !== undefined) {
|
||||
return record.input;
|
||||
}
|
||||
|
||||
if (record.arguments !== undefined) {
|
||||
return record.arguments;
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a `/skill:<name>` prefix from user input.
|
||||
* Returns the skill name, or null if the text is not a skill invocation.
|
||||
*/
|
||||
export function extractSkillNameFromInput(text: string): string | null {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed.startsWith("/skill:")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const afterPrefix = trimmed.slice("/skill:".length);
|
||||
if (!afterPrefix) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const firstWhitespace = afterPrefix.search(/\s/);
|
||||
const skillName = (
|
||||
firstWhitespace === -1 ? afterPrefix : afterPrefix.slice(0, firstWhitespace)
|
||||
).trim();
|
||||
return skillName || null;
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
||||
import type { DecisionRecorder } from "#src/decision-audit";
|
||||
import type { DecisionReporter } from "#src/decision-reporter";
|
||||
import { createPermissionRequestId } from "#src/permission-request-id";
|
||||
import { toRecord } from "#src/value-guards";
|
||||
import type { GateOutcome } from "./gates/types";
|
||||
|
||||
/** The SDK-facing result shape for a `tool_call` handler. */
|
||||
type ToolCallResult = { block?: true; reason?: string };
|
||||
|
||||
/**
|
||||
* Narrow debug surface for the per-call decision trace. The concrete logger
|
||||
* self-gates on `debugLog`, so the boundary emits unconditionally and the
|
||||
* entry is dropped when the toggle is off (no per-call spam in normal use).
|
||||
*/
|
||||
export interface DecisionTracer {
|
||||
debug(event: string, details?: Record<string, unknown>): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The only `tool_call` handler the SDK sees.
|
||||
*
|
||||
* Guarantees fail-closed: it owns the `try/catch → block` and is the sole place
|
||||
* an internal {@link GateOutcome} is translated to the SDK result shape, so
|
||||
* "we didn't decide" can never silently mean "allow."
|
||||
*
|
||||
* The SDK's `emitToolCall` (`@earendil-works/pi-coding-agent`
|
||||
* `dist/core/extensions/runner.js`) awaits the registered handler with **no**
|
||||
* try/catch — unlike `emitUserBash` directly below it, which catches and
|
||||
* continues. A thrown gate therefore yields no `{ block: true }` and the
|
||||
* command runs ungated with nothing logged. This boundary absorbs that throw,
|
||||
* blocks, and writes a `gate_error` review-log entry.
|
||||
*
|
||||
* Fail-closed = **block** (not `ask`) for an unexpected exception: the command
|
||||
* may be unknown and the prompt infrastructure itself may be what threw, so a
|
||||
* hard block is the unambiguous safe outcome.
|
||||
*/
|
||||
export function createFailClosedToolCall(
|
||||
gate: (event: unknown, ctx: ExtensionContext) => Promise<GateOutcome>,
|
||||
reporter: DecisionReporter,
|
||||
audit: DecisionRecorder,
|
||||
tracer: DecisionTracer,
|
||||
): (event: unknown, ctx: ExtensionContext) => Promise<ToolCallResult> {
|
||||
return async (event, ctx) => {
|
||||
try {
|
||||
const outcome = await gate(event, ctx);
|
||||
audit.recordDecision(outcome.action);
|
||||
tracer.debug("permission.decision", {
|
||||
toolName: bestEffortToolName(event),
|
||||
action: outcome.action,
|
||||
...(outcome.action === "block" ? { reason: outcome.reason } : {}),
|
||||
});
|
||||
return outcome.action === "block"
|
||||
? { block: true, reason: outcome.reason }
|
||||
: {};
|
||||
} catch (error) {
|
||||
recordGateError(reporter, audit, event, error);
|
||||
return { block: true, reason: formatGateErrorReason(error) };
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a gate error without ever throwing.
|
||||
*
|
||||
* The block below this must be reached: the SDK does not catch a throwing
|
||||
* handler, so an exception escaping the recording work would leave the command
|
||||
* ungated. The request id is minted here rather than borrowed — the throw may
|
||||
* have come from anywhere in the pipeline, so no gate's id is available.
|
||||
*/
|
||||
function recordGateError(
|
||||
reporter: DecisionReporter,
|
||||
audit: DecisionRecorder,
|
||||
event: unknown,
|
||||
error: unknown,
|
||||
): void {
|
||||
try {
|
||||
audit.recordError();
|
||||
const reason = errorMessage(error);
|
||||
reporter.writeReviewLog("permission_request.blocked", {
|
||||
requestId: createPermissionRequestId(),
|
||||
toolName: bestEffortToolName(event),
|
||||
command: bestEffortCommand(event),
|
||||
resolution: "gate_error",
|
||||
error: reason,
|
||||
// The boundary decided, by failing closed -- no rule and no human did.
|
||||
decidedBy: { kind: "gate_error", reason },
|
||||
});
|
||||
} catch {
|
||||
// The block is the guarantee; its bookkeeping is not.
|
||||
}
|
||||
}
|
||||
|
||||
// ── Defensive event readers (never throw) ──────────────────────────────────
|
||||
|
||||
/** Best-effort tool name from a raw event; never throws. */
|
||||
function bestEffortToolName(event: unknown): string {
|
||||
const record = toRecord(event);
|
||||
const name = record.name ?? record.toolName;
|
||||
return typeof name === "string" && name ? name : "<unknown>";
|
||||
}
|
||||
|
||||
/** Best-effort bash command from a raw event; never throws. */
|
||||
function bestEffortCommand(event: unknown): string | undefined {
|
||||
const record = toRecord(event);
|
||||
const input = toRecord(record.input ?? record.arguments);
|
||||
return typeof input.command === "string" ? input.command : undefined;
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
function formatGateErrorReason(error: unknown): string {
|
||||
return `Permission gate failed and blocked the tool call (fail-closed): ${errorMessage(error)}`;
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||
import { getAgentDir, getPackageDir } from "@earendil-works/pi-coding-agent";
|
||||
import { warmBashParser } from "./access-intent/bash/parser";
|
||||
import { buildResolvedIntentFromMatchValues } from "./access-intent/input-normalizer";
|
||||
import { AuthorizerRegistry } from "./authority/authorizer-registry";
|
||||
import { AuthorizerSelection } from "./authority/authorizer-selection";
|
||||
import {
|
||||
ForwardedRequestServer,
|
||||
type ServingPolicy,
|
||||
} from "./authority/forwarded-request-server";
|
||||
import {
|
||||
ForwardingLivenessJudge,
|
||||
ServingHeartbeatStore,
|
||||
} from "./authority/forwarding-liveness";
|
||||
import { ForwardingManager } from "./authority/forwarding-manager";
|
||||
import { PERMISSION_FORWARDING_TIMEOUT_MS } from "./authority/permission-forwarding";
|
||||
import { requestPermissionDecision } from "./authority/permission-prompt-component";
|
||||
import { PermissionPrompter } from "./authority/permission-prompter";
|
||||
import {
|
||||
composeServingAnnouncers,
|
||||
getServingSessionRegistry,
|
||||
} from "./authority/serving-registry";
|
||||
import { SubagentDetection } from "./authority/subagent-detection";
|
||||
import { subscribeSubagentLifecycle } from "./authority/subagent-lifecycle-events";
|
||||
import { getSubagentSessionRegistry } from "./authority/subagent-registry";
|
||||
import { registerBuiltinToolInputFormatters } from "./builtin-tool-input-formatters";
|
||||
import { registerPermissionSystemCommand } from "./config-modal";
|
||||
import { getGlobalConfigPath } from "./config-paths";
|
||||
import { ConfigStore } from "./config-store";
|
||||
import { DecisionAudit } from "./decision-audit";
|
||||
import { GateDecisionReporter } from "./decision-reporter";
|
||||
import { isYoloModeEnabled } from "./extension-config";
|
||||
import { computeExtensionPaths } from "./extension-paths";
|
||||
import {
|
||||
AgentPrepHandler,
|
||||
PermissionGateHandler,
|
||||
SessionLifecycleHandler,
|
||||
} from "./handlers";
|
||||
import { GateRunner } from "./handlers/gates/runner";
|
||||
import { SkillInputGatePipeline } from "./handlers/gates/skill-input-gate-pipeline";
|
||||
import { ToolCallGatePipeline } from "./handlers/gates/tool-call-gate-pipeline";
|
||||
import { createFailClosedToolCall } from "./handlers/tool-call-boundary";
|
||||
import { pathFlavorForPlatform } from "./path/path-flavor";
|
||||
import { PermissionManager } from "./permission-manager";
|
||||
import { PermissionResolver } from "./permission-resolver";
|
||||
import { PermissionSession } from "./permission-session";
|
||||
import { LocalPermissionsService } from "./permissions-service";
|
||||
import { resolveRenderBudget } from "./presentation/dialog-renderer";
|
||||
import { PermissionServiceLifecycle } from "./service-lifecycle";
|
||||
import { PermissionSessionLogger } from "./session-logger";
|
||||
import { SessionRules } from "./session-rules";
|
||||
import { ToolAccessExtractorRegistry } from "./tool-access-extractor-registry";
|
||||
import { ToolInputFormatterRegistry } from "./tool-input-formatter-registry";
|
||||
|
||||
export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
||||
const agentDir = getAgentDir();
|
||||
// getPackageDir() is Pi's own install dir; auto-allow it for read-only tools
|
||||
// so the agent can read Pi's bundled docs/examples regardless of layout.
|
||||
const paths = computeExtensionPaths(agentDir, getPackageDir());
|
||||
// The single process.platform read for the whole extension, resolved once
|
||||
// into the path-language flavor that every consumer shares (the session's
|
||||
// PathNormalizer, rule evaluation, and subagent detection). Interior modules
|
||||
// must not read process.platform (enforced by the eslint guard scoped to
|
||||
// src/) and never re-derive the win32 flavor — they receive this product.
|
||||
const hostFlavor = pathFlavorForPlatform(process.platform);
|
||||
const sessionRules = new SessionRules();
|
||||
const subagentRegistry = getSubagentSessionRegistry();
|
||||
// Process-global, like subagentRegistry: an in-process child reads it from a
|
||||
// separate jiti instance to learn whether its parent is draining its inbox.
|
||||
const servingRegistry = getServingSessionRegistry();
|
||||
// Single owner of subagent detection, shared across every consumer instead of
|
||||
// threading the (subagentSessionsDir, platform, registry) triple into each.
|
||||
const subagentDetection = new SubagentDetection({
|
||||
subagentSessionsDir: paths.subagentSessionsDir,
|
||||
flavor: hostFlavor,
|
||||
registry: subagentRegistry,
|
||||
});
|
||||
const formatterRegistry = new ToolInputFormatterRegistry();
|
||||
registerBuiltinToolInputFormatters(formatterRegistry);
|
||||
const accessExtractorRegistry = new ToolAccessExtractorRegistry();
|
||||
// One registry instance backs both the registerAuthorizer service surface and
|
||||
// AuthorizerSelection's chain resolution, so a registration is visible to
|
||||
// composition.
|
||||
const authorizerRegistry = new AuthorizerRegistry();
|
||||
|
||||
// Both `configStore` and `session` are forward-declared so the logger's
|
||||
// lazy thunks can close over them without a cast or null-init holder.
|
||||
// TypeScript exempts closure captures from definite-assignment analysis;
|
||||
// all synchronous reads occur after the assignments below.
|
||||
// eslint-disable-next-line prefer-const -- forward-declared let; `const` requires an initializer
|
||||
let configStore: ConfigStore;
|
||||
// eslint-disable-next-line prefer-const -- forward-declared let; `const` requires an initializer
|
||||
let session: PermissionSession;
|
||||
|
||||
// Declared after the `configStore` forward declaration so the reader can
|
||||
// close over it; every call runs after configStore is assigned below. yolo is
|
||||
// a composition-stage ask→allow rewrite (#526) that the gate runner extends
|
||||
// to asks synthesized after resolution (#712), so both share this reader.
|
||||
const isYoloEnabled = (): boolean => isYoloModeEnabled(configStore.current());
|
||||
|
||||
const permissionManager = new PermissionManager({
|
||||
agentDir,
|
||||
flavor: hostFlavor,
|
||||
isYoloEnabled,
|
||||
});
|
||||
|
||||
const logger = new PermissionSessionLogger({
|
||||
globalLogsDir: paths.globalLogsDir,
|
||||
getConfig: () => configStore.current(),
|
||||
notify: (message) => session.notify(message),
|
||||
});
|
||||
|
||||
configStore = new ConfigStore({
|
||||
agentDir,
|
||||
policyPaths: permissionManager,
|
||||
logger,
|
||||
});
|
||||
|
||||
const prompter = new PermissionPrompter({ logger });
|
||||
|
||||
// The filesystem half of the serving announcement. `servingRegistry` reaches
|
||||
// an in-process child through `globalThis`; a child in its own process shares
|
||||
// nothing but this directory, so the served session publishes a heartbeat
|
||||
// there too (#721).
|
||||
const servingHeartbeats = new ServingHeartbeatStore({
|
||||
forwardingDir: paths.forwardingDir,
|
||||
logger,
|
||||
});
|
||||
// The read side of both channels, routed by how the target was resolved.
|
||||
const servingLiveness = new ForwardingLivenessJudge({
|
||||
registry: servingRegistry,
|
||||
heartbeats: servingHeartbeats,
|
||||
});
|
||||
|
||||
const authorizerSelection = new AuthorizerSelection({
|
||||
detection: subagentDetection,
|
||||
events: pi.events,
|
||||
getPromptPreferences: () => ({
|
||||
doublePressToConfirm: configStore.current().doublePressToConfirm,
|
||||
budget: resolveRenderBudget(configStore.current()),
|
||||
}),
|
||||
requestPermissionDecision,
|
||||
forwardingDir: paths.forwardingDir,
|
||||
registry: subagentRegistry,
|
||||
serving: servingLiveness,
|
||||
getForwardingTimeoutMs: () =>
|
||||
configStore.current().forwardingTimeoutMs ??
|
||||
PERMISSION_FORWARDING_TIMEOUT_MS,
|
||||
logger,
|
||||
prompter,
|
||||
// The published service is the narrow, session-scoped PermissionQuery a
|
||||
// chain link is handed (it routes bash/path at gate parity against the live
|
||||
// session cwd). A thunk because `permissionsService` is constructed below;
|
||||
// it resolves at session_start (activate), well after assignment.
|
||||
getPermissionQuery: () => permissionsService,
|
||||
// Same registry instance the registerAuthorizer service surface writes to,
|
||||
// resolved in config order at activation.
|
||||
authorizerRegistry,
|
||||
getAuthorizerChain: () => configStore.current().authorizerChain ?? [],
|
||||
});
|
||||
|
||||
// Resolver composes the manager + session ruleset and owns the
|
||||
// access-path → path-values unwrap. Constructed here (before `session`) so
|
||||
// the forwarded-request server's ServingPolicy can resolve against it; the
|
||||
// service and gates below share this one instance.
|
||||
const resolver = new PermissionResolver(permissionManager, sessionRules);
|
||||
|
||||
// Serving a forwarded request is resolution: resolve the child-fixed
|
||||
// ForwardedAccessIntent (ADR 0008) directly against the serving node's
|
||||
// composed ruleset, agent-scoped to the requester (§3) — the match values
|
||||
// are used as fixed by the child, never re-derived through this session's
|
||||
// PathNormalizer/cwd (#597).
|
||||
const servingPolicy: ServingPolicy = {
|
||||
resolve: (intent) =>
|
||||
resolver.resolve(
|
||||
buildResolvedIntentFromMatchValues(
|
||||
intent.surface,
|
||||
intent.matchValues,
|
||||
intent.principal.agentName,
|
||||
),
|
||||
),
|
||||
};
|
||||
|
||||
const requestServer = new ForwardedRequestServer({
|
||||
forwardingDir: paths.forwardingDir,
|
||||
logger,
|
||||
policy: servingPolicy,
|
||||
escalator: authorizerSelection,
|
||||
// Records a whole-session grant into the same SessionRules the resolver and
|
||||
// gate runner read, so a serving-scope grant governs the parent and future
|
||||
// forwarded resolutions.
|
||||
recorder: sessionRules,
|
||||
registry: subagentRegistry,
|
||||
});
|
||||
|
||||
session = new PermissionSession(
|
||||
paths,
|
||||
new ForwardingManager({
|
||||
detection: subagentDetection,
|
||||
forwarder: requestServer,
|
||||
serving: composeServingAnnouncers(servingRegistry, servingHeartbeats),
|
||||
logger,
|
||||
}),
|
||||
permissionManager,
|
||||
sessionRules,
|
||||
configStore,
|
||||
authorizerSelection,
|
||||
hostFlavor,
|
||||
);
|
||||
|
||||
// refresh() must run after `session` is assigned: a debug-write IO failure
|
||||
// triggers the logger's notify sink — `session.notify(m)` — which no-ops
|
||||
// on the null context but requires `session` to be bound.
|
||||
// No ctx/trust decision exists at factory init, so withhold the project
|
||||
// scope (fail closed); session_start reloads with the real trust decision.
|
||||
configStore.refresh(undefined, false);
|
||||
|
||||
const configPath = getGlobalConfigPath(agentDir);
|
||||
registerPermissionSystemCommand(pi, {
|
||||
config: configStore,
|
||||
configPath,
|
||||
getActiveAgentConfigRules: () =>
|
||||
permissionManager.getComposedConfigRules(
|
||||
session.lastKnownActiveAgentName ?? undefined,
|
||||
),
|
||||
});
|
||||
|
||||
const permissionsService = new LocalPermissionsService(
|
||||
resolver,
|
||||
session,
|
||||
formatterRegistry,
|
||||
accessExtractorRegistry,
|
||||
authorizerRegistry,
|
||||
);
|
||||
|
||||
// Subscribe to @gotgenes/pi-subagents' child lifecycle events so child
|
||||
// sessions register/unregister without the core calling us (ADR 0002).
|
||||
const unsubSubagentLifecycle = subscribeSubagentLifecycle(
|
||||
pi.events,
|
||||
subagentRegistry,
|
||||
);
|
||||
|
||||
// PermissionServiceLifecycle owns the process-global service publication:
|
||||
// activate() publishes (skipped for registered subagent children — see #302)
|
||||
// and emits ready; teardown() unsubscribes all session listeners and
|
||||
// unpublishes. Deferred to session_start because identifying a child
|
||||
// requires the session id from ctx, unavailable at factory-init time.
|
||||
const serviceLifecycle = new PermissionServiceLifecycle(
|
||||
permissionsService,
|
||||
subagentDetection,
|
||||
pi.events,
|
||||
[unsubSubagentLifecycle],
|
||||
);
|
||||
|
||||
const toolRegistry = {
|
||||
getAll: () => pi.getAllTools(),
|
||||
getActive: () => pi.getActiveTools(),
|
||||
setActive: (names: string[]) => pi.setActiveTools(names),
|
||||
};
|
||||
|
||||
const audit = new DecisionAudit();
|
||||
const lifecycle = new SessionLifecycleHandler(
|
||||
session,
|
||||
resolver,
|
||||
serviceLifecycle,
|
||||
logger,
|
||||
audit,
|
||||
);
|
||||
const agentPrep = new AgentPrepHandler(
|
||||
session,
|
||||
resolver,
|
||||
toolRegistry,
|
||||
() => {
|
||||
void warmBashParser();
|
||||
},
|
||||
);
|
||||
|
||||
const reporter = new GateDecisionReporter(logger, pi.events);
|
||||
const gateRunner = new GateRunner(
|
||||
resolver,
|
||||
sessionRules,
|
||||
authorizerSelection,
|
||||
reporter,
|
||||
isYoloEnabled,
|
||||
);
|
||||
const toolCallGatePipeline = new ToolCallGatePipeline(
|
||||
resolver,
|
||||
session,
|
||||
formatterRegistry,
|
||||
accessExtractorRegistry,
|
||||
);
|
||||
const skillInputGatePipeline = new SkillInputGatePipeline(resolver);
|
||||
const gates = new PermissionGateHandler(
|
||||
session,
|
||||
toolRegistry,
|
||||
toolCallGatePipeline,
|
||||
skillInputGatePipeline,
|
||||
gateRunner,
|
||||
);
|
||||
|
||||
pi.on("session_start", (event, ctx) =>
|
||||
lifecycle.handleSessionStart(event, ctx),
|
||||
);
|
||||
pi.on("resources_discover", (event, ctx) =>
|
||||
lifecycle.handleResourcesDiscover(event, ctx),
|
||||
);
|
||||
pi.on("session_shutdown", () => lifecycle.handleSessionShutdown());
|
||||
pi.on("before_agent_start", (event, ctx) => agentPrep.handle(event, ctx));
|
||||
pi.on("input", (event, ctx) => gates.handleInput(event, ctx));
|
||||
pi.on(
|
||||
"tool_call",
|
||||
createFailClosedToolCall(
|
||||
(event, ctx) => gates.handleToolCall(event, ctx),
|
||||
reporter,
|
||||
audit,
|
||||
logger,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* JSON serialization that survives the values a permission log actually
|
||||
* carries: `Error` instances, `bigint`s, and object graphs with cycles.
|
||||
*
|
||||
* Lives apart from the JSONL writer because both the log path and the
|
||||
* permission-prompt path serialize tool input, and only one of them redacts.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Rewrites a value before the standard JSON-safe handling runs.
|
||||
* Returning a replacement short-circuits nothing — the replacement itself
|
||||
* flows through the `Error` / `bigint` / cycle handling below.
|
||||
*/
|
||||
export type JsonValueTransform = (key: string, value: unknown) => unknown;
|
||||
|
||||
/**
|
||||
* Build a `JSON.stringify` replacer. Each call owns a fresh `seen` set, so a
|
||||
* replacer must not be reused across `stringify` calls.
|
||||
*/
|
||||
export function createJsonSafeReplacer(
|
||||
transform?: JsonValueTransform,
|
||||
): (key: string, value: unknown) => unknown {
|
||||
const seen = new WeakSet<object>();
|
||||
|
||||
return (key: string, rawValue: unknown): unknown => {
|
||||
const value = transform ? transform(key, rawValue) : rawValue;
|
||||
|
||||
if (value instanceof Error) {
|
||||
return {
|
||||
name: value.name,
|
||||
message: value.message,
|
||||
stack: value.stack,
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof value === "bigint") {
|
||||
return value.toString();
|
||||
}
|
||||
|
||||
if (typeof value === "object" && value !== null) {
|
||||
if (seen.has(value)) {
|
||||
return "[Circular]";
|
||||
}
|
||||
seen.add(value);
|
||||
}
|
||||
|
||||
return value;
|
||||
};
|
||||
}
|
||||
|
||||
/** Serialize `value` to JSON, tolerating errors, bigints, and cycles. */
|
||||
export function safeJsonStringify(value: unknown): string | undefined {
|
||||
return JSON.stringify(value, createJsonSafeReplacer());
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* The permission review log's width bound (ADR 0011 §6).
|
||||
*
|
||||
* The log renders the prompt payload under its own configured limits, and this
|
||||
* is the limit: every string it writes is narrowed to a configured width. The
|
||||
* bound is applied at `writeLine`, the single place a log line is produced, so
|
||||
* a write path cannot be added that escapes it — the same discipline redaction
|
||||
* already has there.
|
||||
*
|
||||
* A cap is not redaction, and the two must not be conflated
|
||||
* (`docs/decisions/0010-permission-log-secret-exposure.md`). This narrows by
|
||||
* length alone and never reads a value to decide what to shorten; redaction
|
||||
* masks a value because of the key name it is bound to, and still does, so a
|
||||
* sensitive-keyed value is masked whole however long it was.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The width when the operator configures none.
|
||||
*
|
||||
* Not a new number: it is the bound that already governed `toolInputPreview`,
|
||||
* promoted from one field to every field so the log has one limit rather than
|
||||
* one limit and an unbounded remainder.
|
||||
*/
|
||||
export const DEFAULT_REVIEW_LOG_FIELD_MAX_WIDTH = 1000;
|
||||
|
||||
/** The two-field shape this module reads off the extension config. */
|
||||
export interface ReviewLogWidthConfig {
|
||||
readonly reviewLogFieldMaxWidth?: number;
|
||||
}
|
||||
|
||||
/** The configured review-log field width, or the built-in default. */
|
||||
export function resolveReviewLogFieldWidth(
|
||||
config: ReviewLogWidthConfig,
|
||||
): number {
|
||||
return config.reviewLogFieldMaxWidth ?? DEFAULT_REVIEW_LOG_FIELD_MAX_WIDTH;
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrow every string in a log-detail record to `maxWidth`.
|
||||
*
|
||||
* Recurses through plain objects and arrays so a nested detail is bounded too,
|
||||
* and touches strings only — a number, a boolean, or a null passes through as
|
||||
* it was. A shortened value is marked with a bare ellipsis, the same marker the
|
||||
* dialog uses: a character count is a number the reader cannot act on
|
||||
* (ADR 0011 §4).
|
||||
*/
|
||||
export function capLogFieldWidths<T>(details: T, maxWidth: number): T {
|
||||
return capValue(details, maxWidth) as T;
|
||||
}
|
||||
|
||||
function capValue(value: unknown, maxWidth: number): unknown {
|
||||
if (typeof value === "string") {
|
||||
return value.length <= maxWidth
|
||||
? value
|
||||
: `${value.slice(0, maxWidth)}\u2026`;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((entry) => capValue(entry, maxWidth));
|
||||
}
|
||||
if (isPlainObject(value)) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).map(([key, entry]) => [
|
||||
key,
|
||||
capValue(entry, maxWidth),
|
||||
]),
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a value is a record this cap should descend into.
|
||||
*
|
||||
* A class instance (a `Date`, an `Error`) is left alone: rebuilding it as a
|
||||
* plain object would change what the writer serializes, and the cap's job is
|
||||
* to shorten strings, not to reshape a value.
|
||||
*/
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
if (typeof value !== "object" || value === null) return false;
|
||||
const prototype: unknown = Object.getPrototypeOf(value);
|
||||
return prototype === Object.prototype || prototype === null;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { chmodSync } from "node:fs";
|
||||
|
||||
/**
|
||||
* Owner-only POSIX modes for the extension's on-disk artifacts.
|
||||
*
|
||||
* The permission logs record bash command strings and tool-input previews, and
|
||||
* the forwarding files carry the same text between sessions. Left to the
|
||||
* process umask they are created world-readable (0644 / 0755 under the common
|
||||
* default), which is only acceptable on a single-user host.
|
||||
*/
|
||||
|
||||
export const OWNER_ONLY_FILE_MODE = 0o600;
|
||||
export const OWNER_ONLY_DIRECTORY_MODE = 0o700;
|
||||
|
||||
/**
|
||||
* Best-effort tightening of an existing path's mode.
|
||||
*
|
||||
* Creation-time modes cover new files, but an installation that predates this
|
||||
* hardening already has a world-readable log that no `mode` option will fix —
|
||||
* hence the explicit `chmod`.
|
||||
*
|
||||
* Never throws, and never reports. On Windows `chmod` only toggles the
|
||||
* read-only bit and can reject a directory outright; warning about that every
|
||||
* session would be noise, since the file there is governed by NTFS ACL
|
||||
* inheritance rather than POSIX modes. A hardening failure must also never
|
||||
* break the gate, which is the caller's real work.
|
||||
*/
|
||||
export function restrictExistingPathToOwner(path: string, mode: number): void {
|
||||
try {
|
||||
chmodSync(path, mode);
|
||||
} catch {
|
||||
// Intentionally ignored — see above.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { createJsonSafeReplacer } from "./json-safe-stringify";
|
||||
|
||||
/**
|
||||
* Key-name redaction for the permission logs.
|
||||
*
|
||||
* The technique is deliberately structural rather than predictive: a value is
|
||||
* masked because of the *name* it is bound to, never because of what it looks
|
||||
* like. Value-shape secret detection (provider prefixes, entropy heuristics)
|
||||
* was measured against a real 6.7 MB review log and declined — see
|
||||
* `docs/decisions/0010-permission-log-secret-exposure.md`.
|
||||
*
|
||||
* The boundary that follows from this, stated once: a value bound to a
|
||||
* sensitive key name is masked; a secret embedded in a bash command string is
|
||||
* not, because a command string has no keys.
|
||||
*/
|
||||
|
||||
export const REDACTED_PLACEHOLDER = "[redacted]";
|
||||
|
||||
const SENSITIVE_KEY_PATTERN =
|
||||
/authorization|api[-_]?key|secret|token|password|passwd|credential|cookie|private[-_]?key/i;
|
||||
|
||||
/** True when a log key names a credential-bearing value. */
|
||||
export function isSensitiveLogKey(key: string): boolean {
|
||||
return SENSITIVE_KEY_PATTERN.test(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* `safeJsonStringify` with sensitive-keyed values masked.
|
||||
*
|
||||
* Masking runs inside the replacer, so the structure beneath a sensitive key
|
||||
* is never visited and the traversal's existing cycle guard is reused — one
|
||||
* walk, not two. A `null` or `undefined` value is left alone so an absent
|
||||
* field does not read as a suppressed one.
|
||||
*/
|
||||
export function redactedJsonStringify(value: unknown): string | undefined {
|
||||
return JSON.stringify(
|
||||
value,
|
||||
createJsonSafeReplacer((key, currentValue) =>
|
||||
currentValue != null && isSensitiveLogKey(key)
|
||||
? REDACTED_PLACEHOLDER
|
||||
: currentValue,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { appendFileSync } from "node:fs";
|
||||
|
||||
import {
|
||||
EXTENSION_ID,
|
||||
type PermissionSystemExtensionConfig,
|
||||
} from "./extension-config";
|
||||
import { capLogFieldWidths, resolveReviewLogFieldWidth } from "./log-field-cap";
|
||||
import {
|
||||
OWNER_ONLY_FILE_MODE,
|
||||
restrictExistingPathToOwner,
|
||||
} from "./log-file-permissions";
|
||||
import { redactedJsonStringify } from "./log-redaction";
|
||||
|
||||
export interface PermissionSystemLogger {
|
||||
debug: (
|
||||
event: string,
|
||||
details?: Record<string, unknown>,
|
||||
) => string | undefined;
|
||||
review: (
|
||||
event: string,
|
||||
details?: Record<string, unknown>,
|
||||
) => string | undefined;
|
||||
}
|
||||
|
||||
interface PermissionSystemLoggerOptions {
|
||||
getConfig: () => PermissionSystemExtensionConfig;
|
||||
debugLogPath: string;
|
||||
reviewLogPath: string;
|
||||
ensureLogsDirectory: () => string | undefined;
|
||||
}
|
||||
|
||||
export function createPermissionSystemLogger(
|
||||
options: PermissionSystemLoggerOptions,
|
||||
): PermissionSystemLogger {
|
||||
const { debugLogPath, reviewLogPath, ensureLogsDirectory } = options;
|
||||
// Per-session, so a log inherited from an earlier version is tightened once
|
||||
// rather than on every line. Lives in the closure because the factory is
|
||||
// re-invoked per session, unlike module scope, which now outlives one.
|
||||
const hardened = new Set<string>();
|
||||
|
||||
/**
|
||||
* The only place a log line is produced.
|
||||
*
|
||||
* `maxFieldWidth` bounds every string the line carries; it is supplied for
|
||||
* the review stream and withheld for the debug stream, which is opt-in and
|
||||
* exists to be read in full. Capping happens before redaction, which masks
|
||||
* by key name and so still masks a sensitive value whole.
|
||||
*/
|
||||
const writeLine = (
|
||||
stream: "debug" | "review",
|
||||
path: string,
|
||||
event: string,
|
||||
details: Record<string, unknown>,
|
||||
maxFieldWidth?: number,
|
||||
): string | undefined => {
|
||||
const directoryError = ensureLogsDirectory();
|
||||
if (directoryError) {
|
||||
return directoryError;
|
||||
}
|
||||
|
||||
try {
|
||||
const bounded =
|
||||
maxFieldWidth === undefined
|
||||
? details
|
||||
: capLogFieldWidths(details, maxFieldWidth);
|
||||
const line = redactedJsonStringify({
|
||||
timestamp: new Date().toISOString(),
|
||||
extension: EXTENSION_ID,
|
||||
stream,
|
||||
event,
|
||||
...bounded,
|
||||
});
|
||||
if (!line) {
|
||||
return `Failed to write permission-system ${stream} log '${path}': event could not be serialized.`;
|
||||
}
|
||||
appendFileSync(path, `${line}\n`, {
|
||||
encoding: "utf-8",
|
||||
mode: OWNER_ONLY_FILE_MODE,
|
||||
});
|
||||
if (!hardened.has(path)) {
|
||||
hardened.add(path);
|
||||
restrictExistingPathToOwner(path, OWNER_ONLY_FILE_MODE);
|
||||
}
|
||||
return undefined;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return `Failed to write permission-system ${stream} log '${path}': ${message}`;
|
||||
}
|
||||
};
|
||||
|
||||
const debug = (
|
||||
event: string,
|
||||
details: Record<string, unknown> = {},
|
||||
): string | undefined => {
|
||||
if (!options.getConfig().debugLog) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return writeLine("debug", debugLogPath, event, details);
|
||||
};
|
||||
|
||||
const review = (
|
||||
event: string,
|
||||
details: Record<string, unknown> = {},
|
||||
): string | undefined => {
|
||||
const config = options.getConfig();
|
||||
if (!config.permissionReviewLog) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return writeLine(
|
||||
"review",
|
||||
reviewLogPath,
|
||||
event,
|
||||
details,
|
||||
resolveReviewLogFieldWidth(config),
|
||||
);
|
||||
};
|
||||
|
||||
return { debug, review };
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { basename, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
/**
|
||||
* Walk up the directory tree from the given file URL until a directory
|
||||
* literally named `node_modules` is found.
|
||||
*
|
||||
* Returns the `node_modules` path, or `null` if the URL cannot be parsed or
|
||||
* no `node_modules` ancestor exists.
|
||||
*/
|
||||
function walkUpToNodeModules(fromUrl: string): string | null {
|
||||
try {
|
||||
const thisFile = fileURLToPath(fromUrl);
|
||||
let dir = dirname(thisFile);
|
||||
while (dir !== dirname(dir)) {
|
||||
if (basename(dir) === "node_modules") {
|
||||
return dir;
|
||||
}
|
||||
dir = dirname(dir);
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `npm root -g` synchronously and return the trimmed output, or `null` on
|
||||
* any failure (non-zero exit, ENOENT, timeout, non-existent path).
|
||||
*
|
||||
* Only called when the walk-up-from-self strategy fails (i.e. the extension is
|
||||
* running from a local development checkout, not a global install).
|
||||
*/
|
||||
function discoverGlobalNodeModulesViaSubprocess(): string | null {
|
||||
try {
|
||||
const result = spawnSync("npm", ["root", "-g"], {
|
||||
encoding: "utf-8",
|
||||
timeout: 5000,
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
});
|
||||
const root = result.stdout.trim();
|
||||
if (result.status === 0 && root && existsSync(root)) {
|
||||
return root;
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover the global node_modules root.
|
||||
*
|
||||
* Strategy 1 (zero-cost, covers all global installs): walk up from
|
||||
* `fromUrl` (defaults to this module's own `import.meta.url`) looking for a
|
||||
* directory named `node_modules`. This works whenever the extension is
|
||||
* installed inside a `node_modules` tree.
|
||||
*
|
||||
* Strategy 2 (subprocess fallback, dev checkout only): when Strategy 1 fails
|
||||
* because the extension is running from a local development checkout with no
|
||||
* `node_modules` ancestor, run `npm root -g` to discover the global root.
|
||||
* Pi installs skills and extensions via `npm` by default, so `npm root -g`
|
||||
* returns the correct root regardless of the user's own project package
|
||||
* manager.
|
||||
*
|
||||
* Returns `null` when both strategies fail — callers must degrade gracefully.
|
||||
*/
|
||||
export function discoverGlobalNodeModulesRoot(
|
||||
fromUrl = import.meta.url,
|
||||
): string | null {
|
||||
const fromSelf = walkUpToNodeModules(fromUrl);
|
||||
if (fromSelf) return fromSelf;
|
||||
return discoverGlobalNodeModulesViaSubprocess();
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { Rule, Ruleset } from "./rule";
|
||||
import type { FlatPermissionConfig } from "./types";
|
||||
import { isDenyWithReason, isPermissionState } from "./types";
|
||||
|
||||
/**
|
||||
* Convert a flat permission config into a Ruleset.
|
||||
*
|
||||
* Each key is a surface name. A string value is shorthand for
|
||||
* `{ "*": action }`. An object value maps patterns to actions.
|
||||
* A pattern value may be a PermissionState string or a `DenyWithReason`
|
||||
* object (`{ action: "deny", reason?: string }`).
|
||||
* Invalid action values are silently skipped.
|
||||
*
|
||||
* The universal fallback key `"*"` is included if present — callers
|
||||
* that use `"*"` only for `synthesizeDefaults()` should strip it before
|
||||
* calling this function.
|
||||
*/
|
||||
export function normalizeFlatConfig(permission: FlatPermissionConfig): Ruleset {
|
||||
const rules: Rule[] = [];
|
||||
for (const [surface, value] of Object.entries(permission)) {
|
||||
if (typeof value === "string") {
|
||||
if (isPermissionState(value)) {
|
||||
rules.push({ surface, pattern: "*", action: value, origin: "builtin" });
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- defensive null check; value type does not include null but runtime JSON may
|
||||
} else if (typeof value === "object" && value !== null) {
|
||||
for (const [pattern, action] of Object.entries(value)) {
|
||||
if (isDenyWithReason(action)) {
|
||||
rules.push({
|
||||
surface,
|
||||
pattern,
|
||||
action: "deny",
|
||||
reason: action.reason,
|
||||
origin: "builtin",
|
||||
});
|
||||
} else if (isPermissionState(action)) {
|
||||
rules.push({ surface, pattern, action, origin: "builtin" });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return rules;
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
import { lstatSync } from "node:fs";
|
||||
|
||||
import type { PathFlavor } from "#src/path/path-flavor";
|
||||
|
||||
import { AccessPath } from "./access-intent/access-path";
|
||||
import {
|
||||
canonicalNormalizePathForComparison,
|
||||
normalizePathForComparison,
|
||||
normalizePathPolicyLiteral,
|
||||
} from "./access-intent/path-normalization";
|
||||
import { isPathOutsideWorkingDirectory } from "./path/path-containment";
|
||||
import { isPiInfrastructureRead } from "./path/pi-infrastructure-read";
|
||||
|
||||
/**
|
||||
* The interpreted effect of a literal `cd` target on the effective base, under
|
||||
* the host platform's (and, on win32, Git Bash's) semantics.
|
||||
*
|
||||
* - `absolute` — the target names a resolvable absolute base (`value`); an
|
||||
* earlier unknown base is recovered.
|
||||
* - `relative` — the target folds into the current base.
|
||||
* - `unknown` — the target is not deterministically resolvable (a win32
|
||||
* non-mount POSIX absolute like `cd /tmp`, or a device), so the base becomes
|
||||
* conservatively unknown.
|
||||
*/
|
||||
export type BashCdTarget =
|
||||
| { readonly kind: "absolute"; readonly value: string }
|
||||
| { readonly kind: "relative" }
|
||||
| { readonly kind: "unknown" };
|
||||
|
||||
/**
|
||||
* Path-interpretation collaborator, constructed once at the session edge with
|
||||
* the two ambient inputs — the resolved {@link PathFlavor} and the session
|
||||
* `cwd` — baked in, and handed raw path tokens thereafter.
|
||||
*
|
||||
* The bash path pipeline and the per-tool/external-directory gates ask this
|
||||
* object the platform-dependent questions ("is this path absolute *under our
|
||||
* flavor*?", "resolve this `cd` offset *against our cwd*") and receive prepared
|
||||
* {@link AccessPath} values, instead of reading `process.platform` ambiently or
|
||||
* threading `cwd` through every call. All platform semantics live on the
|
||||
* injected `flavor`; this class holds no platform discriminator and no
|
||||
* `win32`/`posix` branch — it delegates to `flavor` and the flavor-parameterized
|
||||
* `path-containment` / `path-normalization` / `AccessPath` primitives.
|
||||
*/
|
||||
export class PathNormalizer {
|
||||
/** Canonical form of the baked cwd, resolved once (the symlink target is stable per session). */
|
||||
private readonly canonicalCwd: string;
|
||||
|
||||
constructor(
|
||||
readonly flavor: PathFlavor,
|
||||
private readonly cwd: string,
|
||||
) {
|
||||
this.canonicalCwd = canonicalNormalizePathForComparison(cwd, cwd, flavor);
|
||||
}
|
||||
|
||||
/** Build an AccessPath for a token, resolved against `resolveBase` (default cwd). */
|
||||
forPath(pathValue: string, options?: { resolveBase?: string }): AccessPath {
|
||||
return AccessPath.forPath(pathValue, {
|
||||
cwd: this.cwd,
|
||||
resolveBase: options?.resolveBase,
|
||||
flavor: this.flavor,
|
||||
});
|
||||
}
|
||||
|
||||
/** Build a literal-only AccessPath (unknown base after a non-literal `cd`). */
|
||||
forLiteral(literal: string): AccessPath {
|
||||
return AccessPath.forLiteral(literal);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an AccessPath for a bash-command token, applying Git Bash/MSYS
|
||||
* semantics on a win32 host.
|
||||
*
|
||||
* Pi core always executes bash through Git Bash on Windows, so a POSIX-shaped
|
||||
* absolute token carries MSYS semantics, not `node:path.win32` semantics. The
|
||||
* flavor classifies the token's shape: on win32 the recognized safe device
|
||||
* paths (`/dev/null`, `/dev/std{in,out,err}`) are preserved verbatim as
|
||||
* devices instead of being resolved into `c:\dev\null`, and MSYS drive mounts
|
||||
* (`/c/…`) are translated to their Windows equivalent (`C:\…`) before
|
||||
* resolution; every other token delegates to {@link forPath}. On POSIX every
|
||||
* token is `plain`, so this is a straight delegation to {@link forPath}.
|
||||
*/
|
||||
forBashToken(token: string, options?: { resolveBase?: string }): AccessPath {
|
||||
const shape = this.flavor.bashTokenShape(token);
|
||||
switch (shape.kind) {
|
||||
case "device":
|
||||
return AccessPath.forDevice(token);
|
||||
case "drive-mount":
|
||||
return this.forPath(shape.windowsPath, options);
|
||||
case "posix-absolute":
|
||||
// A non-mount POSIX absolute (`/tmp`, `/usr`) has an install-dependent
|
||||
// Windows target this package cannot know, so it is kept literal: always
|
||||
// external, matched and displayed as typed, never fabricated into
|
||||
// `c:\tmp` (#533). The win32 path matcher folds separators on both the
|
||||
// rule and the value (#653), so a natural `/tmp/*` rule matches the
|
||||
// as-typed literal directly.
|
||||
return this.forLiteral(normalizePathPolicyLiteral(token));
|
||||
case "plain":
|
||||
return this.forPath(token, options);
|
||||
}
|
||||
}
|
||||
|
||||
/** Platform-aware absoluteness (`win32` vs `posix` rules). */
|
||||
isAbsolute(pathValue: string): boolean {
|
||||
return this.flavor.impl.isAbsolute(pathValue);
|
||||
}
|
||||
|
||||
/**
|
||||
* Interpret a literal `cd` target's effect on the effective base.
|
||||
*
|
||||
* On win32 the target carries Git Bash/MSYS semantics: a drive mount
|
||||
* (`cd /c/x`) resolves to a translated Windows base (`C:\x`), a non-mount
|
||||
* POSIX absolute (`cd /tmp`) is not deterministically resolvable and yields an
|
||||
* `unknown` base, and a native/relative target is handled as usual. On POSIX
|
||||
* every token is `plain`, so an absolute target is absolute and everything
|
||||
* else is relative.
|
||||
*/
|
||||
interpretBashCdTarget(target: string): BashCdTarget {
|
||||
const shape = this.flavor.bashTokenShape(target);
|
||||
switch (shape.kind) {
|
||||
case "drive-mount":
|
||||
return { kind: "absolute", value: shape.windowsPath };
|
||||
case "device":
|
||||
case "posix-absolute":
|
||||
return { kind: "unknown" };
|
||||
case "plain":
|
||||
return this.flavor.impl.isAbsolute(target)
|
||||
? { kind: "absolute", value: target }
|
||||
: { kind: "relative" };
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve a `cd`-folded offset against the baked cwd (platform-aware). */
|
||||
resolveBase(offset: string): string {
|
||||
return this.flavor.impl.resolve(this.cwd, offset);
|
||||
}
|
||||
|
||||
/** Join a `cd` offset with a relative target (platform-aware), for cd-folding. */
|
||||
joinBase(offset: string, target: string): string {
|
||||
return this.flavor.impl.join(offset, target);
|
||||
}
|
||||
|
||||
/** Containment of `pathValue` within `directory` (platform-aware). */
|
||||
isWithinDirectory(pathValue: string, directory: string): boolean {
|
||||
return this.flavor.isWithin(pathValue, directory);
|
||||
}
|
||||
|
||||
/** Canonical (symlink-resolved) outside-cwd test against the baked cwd. */
|
||||
isOutsideWorkingDirectory(pathValue: string): boolean {
|
||||
const canonicalPath = canonicalNormalizePathForComparison(
|
||||
pathValue,
|
||||
this.cwd,
|
||||
this.flavor,
|
||||
);
|
||||
return isPathOutsideWorkingDirectory(
|
||||
canonicalPath,
|
||||
this.canonicalCwd,
|
||||
this.flavor,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Outside-cwd test for an already-canonical boundary value (from
|
||||
* {@link AccessPath.boundaryValue}), against the baked cwd.
|
||||
*
|
||||
* Unlike {@link isOutsideWorkingDirectory}, it does not re-derive the
|
||||
* canonical form — the caller passes a value the {@link AccessPath} already
|
||||
* canonicalized, so a device's preserved `/dev/null` reaches the pure check's
|
||||
* `isSafeSystemPath` exclusion intact.
|
||||
*/
|
||||
isBoundaryOutsideWorkingDirectory(canonicalPath: string): boolean {
|
||||
return isPathOutsideWorkingDirectory(
|
||||
canonicalPath,
|
||||
this.canonicalCwd,
|
||||
this.flavor,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lexical (not symlink-resolved) comparison value, resolved against the baked
|
||||
* cwd. Mirrors the as-typed absolute form used for skill-prompt matching;
|
||||
* touches no filesystem, unlike {@link forPath}'s canonical alias.
|
||||
*/
|
||||
comparableValue(pathValue: string): string {
|
||||
return normalizePathForComparison(pathValue, this.cwd, this.flavor);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pi infrastructure-read containment for a read-only tool, decided against
|
||||
* the canonical (symlink-resolved) path and the baked cwd/flavor. Takes the
|
||||
* already-built {@link AccessPath} so the caller does not re-resolve it.
|
||||
*/
|
||||
isInfrastructureRead(
|
||||
toolName: string,
|
||||
accessPath: AccessPath,
|
||||
infraDirs: readonly string[],
|
||||
): boolean {
|
||||
return isPiInfrastructureRead(
|
||||
toolName,
|
||||
accessPath.boundaryValue(),
|
||||
infraDirs,
|
||||
this.cwd,
|
||||
this.flavor,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when `absolutePath` names an existing filesystem entry.
|
||||
*
|
||||
* The existence probe that resolves an *unknown* bash token: a bare word is a
|
||||
* path candidate iff it names something real (ADR 0009, #645). Uses `lstat`,
|
||||
* not `stat`, so a symlink counts as an entry even when its target is
|
||||
* dangling — the link is the operand the command names, and dropping it would
|
||||
* reopen the bypass this probe closes.
|
||||
*
|
||||
* Any error (ENOENT, ENOTDIR, EACCES, ELOOP) answers `false`: an entry the
|
||||
* gate cannot confirm is not promoted, leaving the token exactly as
|
||||
* unrestricted as it is today.
|
||||
*
|
||||
* Lives here beside {@link forPath}'s canonicalization so the package keeps a
|
||||
* single filesystem edge for path interpretation.
|
||||
*/
|
||||
entryExists(absolutePath: string): boolean {
|
||||
if (!absolutePath) return false;
|
||||
try {
|
||||
lstatSync(absolutePath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import { PATH_BEARING_TOOLS } from "./access-intent/path-surfaces";
|
||||
import { prefix, stripBashCommentLines } from "./bash-arity";
|
||||
import { deriveApprovalPattern } from "./session-rules";
|
||||
|
||||
/** The suggestion returned for a "Yes, for this session" dialog option. */
|
||||
export interface SessionApprovalSuggestion {
|
||||
/** The permission surface this approval applies to. */
|
||||
surface: string;
|
||||
/** The wildcard pattern to store as a session rule. */
|
||||
pattern: string;
|
||||
/** Human-readable label for the "for session" dialog option. */
|
||||
label: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Suggest a bash session-approval pattern from a command string.
|
||||
*
|
||||
* Uses the arity table (`src/bash-arity.ts`) to identify the semantically
|
||||
* meaningful prefix tokens for the command, then produces a wildcard pattern:
|
||||
*
|
||||
* - Single bare token (no args): exact command (`ls`).
|
||||
* - Arity prefix covers all tokens: trailing wildcard (`npm run build*`).
|
||||
* - Arity prefix shorter than token list: space + wildcard (`git checkout *`).
|
||||
* - Unknown command: first token + space wildcard (`mytool *`).
|
||||
*/
|
||||
export function suggestBashPattern(command: string): string {
|
||||
const trimmed = command.trim();
|
||||
if (!trimmed) return "";
|
||||
// Strip leading shell comment lines so the suggestion is based on the
|
||||
// actual command, not a `# description` prefix agents often prepend.
|
||||
const stripped = stripBashCommentLines(trimmed);
|
||||
if (!stripped) return "";
|
||||
const tokens = stripped.split(/\s+/);
|
||||
if (tokens.length === 1) return stripped;
|
||||
const meaningful = prefix(tokens);
|
||||
if (meaningful.length >= tokens.length) {
|
||||
return `${stripped}*`;
|
||||
}
|
||||
return `${meaningful.join(" ")} *`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Suggest an MCP session-approval pattern from a resolved target string.
|
||||
*
|
||||
* - Qualified target (`server:tool`) → `server:*`
|
||||
* - Munged target (`server_tool`) → `server_*`
|
||||
* - Bare target (no separator) → `*`
|
||||
*/
|
||||
export function suggestMcpPattern(target: string): string {
|
||||
const trimmed = target.trim();
|
||||
|
||||
const colonIndex = trimmed.indexOf(":");
|
||||
if (colonIndex > 0) {
|
||||
return `${trimmed.slice(0, colonIndex)}:*`;
|
||||
}
|
||||
|
||||
const underscoreIndex = trimmed.indexOf("_");
|
||||
if (underscoreIndex > 0) {
|
||||
return `${trimmed.slice(0, underscoreIndex)}_*`;
|
||||
}
|
||||
|
||||
return "*";
|
||||
}
|
||||
|
||||
/** Scope labels for the forwarded-approval two-step scope select. */
|
||||
export interface ForwardedScopeLabels {
|
||||
/** Least-privilege default: record on the requesting subagent only. */
|
||||
subagentLabel: string;
|
||||
/** Record on the serving node — covers the parent and all subagents. */
|
||||
servingSessionLabel: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the two scope labels shown when a human grants a forwarded request
|
||||
* "for this session."
|
||||
*
|
||||
* The subagent option names the requester (least privilege); the whole-session
|
||||
* option restates the surface + pattern being granted session-wide.
|
||||
*/
|
||||
export function buildForwardedScopeLabels(
|
||||
agentName: string | null,
|
||||
surface: string,
|
||||
pattern: string,
|
||||
): ForwardedScopeLabels {
|
||||
const subagentLabel = agentName
|
||||
? `This subagent ('${agentName}') only`
|
||||
: "This subagent only";
|
||||
return {
|
||||
subagentLabel,
|
||||
servingSessionLabel: `The whole session — allow ${surface} "${pattern}" for parent and all subagents`,
|
||||
};
|
||||
}
|
||||
|
||||
/** Surface-aware human-readable labels for the session-approval option. */
|
||||
function buildLabel(pattern: string, surface: string): string {
|
||||
switch (surface) {
|
||||
case "bash":
|
||||
return `Yes, allow bash "${pattern}" for this session`;
|
||||
case "mcp":
|
||||
return `Yes, allow mcp tool "${pattern}" for this session`;
|
||||
case "skill":
|
||||
return `Yes, allow skill "${pattern}" for this session`;
|
||||
case "external_directory":
|
||||
return `Yes, allow access to external directory "${pattern}" for this session`;
|
||||
case "path":
|
||||
return `Yes, allow path "${pattern}" for this session`;
|
||||
default:
|
||||
// Path-bearing tools with a specific path pattern show the pattern.
|
||||
if (PATH_BEARING_TOOLS.has(surface) && pattern !== "*") {
|
||||
return `Yes, allow ${surface} "${pattern}" for this session`;
|
||||
}
|
||||
// Tool surfaces with catch-all or extension tools.
|
||||
return `Yes, allow tool "${surface}" for this session`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Suggest a session-approval pattern for the given permission surface and value.
|
||||
*
|
||||
* Returns a `SessionApprovalSuggestion` with the surface, the wildcard pattern
|
||||
* to store in `SessionRules`, and a human-readable dialog label.
|
||||
*
|
||||
* `value` is expected to be the canonical (cwd-resolved, absolute) path for
|
||||
* path surfaces — callers resolve it before suggesting, so the derived pattern
|
||||
* matches the policy values a later tool call produces.
|
||||
*/
|
||||
export function suggestSessionPattern(
|
||||
surface: string,
|
||||
value: string,
|
||||
): SessionApprovalSuggestion {
|
||||
let pattern: string;
|
||||
|
||||
switch (surface) {
|
||||
case "bash":
|
||||
pattern = suggestBashPattern(value);
|
||||
break;
|
||||
case "mcp":
|
||||
pattern = suggestMcpPattern(value);
|
||||
break;
|
||||
case "skill":
|
||||
pattern = value;
|
||||
break;
|
||||
case "external_directory":
|
||||
pattern = deriveApprovalPattern(value);
|
||||
break;
|
||||
case "path":
|
||||
pattern = deriveApprovalPattern(value);
|
||||
break;
|
||||
default:
|
||||
// Path-bearing tools: derive a directory-scoped pattern from the path.
|
||||
if (PATH_BEARING_TOOLS.has(surface) && value !== "*") {
|
||||
pattern = deriveApprovalPattern(value);
|
||||
break;
|
||||
}
|
||||
// Extension tools / fallback.
|
||||
pattern = "*";
|
||||
break;
|
||||
}
|
||||
|
||||
return { surface, pattern, label: buildLabel(pattern, surface) };
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* Permission event channel — public contract.
|
||||
*
|
||||
* Exports channel name constants, TypeScript types for all emitted events,
|
||||
* and thin emit helpers.
|
||||
*
|
||||
* Stability guarantee: fields may be added, but existing fields will not be
|
||||
* removed or renamed without a semver-major version bump.
|
||||
*/
|
||||
|
||||
import type { PromptRequestFacts } from "#src/presentation/prompt-payload";
|
||||
|
||||
/** Minimal event bus interface required by the emit helpers. */
|
||||
export interface PermissionEventBus {
|
||||
emit(channel: string, data: unknown): void;
|
||||
on(channel: string, handler: (data: unknown) => void): () => void;
|
||||
}
|
||||
|
||||
// ── Channel name constants ─────────────────────────────────────────────────
|
||||
|
||||
/** Emitted at `session_start`, after the service is published. */
|
||||
export const PERMISSIONS_READY_CHANNEL = "permissions:ready";
|
||||
|
||||
/** Emitted when a permission request is committed to the active UI prompt path. */
|
||||
export const PERMISSIONS_UI_PROMPT_CHANNEL = "permissions:ui_prompt";
|
||||
|
||||
/** Emitted after every permission gate resolution. */
|
||||
export const PERMISSIONS_DECISION_CHANNEL = "permissions:decision";
|
||||
|
||||
// ── permissions:ready ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Payload emitted on `permissions:ready`.
|
||||
*
|
||||
* Intentionally empty: the channel is a readiness signal. There is no
|
||||
* `protocolVersion` — the published types plus package semver define the
|
||||
* broadcast contract.
|
||||
*/
|
||||
export type PermissionsReadyEvent = Record<string, never>;
|
||||
|
||||
// ── permissions:ui_prompt ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Origin of a UI prompt.
|
||||
*
|
||||
* Forwarding is orthogonal to origin: a forwarded subagent prompt keeps its
|
||||
* original source and is identified by a non-null `forwarding` field, not by a
|
||||
* dedicated source value.
|
||||
*/
|
||||
export type PermissionUiPromptSource =
|
||||
| "tool_call"
|
||||
| "skill_input"
|
||||
| "skill_read";
|
||||
|
||||
/** Forwarding context, present only when a prompt was forwarded from a non-UI subagent. */
|
||||
export interface ForwardedPromptContext {
|
||||
/** Requesting subagent's display name, when known. */
|
||||
requesterAgentName: string | null;
|
||||
/** Requesting subagent's session id, when known. */
|
||||
requesterSessionId: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload emitted on `permissions:ui_prompt`, immediately before the active
|
||||
* user-facing permission UI is shown.
|
||||
*
|
||||
* Lean by design: `surface`/`value` are the normalized display projection a
|
||||
* notification consumer reads; `source` is the origin; `forwarding` is non-null
|
||||
* only for forwarded subagent prompts. There is no `protocolVersion` — the
|
||||
* published types plus package semver define the broadcast contract, and
|
||||
* consumers should read defensively.
|
||||
*/
|
||||
export interface PermissionUiPromptEvent {
|
||||
/** Unique ID for the permission request being prompted. */
|
||||
requestId: string;
|
||||
/** Prompt origin. */
|
||||
source: PermissionUiPromptSource;
|
||||
/** Normalized display surface (e.g. "bash", "skill"), when known. */
|
||||
surface: string | null;
|
||||
/** Normalized display value (command, path, skill name, etc.), when known. */
|
||||
value: string | null;
|
||||
/** Agent name (when known). */
|
||||
agentName: string | null;
|
||||
/**
|
||||
* The ask's invariant core (ADR 0011 §3), verbatim from the prompt payload.
|
||||
*
|
||||
* Nested rather than flattened so the event and the payload share one shape:
|
||||
* a fact added to `PromptRequestFacts` reaches the bus without a second
|
||||
* hand-maintained declaration. Carries no evidence and no annotations — the
|
||||
* bus is the narrowest renderer (ADR 0011 §6), observable by any loaded
|
||||
* extension without the operator having named it.
|
||||
*
|
||||
* `request.surface` is the *gate* surface the rule fired on; the top-level
|
||||
* `surface` is the display projection. Both are here on purpose.
|
||||
*/
|
||||
request: PromptRequestFacts;
|
||||
/** Forwarding context, or null for a direct prompt. */
|
||||
forwarding: ForwardedPromptContext | null;
|
||||
}
|
||||
|
||||
// ── permissions:decision ───────────────────────────────────────────────────
|
||||
|
||||
/** How a permission decision was reached. */
|
||||
export type PermissionDecisionResolution =
|
||||
| "policy_allow"
|
||||
| "policy_deny"
|
||||
| "session_approved"
|
||||
| "infrastructure_auto_allowed"
|
||||
| "user_approved"
|
||||
| "user_approved_for_session"
|
||||
| "user_denied"
|
||||
| "auto_approved"
|
||||
| "confirmation_unavailable";
|
||||
|
||||
/** Payload emitted on `permissions:decision`. */
|
||||
export interface PermissionDecisionEvent {
|
||||
/**
|
||||
* Identifies the permission request this decision resolves, minted when the
|
||||
* request was created. Distinct from the host's tool-call id: one tool call
|
||||
* runs several gates and so raises several requests.
|
||||
*/
|
||||
requestId: string;
|
||||
/** Permission surface: "bash", "read", "mcp", "skill", "external_directory", etc. */
|
||||
surface: string;
|
||||
/** The value that was evaluated (command, tool name, skill name, path). */
|
||||
value: string;
|
||||
/** Final decision. */
|
||||
result: "allow" | "deny";
|
||||
/** How the decision was reached. */
|
||||
resolution: PermissionDecisionResolution;
|
||||
/** Which config scope contributed the winning rule (when available). */
|
||||
origin: string | null;
|
||||
/** Agent name (when known). */
|
||||
agentName: string | null;
|
||||
/** Matched pattern from the winning rule (when available). */
|
||||
matchedPattern: string | null;
|
||||
}
|
||||
|
||||
// ── Emit helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Emit the `permissions:ready` broadcast.
|
||||
* Call at `session_start`, after the service is published, so a consumer
|
||||
* reacting to ready can immediately resolve `getPermissionsService()`.
|
||||
*/
|
||||
export function emitReadyEvent(events: PermissionEventBus): void {
|
||||
const payload: PermissionsReadyEvent = {};
|
||||
try {
|
||||
events.emit(PERMISSIONS_READY_CHANNEL, payload);
|
||||
} catch {
|
||||
// Broadcasts are best-effort. A throwing listener must not block the
|
||||
// permission system from completing session startup.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a `permissions:ui_prompt` broadcast.
|
||||
* Call immediately before invoking the active user-facing permission UI.
|
||||
*/
|
||||
export function emitUiPromptEvent(
|
||||
events: PermissionEventBus,
|
||||
event: PermissionUiPromptEvent,
|
||||
): void {
|
||||
try {
|
||||
events.emit(PERMISSIONS_UI_PROMPT_CHANNEL, event);
|
||||
} catch {
|
||||
// UI-prompt broadcasts are observational. A consumer failure must not block
|
||||
// the permission dialog itself.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a `permissions:decision` broadcast.
|
||||
* Call after every permission gate resolution.
|
||||
*/
|
||||
export function emitDecisionEvent(
|
||||
events: PermissionEventBus,
|
||||
event: PermissionDecisionEvent,
|
||||
): void {
|
||||
try {
|
||||
events.emit(PERMISSIONS_DECISION_CHANNEL, event);
|
||||
} catch {
|
||||
// Broadcasts are best-effort. A throwing listener must not block the
|
||||
// permission gate from resolving.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import type { DecisionSource } from "#src/authority/decision-source";
|
||||
import type { PermissionPromptDecision } from "#src/authority/permission-dialog";
|
||||
|
||||
/** Result of applying the permission gate. */
|
||||
export type PermissionGateResult =
|
||||
| { action: "allow"; sessionApproval?: { surface: string; pattern: string } }
|
||||
| { action: "block"; reason: string };
|
||||
|
||||
/** Everything the gate needs — no direct dependency on ExtensionContext. */
|
||||
export interface PermissionGateParams {
|
||||
/** The resolved permission state from checkPermission(). */
|
||||
state: "allow" | "deny" | "ask";
|
||||
|
||||
/**
|
||||
* Escalate the ask to the session's Authorizer for a decision. Called for
|
||||
* every `ask`; the DenyingAuthorizer answers by denying with the
|
||||
* `confirmationUnavailable` marker when no live authority is reachable.
|
||||
*/
|
||||
promptForApproval: () => Promise<PermissionPromptDecision>;
|
||||
|
||||
/**
|
||||
* Session approval suggestion to record when the user selects
|
||||
* "for this session". When present and the decision is `approved_for_session`,
|
||||
* the result carries the suggestion back to the caller for recording.
|
||||
*/
|
||||
sessionApproval?: { surface: string; pattern: string };
|
||||
|
||||
/** Write a review-log entry. Called for deny and ask-but-unavailable paths. */
|
||||
writeLog: (event: string, extra: Record<string, unknown>) => void;
|
||||
|
||||
/** Log context fields shared across all log calls for this gate. */
|
||||
logContext: Record<string, unknown>;
|
||||
|
||||
/**
|
||||
* The rule that resolved this gate, for the deny arm's review entry.
|
||||
*
|
||||
* A sibling of `logContext` rather than a member of it: the context holds
|
||||
* what every resolution of this gate shares, and the decider is by
|
||||
* definition not shared (#726).
|
||||
*/
|
||||
decidedByRule: DecisionSource;
|
||||
|
||||
/** Message strings/factories for each outcome. */
|
||||
messages: {
|
||||
denyReason: string;
|
||||
unavailableReason: (decision: PermissionPromptDecision) => string;
|
||||
userDeniedReason: (decision: PermissionPromptDecision) => string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the deny/ask/allow permission gate.
|
||||
*
|
||||
* This is a pure decision function: all IO is injected via callbacks.
|
||||
*/
|
||||
export async function applyPermissionGate(
|
||||
params: PermissionGateParams,
|
||||
): Promise<PermissionGateResult> {
|
||||
const { state, promptForApproval, writeLog, logContext, messages } = params;
|
||||
|
||||
if (state === "deny") {
|
||||
writeLog("permission_request.blocked", {
|
||||
...logContext,
|
||||
resolution: "policy_denied",
|
||||
decidedBy: params.decidedByRule,
|
||||
});
|
||||
return { action: "block", reason: messages.denyReason };
|
||||
}
|
||||
|
||||
if (state === "ask") {
|
||||
const decision = await promptForApproval();
|
||||
if (!decision.approved) {
|
||||
// The gate writes no review entry for an ask denial — the prompter
|
||||
// brackets it (waiting/denied). The block reason distinguishes an
|
||||
// absent-authority denial (confirmationUnavailable) from a user denial.
|
||||
return {
|
||||
action: "block",
|
||||
reason: decision.confirmationUnavailable
|
||||
? messages.unavailableReason(decision)
|
||||
: messages.userDeniedReason(decision),
|
||||
};
|
||||
}
|
||||
if (decision.state === "approved_for_session" && params.sessionApproval) {
|
||||
return { action: "allow", sessionApproval: params.sessionApproval };
|
||||
}
|
||||
}
|
||||
|
||||
return { action: "allow" };
|
||||
}
|
||||
@@ -0,0 +1,430 @@
|
||||
import { join } from "node:path";
|
||||
import type { ResolvedAccessIntent } from "./access-intent/access-intent";
|
||||
import { normalizeInput } from "./access-intent/input-normalizer";
|
||||
import { PATH_SURFACES } from "./access-intent/path-surfaces";
|
||||
import { classifyToolKind } from "./access-intent/tool-kind";
|
||||
import {
|
||||
getGlobalConfigPath,
|
||||
getProjectAgentsDir,
|
||||
getProjectConfigPath,
|
||||
} from "./config-paths";
|
||||
import { normalizeFlatConfig } from "./normalize";
|
||||
import { type PathFlavor, posixPathFlavor } from "./path/path-flavor";
|
||||
import {
|
||||
FilePolicyLoader,
|
||||
type PolicyLoader,
|
||||
type PolicyLoaderOptions,
|
||||
type ResolvedPolicyPaths,
|
||||
} from "./policy-loader";
|
||||
import type { Rule, RuleOrigin, Ruleset } from "./rule";
|
||||
import {
|
||||
evaluate,
|
||||
evaluateAnyValue,
|
||||
evaluateFirst,
|
||||
floorAllowsToAsk,
|
||||
rewriteAsksToYolo,
|
||||
} from "./rule";
|
||||
import { mergeScopesWithOrigins } from "./scope-merge";
|
||||
import {
|
||||
composeRuleset,
|
||||
synthesizeBaseline,
|
||||
synthesizeDefaults,
|
||||
} from "./synthesize";
|
||||
import type {
|
||||
FlatPermissionConfig,
|
||||
PermissionCheckResult,
|
||||
PermissionState,
|
||||
} from "./types";
|
||||
import { isPermissionState } from "./types";
|
||||
|
||||
const SPECIAL_PERMISSION_KEYS = new Set(["external_directory", "path"]);
|
||||
|
||||
/** Universal fallback when permission["*"] is absent from all scopes. */
|
||||
const DEFAULT_UNIVERSAL_FALLBACK: PermissionState = "ask";
|
||||
|
||||
/** Default yolo reader — yolo disabled unless the composition root injects one. */
|
||||
const YOLO_DISABLED = (): boolean => false;
|
||||
|
||||
type FileCacheEntry<TValue> = {
|
||||
stamp: string;
|
||||
value: TValue;
|
||||
};
|
||||
|
||||
type ResolvedPermissions = {
|
||||
/**
|
||||
* Fully composed ruleset: synthesized defaults → baseline → config.
|
||||
* Session rules are appended at call-time inside check().
|
||||
*/
|
||||
composedRules: Ruleset;
|
||||
/**
|
||||
* Non-global scopes whose config file failed to load or validate. When
|
||||
* non-empty the composed ruleset has been floored allow→ask (#646); the
|
||||
* names also drive the fail-closed notice in {@link getConfigIssues}.
|
||||
*/
|
||||
failClosedScopes: RuleOrigin[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Narrow interface for session-scoped permission checking.
|
||||
* `PermissionSession` depends on this — not the full concrete class — so
|
||||
* test mocks can satisfy it without an `as unknown as PermissionManager` cast.
|
||||
*/
|
||||
export interface ScopedPermissionManager {
|
||||
configureForCwd(cwd: string | undefined | null): void;
|
||||
/**
|
||||
* Unified resolution entry point (Phase 6 Step 6, #478).
|
||||
*
|
||||
* Replaces the former `checkPermission` + `checkPathPolicy` method pair with
|
||||
* a single dispatched call, making it structurally impossible to stub one
|
||||
* method and forget the other (the #393 false-green class).
|
||||
*/
|
||||
check(
|
||||
intent: ResolvedAccessIntent,
|
||||
sessionRules?: Ruleset,
|
||||
): PermissionCheckResult;
|
||||
getToolPermission(toolName: string, agentName?: string): PermissionState;
|
||||
getConfigIssues(agentName?: string): string[];
|
||||
}
|
||||
|
||||
export interface PermissionManagerOptions extends PolicyLoaderOptions {
|
||||
policyLoader?: PolicyLoader;
|
||||
/**
|
||||
* Pi agent directory. When provided, the manager derives all loader paths
|
||||
* from this value and supports {@link PermissionManager.configureForCwd}.
|
||||
*/
|
||||
agentDir?: string;
|
||||
/**
|
||||
* Resolved path-language flavor, injected from the composition root, that
|
||||
* decides whether path-surface rule matching folds case (and separators) on
|
||||
* Windows. Defaults to the POSIX flavor; production always supplies the real
|
||||
* platform's flavor.
|
||||
*/
|
||||
flavor?: PathFlavor;
|
||||
/**
|
||||
* yolo-mode reader, injected from the composition root. When it reports
|
||||
* true, {@link PermissionManager.check} rewrites every matched `ask` to a
|
||||
* standing `allow` tagged `origin: "yolo"` (recorded authority, #526).
|
||||
* Read per check so a mid-session config change takes effect; defaults to
|
||||
* yolo disabled.
|
||||
*/
|
||||
isYoloEnabled?: () => boolean;
|
||||
}
|
||||
|
||||
export class PermissionManager implements ScopedPermissionManager {
|
||||
private readonly agentDir: string | undefined;
|
||||
private readonly flavor: PathFlavor;
|
||||
private readonly isYoloEnabled: () => boolean;
|
||||
private loader: PolicyLoader;
|
||||
private readonly resolvedPermissionsCache = new Map<
|
||||
string,
|
||||
FileCacheEntry<ResolvedPermissions>
|
||||
>();
|
||||
|
||||
constructor(options: PermissionManagerOptions = {}) {
|
||||
this.agentDir = options.agentDir;
|
||||
this.flavor = options.flavor ?? posixPathFlavor;
|
||||
this.isYoloEnabled = options.isYoloEnabled ?? YOLO_DISABLED;
|
||||
this.loader =
|
||||
options.policyLoader ??
|
||||
new FilePolicyLoader(
|
||||
options.agentDir !== undefined
|
||||
? derivePolicyLoaderOptions(options.agentDir, undefined)
|
||||
: options,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild the policy loader for a new working directory and clear the
|
||||
* resolved-permissions cache.
|
||||
*
|
||||
* When `agentDir` was not provided at construction (e.g. test managers
|
||||
* built with explicit paths), only the cache is cleared.
|
||||
*/
|
||||
configureForCwd(cwd: string | undefined | null): void {
|
||||
if (this.agentDir !== undefined) {
|
||||
this.loader = new FilePolicyLoader(
|
||||
derivePolicyLoaderOptions(this.agentDir, cwd),
|
||||
);
|
||||
}
|
||||
this.resolvedPermissionsCache.clear();
|
||||
}
|
||||
|
||||
getConfigIssues(agentName?: string): string[] {
|
||||
// Trigger a load/resolve to ensure issues are collected.
|
||||
const { failClosedScopes } = this.resolvePermissions(agentName);
|
||||
const issues = [...this.loader.getConfigIssues()];
|
||||
if (failClosedScopes.length > 0) {
|
||||
issues.push(
|
||||
`Invalid ${failClosedScopes.join(", ")} configuration detected — ` +
|
||||
`failing closed: 'allow' rules are clamped to 'ask' for this session ` +
|
||||
`until the configuration is corrected.`,
|
||||
);
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
getResolvedPolicyPaths(): ResolvedPolicyPaths {
|
||||
return this.loader.getResolvedPolicyPaths();
|
||||
}
|
||||
|
||||
private resolvePermissions(agentName?: string): ResolvedPermissions {
|
||||
const cacheKey = agentName ?? "__global__";
|
||||
const stamp = this.loader.getCacheStamp(agentName);
|
||||
const cached = this.resolvedPermissionsCache.get(cacheKey);
|
||||
if (cached?.stamp === stamp) {
|
||||
return cached.value;
|
||||
}
|
||||
|
||||
const globalConfig = this.loader.loadGlobalConfig();
|
||||
const projectConfig = this.loader.loadProjectConfig();
|
||||
const agentConfig = this.loader.loadAgentConfig(agentName);
|
||||
const projectAgentConfig = this.loader.loadProjectAgentConfig(agentName);
|
||||
|
||||
// Merge permission objects across scopes (lowest → highest precedence),
|
||||
// building a parallel origin map that tracks which scope contributed each
|
||||
// (surface, pattern) entry.
|
||||
const { mergedPermission, origins } = mergeScopesWithOrigins([
|
||||
["global", globalConfig],
|
||||
["project", projectConfig],
|
||||
["agent", agentConfig],
|
||||
["project-agent", projectAgentConfig],
|
||||
]);
|
||||
|
||||
// Extract the universal fallback from permission["*"].
|
||||
// The "*" key feeds synthesizeDefaults() only — it is NOT included as a
|
||||
// config rule so that extension tools fall through to source:"default".
|
||||
const universalFallback = isPermissionState(mergedPermission["*"])
|
||||
? mergedPermission["*"]
|
||||
: DEFAULT_UNIVERSAL_FALLBACK;
|
||||
// Track which scope contributed the universal fallback.
|
||||
const universalFallbackOrigin: RuleOrigin =
|
||||
origins.get("*")?.get("*") ?? "builtin";
|
||||
|
||||
// Build config rules from everything except the universal "*" key.
|
||||
const permissionWithoutUniversal: FlatPermissionConfig = Object.fromEntries(
|
||||
Object.entries(mergedPermission).filter(([k]) => k !== "*"),
|
||||
);
|
||||
|
||||
// Normalize to config rules, tagged with "config" layer and their origin.
|
||||
const configRules: Ruleset = normalizeFlatConfig(
|
||||
permissionWithoutUniversal,
|
||||
).map(
|
||||
(r): Rule => ({
|
||||
...r,
|
||||
layer: "config",
|
||||
origin: origins.get(r.surface)?.get(r.pattern) ?? "builtin",
|
||||
}),
|
||||
);
|
||||
|
||||
const composedRules = composeRuleset(
|
||||
synthesizeDefaults(universalFallback, universalFallbackOrigin),
|
||||
synthesizeBaseline(configRules),
|
||||
configRules,
|
||||
);
|
||||
|
||||
// Fail closed when a non-global scope's config is invalid: floor every
|
||||
// `allow` (including one inherited from a lower scope) to `ask` so a
|
||||
// higher scope meant to tighten policy cannot silently fail open (#646).
|
||||
// Global is excluded — nothing more permissive is inherited when it fails.
|
||||
const failClosedScopes: RuleOrigin[] = [];
|
||||
if (projectConfig.invalid === true) failClosedScopes.push("project");
|
||||
if (agentConfig.invalid === true) failClosedScopes.push("agent");
|
||||
if (projectAgentConfig.invalid === true)
|
||||
failClosedScopes.push("project-agent");
|
||||
|
||||
const effectiveRules =
|
||||
failClosedScopes.length > 0
|
||||
? floorAllowsToAsk(composedRules)
|
||||
: composedRules;
|
||||
|
||||
const value: ResolvedPermissions = {
|
||||
composedRules: effectiveRules,
|
||||
failClosedScopes,
|
||||
};
|
||||
this.resolvedPermissionsCache.set(cacheKey, { stamp, value });
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the composed config-layer rules for the given agent scope.
|
||||
* Used by the `/permission-system show` command to display effective rules
|
||||
* with their origin annotations.
|
||||
* Session rules are not included — they are runtime-only.
|
||||
*/
|
||||
getComposedConfigRules(agentName?: string): Ruleset {
|
||||
const { composedRules } = this.resolvePermissions(agentName);
|
||||
return composedRules.filter((r) => r.layer === "config");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the tool-level permission state for a tool, without considering
|
||||
* command-level rules. Used for tool injection decisions.
|
||||
*/
|
||||
getToolPermission(toolName: string, agentName?: string): PermissionState {
|
||||
const { composedRules } = this.resolvePermissions(agentName);
|
||||
// Every surface (special, bash, mcp, skill, path-bearing, and extension
|
||||
// tools) resolves its tool-level state identically: evaluate the surface
|
||||
// name against the "*" catch-all value. There is no per-kind branch.
|
||||
return evaluate(toolName.trim(), "*", composedRules, this.flavor).action;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified resolution entry point — dispatches on intent kind.
|
||||
*
|
||||
* `"tool"` → normalizes raw input through `normalizeInput` (bash, skill, mcp,
|
||||
* extension surfaces). Path-bearing surfaces arrive as `"path-values"` via
|
||||
* the access-path gate (#502) or service/RPC builder (#503).
|
||||
* `"path-values"` → evaluates the precomputed values directly.
|
||||
*
|
||||
* The manager stays string-based by design: it consumes `ResolvedAccessIntent`
|
||||
* (`tool | path-values`) and never imports `AccessPath`. This deliberate
|
||||
* boundary is formalized in ADR-0002
|
||||
* (`docs/decisions/0002-path-values-string-boundary.md`) and guarded by a
|
||||
* `no-restricted-imports` lint rule on this file.
|
||||
*/
|
||||
check(
|
||||
intent: ResolvedAccessIntent,
|
||||
sessionRules?: Ruleset,
|
||||
): PermissionCheckResult {
|
||||
const { composedRules } = this.resolvePermissions(intent.agentName);
|
||||
const composedWithSession: Ruleset = sessionRules?.length
|
||||
? [...composedRules, ...sessionRules]
|
||||
: composedRules;
|
||||
// Apply the yolo rewrite post-cache so the resolved-permissions cache and
|
||||
// the display surfaces (getComposedConfigRules / getToolPermission) stay
|
||||
// yolo-free — only the resolution path sees the ask→allow rewrite (#526).
|
||||
const fullRules: Ruleset = this.isYoloEnabled()
|
||||
? rewriteAsksToYolo(composedWithSession)
|
||||
: composedWithSession;
|
||||
|
||||
if (intent.kind === "path-values") {
|
||||
const lookupValues =
|
||||
intent.values.length > 0 ? [...intent.values] : ["*"];
|
||||
return buildCheckResult(
|
||||
intent.surface,
|
||||
lookupValues,
|
||||
{},
|
||||
intent.surface,
|
||||
intent.surface,
|
||||
fullRules,
|
||||
this.flavor,
|
||||
);
|
||||
}
|
||||
|
||||
// kind === "tool"
|
||||
const toolName = intent.surface.trim();
|
||||
const { surface, values, resultExtras } = normalizeInput(
|
||||
toolName,
|
||||
intent.input,
|
||||
this.loader.getConfiguredMcpServerNames(),
|
||||
);
|
||||
return buildCheckResult(
|
||||
surface,
|
||||
values,
|
||||
resultExtras,
|
||||
toolName,
|
||||
intent.surface,
|
||||
fullRules,
|
||||
this.flavor,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate a normalized surface/values triple and shape the result.
|
||||
*
|
||||
* Path surfaces use {@link evaluateAnyValue} (last-match-wins across equivalent
|
||||
* aliases); every other surface keeps {@link evaluateFirst}. Shared by the
|
||||
* `"tool"` and `"path-values"` branches of {@link PermissionManager.check}.
|
||||
*/
|
||||
function buildCheckResult(
|
||||
surface: string,
|
||||
values: string[],
|
||||
resultExtras: Record<string, unknown>,
|
||||
normalizedToolName: string,
|
||||
toolName: string,
|
||||
fullRules: Ruleset,
|
||||
flavor: PathFlavor,
|
||||
): PermissionCheckResult {
|
||||
const { rule, value } = PATH_SURFACES.has(surface)
|
||||
? evaluateAnyValue(surface, values, fullRules, flavor)
|
||||
: evaluateFirst(surface, values, fullRules, flavor);
|
||||
|
||||
// For MCP, replace the normalizer's fallback target with the actual
|
||||
// matched candidate value so PermissionCheckResult.target is accurate.
|
||||
const extras =
|
||||
classifyToolKind(surface) === "mcp"
|
||||
? { ...resultExtras, target: value }
|
||||
: resultExtras;
|
||||
|
||||
return {
|
||||
toolName,
|
||||
state: rule.action,
|
||||
reason: rule.reason,
|
||||
matchedPattern:
|
||||
rule.layer === "config" || rule.layer === "session"
|
||||
? rule.pattern
|
||||
: undefined,
|
||||
source: deriveSource(rule, normalizedToolName),
|
||||
origin: rule.origin,
|
||||
...extras,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive `PolicyLoaderOptions` from an agentDir + an optional cwd.
|
||||
* Setting agentsDir explicitly from agentDir removes the hidden
|
||||
* `getAgentDir()` env-read that FilePolicyLoader's default would perform.
|
||||
*/
|
||||
function derivePolicyLoaderOptions(
|
||||
agentDir: string,
|
||||
cwd: string | undefined | null,
|
||||
): PolicyLoaderOptions {
|
||||
return {
|
||||
globalConfigPath: getGlobalConfigPath(agentDir),
|
||||
agentsDir: join(agentDir, "agents"),
|
||||
projectGlobalConfigPath: cwd ? getProjectConfigPath(cwd) : undefined,
|
||||
projectAgentsDir: cwd ? getProjectAgentsDir(cwd) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a matched rule + tool name to the correct PermissionCheckResult.source.
|
||||
*
|
||||
* Mirrors the source-derivation logic from the former per-branch
|
||||
* permission-check implementation:
|
||||
*
|
||||
* - session → "session" (always, all surfaces)
|
||||
* - mcp + default → "default"
|
||||
* - mcp + other → "mcp"
|
||||
* - special → "special" (always)
|
||||
* - skill → "skill" (always)
|
||||
* - bash → "bash" (always)
|
||||
* - built-in tool → "tool" (always)
|
||||
* - extension tool → "default" when default layer, "tool" otherwise
|
||||
*/
|
||||
function deriveSource(
|
||||
rule: Rule,
|
||||
toolName: string,
|
||||
): PermissionCheckResult["source"] {
|
||||
if (rule.layer === "session") return "session";
|
||||
if (SPECIAL_PERMISSION_KEYS.has(toolName)) return "special";
|
||||
|
||||
switch (classifyToolKind(toolName)) {
|
||||
case "mcp":
|
||||
return rule.layer === "default" ? "default" : "mcp";
|
||||
case "skill":
|
||||
return "skill";
|
||||
case "bash":
|
||||
return "bash";
|
||||
case "path":
|
||||
// Built-in path-bearing tools (read/write/edit/grep/find/ls).
|
||||
return "tool";
|
||||
case "extension":
|
||||
// Extension tools distinguish a synthesized-default match from a rule.
|
||||
return rule.layer === "default" ? "default" : "tool";
|
||||
}
|
||||
}
|
||||
|
||||
// Re-export types that external modules import from this file.
|
||||
export type { PolicyLoader, ResolvedPolicyPaths } from "./policy-loader";
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user