Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,14 @@
export {
compileFetchRule,
compileFsRule,
globToRegex,
normalisePath,
PermissionPolicy,
POLICY_MANIFEST_SCHEMA,
} from "./policy.js";
export type {
CompiledFetchRule,
CompiledFsRule,
PolicyManifestView,
ResolvedBudgets,
} from "./policy.types.js";

View File

@@ -0,0 +1,127 @@
# `policy` — `PermissionPolicy`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module owns the construction-time value object that holds the
compiled allowlists derived from a `PermissionsManifest`. Building the
policy is the only place where untrusted manifest input crosses a trust
boundary in this package — `Zod` validation runs once here, and every
subsequent `PermissionChecker` call is O(rules) with no further string
parsing.
The policy is intentionally immutable. Hot-reload constructs a fresh
`PermissionPolicy` and the sandbox host swaps the reference atomically;
checker invocations in flight against the old policy keep using the old
allowlist, so a deny / allow decision is always consistent within a
single request.
## Features
- `PermissionPolicy` — compiled value object exposing
`fs[]`, `fetch[]`, `budgets`, and the original `manifest` for
capability gating.
- `POLICY_MANIFEST_SCHEMA` — Zod schema used at the ingestion boundary.
- `compileFsRule(rule)` — converts a `permissions.fs[]` entry into a
`CompiledFsRule` with `write` flag and resolved root.
- `compileFetchRule(rule)` — converts a `permissions.fetch[]` entry
into a `CompiledFetchRule` supporting exact host and `*.suffix`
matching plus optional `http:` prefix.
- `normalisePath(input)` — resolves `..`, strips trailing slashes,
always returns POSIX form.
- `globToRegex(pattern)` — minimal glob → regex translation
(`*`, `**`, `?`, `[abc]`).
## Usage: Basic
```ts
import { PermissionPolicy } from "@suaveplan/api-sandbox-permissions/policy";
const policy = new PermissionPolicy({
permissions: {
fs: ["./uploads/*"],
fetch: ["api.stripe.com"],
budgets: { wallClockMs: 30_000 },
},
});
console.log(policy.budgets.wallClockMs); // 30000
```
## Usage: Advanced
```ts
import {
PermissionPolicy,
compileFsRule,
compileFetchRule,
globToRegex,
} from "@suaveplan/api-sandbox-permissions/policy";
// Inspect compiled rules — used by adapter test fixtures to assert
// the policy was built the way the manifest declared it.
const policy = new PermissionPolicy({
permissions: {
fs: ["rw:./uploads/**", "./readonly/*.txt"],
fetch: ["*.stripe.com", "http:localhost"],
budgets: { cpuPercent: 25, memoryMb: 128, wallClockMs: 30_000 },
},
database: { required: true, schemaVersion: 1, tablePrefix: "billing_" },
events: { publishes: ["billing.invoice.created"], subscribes: [] },
secrets: [{ name: "STRIPE_SECRET_KEY", required: true }],
});
const writeRule = policy.fs.find((rule) => rule.write);
console.assert(writeRule?.root === "./uploads");
// Reusable: build a one-off rule outside a policy (for tests).
const oneRule = compileFsRule("./tmp/*.log");
console.assert(oneRule.test("./tmp/app.log"));
const httpsRule = compileFetchRule("api.stripe.com");
console.assert(httpsRule.test("api.stripe.com"));
const re = globToRegex("./uploads/**");
console.assert(re.test("./uploads/a/b/c"));
```
## API Reference
- `class PermissionPolicy`
- `constructor(manifest: PolicyManifestView)` — throws
`SandboxPermissionError(SAND_PERM.DENIED)` if the input fails the
Zod schema.
- `readonly fs: readonly CompiledFsRule[]`
- `readonly fetch: readonly CompiledFetchRule[]`
- `readonly budgets: ResolvedBudgets` (unset budgets default to `Infinity`).
- `readonly manifest: PolicyManifestView`
- `POLICY_MANIFEST_SCHEMA: ZodObject<…>` — manifest validation schema.
- `compileFsRule(rule: string): CompiledFsRule`
- `compileFetchRule(rule: string): CompiledFetchRule`
- `normalisePath(input: string): string`
- `globToRegex(pattern: string): RegExp`
## Implementation Notes
The glob compiler is deliberately minimal — `*`, `**`, `?`, `[abc]` and
nothing more. Sandbox permission lists are short and operator-authored,
so the rules are written by hand against a constrained vocabulary.
A full POSIX glob engine would add a dependency and a corresponding
attack surface (catastrophic backtracking, character-class escapes)
without buying any expressive power the manifest needs.
Path normalisation uses Node's `posix.normalize` so the same canonical
form runs on Windows hosts. `..` segments are resolved before matching;
the FS checker uses the rule's `root` field to reject paths that
escape the allowed prefix even when the glob pattern alone would
accept them.
The fetch host comparator lowercases both sides so case differences in
the request URL never affect allow / deny.
Budgets default to `Number.POSITIVE_INFINITY` when unset — a manifest
that omits a dimension grants unlimited usage for that dimension. The
budget tracker treats `Infinity` as "never breach".

