Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,8 @@ export * from "./ports/index.ts";
export { PageTextGuard, SYSTEM_PREAMBLE } from "./policy/inject.ts";
export { SecretRedactor } from "./identity/redact.ts";
export { SystemClock } from "./clock/system.ts";
export type { ParamSpec, ParamType, ParamValue, Recipe, RecipeStatus, RegressionCase, Step, VerifyRule } from "./recipe/types.ts";
export { parseRecipe, type ParseResult } from "./recipe/parse.ts";
export { lintRecipe } from "./recipe/lint.ts";
export { evalWithParams, renderRecipe, type RenderResult } from "./recipe/render.ts";
export { verifyResult, type VerifyOutcome } from "./recipe/verify.ts";
71 changes: 71 additions & 0 deletions packages/core/src/recipe/lint.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import type { Recipe, Step } from "./types.ts";

const ALLOWED = new Set([
"open", "wait", "find", "click", "dblclick", "fill", "type", "select", "check", "uncheck", "hover", "press", "scroll", "get", "eval",
]);

/** Read-only backstop for eval code. The approval gate is the real control for logged-in recipes. */
const EVAL_FORBIDDEN: ReadonlyArray<[RegExp, string]> = [
[/\bfetch\s*[(]/, "fetch"],
[/\bXMLHttpRequest\b/, "XMLHttpRequest"],
[/\bsendBeacon\b/, "sendBeacon"],
[/\bWebSocket\b/, "WebSocket"],
[/\bimport\s*\(/, "import("],
[/document\s*\.\s*cookie/, "document.cookie"],
[/\blocalStorage\b/, "localStorage"],
[/\bsessionStorage\b/, "sessionStorage"],
[/\blocation(?:\s*\.\s*href)?\s*=(?!=)/, "location assignment"],
[/\blocation\s*\.\s*(?:assign|replace)\s*\(/, "location.assign/replace"],
[/\bwindow\s*\.\s*open\s*\(/, "window.open("],
[/\.submit\s*\(/, ".submit("],
];

const SENSITIVE =
/(?:^|[^a-z])(?:pass(?:word|wd|code|phrase)?|token|secret|otp|one[-_ ]?time|cvv|cvc|ssn|pin)(?:[^a-z]|$)|cc-(?:number|csc|exp)|card/i;

const URL_PARAM_ONLY = /^\{\{\s*\w+\s*\|\s*url\s*\}\}$/;

function opensAllowedOrigin(url: string, origins: readonly string[]): boolean {
if (URL_PARAM_ONLY.test(url)) return true; // checked against origins at render time
return origins.some((o) => url === o || url.startsWith(`${o}/`) || url.startsWith(`${o}?`) || url.startsWith(`${o}#`));
}

function sensitiveLocator(step: Step): string | null {
const cmd = step[0];
if (cmd === "fill" || cmd === "type") return SENSITIVE.test(step[1] ?? "") ? (step[1] ?? "") : null;
if (cmd === "find") {
const action = step.findIndex((a, i) => i > 0 && (a === "fill" || a === "type"));
if (action < 0) return null;
const locator = [...step.slice(1, action), ...step.slice(action + 2)].join(" ");
return SENSITIVE.test(locator) ? locator : null;
}
return null;
}

/** Security rules. A recipe with any problem must not be stored or run. */
export function lintRecipe(recipe: Recipe): string[] {
const problems: string[] = [];
recipe.steps.forEach((step, i) => {
const at = `step ${i + 1}`;
const cmd = step[0] ?? "";
if (!ALLOWED.has(cmd)) problems.push(`${at}: command "${cmd}" is not allowed in recipes`);
for (const arg of step) {
const ref = /(?:^|[^\w])(@e\d+)\b/.exec(arg);
if (ref) problems.push(`${at}: uses snapshot ref ${ref[1]}, which only exists in one snapshot`);
if (arg.includes(":nth-of-type(") && arg.includes("{{"))
problems.push(`${at}: :nth-of-type built from a param counts mixed siblings; count the items themselves`);
}
if (cmd === "eval") {
const code = step[1] ?? "";
if (step.length !== 2) problems.push(`${at}: eval takes exactly one code argument`);
if (code.includes("{{")) problems.push(`${at}: eval code must read params, not use {{…}} templates`);
for (const [re, label] of EVAL_FORBIDDEN) if (re.test(code)) problems.push(`${at}: eval code uses ${label}`);
}
if (cmd === "open" && !opensAllowedOrigin(step[1] ?? "", recipe.origins)) {
problems.push(`${at}: opens a URL outside the recipe's origins`);
}
const sensitive = sensitiveLocator(step);
if (sensitive !== null) problems.push(`${at}: fills a password, token, OTP, or card field (${sensitive}); use agent-browser auth`);
});
return problems;
}
159 changes: 159 additions & 0 deletions packages/core/src/recipe/parse.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import type { ParamSpec, ParamType, Recipe, RecipeStatus, RegressionCase, Step, VerifyRule } from "./types.ts";

export type ParseResult = { ok: true; recipe: Recipe } | { ok: false; errors: string[] };

const NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
const PARAM_TYPES: readonly ParamType[] = ["string", "int", "enum"];
const STATUSES: readonly RecipeStatus[] = ["draft", "approved"];

function isRecord(v: unknown): v is Record<string, unknown> {
return typeof v === "object" && v !== null && !Array.isArray(v);
}

function isStringArray(v: unknown): v is string[] {
return Array.isArray(v) && v.every((x) => typeof x === "string");
}

function isOrigin(v: string): boolean {
try {
const u = new URL(v);
return (u.protocol === "https:" || u.protocol === "http:") && u.origin === v;
} catch {
return false;
}
}

function parseParam(name: string, v: unknown, errors: string[]): ParamSpec | null {
if (!isRecord(v)) {
errors.push(`param ${name}: must be an object`);
return null;
}
const type = v.type;
if (typeof type !== "string" || !PARAM_TYPES.includes(type as ParamType)) {
errors.push(`param ${name}: type must be string, int, or enum`);
return null;
}
if (typeof v.description !== "string" || typeof v.example !== "string") {
errors.push(`param ${name}: description and example must be strings`);
return null;
}
const spec: ParamSpec = { type: type as ParamType, description: v.description, example: v.example };
if (v.default !== undefined) {
if (typeof v.default !== "string") errors.push(`param ${name}: default must be a string`);
else spec.default = v.default;
}
if (type === "enum") {
if (!isStringArray(v.values) || v.values.length === 0) errors.push(`param ${name}: enum needs non-empty values`);
else spec.values = v.values;
}
if (type === "int" && spec.default !== undefined && !/^-?\d+$/.test(spec.default)) {
errors.push(`param ${name}: default is not an integer`);
}
if (type === "enum" && spec.default !== undefined && spec.values && !spec.values.includes(spec.default)) {
errors.push(`param ${name}: default is not one of values`);
}
return spec;
}

function parseVerify(v: unknown, errors: string[]): VerifyRule {
if (!isRecord(v)) {
errors.push("verify must be an object");
return { required: [], match: {} };
}
const required = v.required === undefined ? [] : v.required;
if (!isStringArray(required)) errors.push("verify.required must be a string array");
const match: Record<string, string> = {};
if (v.match !== undefined) {
if (!isRecord(v.match)) errors.push("verify.match must be an object");
else
for (const [k, pat] of Object.entries(v.match)) {
if (typeof pat !== "string") errors.push(`verify.match.${k} must be a string`);
else match[k] = pat;
}
}
return { required: isStringArray(required) ? required : [], match };
}

function parseRegression(v: unknown, errors: string[]): RegressionCase[] {
if (v === undefined) return [];
if (!Array.isArray(v)) {
errors.push("regression must be an array");
return [];
}
const out: RegressionCase[] = [];
for (const c of v) {
if (!isRecord(c) || !isRecord(c.params) || !isRecord(c.expect)) {
errors.push("regression cases need params and expect objects");
continue;
}
const params: Record<string, string> = {};
for (const [k, val] of Object.entries(c.params)) {
if (typeof val !== "string") errors.push(`regression param ${k} must be a string`);
else params[k] = val;
}
out.push({ params, expect: c.expect });
}
return out;
}

/** Structural validation. Security rules live in `lintRecipe`. */
export function parseRecipe(input: unknown): ParseResult {
const errors: string[] = [];
if (!isRecord(input)) return { ok: false, errors: ["recipe must be a JSON object"] };

const name = input.name;
if (typeof name !== "string" || !NAME.test(name)) errors.push("name must be kebab-case");
if (input.version !== 1) errors.push("version must be 1");
const status = input.status ?? "draft";
if (typeof status !== "string" || !STATUSES.includes(status as RecipeStatus)) errors.push("status must be draft or approved");
const auth = input.auth ?? false;
if (typeof auth !== "boolean") errors.push("auth must be a boolean");
if (typeof input.intent !== "string" || input.intent.trim() === "") errors.push("intent must be a non-empty string");
const examples = input.examples ?? [];
if (!isStringArray(examples)) errors.push("examples must be a string array");
const origins = input.origins;
if (!isStringArray(origins) || origins.length === 0) errors.push("origins must be a non-empty string array");
else for (const o of origins) if (!isOrigin(o)) errors.push(`origin ${o} must look like https://host[:port]`);

const params: Record<string, ParamSpec> = {};
if (input.params !== undefined) {
if (!isRecord(input.params)) errors.push("params must be an object");
else
for (const [k, v] of Object.entries(input.params)) {
if (!/^\w+$/.test(k)) errors.push(`param name ${k} must be a word`);
const spec = parseParam(k, v, errors);
if (spec) params[k] = spec;
}
}

const steps: Step[] = [];
if (!Array.isArray(input.steps) || input.steps.length === 0) errors.push("steps must be a non-empty array");
else
for (const s of input.steps) {
if (!isStringArray(s) || s.length === 0) errors.push("each step must be a non-empty array of strings");
else steps.push(s);
}
const last = steps.at(-1);
if (!last || last[0] !== "eval" || last.length !== 2) errors.push('the last step must be ["eval", "<code>"]');

const verify = parseVerify(input.verify, errors);
const regression = parseRegression(input.regression, errors);

if (errors.length) return { ok: false, errors };
return {
ok: true,
recipe: {
name: name as string,
version: 1,
status: status as RecipeStatus,
auth: auth as boolean,
intent: input.intent as string,
examples: examples as string[],
origins: origins as string[],
params,
steps,
verify,
regression,
},
};
}
101 changes: 101 additions & 0 deletions packages/core/src/recipe/render.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { TEMPLATE, type ParamValue, type Recipe } from "./types.ts";

export type RenderResult =
| { ok: true; steps: string[][]; params: Record<string, ParamValue> }
| { ok: false; error: string };

class RenderError extends Error {}

function resolveParams(recipe: Recipe, input: Readonly<Record<string, string>>): Record<string, ParamValue> {
for (const k of Object.keys(input)) if (!(k in recipe.params)) throw new RenderError(`unknown param ${k}`);
const out: Record<string, ParamValue> = {};
for (const [name, spec] of Object.entries(recipe.params)) {
const raw = input[name] ?? spec.default;
if (raw === undefined) throw new RenderError(`missing param ${name}`);
switch (spec.type) {
case "string":
out[name] = raw;
break;
case "int":
if (!/^-?\d+$/.test(raw)) throw new RenderError(`param ${name} must be an integer`);
out[name] = Number.parseInt(raw, 10);
break;
case "enum":
if (!spec.values?.includes(raw)) throw new RenderError(`param ${name} must be one of ${spec.values?.join(", ") ?? ""}`);
out[name] = raw;
break;
default: {
const never: never = spec.type;
throw new RenderError(`unknown param type ${String(never)}`);
}
}
}
return out;
}

function inOrigins(url: string, origins: readonly string[]): boolean {
try {
const u = new URL(url);
return (u.protocol === "https:" || u.protocol === "http:") && origins.includes(u.origin);
} catch {
return false;
}
}

function applyFilter(value: string, filter: string | undefined, origins: readonly string[]): string {
switch (filter) {
case undefined:
return value;
case "lower":
return value.toLowerCase();
case "underscore":
return value.replace(/ /g, "_");
case "path":
return value.split("/").map(encodeURIComponent).join("/");
case "url":
if (!inOrigins(value, origins)) throw new RenderError("url param is outside the recipe's origins");
return value;
default:
throw new RenderError(`unknown filter ${filter}`);
}
}

function renderArg(arg: string, params: Record<string, ParamValue>, origins: readonly string[]): string {
if (!arg.includes("{{")) return arg;
const out = arg.replace(TEMPLATE, (_m, name: string, filter: string | undefined) => {
const v = params[name];
if (v === undefined) throw new RenderError(`template uses unknown param ${name}`);
return applyFilter(String(v), filter, origins);
});
if (out.startsWith("-")) throw new RenderError("a param value may not start an argument with '-'");
return out;
}

function utf8Base64(text: string): string {
let binary = "";
for (const byte of new TextEncoder().encode(text)) binary += String.fromCharCode(byte);
return btoa(binary);
}

/** Wrap eval code so it reads `params` from a JSON literal; values are never pasted into code. */
export function evalWithParams(code: string, params: Record<string, ParamValue>): string {
return `(function (params) { return eval(${JSON.stringify(code)}); })(JSON.parse(${JSON.stringify(JSON.stringify(params))}))`;
}

export function renderRecipe(recipe: Recipe, input: Readonly<Record<string, string>>): RenderResult {
try {
const params = resolveParams(recipe, input);
const steps = recipe.steps.map((step) => {
if (step[0] === "eval") return ["eval", "-b", utf8Base64(evalWithParams(step[1] ?? "", params))];
const rendered = step.map((arg) => renderArg(arg, params, recipe.origins));
if (rendered[0] === "open" && !inOrigins(rendered[1] ?? "", recipe.origins)) {
throw new RenderError("rendered URL is outside the recipe's origins");
}
return rendered;
});
return { ok: true, steps, params };
} catch (e) {
if (e instanceof RenderError) return { ok: false, error: e.message };
throw e;
}
}
46 changes: 46 additions & 0 deletions packages/core/src/recipe/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
export type ParamType = "string" | "int" | "enum";

export type ParamSpec = {
type: ParamType;
description: string;
example: string;
default?: string;
values?: readonly string[];
};

/** One agent-browser command as an argv array, without `--session`. */
export type Step = readonly string[];

export type VerifyRule = {
required: readonly string[];
/** field → regex; `{{param}}` is replaced by the regex-escaped param value. */
match: Readonly<Record<string, string>>;
};

export type RegressionCase = {
params: Readonly<Record<string, string>>;
expect: Readonly<Record<string, unknown>>;
};

export type RecipeStatus = "draft" | "approved";

export type Recipe = {
name: string;
version: 1;
status: RecipeStatus;
/** Needs the user's saved logins; runs only when approved. */
auth: boolean;
intent: string;
examples: readonly string[];
/** Origins the recipe may open, e.g. "https://github.com". */
origins: readonly string[];
params: Readonly<Record<string, ParamSpec>>;
steps: readonly Step[];
verify: VerifyRule;
regression: readonly RegressionCase[];
};

export type ParamValue = string | number;

/** `{{name}}` or `{{name|filter}}`. */
export const TEMPLATE = /\{\{\s*(\w+)(?:\s*\|\s*(\w+))?\s*\}\}/g;
Loading
Loading