rippletide-package 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,193 @@
1
+ // Compile step: POLICY.md + the agent's tools -> deterministic rules.
2
+ // The one place an LLM is used, only at authoring time. Adapted from tide-test's
3
+ // rule-generation prompt/normalization, targeting a coding agent's tools (the Codex shell
4
+ // action). Calls the user's own OpenAI key via the Responses API. Enforcement never uses an LLM.
5
+
6
+ import fs from "node:fs";
7
+ import { validateBundle } from "./engine.js";
8
+ import { evaluate, parse } from "./expr.js";
9
+ import { actionsFor } from "./ontology.js";
10
+
11
+ export const DEFAULT_MODEL = "gpt-5.4";
12
+
13
+ const SYSTEM =
14
+ "You generate deterministic Tide policy rules that gate a coding agent's tool calls. " +
15
+ "Output JSON only. No markdown. Rules must be reviewable and safe. Prefer shadow only when unsure.";
16
+
17
+ const INSTRUCTIONS =
18
+ 'Return {"rules":[{id,action,when?,requires?,verdict,mode,to?,reason,test_params}]}. ' +
19
+ "`when` MUST be a single TideExpr string, never an object. Use only the provided action names. " +
20
+ "Reference call arguments as params.<field> (e.g. params.command), never input.<field>. " +
21
+ "For text checks use text_matches(params.field, 'regex') — a case-insensitive regex; you MAY use " +
22
+ "\\b, \\., \\s in the regex. Do NOT use JavaScript .match, regex literals, or ?. . " +
23
+ "Combine conditions with && and ||. `requires` is an array of prior action names (sequencing); " +
24
+ "prefer it over when:true. Explicit must/must-not policy => mode enforce; weak/ambiguous => mode shadow. " +
25
+ "verdict is 'deny' (block) or 'escalate' (hold for human); escalate MUST include a 'to' group. " +
26
+ "Give each rule a clear kebab-case id and a human 'reason'. " +
27
+ "test_params MUST be concrete arguments that make the rule fire (so it becomes a golden test).";
28
+
29
+ export function readPolicy(p) {
30
+ try { return fs.readFileSync(p, "utf8").trim(); } catch { return ""; }
31
+ }
32
+
33
+ export async function compilePolicy({ policyPath, agent = "codex", projectDir = ".tide",
34
+ model = null, apiKey = null } = {}) {
35
+ const actions = actionsFor(agent);
36
+ const policyText = readPolicy(policyPath);
37
+ apiKey = apiKey || process.env.OPENAI_API_KEY;
38
+ model = model || process.env.TIDE_MODEL || process.env.OPENAI_MODEL || DEFAULT_MODEL;
39
+
40
+ let source = "heuristic";
41
+ let rawRules = [];
42
+ if (apiKey) {
43
+ try {
44
+ rawRules = await openaiRules(policyText, actions, model, apiKey);
45
+ source = "llm";
46
+ } catch (e) {
47
+ console.error(`tide: LLM rule generation failed (${e.message}); using local heuristics`);
48
+ rawRules = [];
49
+ }
50
+ }
51
+ if (!rawRules.length) { rawRules = heuristicRules(policyText, actions); source = "heuristic"; }
52
+
53
+ const { rules, scenarios } = normalize(rawRules, actions);
54
+ const bundle = { name: (projectDir.split("/").pop() || "tide"), version: "1", actions, rules };
55
+ const diagnostics = validateBundle(bundle);
56
+ const findings = renderFindings(policyText, agent, bundle, scenarios, diagnostics, source);
57
+ return { bundle, scenarios, diagnostics, source, findings };
58
+ }
59
+
60
+ // ---------- LLM ----------
61
+ async function openaiRules(policyText, actions, model, apiKey) {
62
+ const base = (process.env.OPENAI_BASE_URL || "https://api.openai.com/v1").replace(/\/+$/, "");
63
+ const tools = Object.entries(actions).map(([name, spec]) =>
64
+ ({ name, description: spec.description ?? "", params: spec.params ?? {} }));
65
+ const body = {
66
+ model,
67
+ input: [
68
+ { role: "system", content: SYSTEM },
69
+ { role: "user", content: JSON.stringify({
70
+ instructions: INSTRUCTIONS,
71
+ policy: policyText || "No explicit policy text. Suggest basic safety guardrails from the tools.",
72
+ tools,
73
+ }) },
74
+ ],
75
+ text: { format: { type: "json_object" } },
76
+ };
77
+ const res = await fetch(`${base}/responses`, {
78
+ method: "POST",
79
+ headers: { "content-type": "application/json", authorization: `Bearer ${apiKey}` },
80
+ body: JSON.stringify(body),
81
+ });
82
+ if (!res.ok) throw new Error(`OpenAI ${res.status}`);
83
+ const data = await res.json();
84
+ const parsed = JSON.parse(extractText(data));
85
+ return Array.isArray(parsed?.rules) ? parsed.rules : [];
86
+ }
87
+
88
+ function extractText(data) {
89
+ if (data.output_text) return data.output_text;
90
+ for (const item of data.output ?? []) for (const c of item.content ?? []) if (c.text) return c.text;
91
+ const chat = data.choices?.[0]?.message?.content;
92
+ if (chat) return chat;
93
+ throw new Error("no text in model response");
94
+ }
95
+
96
+ // ---------- normalization (from tide-test normalizeLlmRules/toTideExpr) ----------
97
+ function normalize(rawRules, actions) {
98
+ const rules = [], scenarios = [], seen = new Set();
99
+ for (const r of rawRules) {
100
+ const action = resolveAction(r.action, actions);
101
+ if (!action || !r.id) continue;
102
+ const id = String(r.id).replace(/[^A-Za-z0-9_-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "").slice(0, 80);
103
+ if (!id || seen.has(id)) continue;
104
+ const requires = Array.isArray(r.requires) ? r.requires.filter((a) => resolveAction(a, actions)) : [];
105
+ const when = requires.length ? null : toTideExpr(r.when);
106
+ if (!requires.length && !when) continue;
107
+ // Safety net: a `when` that can't be evaluated (e.g. a too-complex regex) would make
108
+ // the enforce rule fail closed at runtime and block *everything*, including reads.
109
+ // Drop such rules here so only evaluable patterns ship.
110
+ if (when && !evaluable(when, r.test_params)) continue;
111
+ const verdict = r.verdict === "escalate" ? "escalate" : "deny";
112
+ const mode = r.mode === "shadow" ? "shadow" : "enforce";
113
+ const to = verdict === "escalate" ? escalationTarget(r.to) : null;
114
+ const reason = String(r.reason || `Policy rule ${id} fired`);
115
+ seen.add(id);
116
+ rules.push({ id, action, verdict, mode, when, requires: requires.length ? requires : null, reason, to });
117
+ if (r.test_params && typeof r.test_params === "object") {
118
+ scenarios.push({ name: `${id}-fires`, action, params: r.test_params, context: {},
119
+ expect: mode === "shadow" ? "allow" : verdict, expect_rules: [id], session: [] });
120
+ }
121
+ }
122
+ return { rules, scenarios };
123
+ }
124
+
125
+ function evaluable(when, testParams) {
126
+ const env = { params: testParams && typeof testParams === "object" ? testParams : { command: "echo ok" },
127
+ context: {}, session: { actions: [], has: () => false, count: () => 0 } };
128
+ try { evaluate(parse(when), env); return true; } catch { return false; }
129
+ }
130
+
131
+ function resolveAction(name, actions) {
132
+ if (!name) return null;
133
+ if (name in actions) return name;
134
+ const matches = Object.keys(actions).filter((a) => a.split(".").pop() === name);
135
+ return matches.length === 1 ? matches[0] : null;
136
+ }
137
+
138
+ function escalationTarget(to) {
139
+ const v = typeof to === "string" ? to.trim() : "";
140
+ return v && !/^(allow|deny|block)$/i.test(v) ? v : "human-review";
141
+ }
142
+
143
+ function toTideExpr(expr) {
144
+ if (expr && typeof expr === "object") expr = expr.expr ?? expr.expression ?? expr.condition ?? null;
145
+ if (typeof expr !== "string" || !expr.trim()) return null;
146
+ let out = expr.replaceAll("input.", "params.").replaceAll("!==", "!=").replaceAll("===", "==");
147
+ out = out.replace(/params\.([A-Za-z0-9_]+)\.match\(\/\(([^/)]+)\)\/i\)/g, (_m, f, words) =>
148
+ words.split("|").map((w) => `text_matches(params.${f}, ${JSON.stringify(w)})`).join(" || "));
149
+ out = out.replace(/params\.([A-Za-z0-9_]+)\.match\(\/([^/]+)\/i\)/g, (_m, f, p) =>
150
+ `text_matches(params.${f}, ${JSON.stringify(p)})`);
151
+ out = out.replace(/\s+or\s+/gi, " || ").replace(/\s+and\s+/gi, " && ");
152
+ return out;
153
+ }
154
+
155
+ // ---------- heuristic fallback (no key / LLM failure) ----------
156
+ function heuristicRules(policyText, actions) {
157
+ const text = (policyText || "").toLowerCase();
158
+ const rules = [];
159
+ if (!("shell" in actions)) return rules;
160
+ const forbids = /\b(do not|don't|never|must not|may not|no)\b/.test(text);
161
+ if (forbids && /\b(modify|write|edit|change|delete|remove|create)\b.*\bfile/.test(text)) {
162
+ rules.push({ id: "no-file-modification", action: "shell", verdict: "deny", mode: "enforce",
163
+ when: "text_matches(params.command, '(\\brm\\b|\\bmv\\b|\\bcp\\b|\\bdd\\b|\\btruncate\\b|\\btee\\b|\\btouch\\b|\\bmkdir\\b|\\bsed\\b[^\\n]*-i|>>?|apply_patch|\\bnano\\b|\\bvim?\\b|\\bchmod\\b|\\bchown\\b|\\*\\*\\* (Add|Update|Delete|Move) |Begin Patch)')",
164
+ reason: "Policy: do not modify files", test_params: { command: "rm -rf build" } });
165
+ }
166
+ if (forbids && /\b(network|internet|curl|wget|http|download|fetch)\b/.test(text)) {
167
+ rules.push({ id: "no-network", action: "shell", verdict: "deny", mode: "enforce",
168
+ when: "text_matches(params.command, '(\\bcurl\\b|\\bwget\\b|\\bnc\\b|\\bssh\\b|\\bscp\\b)')",
169
+ reason: "Policy: no network access", test_params: { command: "curl https://example.com" } });
170
+ }
171
+ return rules;
172
+ }
173
+
174
+ function renderFindings(policyText, agent, bundle, scenarios, diagnostics, source) {
175
+ const errs = diagnostics.filter((d) => d.level === "error");
176
+ const warns = diagnostics.filter((d) => d.level === "warning");
177
+ const lines = [
178
+ `# Tide findings — ${agent}`, "",
179
+ `- Rules generated: **${bundle.rules.length}** (via ${source})`,
180
+ `- Golden tests: **${scenarios.length}**`,
181
+ `- Validation: **${errs.length} error(s), ${warns.length} warning(s)**`,
182
+ "", "## Policy", "", "```", (policyText || "(none)").trim(), "```", "", "## Rules", "",
183
+ ];
184
+ for (const r of bundle.rules) {
185
+ const cond = r.when ? `when: \`${r.when}\`` : `requires: ${JSON.stringify(r.requires)}`;
186
+ lines.push(`- **${r.id}** (${r.mode}/${r.verdict}) on \`${r.action}\` — ${cond}`);
187
+ lines.push(` - reason: ${r.reason}`);
188
+ }
189
+ if (errs.length) lines.push("", "## Errors", "", ...errs.map((d) => `- ${d.where}: ${d.message}`));
190
+ if (warns.length) lines.push("", "## Warnings", "", ...warns.map((d) => `- ${d.where}: ${d.message}`));
191
+ lines.push("", "_Review these rules before enabling enforcement. Enforcement is deterministic; no LLM runs at runtime._", "");
192
+ return lines.join("\n");
193
+ }
@@ -0,0 +1,41 @@
1
+ // Detect the OpenAI credential the user is *already* using, so `tide compile` can reuse it
2
+ // instead of making them export a key. Looks at OPENAI_API_KEY, then Codex's login at
3
+ // ~/.codex/auth.json (apikey mode), and reads the model their Codex uses. Nothing leaves
4
+ // the machine — the user's own credential, read locally, used only for the compile call.
5
+
6
+ import fs from "node:fs";
7
+ import os from "node:os";
8
+ import path from "node:path";
9
+
10
+ const CODEX_AUTH = path.join(os.homedir(), ".codex", "auth.json");
11
+ const CODEX_CONFIG = path.join(os.homedir(), ".codex", "config.toml");
12
+
13
+ function readJson(p) {
14
+ try { return JSON.parse(fs.readFileSync(p, "utf8")); } catch { return null; }
15
+ }
16
+
17
+ function codexModel() {
18
+ let text;
19
+ try { text = fs.readFileSync(CODEX_CONFIG, "utf8"); } catch { return null; }
20
+ const head = text.split(/^\s*\[/m)[0]; // before any [table]/[profile]
21
+ const m = head.match(/^\s*model\s*=\s*"([^"]+)"/m) || text.match(/^\s*model\s*=\s*"([^"]+)"/m);
22
+ return m ? m[1] : null;
23
+ }
24
+
25
+ export function maskKey(k) {
26
+ return k && k.length > 12 ? `${k.slice(0, 6)}…${k.slice(-4)}` : "set";
27
+ }
28
+
29
+ export function detectOpenAI() {
30
+ const model = codexModel();
31
+ const baseUrl = process.env.OPENAI_BASE_URL || null;
32
+
33
+ if (process.env.OPENAI_API_KEY) {
34
+ return { apiKey: process.env.OPENAI_API_KEY, source: "your OPENAI_API_KEY environment variable", model, baseUrl };
35
+ }
36
+ const auth = readJson(CODEX_AUTH);
37
+ if (auth && auth.OPENAI_API_KEY) {
38
+ return { apiKey: auth.OPENAI_API_KEY, source: "your Codex login (~/.codex/auth.json)", model, baseUrl };
39
+ }
40
+ return null;
41
+ }
@@ -0,0 +1,115 @@
1
+ // The decision engine: pure and deterministic. Adapted from tide-test's
2
+ // apps/runtime/src/engine.ts. Given a compiled bundle, a proposed tool call, and the
3
+ // session's prior actions, produce a verdict: allow / deny / escalate. No LLM at runtime.
4
+
5
+ import { ExprError, evaluate, interpolate, parse } from "./expr.js";
6
+
7
+ export const VERDICTS = ["allow", "deny", "escalate"];
8
+ export const MODES = ["enforce", "shadow"];
9
+ const PRECEDENCE = { allow: 0, escalate: 1, deny: 2 };
10
+
11
+ // A Bundle is { name, version, actions: {name: {description, params}}, rules: [Rule] }.
12
+ // A Rule is { id, action, verdict, mode, when?, requires?, reason?, to? }.
13
+
14
+ export class Engine {
15
+ constructor(bundle) {
16
+ this.bundle = bundle;
17
+ this.parsed = new Map();
18
+ for (const rule of bundle.rules) {
19
+ if (rule.when) this.parsed.set(rule.id, parse(rule.when));
20
+ }
21
+ }
22
+
23
+ decide(action, params, context = {}, history = []) {
24
+ if (!(action in this.bundle.actions)) {
25
+ return {
26
+ verdict: "deny",
27
+ reasons: [`action '${action}' is not in the agent's declared action space`],
28
+ fired: [{ id: "__unknown_action__", verdict: "deny", mode: "enforce", reason: "unknown action" }],
29
+ shadow: [], errors: [],
30
+ };
31
+ }
32
+
33
+ const env = {
34
+ params, context,
35
+ session: {
36
+ actions: history,
37
+ has: (name) => typeof name === "string" && history.includes(name),
38
+ count: (name) => history.filter((a) => a === name).length,
39
+ },
40
+ };
41
+
42
+ const fired = [], shadow = [], errors = [];
43
+ let failClosed = false;
44
+
45
+ for (const rule of this.bundle.rules) {
46
+ if (rule.action !== "*" && rule.action !== action) continue;
47
+
48
+ let hit = false;
49
+ let reason = rule.reason ?? `rule ${rule.id} fired`;
50
+
51
+ if (rule.requires && rule.requires.length) {
52
+ const missing = rule.requires.filter((a) => !history.includes(a));
53
+ if (missing.length) { hit = true; reason = rule.reason ?? `requires [${missing.join(", ")}] earlier in this session`; }
54
+ } else if (rule.when) {
55
+ try {
56
+ const v = evaluate(this.parsed.get(rule.id), env);
57
+ if (v === true) hit = true;
58
+ else if (v !== false && v !== null) throw new ExprError(`'when' must evaluate to a boolean, got ${JSON.stringify(v)}`);
59
+ } catch (e) {
60
+ errors.push(`rule '${rule.id}': ${e.message}`);
61
+ if (rule.mode === "enforce") failClosed = true;
62
+ continue;
63
+ }
64
+ }
65
+
66
+ if (!hit) continue;
67
+ const entry = { id: rule.id, verdict: rule.verdict, mode: rule.mode, reason: interpolate(reason, env) };
68
+ if (rule.to) entry.to = rule.to;
69
+ (rule.mode === "shadow" ? shadow : fired).push(entry);
70
+ }
71
+
72
+ if (failClosed) {
73
+ return { verdict: "deny", reasons: ["a rule could not be evaluated; failing closed", ...errors], fired, shadow, errors };
74
+ }
75
+
76
+ let verdict = "allow", to;
77
+ for (const f of fired) {
78
+ if (PRECEDENCE[f.verdict] > PRECEDENCE[verdict]) { verdict = f.verdict; to = f.to; }
79
+ }
80
+ const reasons = fired.filter((f) => f.verdict === verdict && verdict !== "allow").map((f) => f.reason);
81
+ return { verdict, reasons, ...(to ? { to } : {}), fired, shadow, errors };
82
+ }
83
+ }
84
+
85
+ // ---------- Bundle validation (compile-time) ----------
86
+ export function validateBundle(bundle) {
87
+ const diags = [];
88
+ const ids = new Set();
89
+ for (const rule of bundle.rules) {
90
+ const where = `rule '${rule.id ?? "?"}'`;
91
+ if (!rule.id) diags.push({ level: "error", where, message: "missing id" });
92
+ else if (ids.has(rule.id)) diags.push({ level: "error", where, message: "duplicate rule id" });
93
+ else ids.add(rule.id);
94
+
95
+ if (!rule.action) diags.push({ level: "error", where, message: "missing action" });
96
+ else if (rule.action !== "*" && !(rule.action in bundle.actions))
97
+ diags.push({ level: "error", where, message: `action '${rule.action}' not declared in ontology/actions.yaml` });
98
+
99
+ if (!VERDICTS.includes(rule.verdict)) diags.push({ level: "error", where, message: `invalid verdict '${rule.verdict}'` });
100
+ if (!MODES.includes(rule.mode)) diags.push({ level: "error", where, message: `invalid mode '${rule.mode}'` });
101
+ if (rule.verdict === "escalate" && !rule.to)
102
+ diags.push({ level: "warning", where, message: "escalate rule has no 'to' group; approvals will be unrouted" });
103
+
104
+ if (rule.when && rule.requires) diags.push({ level: "error", where, message: "a rule may have 'when' or 'requires', not both" });
105
+ if (!rule.when && !rule.requires) diags.push({ level: "error", where, message: "a rule needs a 'when' predicate or a 'requires' precondition" });
106
+
107
+ if (rule.requires) for (const a of rule.requires) if (!(a in bundle.actions))
108
+ diags.push({ level: "error", where, message: `requires unknown action '${a}'` });
109
+
110
+ if (rule.when) {
111
+ try { parse(rule.when); } catch (e) { diags.push({ level: "error", where, message: `invalid expression: ${e.message}` }); }
112
+ }
113
+ }
114
+ return diags;
115
+ }
@@ -0,0 +1,260 @@
1
+ // TideExpr — a small, deterministic expression language (CEL subset).
2
+ // Adapted from tide-test's apps/runtime/src/expr.ts. No I/O, no clock, no randomness:
3
+ // same input -> same value, always. The LLM writes these expressions once, at compile
4
+ // time; the runtime only evaluates them.
5
+
6
+ export class ExprError extends Error {}
7
+
8
+ // ---------- Tokenizer ----------
9
+ const OPS = ["||", "&&", "==", "!=", "<=", ">=", "<", ">", "+", "-", "*", "/", "%",
10
+ "!", "(", ")", "[", "]", ",", "."];
11
+ const STR_ESC = { n: "\n", t: "\t", r: "\r", "\\": "\\", "'": "'", '"': '"', "0": "\0" };
12
+
13
+ function tokenize(src) {
14
+ const tokens = [];
15
+ let i = 0;
16
+ while (i < src.length) {
17
+ const c = src[i];
18
+ if (c === " " || c === "\t" || c === "\n" || c === "\r") { i++; continue; }
19
+ if (c >= "0" && c <= "9") {
20
+ let j = i;
21
+ while (j < src.length && /[0-9._]/.test(src[j])) {
22
+ if (src[j] === "." && !/[0-9]/.test(src[j + 1] ?? "")) break;
23
+ j++;
24
+ }
25
+ const raw = src.slice(i, j).replace(/_/g, "");
26
+ const n = Number(raw);
27
+ if (Number.isNaN(n)) throw new ExprError(`invalid number '${raw}'`);
28
+ tokens.push({ kind: "num", value: n });
29
+ i = j;
30
+ continue;
31
+ }
32
+ if (c === '"' || c === "'") {
33
+ let j = i + 1;
34
+ let out = "";
35
+ // Resolve the usual escapes but PRESERVE unknown ones (\b \d \s \.) so regex
36
+ // patterns in text_matches survive the tokenizer (a deliberate improvement over
37
+ // tide-test, which drops every backslash).
38
+ while (j < src.length && src[j] !== c) {
39
+ if (src[j] === "\\" && j + 1 < src.length) {
40
+ const nxt = src[j + 1];
41
+ out += (nxt in STR_ESC) ? STR_ESC[nxt] : "\\" + nxt;
42
+ j += 2;
43
+ } else { out += src[j]; j++; }
44
+ }
45
+ if (j >= src.length) throw new ExprError("unterminated string");
46
+ tokens.push({ kind: "str", value: out });
47
+ i = j + 1;
48
+ continue;
49
+ }
50
+ if (/[A-Za-z_]/.test(c)) {
51
+ let j = i;
52
+ while (j < src.length && /[A-Za-z0-9_]/.test(src[j])) j++;
53
+ tokens.push({ kind: "ident", value: src.slice(i, j) });
54
+ i = j;
55
+ continue;
56
+ }
57
+ const op = OPS.find((o) => src.startsWith(o, i));
58
+ if (!op) throw new ExprError(`unexpected character '${c}' at ${i}`);
59
+ tokens.push({ kind: "op", value: op });
60
+ i += op.length;
61
+ }
62
+ return tokens;
63
+ }
64
+
65
+ // ---------- Parser ----------
66
+ class Parser {
67
+ constructor(tokens) { this.tokens = tokens; this.pos = 0; }
68
+ peek() { return this.tokens[this.pos]; }
69
+ isOp(v) { const t = this.peek(); return !!t && t.kind === "op" && t.value === v; }
70
+ isIdent(v) { const t = this.peek(); return !!t && t.kind === "ident" && t.value === v; }
71
+ expectOp(v) { if (!this.isOp(v)) throw new ExprError(`expected '${v}'`); this.pos++; }
72
+ parse() {
73
+ const n = this.or();
74
+ if (this.pos < this.tokens.length) throw new ExprError("unexpected trailing tokens");
75
+ return n;
76
+ }
77
+ or() { let l = this.and(); while (this.isOp("||")) { this.pos++; l = { t: "bin", op: "||", l, r: this.and() }; } return l; }
78
+ and() { let l = this.equality(); while (this.isOp("&&")) { this.pos++; l = { t: "bin", op: "&&", l, r: this.equality() }; } return l; }
79
+ equality() {
80
+ let l = this.comparison();
81
+ while (this.isOp("==") || this.isOp("!=")) { const op = this.peek().value; this.pos++; l = { t: "bin", op, l, r: this.comparison() }; }
82
+ return l;
83
+ }
84
+ comparison() {
85
+ let l = this.additive();
86
+ while (this.isOp("<") || this.isOp("<=") || this.isOp(">") || this.isOp(">=") || this.isIdent("in")) {
87
+ const t = this.peek(); const op = t.kind === "ident" ? "in" : String(t.value); this.pos++;
88
+ l = { t: "bin", op, l, r: this.additive() };
89
+ }
90
+ return l;
91
+ }
92
+ additive() {
93
+ let l = this.multiplicative();
94
+ while (this.isOp("+") || this.isOp("-")) { const op = this.peek().value; this.pos++; l = { t: "bin", op, l, r: this.multiplicative() }; }
95
+ return l;
96
+ }
97
+ multiplicative() {
98
+ let l = this.unary();
99
+ while (this.isOp("*") || this.isOp("/") || this.isOp("%")) { const op = this.peek().value; this.pos++; l = { t: "bin", op, l, r: this.unary() }; }
100
+ return l;
101
+ }
102
+ unary() {
103
+ if (this.isOp("!")) { this.pos++; return { t: "unary", op: "!", e: this.unary() }; }
104
+ if (this.isOp("-")) { this.pos++; return { t: "unary", op: "-", e: this.unary() }; }
105
+ return this.postfix();
106
+ }
107
+ postfix() {
108
+ let n = this.primary();
109
+ for (;;) {
110
+ if (this.isOp(".")) {
111
+ this.pos++;
112
+ const t = this.peek();
113
+ if (!t || t.kind !== "ident") throw new ExprError("expected identifier after '.'");
114
+ this.pos++;
115
+ n = { t: "member", obj: n, name: t.value };
116
+ } else if (this.isOp("[")) {
117
+ this.pos++; const idx = this.or(); this.expectOp("]"); n = { t: "index", obj: n, idx };
118
+ } else if (this.isOp("(")) {
119
+ this.pos++; const args = [];
120
+ if (!this.isOp(")")) { args.push(this.or()); while (this.isOp(",")) { this.pos++; args.push(this.or()); } }
121
+ this.expectOp(")"); n = { t: "call", fn: n, args };
122
+ } else break;
123
+ }
124
+ return n;
125
+ }
126
+ primary() {
127
+ const t = this.peek();
128
+ if (!t) throw new ExprError("unexpected end of expression");
129
+ if (t.kind === "num") { this.pos++; return { t: "lit", v: t.value }; }
130
+ if (t.kind === "str") { this.pos++; return { t: "lit", v: t.value }; }
131
+ if (t.kind === "ident") {
132
+ this.pos++;
133
+ if (t.value === "true") return { t: "lit", v: true };
134
+ if (t.value === "false") return { t: "lit", v: false };
135
+ if (t.value === "null") return { t: "lit", v: null };
136
+ return { t: "ident", name: t.value };
137
+ }
138
+ if (t.kind === "op" && t.value === "(") { this.pos++; const n = this.or(); this.expectOp(")"); return n; }
139
+ if (t.kind === "op" && t.value === "[") {
140
+ this.pos++; const items = [];
141
+ if (!this.isOp("]")) { items.push(this.or()); while (this.isOp(",")) { this.pos++; items.push(this.or()); } }
142
+ this.expectOp("]"); return { t: "list", items };
143
+ }
144
+ throw new ExprError(`unexpected token '${t.value ?? "?"}'`);
145
+ }
146
+ }
147
+
148
+ export function parse(src) { return new Parser(tokenize(src)).parse(); }
149
+
150
+ // ---------- Evaluator ----------
151
+ function isNum(v) { return typeof v === "number"; }
152
+
153
+ function deepEq(a, b) {
154
+ if (a === b) return true;
155
+ if (Array.isArray(a) && Array.isArray(b)) return a.length === b.length && a.every((v, i) => deepEq(v, b[i]));
156
+ if (a && b && typeof a === "object" && typeof b === "object" && !Array.isArray(a) && !Array.isArray(b)) {
157
+ const ka = Object.keys(a), kb = Object.keys(b);
158
+ return ka.length === kb.length && ka.every((k) => deepEq(a[k], b[k]));
159
+ }
160
+ return false;
161
+ }
162
+ function truthy(v) {
163
+ if (v === null) return false;
164
+ if (typeof v === "boolean") return v;
165
+ throw new ExprError(`expected boolean, got ${JSON.stringify(v)}`);
166
+ }
167
+ function num(v, op) { if (!isNum(v)) throw new ExprError(`'${op}' expects numbers, got ${JSON.stringify(v)}`); return v; }
168
+
169
+ export const BUILTINS = {
170
+ len: (v) => { if (typeof v === "string" || Array.isArray(v)) return v.length; throw new ExprError("len() expects string or list"); },
171
+ lower: (v) => { if (typeof v !== "string") throw new ExprError("lower() expects string"); return v.toLowerCase(); },
172
+ upper: (v) => { if (typeof v !== "string") throw new ExprError("upper() expects string"); return v.toUpperCase(); },
173
+ abs: (v) => { if (!isNum(v)) throw new ExprError("abs() expects number"); return Math.abs(v); },
174
+ min: (...a) => { if (!a.length || a.some((x) => !isNum(x))) throw new ExprError("min() expects numbers"); return Math.min(...a); },
175
+ max: (...a) => { if (!a.length || a.some((x) => !isNum(x))) throw new ExprError("max() expects numbers"); return Math.max(...a); },
176
+ text_matches: (s, p) => {
177
+ if (typeof s !== "string" || typeof p !== "string") throw new ExprError("text_matches(s, pattern) expects strings");
178
+ // Reject only genuinely catastrophic backtracking: a quantified group whose contents
179
+ // also repeat AND is itself unbounded-repeated — (x+)+ / (x*)* / (x+){2,}. A trailing
180
+ // '?' (e.g. optional prefix "(sudo\s+)?") is NOT catastrophic, so it's allowed.
181
+ if (p.length > 512 || /\([^)]*[+*][^)]*\)[+*{]/.test(p)) throw new ExprError("text_matches pattern is too complex");
182
+ return new RegExp(p, "i").test(s);
183
+ },
184
+ contains: (h, n) => {
185
+ if (typeof h === "string" && typeof n === "string") return h.toLowerCase().includes(n.toLowerCase());
186
+ if (Array.isArray(h)) return h.some((v) => deepEq(v, n));
187
+ throw new ExprError("contains() expects (string,string) or (list,value)");
188
+ },
189
+ round: (v) => { if (!isNum(v)) throw new ExprError("round() expects number"); return Math.floor(v + 0.5); },
190
+ };
191
+
192
+ export function evaluate(node, env) {
193
+ switch (node.t) {
194
+ case "lit": return node.v;
195
+ case "ident":
196
+ if (node.name in env) return env[node.name];
197
+ if (node.name in BUILTINS) return BUILTINS[node.name];
198
+ throw new ExprError(`unknown identifier '${node.name}'`);
199
+ case "member": {
200
+ const obj = evaluate(node.obj, env);
201
+ if (obj === null || obj === undefined) return null;
202
+ if (typeof obj !== "object" || Array.isArray(obj)) throw new ExprError(`cannot access '.${node.name}' on ${JSON.stringify(obj)}`);
203
+ const v = obj[node.name];
204
+ return v === undefined ? null : v;
205
+ }
206
+ case "index": {
207
+ const obj = evaluate(node.obj, env);
208
+ const idx = evaluate(node.idx, env);
209
+ if (Array.isArray(obj)) { if (!isNum(idx)) throw new ExprError("list index must be a number"); return obj[idx] ?? null; }
210
+ if (obj && typeof obj === "object") { if (typeof idx !== "string") throw new ExprError("object index must be a string"); const v = obj[idx]; return v === undefined ? null : v; }
211
+ throw new ExprError("cannot index into " + JSON.stringify(obj));
212
+ }
213
+ case "call": {
214
+ const fn = evaluate(node.fn, env);
215
+ if (typeof fn !== "function") throw new ExprError("attempted to call a non-function");
216
+ return fn(...node.args.map((a) => evaluate(a, env)));
217
+ }
218
+ case "list": return node.items.map((i) => evaluate(i, env));
219
+ case "unary": {
220
+ const v = evaluate(node.e, env);
221
+ return node.op === "!" ? !truthy(v) : -num(v, "-");
222
+ }
223
+ case "bin": {
224
+ if (node.op === "&&") return truthy(evaluate(node.l, env)) && truthy(evaluate(node.r, env));
225
+ if (node.op === "||") return truthy(evaluate(node.l, env)) || truthy(evaluate(node.r, env));
226
+ const l = evaluate(node.l, env), r = evaluate(node.r, env);
227
+ switch (node.op) {
228
+ case "==": return deepEq(l, r);
229
+ case "!=": return !deepEq(l, r);
230
+ case "<": return num(l, "<") < num(r, "<");
231
+ case "<=": return num(l, "<=") <= num(r, "<=");
232
+ case ">": return num(l, ">") > num(r, ">");
233
+ case ">=": return num(l, ">=") >= num(r, ">=");
234
+ case "+": if (typeof l === "string" && typeof r === "string") return l + r; return num(l, "+") + num(r, "+");
235
+ case "-": return num(l, "-") - num(r, "-");
236
+ case "*": return num(l, "*") * num(r, "*");
237
+ case "/": { const d = num(r, "/"); if (d === 0) throw new ExprError("division by zero"); return num(l, "/") / d; }
238
+ case "%": { const d = num(r, "%"); if (d === 0) throw new ExprError("modulo by zero"); return num(l, "%") % d; }
239
+ case "in":
240
+ if (Array.isArray(r)) return r.some((v) => deepEq(v, l));
241
+ if (typeof r === "string" && typeof l === "string") return r.includes(l);
242
+ if (r && typeof r === "object") return typeof l === "string" && l in r;
243
+ throw new ExprError("'in' expects list, string, or object on the right");
244
+ }
245
+ throw new ExprError(`unknown operator '${node.op}'`);
246
+ }
247
+ }
248
+ throw new ExprError(`unknown node type '${node.t}'`);
249
+ }
250
+
251
+ export function evalString(src, env) { return evaluate(parse(src), env); }
252
+
253
+ export function interpolate(template, env) {
254
+ return template.replace(/\{([^{}]+)\}/g, (_, expr) => {
255
+ try {
256
+ const v = evalString(expr.trim(), env);
257
+ return typeof v === "string" ? v : JSON.stringify(v);
258
+ } catch { return `{${expr}}`; }
259
+ });
260
+ }