View File

@@ -0,0 +1,181 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
SAND_PERM_CODES,
SandboxPermissionError,
} from "../error-codes/error-codes.js";
import {
compileFetchRule,
compileFsRule,
globToRegex,
normalisePath,
PermissionPolicy,
} from "./policy.js";
describe("PermissionPolicy", () => {
it("PermissionPolicy reflects manifest verbatim", {
timeout: 5000,
}, () => {
const policy = new PermissionPolicy({
permissions: {
fs: ["./uploads/*"],
fetch: ["api.stripe.com"],
budgets: { wallClockMs: 30_000 },
},
});
expect(policy.fs).toHaveLength(1);
expect(policy.fs[0]?.source).toBe("./uploads/*");
expect(policy.fs[0]?.write).toBe(false);
expect(policy.fetch).toHaveLength(1);
expect(policy.fetch[0]?.source).toBe("api.stripe.com");
expect(policy.budgets.wallClockMs).toBe(30_000);
expect(policy.budgets.cpuPercent).toBe(Number.POSITIVE_INFINITY);
expect(policy.budgets.memoryMb).toBe(Number.POSITIVE_INFINITY);
});
it("empty manifest yields empty arrays and Infinity budgets", {
timeout: 5000,
}, () => {
const policy = new PermissionPolicy({});
expect(policy.fs).toEqual([]);
expect(policy.fetch).toEqual([]);
expect(policy.budgets.cpuPercent).toBe(Number.POSITIVE_INFINITY);
expect(policy.budgets.memoryMb).toBe(Number.POSITIVE_INFINITY);
expect(policy.budgets.wallClockMs).toBe(Number.POSITIVE_INFINITY);
});
it("rw: prefix on fs rule sets write=true", { timeout: 5000 }, () => {
const policy = new PermissionPolicy({
permissions: { fs: ["rw:./uploads/*"] },
});
expect(policy.fs[0]?.write).toBe(true);
});
it("invalid manifest throws SAND_PERM.DENIED", {
timeout: 5000,
}, () => {
let err: SandboxPermissionError | undefined;
try {
// @ts-expect-error — intentionally invalid input shape
new PermissionPolicy({ permissions: { fs: [123] } });
} catch (e) {
err = e as SandboxPermissionError;
}
expect(err).toBeInstanceOf(SandboxPermissionError);
expect(err?.code).toBe(SAND_PERM_CODES.DENIED);
});
});
describe("normalisePath", () => {
it("resolves .. and strips trailing slashes", {
timeout: 5000,
}, () => {
expect(normalisePath("./uploads/../etc/passwd")).toBe("./etc/passwd");
expect(normalisePath("./uploads/")).toBe("./uploads");
expect(normalisePath("./uploads/inner/")).toBe("./uploads/inner");
});
it("preserves absolute paths", { timeout: 5000 }, () => {
expect(normalisePath("/var/log/")).toBe("/var/log");
expect(normalisePath("/")).toBe("/");
});
it("normalises backslashes to posix", { timeout: 5000 }, () => {
expect(normalisePath(".\\uploads\\a")).toBe("./uploads/a");
});
it("collapses current-dir input to .", { timeout: 5000 }, () => {
expect(normalisePath(".")).toBe(".");
});
it("keeps .. escape paths without re-prefixing", {
timeout: 5000,
}, () => {
expect(normalisePath("..")).toBe("..");
expect(normalisePath("../sibling")).toBe("../sibling");
});
});
describe("globToRegex", () => {
it("translates * to single-segment match", { timeout: 5000 }, () => {
const re = globToRegex("./uploads/*");
expect(re.test("./uploads/a.png")).toBe(true);
expect(re.test("./uploads/sub/a.png")).toBe(false);
});
it("translates ** to multi-segment match", { timeout: 5000 }, () => {
const re = globToRegex("./uploads/**");
expect(re.test("./uploads/a")).toBe(true);
expect(re.test("./uploads/a/b/c")).toBe(true);
});
it("translates ? to single non-slash character", {
timeout: 5000,
}, () => {
const re = globToRegex("a?c");
expect(re.test("abc")).toBe(true);
expect(re.test("a/c")).toBe(false);
});
it("preserves character class [abc]", { timeout: 5000 }, () => {
const re = globToRegex("file.[tj]s");
expect(re.test("file.ts")).toBe(true);
expect(re.test("file.js")).toBe(true);
expect(re.test("file.cs")).toBe(false);
});
it("escapes regex metacharacters", { timeout: 5000 }, () => {
const re = globToRegex("a.b+c(d)");
expect(re.test("a.b+c(d)")).toBe(true);
expect(re.test("aXbXc-d-")).toBe(false);
});
it("treats unclosed [ as literal", { timeout: 5000 }, () => {
const re = globToRegex("a[b");
expect(re.test("a[b")).toBe(true);
});
});
describe("compileFsRule + compileFetchRule", () => {
it("compileFsRule extracts root prefix", { timeout: 5000 }, () => {
const rule = compileFsRule("./uploads/*");
expect(rule.root).toBe("./uploads");
});
it("compileFsRule literal-only rule has whole path as root", {
timeout: 5000,
}, () => {
const rule = compileFsRule("./uploads/exact.txt");
expect(rule.root).toBe("./uploads/exact.txt");
expect(rule.test("./uploads/exact.txt")).toBe(true);
});
it("compileFsRule wildcard-only with no slash before star", {
timeout: 5000,
}, () => {
const rule = compileFsRule("*.txt");
// root falls back to the empty prefix when no '/' precedes the wildcard
expect(rule.root).toBe("");
expect(rule.test("a.txt")).toBe(true);
});
it("compileFetchRule recognises *.suffix", { timeout: 5000 }, () => {
const rule = compileFetchRule("*.stripe.com");
expect(rule.test("eu.api.stripe.com")).toBe(true);
expect(rule.test("stripe.com")).toBe(false);
});
it("compileFetchRule recognises http: prefix", {
timeout: 5000,
}, () => {
const rule = compileFetchRule("http:localhost");
expect(rule.allowHttp).toBe(true);
expect(rule.test("localhost")).toBe(true);
});
it("compileFetchRule exact match is case-insensitive", {
timeout: 5000,
}, () => {
const rule = compileFetchRule("API.Stripe.COM");
expect(rule.test("api.stripe.com")).toBe(true);
});
});

