@adonis-agora/agent
Version:
Governed, durable-backed AI agent (chat + tool-calling + governance) for AdonisJS — part of the Agora ecosystem.
324 lines • 14 kB
JavaScript
import { detectKeywords, detectRegex } from './detectors/custom.js';
import { scoreInjection } from './detectors/injection.js';
import { detectPii } from './detectors/pii.js';
import { detectSecrets } from './detectors/secrets.js';
import { scoreToolText } from './detectors/tool-poisoning.js';
import { matchesAny } from './glob.js';
import { ACTION_SEVERITY, } from './types.js';
import { labelFor, Vault } from './vault.js';
export const DEFAULT_MESSAGES = {
llm_request: "This message was blocked by your organization's AI guardrails.",
llm_response: "The answer was withheld by your organization's AI guardrails.",
tool_args: "This tool call was blocked by your organization's AI guardrails.",
tool_result: "The tool's result was withheld by your organization's AI guardrails.",
tool_description: "This tool was quarantined by your organization's AI guardrails.",
};
export const DEFAULT_APPROVAL_MESSAGE = "This needs approval under your organization's AI guardrails before it can go ahead.";
/** What replaces an instruction-like finding: it is removed, not tokenized. */
export const INJECTION_REPLACEMENT = '[removed: suspected prompt injection]';
const ruleName = (rule) => rule.name ?? rule.id;
/** Whether a rule's scope covers the context (stage, tool, `when`). Segments are filtered apart. */
export function ruleApplies(rule, ctx) {
if (rule.enabled === false || !rule.stages.includes(ctx.stage))
return false;
const tools = rule.match?.tools;
if (tools?.length) {
const toolStage = ctx.stage === 'tool_args' || ctx.stage === 'tool_result' || ctx.stage === 'tool_description';
if (!toolStage || !matchesAny(ctx.tool ?? '', tools))
return false;
}
return rule.when === undefined || rule.when(ctx);
}
export function orderRules(rules) {
return [...rules].sort((a, b) => (a.priority ?? 100) - (b.priority ?? 100) || ruleName(a).localeCompare(ruleName(b)));
}
/** Runs one detector on one text. A custom detector's throw is the rule's fail mode to decide. */
export async function runDetector(spec, text, ctx) {
if (!text)
return [];
switch (spec.kind) {
case 'pii':
return detectPii(text, spec.types?.length ? spec.types : undefined);
case 'secrets':
return detectSecrets(text, spec.types?.length ? spec.types : undefined);
case 'injection':
return scoreInjection(text, spec.threshold ?? 0.5).findings;
case 'tool_poisoning':
return scoreToolText(text, spec.threshold ?? 0.5).findings;
case 'regex':
return detectRegex(text, spec.patterns, spec.label ?? 'custom');
case 'keywords':
return detectKeywords(text, spec.words, spec.label ?? 'keywords');
case 'custom':
return spec.detect(text, ctx);
}
}
/**
* The in-process detectors of a rule set, as one span locator — what a stream window must never
* cut through (see `StreamGuard`'s `locate`). Custom detectors are left out: they may be remote.
*/
export function spanLocator(rules) {
const specs = rules
.flatMap((r) => r.detectors)
.filter((d) => d.kind !== 'custom' && d.kind !== 'tool_poisoning');
if (specs.length === 0)
return undefined;
return (text) => specs.flatMap((d) => {
switch (d.kind) {
case 'pii':
return detectPii(text, d.types?.length ? d.types : undefined);
case 'secrets':
return detectSecrets(text, d.types?.length ? d.types : undefined);
case 'regex':
return detectRegex(text, d.patterns);
case 'keywords':
return detectKeywords(text, d.words);
case 'injection':
return scoreInjection(text, 0).findings;
default:
return [];
}
});
}
/**
* Non-overlapping spans. Overlapping findings are merged into their union (nothing that any
* detector found is left uncovered), labelled by the longer finding (ties: the higher score) —
* an injected HTML comment that contains an email address is removed as a whole. With `text`, a
* merged span's value is re-read from it.
*/
export function resolveOverlaps(findings, text) {
const sorted = [...findings]
.filter((f) => f.spanned !== false && f.end > f.start)
.sort((a, b) => a.start - b.start || b.end - a.end || b.score - a.score);
const out = [];
for (const f of sorted) {
const last = out[out.length - 1];
if (!last || f.start >= last.end) {
out.push({ ...f });
continue;
}
const longer = f.end - f.start > last.end - last.start ||
(f.end - f.start === last.end - last.start && f.score > last.score)
? f
: last;
const merged = { ...longer, start: last.start, end: Math.max(last.end, f.end) };
if (text !== undefined)
merged.value = text.slice(merged.start, merged.end);
out[out.length - 1] = merged;
}
return out;
}
/** Placeholder text for a finding. Instruction-like findings are removed, not tokenized. */
function replacementFor(f, vault, restorable) {
if (f.replacement !== undefined)
return f.replacement;
if (f.detector === 'injection' || f.detector === 'tool_poisoning')
return INJECTION_REPLACEMENT;
return vault.tokenFor(labelFor(f.category), f.value, restorable);
}
export function applyRedactions(text, findings, vault, restorable) {
const whole = findings.find((f) => f.spanned === false);
if (whole) {
return { text: whole.replacement ?? `[removed: ${whole.category}]`, count: 1 };
}
const spans = resolveOverlaps(findings, text);
if (spans.length === 0)
return { text, count: 0 };
let out = '';
let at = 0;
for (const f of spans) {
out += text.slice(at, f.start) + replacementFor(f, vault, restorable);
at = f.end;
}
return { text: out + text.slice(at), count: spans.length };
}
function memoKey(spec) {
return spec.kind === 'custom' ? `custom\u0000${spec.name}` : JSON.stringify(spec);
}
/**
* Evaluates the rules for one stage over a set of segments. Rules run by ascending priority; an
* `allow` rule that fires stops evaluation; otherwise every firing rule contributes and the most
* severe action wins (block > approve > redact > log). Detector results are shared between rules.
*
* Blocking applies to the NEWEST turn: content found only in re-sent history (`fresh: false`) was
* already refused once, so it is removed one-way instead — the conversation can go on and the value
* still never reaches the model.
*/
export async function scan(rules, ctx, segments, vault = new Vault(), options = {}) {
const started = performance.now();
const applicable = orderRules(rules).filter((r) => ruleApplies(r, ctx));
const result = {
stage: ctx.stage,
action: 'allow',
segments: segments.map((s) => s.text),
changed: false,
hits: [],
vault,
redactions: 0,
latencyMs: 0,
};
if (applicable.length === 0 || segments.length === 0) {
result.latencyMs = performance.now() - started;
return result;
}
const stageMessage = options.messages?.[ctx.stage] ?? DEFAULT_MESSAGES[ctx.stage];
const restorableStage = (options.restorableStages ?? ['llm_request']).includes(ctx.stage);
const memo = new Map();
const detect = (spec, index) => {
const key = `${index}\u0000${memoKey(spec)}`;
let p = memo.get(key);
if (!p) {
// Wrapped so a synchronous throw becomes a rejection the fail mode can decide on.
p = Promise.resolve().then(() => runDetector(spec, segments[index]?.text ?? '', ctx));
memo.set(key, p);
}
return p;
};
const outcomes = [];
for (const rule of applicable) {
const sources = rule.match?.sources;
const indexes = segments
.map((s, i) => ({ s, i }))
.filter(({ s }) => !sources?.length || !s.source || sources.includes(s.source))
.map(({ i }) => i);
const outcome = { rule, bySegment: new Map() };
if (rule.detectors.length === 0) {
// Scope-only rule: fires for everything in scope (an exemption, or a blanket block/approval).
if (rule.action === 'allow' || rule.action === 'block' || rule.action === 'approve')
outcomes.push(outcome);
if (rule.action === 'allow')
break;
continue;
}
const jobs = rule.detectors.flatMap((spec) => indexes.map(async (i) => ({ i, found: await detect(spec, i) })));
const settled = await Promise.allSettled(jobs);
for (const s of settled) {
if (s.status === 'rejected') {
outcome.failed = s.reason?.message ?? String(s.reason);
continue;
}
if (s.value.found.length === 0)
continue;
const list = outcome.bySegment.get(s.value.i) ?? [];
list.push(...s.value.found);
outcome.bySegment.set(s.value.i, list);
}
if (outcome.bySegment.size === 0 && !outcome.failed)
continue;
outcomes.push(outcome);
if (rule.action === 'allow' && outcome.bySegment.size > 0)
break;
}
// Combine: most severe action; redactions of every redact rule.
const redactBySegment = new Map();
for (const o of outcomes) {
const { rule } = o;
const name = ruleName(rule);
const failMode = rule.options?.failMode ?? 'open';
let action = rule.action;
if (o.failed) {
result.hits.push({
ruleId: rule.id,
ruleName: name,
action: failMode === 'closed' ? 'block' : 'log',
detector: 'detector_error',
category: 'error.detector_unavailable',
score: 0,
count: 1,
values: [],
fresh: true,
error: o.failed.slice(0, 300),
failMode,
});
if (failMode === 'closed' && o.bySegment.size === 0)
action = 'block';
else if (o.bySegment.size === 0)
continue;
}
const historyOnly = (action === 'block' || action === 'approve') &&
o.bySegment.size > 0 &&
[...o.bySegment.keys()].every((i) => segments[i]?.fresh === false);
if (historyOnly) {
action = 'redact';
for (const [i, findings] of o.bySegment) {
const entry = redactBySegment.get(i) ?? { findings: [], restorable: false };
entry.findings.push(...findings);
redactBySegment.set(i, entry);
}
}
if (ACTION_SEVERITY[action] > ACTION_SEVERITY[result.action]) {
result.action = action;
if (action === 'block' || action === 'approve') {
result.decisive = {
id: rule.id,
name,
message: rule.options?.message ||
(o.failed && o.bySegment.size === 0
? `${stageMessage} (a required check could not run)`
: action === 'approve'
? (options.approvalMessage ?? DEFAULT_APPROVAL_MESSAGE)
: stageMessage),
};
}
}
if (o.bySegment.size === 0 && rule.detectors.length === 0) {
result.hits.push({
ruleId: rule.id,
ruleName: name,
action,
detector: 'scope',
category: 'scope.match',
score: 1,
count: 1,
values: [],
fresh: true,
});
continue;
}
// Hits: one per category per rule, with distinct values and whether any occurrence is fresh.
const grouped = new Map();
for (const [i, findings] of o.bySegment) {
for (const f of findings) {
const hit = grouped.get(f.category) ?? {
ruleId: rule.id,
ruleName: name,
action,
detector: f.detector,
category: f.category,
score: 0,
count: 0,
values: [],
fresh: false,
};
hit.count++;
hit.score = Math.max(hit.score, f.score);
if (f.value && !hit.values.includes(f.value) && hit.values.length < 20)
hit.values.push(f.value);
if (segments[i]?.fresh !== false)
hit.fresh = true;
grouped.set(f.category, hit);
}
if (action === 'redact' && !historyOnly) {
const entry = redactBySegment.get(i) ?? { findings: [], restorable: false };
entry.findings.push(...findings);
entry.restorable ||= restorableStage && rule.options?.restore !== false;
redactBySegment.set(i, entry);
}
}
result.hits.push(...grouped.values());
}
if (result.action !== 'block' && result.action !== 'approve') {
for (const [i, { findings, restorable }] of redactBySegment) {
const original = segments[i]?.text ?? '';
const { text, count } = applyRedactions(original, findings, vault, restorable);
if (count > 0 && text !== original) {
result.segments[i] = text;
result.redactions += count;
result.changed = true;
}
}
}
result.latencyMs = performance.now() - started;
return result;
}
//# sourceMappingURL=engine.js.map