Files
oh-my-pi/packages/coding-agent/src/discovery/contained-path.ts
T
can1357 2ad61c7b92 feat(discovery): added Agent Plugins 1.0.0 standard support
- New agent-plugins provider discovers packages with a root plugin.json
  targeting the canonical schema (agent-plugins.org) from marketplace
  installs, --plugin-dir, and configured extension roots; skills/ and
  mcp.json load per spec with closed-schema validation,
  ${PLUGIN_ROOT}/${PLUGIN_DATA} expansion, reserved subprocess
  environment, instance-keyed data dirs, and per-component isolation.
- Package-boundary containment (spec §4.1) is enforced before every
  read via the new contained-path helpers, including skill:// resource
  access from the read tool and bash; plugin skill files must
  realpath-resolve inside the plugin root (skills carry containRoot).
- Legacy claude-plugins/omp-plugins providers yield skills and MCP
  surfaces of standard-targeting roots to the new provider and skip
  fatally invalid packages.
2026-08-07 05:59:52 +02:00

79 lines
3.2 KiB
TypeScript

/**
* Package-boundary containment for plugin directories (Agent Plugins §4.1).
*
* A file or directory supplied by a plugin package may only be discovered,
* read, or executed when its filesystem-resolved path stays within the
* filesystem-resolved plugin root. These helpers resolve paths WITHOUT reading
* them, so callers can prove containment before any read, readdir, or
* symlink-following stat touches content outside the package.
*
* Shared by the Agent Plugins format module and the plugin-root helpers;
* imports nothing from either to stay cycle-free.
*/
import * as fs from "node:fs";
import * as path from "node:path";
/** Lexical containment: `target` is `base` itself or a descendant of it. */
export function isContained(base: string, target: string): boolean {
const relative = path.relative(base, target);
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
}
/** Resolve symlinks and equivalents; `null` when the path is missing or unresolvable. */
export async function realpathIfExists(p: string): Promise<string | null> {
try {
return await fs.promises.realpath(p);
} catch {
return null;
}
}
/** Outcome of resolving a fixed package path without reading it. */
export type ContainedPathResolution =
| { status: "missing" }
| { status: "outside" }
| { status: "ok"; realPath: string };
/**
* Resolve a fixed package path WITHOUT reading it: symlinks and equivalent
* mechanisms are resolved first, and a path escaping the filesystem-resolved
* plugin root is rejected before any I/O could consume outside content.
*/
export async function resolveContainedPath(realBase: string, target: string): Promise<ContainedPathResolution> {
if (!isContained(realBase, target)) return { status: "outside" };
const real = await realpathIfExists(target);
if (real === null) return { status: "missing" };
return isContained(realBase, real) ? { status: "ok", realPath: real } : { status: "outside" };
}
/**
* Verify a package path stays within the filesystem-resolved plugin root:
* lexically, and — when the target exists — after resolving symlinks. Unlike
* {@link resolveContainedPath}, a missing target passes. ONLY for configured
* paths that are validated but never read or executed at load time (stdio
* `command`, `cwd` — including `${PLUGIN_DATA}` paths created after
* validation); access paths MUST use {@link resolveContainedPath} instead,
* which fails closed on unresolvable targets.
*/
export async function isContainedResolved(realBase: string, target: string): Promise<boolean> {
if (!isContained(realBase, target)) return false;
const real = await realpathIfExists(target);
return real === null || isContained(realBase, real);
}
/**
* Sync variant of {@link resolveContainedPath} for synchronous callsites
* (bash `skill://` expansion).
*/
export function resolveContainedPathSync(realBase: string, target: string): ContainedPathResolution {
if (!isContained(realBase, target)) return { status: "outside" };
let real: string | null;
try {
real = fs.realpathSync(target);
} catch {
real = null;
}
if (real === null) return { status: "missing" };
return isContained(realBase, real) ? { status: "ok", realPath: real } : { status: "outside" };
}