@adonis-agora/agent
Version:
Governed, durable-backed AI agent (chat + tool-calling + governance) for AdonisJS — part of the Agora ecosystem.
557 lines • 23.3 kB
JavaScript
/**
* The sandbox's code, checked on the server BEFORE the call is accepted — so a model that wrote a
* view the frame cannot draw gets a short, exact tool error ("jsx line 12:5: `flippy.send` — the
* bridge is `agent.send(payload)`") and its retry succeeds, instead of the user seeing a broken
* frame.
*
* - JSX goes through the SAME transpiler the frame runs ({@link transpileJsx}) and is parsed with
* acorn (+ acorn-jsx) for exact positions; `App` must be defined.
* - HTML: every inline `<script>` and `on*=` handler, `jsFunctions` and each `jsExpressions` entry
* is parsed with acorn; a `<script src>` the policy blocks is refused.
* - Anything that looks like a bridge (`x.send(…)` on an undeclared `x`, `agent.post(…)`) other than
* `agent.send` is refused.
* - With the kit's docs, a `<Component>` the kit does not have is refused (it would throw in the
* frame); a prop the component does not declare is a WARNING — the call is accepted and the model
* is told.
*
* The parsers are loaded on first use (`await import`): this file is part of the isomorphic genui
* entry, and a browser bundle that never validates never loads them.
*/
import { prepareSandboxJsx, transpileJsx } from './sandbox-jsx.js';
/** `jsx line 12:5: …` / `jsExpressions[2] line 1:3: …` — the issue as the model reads it. */
export function formatSandboxIssue(issue) {
const field = issue.index !== undefined ? `${issue.field}[${issue.index}]` : issue.field;
const where = issue.line !== undefined
? ` line ${issue.line}${issue.column !== undefined ? `:${issue.column}` : ''}`
: '';
return `${field}${where}: ${issue.message}`;
}
/** Names the frame puts in scope for JSX besides the kit's components. */
const JSX_SCOPE = new Set([
'useState',
'useEffect',
'useMemo',
'useRef',
'useCallback',
'useReducer',
'useId',
'useLayoutEffect',
'Fragment',
'React',
'Kit',
]);
/** Props every element takes (React's own, and attributes a component passes through). */
const UNIVERSAL_PROPS = new Set(['key', 'ref', 'children', 'className', 'style', 'id']);
const BRIDGE_HOLDERS = new Set(['window', 'globalThis', 'self', 'parent', 'top']);
let parsers;
async function loadParsers() {
parsers ??= (async () => {
const acorn = await import('acorn');
const jsxModule = (await import('acorn-jsx'));
const plugin = (jsxModule.default ?? jsxModule);
const JsxParser = acorn.Parser.extend(plugin());
return {
js: (code, options) => acorn.parse(code, options),
jsx: (code, options) => JsxParser.parse(code, options),
};
})();
return parsers;
}
/**
* The sandbox's code, checked: errors first (each refuses the call), then warnings. JSX when the
* kit allows it and the view is in `jsx` (the longer of `jsx`/`html`, as the renderer decides);
* otherwise the HTML and its scripts.
*/
export async function validateSandboxCode(props, options = {}) {
const { js, jsx } = await loadParsers();
const jsxSource = typeof props.jsx === 'string' ? props.jsx : '';
const html = typeof props.html === 'string' ? props.html : '';
const drawsJsx = jsxSource.trim() !== '' && jsxSource.trim().length >= html.trim().length;
const issues = [];
if (drawsJsx && (options.jsx ?? options.kit?.jsx === true)) {
issues.push(...checkJsx(jsxSource, options.kit, js, jsx));
}
else {
issues.push(...checkHtml(html, options, js));
if (typeof props.jsFunctions === 'string' && props.jsFunctions.trim() !== '') {
issues.push(...checkScript(props.jsFunctions, 'jsFunctions', undefined, js));
}
if (Array.isArray(props.jsExpressions)) {
props.jsExpressions.forEach((code, index) => {
if (typeof code === 'string' && code.trim() !== '') {
issues.push(...checkScript(code, 'jsExpressions', index, js));
}
});
}
}
return [
...issues.filter((issue) => issue.severity === 'error'),
...issues.filter((issue) => issue.severity === 'warning'),
];
}
// ─── JSX ────────────────────────────────────────────────────────────────────────────────────────
/** {@link prepareSandboxJsx} line by line, so every line stays where it was (positions match the model's text). */
function prepareKeepingLines(source) {
return source.split('\n').map(prepareSandboxJsx).join('\n');
}
function checkJsx(source, kit, js, jsx) {
const prepared = prepareKeepingLines(source);
let output;
try {
output = transpileJsx(prepared);
}
catch (error) {
const text = error instanceof Error ? error.message : String(error);
const at = /\s*\(at (\d+)\)\s*$/.exec(text);
const offset = at !== null ? Number(at[1]) : undefined;
return [
{
severity: 'error',
category: 'syntax',
field: 'jsx',
...(offset !== undefined ? position(prepared, offset) : {}),
message: `${at !== null ? text.slice(0, at.index) : text}${hintFor(prepared, offset)}`,
},
];
}
let ast;
let located = true;
try {
ast = jsx(prepared, PARSE_OPTIONS);
}
catch (error) {
// acorn-jsx is stricter than the frame in places: what counts is whether the program the frame
// runs (the transpiled one) parses.
try {
ast = js(output, PARSE_OPTIONS);
located = false;
}
catch {
return [syntaxIssue(error, 'jsx', undefined, prepared)];
}
}
const issues = [];
const declared = declaredNames(ast);
if (!definesApp(ast)) {
const candidate = topLevelComponent(ast);
issues.push({
severity: 'error',
category: 'missing-app',
field: 'jsx',
message: candidate !== undefined
? `no \`App\` — rename \`function ${candidate}\` to \`function App\`; App is what gets drawn`
: 'no `App` — define `function App() { return <…/>; }`; App is what gets drawn',
});
}
issues.push(...bridgeIssues(ast, declared, 'jsx', undefined, located ? prepared : undefined));
if (kit !== undefined)
issues.push(...kitIssues(ast, declared, kit, located ? prepared : undefined));
return issues;
}
const PARSE_OPTIONS = {
ecmaVersion: 'latest',
sourceType: 'script',
locations: false,
allowReturnOutsideFunction: false,
};
/** Does the program define `App` at its top level (a function, a `const App = …`, a class)? */
function definesApp(ast) {
for (const statement of ast.body ?? []) {
if ((statement.type === 'FunctionDeclaration' || statement.type === 'ClassDeclaration') &&
statement.id?.name === 'App') {
return true;
}
if (statement.type === 'VariableDeclaration') {
for (const declarator of statement.declarations) {
if (declarator.id.name === 'App')
return true;
}
}
}
return false;
}
/** The one capitalized top-level function, when the model named its view something else. */
function topLevelComponent(ast) {
const names = (ast.body ?? [])
.filter((statement) => statement.type === 'FunctionDeclaration')
.map((statement) => statement.id?.name ?? '')
.filter((name) => /^[A-Z]/.test(name));
return names.length === 1 ? names[0] : undefined;
}
function kitIssues(ast, declared, kit, source) {
const components = new Map(kit.components.map((component) => [component.name, component]));
const issues = [];
const reported = new Set();
walk(ast, (node) => {
let name;
let attributes = [];
if (node.type === 'JSXOpeningElement') {
name = jsxName(node.name);
attributes = node.attributes ?? [];
}
else if (node.type === 'CallExpression' && isCreateElement(node.callee)) {
// The transpiled program (acorn-jsx refused the source): `React.createElement(Name, {…})`.
const [first] = node.arguments;
if (first?.type === 'Identifier')
name = first.name;
}
if (name === undefined || !/^[A-Z]/.test(name))
return;
const bare = name.startsWith('Kit.') ? name.slice(4) : name;
if (name.includes('.') && !name.startsWith('Kit.'))
return;
if (!name.startsWith('Kit.') && (declared.has(bare) || JSX_SCOPE.has(bare)))
return;
const doc = components.get(bare);
if (doc === undefined) {
if (reported.has(bare))
return;
reported.add(bare);
const known = kit.components.map((component) => component.name);
issues.push({
severity: 'error',
category: 'unknown-component',
field: 'jsx',
...(source !== undefined ? position(source, node.start) : {}),
message: known.length > 0
? `\`<${name}>\` is not a kit component — use one of ${listOf(known)}, or a plain element`
: `\`<${name}>\` does not exist here — use plain elements (or define ${bare} yourself)`,
});
return;
}
if (doc.inherits !== undefined || doc.props.length === 0)
return;
const allowed = new Set(doc.props.map((prop) => prop.name));
for (const attribute of attributes) {
if (attribute.type !== 'JSXAttribute')
continue;
const prop = jsxName(attribute.name);
if (prop === undefined ||
allowed.has(prop) ||
UNIVERSAL_PROPS.has(prop) ||
/^(data|aria)-/.test(prop)) {
continue;
}
issues.push({
severity: 'warning',
category: 'unknown-prop',
field: 'jsx',
...(source !== undefined ? position(source, attribute.start) : {}),
message: `\`<${bare} ${prop}>\`: ${bare} has no prop \`${prop}\` (it takes ${listOf([...allowed])})`,
});
}
});
return issues;
}
function isCreateElement(callee) {
return (callee.type === 'MemberExpression' &&
callee.object.name === 'React' &&
callee.property.name === 'createElement');
}
function jsxName(node) {
if (node === undefined || node === null)
return undefined;
if (node.type === 'JSXIdentifier')
return node.name;
if (node.type === 'JSXMemberExpression') {
const object = jsxName(node.object);
const property = jsxName(node.property);
return object !== undefined && property !== undefined ? `${object}.${property}` : undefined;
}
if (node.type === 'JSXNamespacedName') {
return `${jsxName(node.namespace)}:${jsxName(node.name)}`;
}
return undefined;
}
// ─── HTML and plain scripts ─────────────────────────────────────────────────────────────────────
const SCRIPT = /<script\b([^>]*)>([\s\S]*?)(<\/script\s*>|$)/gi;
const JS_TYPES = /^(|text\/javascript|application\/javascript|module|text\/ecmascript)$/i;
function checkHtml(html, options, js) {
const issues = [];
let blanked = html;
for (const match of html.matchAll(SCRIPT)) {
const attributes = match[1] ?? '';
const body = match[2] ?? '';
const at = match.index ?? 0;
const bodyStart = at + match[0].indexOf('>') + 1;
blanked =
blanked.slice(0, at) + match[0].replace(/[^\n]/g, ' ') + blanked.slice(at + match[0].length);
if (match[3] === '') {
issues.push({
severity: 'error',
category: 'syntax',
field: 'html',
...position(html, at),
message: '`<script>` is never closed — end it with `</script>`',
});
continue;
}
const src = /\bsrc\s*=\s*["']?([^"'\s>]+)/i.exec(attributes);
if (src !== null) {
const origin = originOf(src[1] ?? '');
if (origin === undefined ||
!(options.scriptOrigins ?? []).some((each) => allows(each, origin))) {
issues.push({
severity: 'error',
category: 'syntax',
field: 'html',
...position(html, at),
message: `\`<script src="${src[1]}">\` is blocked (the sandbox has no network) — inline the code instead`,
});
}
continue;
}
const type = /\btype\s*=\s*["']?([^"'\s>]*)/i.exec(attributes)?.[1] ?? '';
if (!JS_TYPES.test(type))
continue;
const parsed = parseAt(body, js, type.toLowerCase() === 'module' ? 'module' : 'script');
if (!parsed.ok) {
issues.push(syntaxIssue(parsed.error, 'html', undefined, html, bodyStart));
continue;
}
issues.push(...bridgeIssues(parsed.ast, declaredNames(parsed.ast), 'html', undefined, html, bodyStart));
}
// Inline handlers (`onclick="…"`), outside the scripts.
const handler = /<[a-zA-Z][^>]*?\son[a-z]+\s*=\s*("([^"]*)"|'([^']*)')/g;
const attribute = /\son[a-z]+\s*=\s*("([^"]*)"|'([^']*)')/g;
for (const tag of blanked.matchAll(handler)) {
const tagStart = tag.index ?? 0;
const whole = blanked.slice(tagStart, blanked.indexOf('>', tagStart) + 1 || undefined);
for (const each of whole.matchAll(attribute)) {
const value = each[2] ?? each[3] ?? '';
const valueStart = tagStart + (each.index ?? 0) + each[0].indexOf(each[1] ?? '') + 1;
const code = decodeEntities(value);
const wrapped = `(function(event){${code}\n})`;
const parsed = parseAt(wrapped, js, 'script');
if (!parsed.ok) {
const offset = Math.max(0, errorOffset(parsed.error) - '(function(event){'.length);
issues.push({
severity: 'error',
category: 'syntax',
field: 'html',
...position(html, valueStart + Math.min(offset, value.length)),
message: `${errorText(parsed.error)} in an inline handler`,
});
continue;
}
issues.push(...bridgeIssues(parsed.ast, declaredNames(parsed.ast), 'html', undefined, html, valueStart - '(function(event){'.length));
}
}
return dedupe(issues);
}
function checkScript(code, field, index, js) {
const parsed = parseAt(code, js, 'script');
if (!parsed.ok)
return [syntaxIssue(parsed.error, field, index, code)];
return bridgeIssues(parsed.ast, declaredNames(parsed.ast), field, index, code);
}
function parseAt(code, js, sourceType) {
try {
return { ok: true, ast: js(code, { ...PARSE_OPTIONS, sourceType }) };
}
catch (error) {
return { ok: false, error };
}
}
function originOf(url) {
const match = /^(https:\/\/[^/?#]+)/i.exec(url);
return match?.[1]?.toLowerCase();
}
function allows(pattern, origin) {
const lower = pattern.toLowerCase();
if (!lower.includes('*.'))
return lower === origin;
const suffix = lower.replace('https://*.', '');
return origin.startsWith('https://') && origin.slice(8).endsWith(`.${suffix}`);
}
function decodeEntities(text) {
return text
.replace(/"/g, '"')
.replace(/'|'/g, "'")
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/&/g, '&');
}
// ─── The bridge ─────────────────────────────────────────────────────────────────────────────────
/**
* Calls that look like a way out of the frame but are not `agent.send`: `flippy.send(…)` on an
* undeclared name, `window.app.send(…)`, `agent.post(…)`.
*/
function bridgeIssues(ast, declared, field, index, source, base = 0) {
const issues = [];
walk(ast, (node) => {
if (node.type !== 'CallExpression')
return;
const callee = node.callee;
if (callee.type !== 'MemberExpression' || callee.computed === true)
return;
const method = callee.property.name;
const object = callee.object;
let holder;
if (object.type === 'Identifier')
holder = object.name;
else if (object.type === 'MemberExpression' &&
object.computed !== true &&
BRIDGE_HOLDERS.has(object.object.name ?? '')) {
const inner = object.property.name;
if (inner === 'agent')
holder = 'agent';
else if (inner !== undefined)
holder = `${object.object.name}.${inner}`;
}
if (holder === undefined)
return;
const where = {
...(index !== undefined ? { index } : {}),
...(source !== undefined ? position(source, base + callee.start) : {}),
};
if (holder === 'agent') {
if (method === 'send' || declared.has('agent'))
return;
issues.push({
severity: 'error',
category: 'bridge',
field,
...where,
message: `\`agent.${method}\` — the bridge has one method: \`agent.send(payload)\``,
});
return;
}
if (method !== 'send')
return;
const root = holder.split('.')[0] ?? holder;
if (!holder.includes('.') && declared.has(root))
return;
issues.push({
severity: 'error',
category: 'bridge',
field,
...where,
message: `\`${holder}.send\` — the bridge is \`agent.send(payload)\``,
});
});
return issues;
}
// ─── AST helpers ────────────────────────────────────────────────────────────────────────────────
function walk(node, visit) {
if (Array.isArray(node)) {
for (const item of node)
walk(item, visit);
return;
}
if (typeof node !== 'object' || node === null || typeof node.type !== 'string')
return;
visit(node);
for (const [key, value] of Object.entries(node)) {
if (key === 'type' || key === 'start' || key === 'end' || key === 'loc')
continue;
if (typeof value === 'object' && value !== null)
walk(value, visit);
}
}
/** Every name the program binds anywhere (conservative: scope is ignored). */
function declaredNames(ast) {
const names = new Set();
const bind = (pattern) => {
if (typeof pattern !== 'object' || pattern === null)
return;
const node = pattern;
switch (node.type) {
case 'Identifier':
names.add(node.name);
return;
case 'ObjectPattern':
for (const property of node.properties) {
bind(property.type === 'RestElement' ? property.argument : property.value);
}
return;
case 'ArrayPattern':
for (const element of node.elements)
bind(element);
return;
case 'RestElement':
bind(node.argument);
return;
case 'AssignmentPattern':
bind(node.left);
return;
}
};
walk(ast, (node) => {
if (node.type === 'VariableDeclarator')
bind(node.id);
else if (node.type === 'FunctionDeclaration' ||
node.type === 'FunctionExpression' ||
node.type === 'ArrowFunctionExpression') {
if (node.id !== null && node.id !== undefined)
bind(node.id);
for (const param of node.params)
bind(param);
}
else if (node.type === 'ClassDeclaration' && node.id !== null)
bind(node.id);
else if (node.type === 'CatchClause' && node.param !== null)
bind(node.param);
else if (node.type === 'ImportSpecifier' || node.type === 'ImportDefaultSpecifier')
bind(node.local);
});
return names;
}
function position(source, offset) {
const before = source.slice(0, Math.max(0, Math.min(offset, source.length)));
const lines = before.split('\n');
return { line: lines.length, column: (lines[lines.length - 1]?.length ?? 0) + 1 };
}
function errorOffset(error) {
const pos = error.pos;
return typeof pos === 'number' ? pos : 0;
}
/** acorn's message without its `(line:column)` suffix. */
function errorText(error) {
const text = error instanceof Error ? error.message : String(error);
return text.replace(/\s*\(\d+:\d+\)\s*$/, '');
}
function syntaxIssue(error, field, index, source, base = 0) {
const offset = base + errorOffset(error);
return {
severity: 'error',
category: 'syntax',
field,
...(index !== undefined ? { index } : {}),
...position(source, offset),
message: `${errorText(error)}${hintFor(source, offset)}`,
};
}
/** A one-line fix for the syntax errors models make most. */
function hintFor(source, offset) {
if (offset === undefined)
return '';
const lineStart = source.lastIndexOf('\n', Math.max(0, offset - 1)) + 1;
const lineEnd = source.indexOf('\n', offset);
const line = source.slice(lineStart, lineEnd < 0 ? undefined : lineEnd);
if (/^\s*(type|interface)\s+\w+/.test(line) ||
/\)\s*:\s*[\w.]+[\w.<>[\]|]*\s*(=>|\{)/.test(line) ||
/\b(useState|useRef|useMemo)<|\bas\s+(const|[A-Z]\w*|string|number)\b/.test(line)) {
return ' — plain JavaScript only: drop the TypeScript types';
}
if (/\b(class|for)=/.test(line) && /</.test(line))
return '';
if (/<\/?[A-Za-z]/.test(line))
return ' — check that every JSX tag is closed in order';
return '';
}
function listOf(names, max = 12) {
const shown = names.slice(0, max).map((name) => `\`${name}\``);
return names.length > max ? `${shown.join(', ')}…` : shown.join(', ');
}
function dedupe(issues) {
const seen = new Set();
return issues.filter((issue) => {
const key = formatSandboxIssue(issue);
if (seen.has(key))
return false;
seen.add(key);
return true;
});
}
//# sourceMappingURL=sandbox-validate.js.map