View File

@@ -0,0 +1,254 @@
import { posix } from "node:path";
import z from "zod";
import {
SAND_PERM_CODES,
SandboxPermissionError,
} from "../error-codes/error-codes.js";
import type {
CompiledFetchRule,
CompiledFsRule,
PolicyManifestView,
ResolvedBudgets,
} from "./policy.types.js";
/**
* Zod schema applied at the ingestion boundary. The runtime types in
* `@suaveplan/api-types` already model the same shape structurally;
* this schema gives us the trust boundary for untrusted manifest
* payloads (worker boundary, hot-reload, admin patch).
*/
export const POLICY_MANIFEST_SCHEMA = z
.object({
permissions: z
.object({
fs: z.array(z.string().min(1)).optional(),
fetch: z.array(z.string().min(1)).optional(),
budgets: z
.object({
cpuPercent: z.number().positive().optional(),
memoryMb: z.number().positive().optional(),
wallClockMs: z.number().positive().optional(),
})
.optional(),
})
.optional(),
events: z
.object({
publishes: z.array(z.string().min(1)).optional(),
subscribes: z.array(z.string().min(1)).optional(),
})
.optional(),
database: z
.object({
required: z.boolean(),
schemaVersion: z.number(),
tablePrefix: z.string().min(1).optional(),
tenantIsolation: z
.enum(["row-level", "schema", "database"])
.optional(),
preferredEngine: z.string().optional(),
compatibleEngines: z.array(z.string()).optional(),
migrationsDir: z.string().optional(),
})
.optional(),
secrets: z
.array(
z.object({
name: z.string().min(1),
required: z.boolean(),
})
)
.optional(),
})
.strict();
/**
* Normalise a filesystem path for comparison against the allowlist.
* Resolves `..` segments and strips trailing slashes. Always returns a
* POSIX-style path; rules and check inputs are compared on the same
* canonical form regardless of host platform.
*/
export function normalisePath(input: string): string {
const cleaned = input.replace(/\\/g, "/");
if (cleaned.startsWith("/")) {
return posix.normalize(cleaned).replace(/\/+$/, "") || "/";
}
const hadDot = cleaned.startsWith("./") || cleaned === ".";
const normalised = posix.normalize(cleaned).replace(/\/+$/, "");
if (normalised === ".") {
return ".";
}
if (normalised.startsWith("../") || normalised === "..") {
// Path escaped its starting point — keep as-is (no leading ./).
return normalised;
}
if (hadDot) {
return `./${normalised}`;
}
return normalised;
}
/**
* Compile a single `permissions.fs[]` entry into a {@link CompiledFsRule}.
* The optional `rw:` prefix toggles write access; everything else is
* read-only.
*/
export function compileFsRule(rule: string): CompiledFsRule {
let source = rule;
let write = false;
if (rule.startsWith("rw:")) {
write = true;
source = rule.slice(3);
}
const normalised = normalisePath(source);
const root = extractGlobRoot(normalised);
const pattern = globToRegex(normalised);
return {
source: rule,
write,
root,
test(resolved: string): boolean {
return pattern.test(resolved);
},
};
}
/**
* Extract the literal-path prefix from a glob pattern (everything up to
* the first `*` / `?` / `[`). Used as the deny-reason boundary check —
* resolved paths that escape this prefix are rejected before the regex
* even runs.
*/
function extractGlobRoot(pattern: string): string {
const wildcardAt = pattern.search(/[*?[]/);
if (wildcardAt === -1) {
return pattern;
}
const prefix = pattern.slice(0, wildcardAt);
const lastSlash = prefix.lastIndexOf("/");
return lastSlash >= 0 ? prefix.slice(0, lastSlash) : prefix;
}
/**
* Translate a simple glob pattern (`*`, `**`, `?`, `[abc]`) into a
* Regex with anchored ends. The transformation is intentionally minimal
* — sandboxed permissions are simple allowlists, not full POSIX glob.
*/
export function globToRegex(pattern: string): RegExp {
let out = "";
let i = 0;
while (i < pattern.length) {
const ch = pattern[i] as string;
if (ch === "*") {
const next = pattern[i + 1];
if (next === "*") {
out += ".*";
i += 2;
continue;
}
out += "[^/]*";
i += 1;
continue;
}
if (ch === "?") {
out += "[^/]";
i += 1;
continue;
}
if (ch === "[") {
const close = pattern.indexOf("]", i + 1);
if (close !== -1) {
out += pattern.slice(i, close + 1);
i = close + 1;
continue;
}
// Unclosed bracket — treat as literal.
out += "\\[";
i += 1;
continue;
}
if (/[.+^${}()|\\]/.test(ch)) {
out += `\\${ch}`;
} else {
out += ch;
}
i += 1;
}
return new RegExp(`^${out}$`);
}
/**
* Compile a single `permissions.fetch[]` host pattern. Optional
* `http:` prefix toggles insecure-scheme permission.
*/
export function compileFetchRule(rule: string): CompiledFetchRule {
let allowHttp = false;
let source = rule;
if (rule.startsWith("http:")) {
allowHttp = true;
source = rule.slice(5);
}
const lowered = source.toLowerCase();
if (lowered.startsWith("*.")) {
const suffix = lowered.slice(2);
return {
source: rule,
allowHttp,
test(host) {
return host.endsWith(`.${suffix}`);
},
};
}
return {
source: rule,
allowHttp,
test(host) {
return host === lowered;
},
};
}
/**
* Resolved permission policy. Built once per plugin start; consumed
* by `PermissionChecker` and `BudgetTracker`.
*/
export class PermissionPolicy {
public readonly fs: readonly CompiledFsRule[];
public readonly fetch: readonly CompiledFetchRule[];
public readonly budgets: ResolvedBudgets;
public readonly manifest: PolicyManifestView;
/**
* Construct from a manifest view. The manifest is validated via
* {@link POLICY_MANIFEST_SCHEMA} so untrusted ingestion paths
* cannot bypass the contract. Throws `SandboxPermissionError`
* (code `SAND_PERM.DENIED`) when the input does not satisfy the
* schema — same code surface as a deny at request time, so
* upstream catch-blocks need only one branch.
*/
public constructor(manifest: PolicyManifestView) {
const parsed = POLICY_MANIFEST_SCHEMA.safeParse(manifest);
if (!parsed.success) {
throw new SandboxPermissionError(
SAND_PERM_CODES.DENIED,
"sandbox permission manifest failed validation",
{
context: {
capability: "policy",
reason: "manifest-invalid",
},
cause: parsed.error,
}
);
}
this.manifest = manifest;
this.fs = (manifest.permissions?.fs ?? []).map(compileFsRule);
this.fetch = (manifest.permissions?.fetch ?? []).map(compileFetchRule);
const budgets = manifest.permissions?.budgets ?? {};
this.budgets = {
cpuPercent: budgets.cpuPercent ?? Number.POSITIVE_INFINITY,
memoryMb: budgets.memoryMb ?? Number.POSITIVE_INFINITY,
wallClockMs: budgets.wallClockMs ?? Number.POSITIVE_INFINITY,
};
}
}

View File

@@ -0,0 +1,52 @@
import type {
DatabaseManifest,
EventManifest,
PermissionsManifest,
} from "@suaveplan/api-types/manifest";
/** Compiled glob row used by the filesystem checker. */
export interface CompiledFsRule {
/** Original rule string (kept for diagnostics — never leaked into errors). */
readonly source: string;
/** `true` when the rule was prefixed `rw:` — write access is permitted. */
readonly write: boolean;
/** Normalised allowlist root (path-prefix). */
readonly root: string;
/** Compiled glob test for the suffix portion (after the root). */
test(resolved: string): boolean;
}
/** Compiled host row used by the fetch checker. */
export interface CompiledFetchRule {
readonly source: string;
/** When `true`, the rule permits insecure `http:` requests. */
readonly allowHttp: boolean;
/** Test the URL's host (lowercased, no port). */
test(host: string): boolean;
}
/** Resolved per-request budgets, with `Infinity` for unset dimensions. */
export interface ResolvedBudgets {
readonly cpuPercent: number;
readonly memoryMb: number;
readonly wallClockMs: number;
}
/**
* The portion of the {@link PluginManifest} this layer needs at
* construction time. Kept narrow so the layer does not depend on
* unrelated fields (routes, jobs, telemetry, etc.).
*/
export interface PolicyManifestView {
/** Permissions block per design doc §3. */
readonly permissions?: PermissionsManifest;
/** Declared event topics — used by capability gating. */
readonly events?: EventManifest;
/** Declared database surface — used by capability gating. */
readonly database?: DatabaseManifest;
/** Manifest-declared secret entries. */
readonly secrets?: ReadonlyArray<{
readonly name: string;
readonly required: boolean;
}>;
}