diff --git a/packages/js/src/approval.ts b/packages/js/src/approval.ts new file mode 100644 index 0000000..bb9df78 --- /dev/null +++ b/packages/js/src/approval.ts @@ -0,0 +1,159 @@ +import * as readline from 'node:readline'; +import type { ProposedAction } from './policy.js'; + +/** + * The human gate (M7.4): pause a gated write for approval before it executes. The + * policy decides WHETHER approval is needed; the handler decides HOW the human is + * asked. Everything fails closed (invariant §2.2): the default handler denies, and + * TerminalApprovalHandler denies on EOF / no interactive input. + */ + +const VALUE_WIDTH = 48; // bound each rendered argument value in the preview +const MAX_PROMPTS = 5; // bound the terminal re-prompt loop (§2.13) +const PREVIEW_CAPS = 8; // bound the listed actions + +/** One gated batch: the exact calls about to run, and why. */ +export interface ApprovalRequest { + actions: ProposedAction[]; + reason: string; + bulkCount?: number; +} + +/** The gate: return true to execute the gated actions, false to deny. May be async. */ +export interface ApprovalHandler { + approve(request: ApprovalRequest): boolean | Promise; +} + +/** A plain function form of {@link ApprovalHandler}. */ +export type ApprovalFn = (request: ApprovalRequest) => boolean | Promise; + +/** Normalize a handler or a plain function into an {@link ApprovalHandler}. */ +export function toApprovalHandler(a: ApprovalHandler | ApprovalFn): ApprovalHandler { + return typeof a === 'function' ? { approve: a } : a; +} + +/** The fail-closed default gate — no approval, no write. */ +export const denyAll: ApprovalHandler = { approve: () => false }; + +/** The human-facing rendering: exact calls, values bounded. */ +export function previewApproval(request: ApprovalRequest): string { + const lines: string[] = []; + if (request.actions.length === 1) { + const a = request.actions[0]!; + lines.push(`⚠ Reins wants to run a ${accessWord(a)}: ${renderCall(a)}`); + } else { + lines.push(`⚠ Reins wants to run ${request.actions.length} gated operations:`); + request.actions.forEach((a, i) => { + if (i >= PREVIEW_CAPS) { + return; + } + lines.push(` ${renderCall(a)} [${accessWord(a).toLowerCase()}]`); + }); + if (request.actions.length > PREVIEW_CAPS) { + lines.push(` … and ${request.actions.length - PREVIEW_CAPS} more`); + } + } + if (request.bulkCount !== undefined) { + lines.push(` This will touch up to ${request.bulkCount} items.`); + } + return lines.join('\n'); +} + +/** + * The largest count of a >1-item list argument on a write, or undefined. The + * pre-ORM row-limit default: such a call is treated as touching that many rows. + */ +export function bulkCount(actions: ProposedAction[]): number | undefined { + let best = 0; + for (const a of actions) { + if (a.capability.access === 'read') { + continue; + } + for (const v of Object.values(a.call.arguments ?? {})) { + if (Array.isArray(v) && v.length > 1 && v.length > best) { + best = v.length; + } + } + } + return best === 0 ? undefined : best; +} + +type Writer = { write(chunk: string): unknown }; + +/** + * TerminalApprovalHandler prints the preview and asks [y]es / [n]o / [s]how on a + * terminal, failing closed on EOF or an exhausted re-prompt bound. `input` and + * `output` are injectable for tests; defaults are stdin/stdout. + */ +export class TerminalApprovalHandler implements ApprovalHandler { + constructor( + private readonly input: NodeJS.ReadableStream = process.stdin, + private readonly output: Writer = process.stdout, + ) {} + + async approve(request: ApprovalRequest): Promise { + const out = (s: string): void => { + this.output.write(s + '\n'); + }; + out(previewApproval(request)); + + const rl = readline.createInterface({ input: this.input }); + const lines = rl[Symbol.asyncIterator](); + try { + for (let i = 0; i < MAX_PROMPTS; i++) { + this.output.write('Approve? [y]es / [n]o / [s]how: '); + const next = await lines.next(); + if (next.done) { + out( + 'No interactive terminal — denying.\n → run interactively, or pass an approve handler.', + ); + return false; // EOF / closed stdin ⇒ deny (fail closed) + } + const answer = String(next.value).trim().toLowerCase(); + if (answer === 'y' || answer === 'yes') { + return true; + } + if (answer === 'n' || answer === 'no') { + return false; + } + if (answer === 's' || answer === 'show') { + out(detail(request)); + } else { + out('Unrecognized — answer y, n, or s.'); + } + } + return false; // re-prompt bound exhausted ⇒ deny + } finally { + rl.close(); + } + } +} + +function accessWord(a: ProposedAction): string { + if (a.capability.access === 'destructive') { + return 'DESTRUCTIVE WRITE'; + } + if (a.capability.access === 'read') { + return 'READ'; + } + return 'WRITE'; +} + +function renderCall(a: ProposedAction): string { + const args = a.call.arguments ?? {}; + const parts = Object.keys(args) + .sort() + .map((k) => `${k}=${bounded(args[k])}`); + return `${a.call.name}(${parts.join(', ')})`; +} + +function detail(request: ApprovalRequest): string { + return request.actions + .map((a) => ` ${a.call.name} ${JSON.stringify(a.call.arguments ?? {})}`) + .join('\n'); +} + +function bounded(v: unknown): string { + const s = typeof v === 'string' ? v : JSON.stringify(v); + return s.length > VALUE_WIDTH ? s.slice(0, VALUE_WIDTH) + '…' : s; +} diff --git a/packages/js/src/audit.ts b/packages/js/src/audit.ts new file mode 100644 index 0000000..b002937 --- /dev/null +++ b/packages/js/src/audit.ts @@ -0,0 +1,94 @@ +import type { Access } from './types.js'; + +/** + * The audit log (M7.4): a structured record of every write attempt. Whenever the + * harness blocks, gates, or executes a write it emits an {@link AuditRecord} — + * principal, capability, PII-redacted arguments, decision, timestamp, trace id — to + * the sink. Auditing is part of the gate chain (§2.2): a write that cannot be + * recorded does not run (no audit, no action). Reads are not audited. + */ + +/** Replaces a sensitive argument value in the audit copy. */ +export const REDACTED = '[redacted]'; + +const sensitiveKeyParts = [ + 'password', + 'passwd', + 'secret', + 'token', + 'api_key', + 'apikey', + 'authorization', + 'credential', + 'private_key', + 'ssn', + 'card_number', + 'cvv', +]; + +/** + * One write attempt: who, what, with which (redacted) args, and the decision + * (refused / denied / approved / approval_denied / executed). + */ +export interface AuditRecord { + timestamp: string; + principal: string; + capability: string; + arguments: Record; + access: Access; + decision: string; + reason: string; + trace_id: string; +} + +/** + * Where records go. `record` persists (or forwards) one record; throwing (or a + * rejected promise) means the write it logs must not run. + */ +export interface AuditSink { + record(record: AuditRecord): void | Promise; +} + +/** The default sink: an in-process, in-order list. */ +export class MemoryAuditSink implements AuditSink { + readonly records: AuditRecord[] = []; + + record(record: AuditRecord): void { + this.records.push(record); + } +} + +/** + * A deep copy of args with sensitive-looking keys masked; the original is never + * mutated (the capability still receives the real values). + */ +export function redactArguments(args: Record): Record { + const out: Record = {}; + for (const [k, v] of Object.entries(args)) { + out[k] = redactValue(k, v); + } + return out; +} + +function redactValue(key: string, value: unknown): unknown { + if (isSensitive(key)) { + return REDACTED; + } + if (Array.isArray(value)) { + return value.map((inner) => redactValue(key, inner)); + } + if (typeof value === 'object' && value !== null) { + const out: Record = {}; + for (const [k, inner] of Object.entries(value)) { + out[k] = redactValue(k, inner); + } + return out; + } + return value; +} + +/** Whether a key name looks sensitive (substring match, case-insensitive). */ +export function isSensitive(key: string): boolean { + const lowered = key.toLowerCase(); + return sensitiveKeyParts.some((part) => lowered.includes(part)); +} diff --git a/packages/js/src/index.ts b/packages/js/src/index.ts index b8dac9d..5fe8c8d 100644 --- a/packages/js/src/index.ts +++ b/packages/js/src/index.ts @@ -17,4 +17,8 @@ export * from './capability.js'; export * from './registry.js'; export * from './budget.js'; export * from './stop.js'; +export * from './policy.js'; +export * from './approval.js'; +export * from './audit.js'; +export * from './rls.js'; export * from './loop.js'; diff --git a/packages/js/src/loop.ts b/packages/js/src/loop.ts index 3b9817e..75b1a10 100644 --- a/packages/js/src/loop.ts +++ b/packages/js/src/loop.ts @@ -12,20 +12,33 @@ import type { import type { Model } from './model.js'; import type { BoundCapability } from './capability.js'; import { CapabilityRegistry } from './registry.js'; -import { defaultModelParams } from './types.js'; +import { resultError, defaultModelParams } from './types.js'; import { type Budget, defaultBudget, overTokens } from './budget.js'; import { GoalReached, MaxTurns, type StopCondition, firstStop } from './stop.js'; +import { type AutonomyLevel, type Policy, type ProposedAction, DefaultPolicy } from './policy.js'; +import { + type ApprovalHandler, + type ApprovalFn, + type ApprovalRequest, + bulkCount, + denyAll, + toApprovalHandler, +} from './approval.js'; +import { type AuditRecord, type AuditSink, MemoryAuditSink, redactArguments } from './audit.js'; +import { scopeViolation } from './rls.js'; /** * The harness loop and the Agent — the TypeScript mirror of the ETCSLV lifecycle: * Engage → (Think → Call → Sense → Loop?) → Verify. The model is the only - * stochastic step; dispatch and stopping are deterministic and owned here + * stochastic step; dispatch, policy, and the gate are deterministic and owned here * (invariant §2.14). * * ask() is structurally read-only — a requested write is refused before anything - * executes (§2.1). run() may write, but the policy → approval → audit gate arrives - * in M7.4; until then a write under run() fails closed (denied, never executed). - * The caller's principal propagates as a Context into every call (§2.7). + * executes (§2.1). run() may write; a write travels policy → human approval → audit + * → execution (M7.4). Every write attempt is audited, PII-redacted, and a write + * that cannot be recorded does not run (no audit, no action). The caller's principal + * propagates as a Context into every call (§2.7); the gate and policy fail closed, + * and neither is described to the model (§2.5). */ const SYSTEM_PROMPT = @@ -36,8 +49,8 @@ function newTraceId(): string { return randomBytes(16).toString('hex'); } -/** The outcome of a run: `completed` | `refused` | `not_executed` | `stopped`. */ -export type Outcome = 'completed' | 'refused' | 'not_executed' | 'stopped'; +/** The outcome of a run. */ +export type Outcome = 'completed' | 'executed' | 'refused' | 'not_executed' | 'stopped'; /** The detailed result of one run: outcome plus telemetry. */ export interface RunReport { @@ -48,17 +61,44 @@ export interface RunReport { turn: number; executed: string[]; reason: string; + audited: boolean; + audit: AuditRecord[]; trace_id: string; } -/** Per-run governance. M7.3 wires identity and the budget; safety options arrive in M7.4. */ +/** Per-run governance. Zero values are safe defaults (deny-all gate, in-memory audit). */ export interface RunOptions { principal?: string; budget?: Budget; + autonomy?: AutonomyLevel; + policy?: Policy; + approve?: ApprovalHandler | ApprovalFn; + audit?: AuditSink; + confirmBulk?: boolean; +} + +/** The immutable per-run wiring the loop's helpers share. */ +interface RunEnv { + registry: CapabilityRegistry; + policy: Policy; + gate: ApprovalHandler; + confirmBulk: boolean; + audit: AuditSink; + ctx: Context; + canWrite: boolean; + autonomy: AutonomyLevel; +} + +/** The mutable per-run state the loop accumulates. */ +interface Tally { + executed: string[]; + wrote: boolean; + audit: AuditRecord[]; } /** - * Drive the loop. A control-plane failure (the model rejecting) propagates as a + * Drive the loop with default governance (reads free; writes fail closed unless a + * gate approves). A control-plane failure (the model rejecting) propagates as a * thrown error; everything the model does wrong is data. */ export async function runLoop( @@ -70,15 +110,26 @@ export async function runLoop( ): Promise { const budget = options.budget ?? defaultBudget(); const maxTurns = budget.max_turns || defaultBudget().max_turns; - const traceId = newTraceId(); - const ctx: Context = { principal: options.principal, trace_id: traceId }; + + const autonomy: AutonomyLevel = !canWrite ? 'read_only' : (options.autonomy ?? 'approved_writes'); + + const env: RunEnv = { + registry, + policy: options.policy ?? new DefaultPolicy(), + gate: options.approve ? toApprovalHandler(options.approve) : denyAll, + confirmBulk: options.confirmBulk ?? true, + audit: options.audit ?? new MemoryAuditSink(), + ctx: { principal: options.principal, trace_id: newTraceId() }, + canWrite, + autonomy, + }; + const tally: Tally = { executed: [], wrote: false, audit: [] }; const messages: Message[] = [ { role: 'system', text: SYSTEM_PROMPT }, { role: 'user', text: goal }, ]; const stops: StopCondition[] = [new GoalReached(), new MaxTurns(maxTurns)]; - const executed: string[] = []; const params = defaultModelParams(); let usage: Usage = { input_tokens: 0, output_tokens: 0, cost: 0 }; @@ -88,14 +139,14 @@ export async function runLoop( for (;;) { if (overTokens(budget, usage)) { return report( + env, + tally, 'stopped', synthetic('Stopped: token budget exhausted.\n → raise max_tokens or simplify the goal.'), 'interrupted', usage, turn, - executed, 'budget_exhausted', - traceId, ); } @@ -105,71 +156,63 @@ export async function runLoop( last = resp; if (resp.finish_reason === 'tool_calls' && (resp.message.tool_calls?.length ?? 0) > 0) { - const blocked = await act( - registry, - resp.message.tool_calls!, - canWrite, - ctx, - messages, - executed, - usage, - turn, - traceId, - ); + const blocked = await act(env, tally, resp.message.tool_calls!, messages, usage, turn); if (blocked) { return blocked; } } turn += 1; - const state: RunState = { - messages, - turn, - cumulative_usage: usage, - last_response: last, - }; + const state: RunState = { messages, turn, cumulative_usage: usage, last_response: last }; const stop = firstStop(stops, state); if (stop.stop) { if (stop.reason === 'goal_reached') { - return report('completed', resp.message, 'stop', usage, turn, executed, '', traceId); + const outcome: Outcome = tally.wrote ? 'executed' : 'completed'; + return report(env, tally, outcome, resp.message, 'stop', usage, turn, ''); } return report( + env, + tally, 'stopped', synthetic('Stopped: turn budget exhausted.\n → raise the budget or simplify the goal.'), 'interrupted', usage, turn, - executed, 'budget_exhausted', - traceId, ); } } } /** - * Execute one turn's tool calls. Returns a terminal report if the turn is refused - * (ask + write) or denied (run + write, until M7.4), otherwise mutates `messages` - * and `executed` and returns undefined so the loop continues. + * Execute one turn's tool calls through the safety chain. Returns a terminal report + * if the turn is refused/denied/not-executed, otherwise mutates `messages` and the + * tally and returns undefined so the loop continues. */ async function act( - registry: CapabilityRegistry, + env: RunEnv, + tally: Tally, toolCalls: NonNullable, - canWrite: boolean, - ctx: Context, messages: Message[], - executed: string[], usage: Usage, turn: number, - traceId: string, ): Promise { - const items = toolCalls.map((call) => ({ call, cap: registry.get(call.name) })); + const items = toolCalls.map((call) => ({ call, cap: env.registry.get(call.name) })); - for (const { call, cap } of items) { - if (cap && cap.spec.access !== 'read') { - if (!canWrite) { - // ask(): structurally read-only — refuse the whole turn (§2.1). + if (!env.canWrite) { + // ask(): structurally read-only — refuse the whole turn if any known call writes (§2.1). + for (const { call, cap } of items) { + if (cap && cap.spec.access !== 'read') { + await auditAction( + env, + tally, + { call, capability: cap.spec }, + 'refused', + 'write_in_read_only', + ); return report( + env, + tally, 'refused', synthetic( `Refused: "${call.name}" would write, but this is a read-only ask().\n → use run(...) if writing is intended.`, @@ -177,37 +220,167 @@ async function act( 'stop', usage, turn, - executed, 'write_in_read_only', - traceId, ); } - // run(): the policy/approval gate arrives in M7.4; until then writes fail closed. - return report( - 'not_executed', - synthetic( - `Not executed: "${call.name}" is a write, which needs an approval policy.\n → configure an autonomy level and approval handler (M7.4).`, - ), - 'stop', + } + } else { + const proposed: ProposedAction[] = items + .filter((it) => it.cap) + .map((it) => ({ call: it.call, capability: it.cap!.spec })); + if (proposed.length > 0) { + const verdict = env.policy.checkActions(proposed, env.autonomy); + if (verdict.decision === 'deny') { + for (const a of proposed) { + if (a.capability.access !== 'read') { + await auditAction(env, tally, a, 'denied', verdict.reason); + } + } + const outcome: Outcome = + verdict.reason === 'write_in_read_only' ? 'refused' : 'not_executed'; + return report( + env, + tally, + outcome, + synthetic( + `Not run (${verdict.reason}).\n → adjust the goal or the agent's autonomy level.`, + ), + 'stop', + usage, + turn, + verdict.reason, + ); + } + const blocked = await gateTurn( + env, + tally, + proposed, + verdict.decision === 'require_approval', + verdict.reason, usage, turn, - executed, - 'write_denied', - traceId, ); + if (blocked) { + return blocked; + } } } for (const { call, cap } of items) { - const result = await registry.call(call.name, call.arguments ?? {}, ctx); - if (cap) { - executed.push(call.name); + if (!cap) { + messages.push(toolMessage(call.id, await env.registry.call(call.name, {}, env.ctx))); + continue; } + // A scope-refused call never reaches user code (§2.8): not audited as executed, not a write. + const violation = scopeViolation(cap.spec, env.ctx); + if (violation) { + messages.push(toolMessage(call.id, resultError(violation))); + continue; + } + if (cap.spec.access !== 'read') { + const recorded = await auditAction( + env, + tally, + { call, capability: cap.spec }, + 'executed', + '', + ); + if (!recorded) { + // no audit, no action (§2.2) + messages.push( + toolMessage( + call.id, + resultError('audit sink failed — the write was not executed\n → fix the audit sink'), + ), + ); + continue; + } + tally.wrote = true; + } + const result = await env.registry.call(call.name, call.arguments ?? {}, env.ctx); + tally.executed.push(call.name); messages.push(toolMessage(call.id, result)); } return undefined; } +async function gateTurn( + env: RunEnv, + tally: Tally, + proposed: ProposedAction[], + verdictRequires: boolean, + verdictReason: string, + usage: Usage, + turn: number, +): Promise { + const gated = proposed.filter((a) => a.capability.access !== 'read'); + const count = env.confirmBulk ? bulkCount(gated) : undefined; + + let needsApproval = verdictRequires; + let reason = verdictReason || 'approval_required'; + if (count !== undefined && !needsApproval) { + needsApproval = true; + reason = 'bulk_confirm'; + } + if (!needsApproval) { + return undefined; + } + + const batch = gated.length > 0 ? gated : proposed; + const request: ApprovalRequest = { actions: batch, reason }; + if (count !== undefined) { + request.bulkCount = count; + } + const approved = await env.gate.approve(request); + const decision = approved ? 'approved' : 'approval_denied'; + + let recorded = true; + for (const a of request.actions) { + if (!(await auditAction(env, tally, a, decision, reason))) { + recorded = false; + } + } + if (approved && recorded) { + return undefined; + } + + let text = + 'Not executed: the write was not approved.\n → approve it, or pass an approve handler that returns true.'; + let reportReason = 'approval_denied'; + if (approved && !recorded) { + text = + 'Not executed: the write was approved but could not be audited.\n → fix the audit sink; no audit, no action.'; + reportReason = 'audit_failed'; + } + return report(env, tally, 'not_executed', synthetic(text), 'stop', usage, turn, reportReason); +} + +async function auditAction( + env: RunEnv, + tally: Tally, + action: ProposedAction, + decision: string, + reason: string, +): Promise { + const record: AuditRecord = { + timestamp: new Date().toISOString(), + principal: env.ctx.principal ?? '', + capability: action.capability.name, + arguments: redactArguments(action.call.arguments ?? {}), + access: action.capability.access, + decision, + reason, + trace_id: env.ctx.trace_id ?? '', + }; + try { + await env.audit.record(record); + } catch { + return false; + } + tally.audit.push(record); + return true; +} + function toolMessage(callId: string, result: CapabilityResult): Message { const text = result.ok ? stringifyValue(result.value) : (result.error ?? ''); return { role: 'tool', tool_call_id: callId, text }; @@ -233,14 +406,14 @@ function addUsage(a: Usage, b: Usage): Usage { } function report( + env: RunEnv, + tally: Tally, outcome: Outcome, output: Message, finish: FinishReason, usage: Usage, turn: number, - executed: string[], reason: string, - traceId: string, ): RunReport { return { outcome, @@ -248,37 +421,50 @@ function report( finish, usage, turn, - executed: [...executed], + executed: [...tally.executed], reason, - trace_id: traceId, + audited: tally.audit.length > 0, + audit: [...tally.audit], + trace_id: env.ctx.trace_id ?? '', }; } // --- the Agent -------------------------------------------------------------------- -/** Configuration for an {@link Agent}. Safety options (autonomy, approval, audit) arrive in M7.4. */ +/** Configuration for an {@link Agent}. */ export interface AgentConfig { capabilities?: BoundCapability[]; budget?: Budget; /** Who the agent acts for — propagated into every capability call (identity → RLS). */ principal?: string; + /** The autonomy level for run() (default 'approved_writes'). */ + autonomy?: AutonomyLevel; + /** The human gate for gated writes (default: fail-closed deny-all). */ + approve?: ApprovalHandler | ApprovalFn; + /** Where write attempts are recorded (default: an in-memory sink, see {@link Agent.audit}). */ + audit?: AuditSink; + /** Toggle the >1-row bulk-confirm default (on by default). */ + confirmBulk?: boolean; + /** Override the policy engine (default: the autonomy ladder). */ + policy?: Policy; } /** * The user-facing harness: give it your capabilities, hand it a goal. `ask()` is - * read-only and never prompts; `run()` may write (gated by the autonomy ladder from - * M7.4 onward). A duplicate capability name is a control-plane error (thrown). + * read-only and never prompts; `run()` may write, gated by the autonomy ladder. A + * duplicate capability name is a control-plane error (thrown). */ export class Agent { readonly registry: CapabilityRegistry; + /** The audit sink (default in-memory) — read `agent.audit` for the recorded trail. */ + readonly audit: AuditSink; private readonly model: Model; - private readonly budget: Budget; - private readonly principal: string | undefined; + private readonly config: AgentConfig; constructor(model: Model, config: AgentConfig = {}) { this.model = model; - this.budget = config.budget ?? defaultBudget(); - this.principal = config.principal; + this.config = config; + this.audit = config.audit ?? new MemoryAuditSink(); this.registry = new CapabilityRegistry(); for (const cap of config.capabilities ?? []) { this.registry.register(cap); @@ -290,16 +476,21 @@ export class Agent { return this.drive(goal, false); } - /** Accomplish a goal that may write; writes are gated by the autonomy ladder (M7.4). */ + /** Accomplish a goal that may write; writes are gated by the autonomy ladder. */ run(goal: string): Promise { return this.drive(goal, true); } private async drive(goal: string, canWrite: boolean): Promise { - const rep = await runLoop(this.model, this.registry, goal, canWrite, { - principal: this.principal, - budget: this.budget, - }); + const options: RunOptions = { audit: this.audit }; + if (this.config.budget !== undefined) options.budget = this.config.budget; + if (this.config.principal !== undefined) options.principal = this.config.principal; + if (this.config.autonomy !== undefined) options.autonomy = this.config.autonomy; + if (this.config.approve !== undefined) options.approve = this.config.approve; + if (this.config.confirmBulk !== undefined) options.confirmBulk = this.config.confirmBulk; + if (this.config.policy !== undefined) options.policy = this.config.policy; + + const rep = await runLoop(this.model, this.registry, goal, canWrite, options); return { output: rep.output, reason: rep.finish, usage: rep.usage, trace_id: rep.trace_id }; } } diff --git a/packages/js/src/policy.ts b/packages/js/src/policy.ts new file mode 100644 index 0000000..f474583 --- /dev/null +++ b/packages/js/src/policy.ts @@ -0,0 +1,73 @@ +import type { Capability, Message, ToolCall } from './types.js'; + +/** + * The policy engine (M7.4): deterministic, code-owned governance for writes. Reads + * run freely; writes are gated by the autonomy ladder + * (read_only → draft_writes → approved_writes → trusted, invariant §2.10). Its + * constraints are enforced here, never described to the model (§2.5). The default + * never auto-executes a write, and destructive ops are never auto-executed. + */ +export type AutonomyLevel = 'read_only' | 'draft_writes' | 'approved_writes' | 'trusted'; + +export type Decision = 'allow' | 'deny' | 'require_approval'; + +/** A verdict plus an optional machine-readable reason. */ +export interface PolicyDecision { + decision: Decision; + reason: string; +} + +/** One capability call the model wants to make, with its descriptor. */ +export interface ProposedAction { + call: ToolCall; + capability: Capability; +} + +/** + * Policy governs a run's actions. `checkActions` runs before execution; + * `checkOutput` after (e.g. PII redaction, a later milestone). + */ +export interface Policy { + checkActions(actions: ProposedAction[], autonomy: AutonomyLevel): PolicyDecision; + checkOutput(output: Message): PolicyDecision; +} + +const decisionRank: Record = { allow: 0, require_approval: 1, deny: 2 }; + +/** The autonomy ladder: reads free, writes gated. */ +export class DefaultPolicy implements Policy { + checkActions(actions: ProposedAction[], autonomy: AutonomyLevel): PolicyDecision { + let verdict: PolicyDecision = { decision: 'allow', reason: '' }; + for (const a of actions) { + // The batch takes the most restrictive decision. + const candidate = decideOne(a.capability, autonomy); + if (decisionRank[candidate.decision] > decisionRank[verdict.decision]) { + verdict = candidate; + } + } + return verdict; + } + + checkOutput(_output: Message): PolicyDecision { + return { decision: 'allow', reason: '' }; + } +} + +function decideOne(cap: Capability, autonomy: AutonomyLevel): PolicyDecision { + if (cap.access === 'read') { + return { decision: 'allow', reason: '' }; // reads run freely (§2.2) + } + switch (autonomy) { + case 'read_only': + return { decision: 'deny', reason: 'write_in_read_only' }; + case 'draft_writes': + return { decision: 'deny', reason: 'draft_only' }; + case 'approved_writes': + return { decision: 'require_approval', reason: 'approval_required' }; + default: // trusted: plain writes auto-allow; destructive or confirm-marked stay gated + if (cap.access === 'destructive' || cap.confirm) { + return { decision: 'require_approval', reason: 'approval_required' }; + } + return { decision: 'allow', reason: '' }; + } +} diff --git a/packages/js/src/registry.ts b/packages/js/src/registry.ts index a7bfb53..acbc3e0 100644 --- a/packages/js/src/registry.ts +++ b/packages/js/src/registry.ts @@ -2,6 +2,7 @@ import type { BoundCapability } from './capability.js'; import type { Capability, CapabilityResult, Context, JSONSchema, ToolSpec } from './types.js'; import { resultOk, resultError, toolSpec } from './types.js'; import { ReinsError } from './errors.js'; +import { scopeViolation } from './rls.js'; /** * CapabilityRegistry is the only path by which the agent reaches a capability. It @@ -67,6 +68,12 @@ export class CapabilityRegistry { : 'register it with a capability'; return resultError(`no capability named "${name}"\n → ${fix}`); } + // RLS before anything runs (§2.8): the registry is the only path to a + // capability, so it guards scope itself — not just the loop (defense in depth). + const violation = scopeViolation(cap.spec, ctx); + if (violation) { + return resultError(violation); + } const err = validateArgs(cap.spec.input_schema, args, name); if (err) { return resultError(err.message); @@ -91,14 +98,16 @@ export function validateArgs( const properties = asObject(schema?.['properties']) ?? {}; const allowExtra = schema?.['additionalProperties'] === true; + // Use own-property checks throughout: args are untrusted model output (§2.6), so + // keys like "toString"/"constructor"/"__proto__" must not resolve via the + // prototype chain and slip past the schema (Go's maps have no such chain). for (const key of requiredKeys(schema)) { - if (!(key in args)) { + if (!Object.hasOwn(args, key)) { return new ReinsError(`${capName}: missing required argument "${key}"`, `include ${key}`); } } for (const [key, value] of Object.entries(args)) { - const spec = properties[key]; - if (spec === undefined) { + if (!Object.hasOwn(properties, key)) { if (allowExtra) { continue; } @@ -107,7 +116,7 @@ export function validateArgs( `allowed: ${Object.keys(properties).sort().join(', ')}`, ); } - const expected = asObject(spec)?.['type']; + const expected = asObject(properties[key])?.['type']; if (typeof expected === 'string' && !typeMatches(expected, value)) { return new ReinsError( `${capName}: argument "${key}" must be of type ${expected}`, diff --git a/packages/js/src/rls.ts b/packages/js/src/rls.ts new file mode 100644 index 0000000..d14dffb --- /dev/null +++ b/packages/js/src/rls.ts @@ -0,0 +1,26 @@ +import type { Capability, Context } from './types.js'; + +/** + * Row-level security (M7.4): capabilities are scoped to the caller's identity. The + * principal set at the entry point reaches every call as `Context.principal` (§2.7), + * and a scope-annotated capability refuses to run without one (fail closed, §2.8). + * The capability's own code (or the ORM adapters) then filters rows — Reins never + * guesses which rows belong to whom. + */ + +/** + * Why a call must not run, or null if scoping is satisfied. A scope-annotated + * capability requires an authenticated principal. + */ +export function scopeViolation(cap: Capability, ctx: Context): string | null { + if (!cap.scope) { + return null; + } + if (!ctx.principal) { + return ( + `"${cap.name}" is scoped to "${cap.scope}" but the run has no principal` + + '\n → set a principal on the Agent (or the request identity)' + ); + } + return null; +} diff --git a/packages/js/tests/approval.test.ts b/packages/js/tests/approval.test.ts new file mode 100644 index 0000000..a60501f --- /dev/null +++ b/packages/js/tests/approval.test.ts @@ -0,0 +1,108 @@ +import { describe, it, expect } from 'vitest'; +import { Readable } from 'node:stream'; +import { + denyAll, + toApprovalHandler, + previewApproval, + bulkCount, + TerminalApprovalHandler, + type Access, + type ProposedAction, + type ApprovalRequest, +} from '../src/index.js'; + +function action(name: string, access: Access, args: Record = {}): ProposedAction { + return { + call: { id: 'c', name, arguments: args }, + capability: { name, description: 'x', access, confirm: false, idempotent: false }, + }; +} + +function fakeOut() { + const chunks: string[] = []; + return { + write: (s: string) => { + chunks.push(s); + return true; + }, + text: () => chunks.join(''), + }; +} + +describe('gate defaults', () => { + it('denyAll fails closed', async () => { + expect(await denyAll.approve({ actions: [], reason: 'x' })).toBe(false); + }); + + it('toApprovalHandler wraps a plain function and passes through a handler', async () => { + const fromFn = toApprovalHandler(() => true); + expect(await fromFn.approve({ actions: [], reason: 'x' })).toBe(true); + expect(toApprovalHandler(denyAll)).toBe(denyAll); + }); +}); + +describe('previewApproval', () => { + it('renders a single gated call with its bounded arguments', () => { + const preview = previewApproval({ + actions: [action('create_order', 'write', { id: 1 })], + reason: 'x', + }); + expect(preview).toContain('⚠'); + expect(preview).toContain('WRITE'); + expect(preview).toContain('create_order(id=1)'); + }); + + it('renders a batch and the bulk count', () => { + const req: ApprovalRequest = { + actions: [action('update_a', 'write'), action('delete_b', 'destructive')], + reason: 'x', + bulkCount: 42, + }; + const preview = previewApproval(req); + expect(preview).toContain('2 gated operations'); + expect(preview).toContain('up to 42 items'); + }); +}); + +describe('bulkCount', () => { + it('reports the largest >1 list argument on a write', () => { + expect(bulkCount([action('update_orders', 'write', { ids: [1, 2, 3] })])).toBe(3); + }); + + it('ignores reads and single/absent lists', () => { + expect(bulkCount([action('find_orders', 'read', { ids: [1, 2, 3] })])).toBeUndefined(); + expect(bulkCount([action('update_order', 'write', { ids: [1] })])).toBeUndefined(); + expect(bulkCount([action('update_order', 'write', { id: 1 })])).toBeUndefined(); + }); +}); + +describe('TerminalApprovalHandler (fails closed)', () => { + const req: ApprovalRequest = { + actions: [action('create_order', 'write', { id: 1 })], + reason: 'x', + }; + + it('approves on "y"', async () => { + const h = new TerminalApprovalHandler(Readable.from(['y\n']), fakeOut()); + expect(await h.approve(req)).toBe(true); + }); + + it('denies on "n"', async () => { + const h = new TerminalApprovalHandler(Readable.from(['n\n']), fakeOut()); + expect(await h.approve(req)).toBe(false); + }); + + it('denies on EOF (no interactive input)', async () => { + const out = fakeOut(); + const h = new TerminalApprovalHandler(Readable.from([]), out); + expect(await h.approve(req)).toBe(false); + expect(out.text()).toContain('No interactive terminal'); + }); + + it('shows detail then honors the next answer', async () => { + const out = fakeOut(); + const h = new TerminalApprovalHandler(Readable.from(['s\n', 'y\n']), out); + expect(await h.approve(req)).toBe(true); + expect(out.text()).toContain('create_order'); + }); +}); diff --git a/packages/js/tests/audit.test.ts b/packages/js/tests/audit.test.ts new file mode 100644 index 0000000..bf1d630 --- /dev/null +++ b/packages/js/tests/audit.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect } from 'vitest'; +import { + redactArguments, + isSensitive, + MemoryAuditSink, + REDACTED, + type AuditRecord, +} from '../src/index.js'; + +describe('redactArguments', () => { + it('masks sensitive top-level keys and leaves the rest', () => { + expect(redactArguments({ password: 'hunter2', name: 'Ada' })).toEqual({ + password: REDACTED, + name: 'Ada', + }); + }); + + it('redacts nested objects and arrays', () => { + const out = redactArguments({ + user: { api_key: 'sk-123', id: 7 }, + cards: [{ card_number: '4111' }], + }); + expect(out).toEqual({ + user: { api_key: REDACTED, id: 7 }, + cards: [{ card_number: REDACTED }], + }); + }); + + it('does not mutate the original arguments', () => { + const original = { token: 'secret-abc', keep: 1 }; + redactArguments(original); + expect(original.token).toBe('secret-abc'); + }); +}); + +describe('isSensitive', () => { + it('matches sensitive substrings case-insensitively', () => { + for (const key of ['password', 'apiKey', 'access_token', 'Authorization', 'card_number']) { + expect(isSensitive(key)).toBe(true); + } + }); + + it('leaves ordinary keys alone', () => { + for (const key of ['name', 'email', 'order_id', 'quantity']) { + expect(isSensitive(key)).toBe(false); + } + }); +}); + +describe('MemoryAuditSink', () => { + it('records in order', () => { + const sink = new MemoryAuditSink(); + const rec: AuditRecord = { + timestamp: '2026-01-01T00:00:00.000Z', + principal: 'u1', + capability: 'create_order', + arguments: {}, + access: 'write', + decision: 'executed', + reason: '', + trace_id: 't', + }; + sink.record(rec); + expect(sink.records).toHaveLength(1); + expect(sink.records[0]?.capability).toBe('create_order'); + }); +}); diff --git a/packages/js/tests/loop.test.ts b/packages/js/tests/loop.test.ts index 9917a48..88188da 100644 --- a/packages/js/tests/loop.test.ts +++ b/packages/js/tests/loop.test.ts @@ -86,7 +86,7 @@ describe('runLoop — the ask/run write boundary', () => { expect(deleteCalls).toBe(0); // the handler was never invoked }); - it('run() fails closed on a write until the M7.4 approval gate', async () => { + it('run() fails closed on a write with the default deny-all gate', async () => { deleteCalls = 0; const model = new FakeModel( callResponse('c1', 'delete_order', { id: 1 }), @@ -95,9 +95,10 @@ describe('runLoop — the ask/run write boundary', () => { const reg = new CapabilityRegistry(); reg.register(deleteCap()); + // No approve handler → the fail-closed deny-all gate denies the destructive write. const rep = await runLoop(model, reg, 'delete order 1', true); expect(rep.outcome).toBe('not_executed'); - expect(rep.reason).toBe('write_denied'); + expect(rep.reason).toBe('approval_denied'); expect(deleteCalls).toBe(0); }); }); diff --git a/packages/js/tests/policy.test.ts b/packages/js/tests/policy.test.ts new file mode 100644 index 0000000..a23642f --- /dev/null +++ b/packages/js/tests/policy.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect } from 'vitest'; +import { + DefaultPolicy, + type Access, + type AutonomyLevel, + type ProposedAction, +} from '../src/index.js'; + +function action(name: string, access: Access, confirm = false): ProposedAction { + return { + call: { id: 'c', name, arguments: {} }, + capability: { name, description: 'x', access, confirm, idempotent: false }, + }; +} + +const policy = new DefaultPolicy(); +const read = action('get_x', 'read'); +const write = action('create_x', 'write'); +const destructive = action('delete_x', 'destructive'); +const confirmWrite = action('send_x', 'write', true); + +const levels: AutonomyLevel[] = ['read_only', 'draft_writes', 'approved_writes', 'trusted']; + +describe('DefaultPolicy — the autonomy ladder', () => { + it('allows reads at every autonomy level (§2.2)', () => { + for (const level of levels) { + expect(policy.checkActions([read], level)).toEqual({ decision: 'allow', reason: '' }); + } + }); + + it('gates a plain write by autonomy level', () => { + expect(policy.checkActions([write], 'read_only')).toEqual({ + decision: 'deny', + reason: 'write_in_read_only', + }); + expect(policy.checkActions([write], 'draft_writes')).toEqual({ + decision: 'deny', + reason: 'draft_only', + }); + expect(policy.checkActions([write], 'approved_writes')).toEqual({ + decision: 'require_approval', + reason: 'approval_required', + }); + expect(policy.checkActions([write], 'trusted')).toEqual({ decision: 'allow', reason: '' }); + }); + + it('never auto-executes a destructive or confirm-marked write, even when trusted', () => { + expect(policy.checkActions([destructive], 'trusted').decision).toBe('require_approval'); + expect(policy.checkActions([confirmWrite], 'trusted').decision).toBe('require_approval'); + }); + + it('takes the most restrictive decision across a batch', () => { + expect(policy.checkActions([read, write], 'approved_writes').decision).toBe('require_approval'); + expect(policy.checkActions([read, write], 'read_only').decision).toBe('deny'); + expect(policy.checkActions([write, destructive], 'trusted').decision).toBe('require_approval'); + }); + + it('allows output by default (checkOutput)', () => { + expect(policy.checkOutput({ role: 'assistant', text: 'hi' }).decision).toBe('allow'); + }); +}); diff --git a/packages/js/tests/registry.test.ts b/packages/js/tests/registry.test.ts index 92300b3..d7b7bd0 100644 --- a/packages/js/tests/registry.test.ts +++ b/packages/js/tests/registry.test.ts @@ -94,6 +94,17 @@ describe('argument validation (§2.6 — model output is untrusted)', () => { expect(() => reg.validate('get_order', { id: 5 })).not.toThrow(); }); + it('rejects prototype-named arguments (no prototype-chain bypass, §2.6)', () => { + const reg = orderRegistry(); + // "toString"/"constructor" are Object.prototype members, not schema properties. + expect(() => reg.validate('get_order', { id: 1, toString: 'x' })).toThrow( + /unexpected argument/, + ); + expect(() => reg.validate('get_order', { id: 1, constructor: 'x' })).toThrow( + /unexpected argument/, + ); + }); + it('returns a validation failure as data through call()', async () => { const reg = orderRegistry(); const result = await reg.call('get_order', { id: 'bad' }, ctx); @@ -102,6 +113,23 @@ describe('argument validation (§2.6 — model output is untrusted)', () => { }); }); +describe('registry-level RLS (defense in depth, §2.8)', () => { + it('refuses a scoped capability at the chokepoint when there is no principal', async () => { + const reg = new CapabilityRegistry(); + let ran = false; + reg.register( + capability('get_my_orders', { description: 'List the caller orders.', scope: 'user' }, () => { + ran = true; + return []; + }), + ); + const result = await reg.call('get_my_orders', {}, {}); // no principal + expect(result.ok).toBe(false); + expect(result.error).toContain('scoped to'); + expect(ran).toBe(false); + }); +}); + describe('closestMatches', () => { it('ranks near names first and drops distant ones', () => { const matches = closestMatches('get_ordr', ['get_order', 'delete_order', 'list_users'], 3); diff --git a/packages/js/tests/safety.test.ts b/packages/js/tests/safety.test.ts new file mode 100644 index 0000000..bb3f76f --- /dev/null +++ b/packages/js/tests/safety.test.ts @@ -0,0 +1,279 @@ +import { describe, it, expect } from 'vitest'; +import { + CapabilityRegistry, + FakeModel, + capability, + params, + runLoop, + callResponse, + finalResponse, + MemoryAuditSink, + type AuditSink, + type RunOptions, +} from '../src/index.js'; + +/** A write capability that records how many times its handler actually ran. */ +function writeCap(counter: { n: number }) { + return capability( + 'create_order', + { description: 'Create a new order for a customer.', parameters: params({ sku: 'string' }) }, + () => { + counter.n += 1; + return { id: 1, sku: 'A1' }; + }, + ); +} + +function destructiveCap(counter: { n: number }) { + return capability( + 'delete_order', + { description: 'Delete an order by its id.', parameters: params({ id: 'integer' }) }, + () => { + counter.n += 1; + return { deleted: true }; + }, + ); +} + +function registryWith(...caps: ReturnType[]): CapabilityRegistry { + const reg = new CapabilityRegistry(); + for (const c of caps) reg.register(c); + return reg; +} + +function writeThenAnswer(name = 'create_order', args: Record = { sku: 'A1' }) { + return new FakeModel(callResponse('c1', name, args), finalResponse('Done.')); +} + +describe('run() write gate — policy → approval → audit → execute', () => { + it('executes an approved write and audits it as executed', async () => { + const counter = { n: 0 }; + const audit = new MemoryAuditSink(); + const rep = await runLoop( + writeThenAnswer(), + registryWith(writeCap(counter)), + 'create an order', + true, + { + autonomy: 'approved_writes', + approve: () => true, + audit, + }, + ); + expect(rep.outcome).toBe('executed'); + expect(counter.n).toBe(1); + expect( + audit.records.some((r) => r.decision === 'executed' && r.capability === 'create_order'), + ).toBe(true); + }); + + it('fails closed on the default deny-all gate (no approval, no write)', async () => { + const counter = { n: 0 }; + const audit = new MemoryAuditSink(); + const rep = await runLoop( + writeThenAnswer(), + registryWith(writeCap(counter)), + 'create an order', + true, + { + autonomy: 'approved_writes', + audit, + }, + ); + expect(rep.outcome).toBe('not_executed'); + expect(rep.reason).toBe('approval_denied'); + expect(counter.n).toBe(0); + expect(audit.records.some((r) => r.decision === 'approval_denied')).toBe(true); + }); + + it('refuses a write under read_only autonomy even in run()', async () => { + const counter = { n: 0 }; + const rep = await runLoop( + writeThenAnswer(), + registryWith(writeCap(counter)), + 'create an order', + true, + { + autonomy: 'read_only', + approve: () => true, + }, + ); + expect(rep.outcome).toBe('refused'); + expect(rep.reason).toBe('write_in_read_only'); + expect(counter.n).toBe(0); + }); + + it('auto-executes a plain write when trusted, without calling the gate', async () => { + const counter = { n: 0 }; + const rep = await runLoop( + writeThenAnswer(), + registryWith(writeCap(counter)), + 'create an order', + true, + { + autonomy: 'trusted', + approve: () => { + throw new Error('the gate must not be called for a trusted plain write'); + }, + }, + ); + expect(rep.outcome).toBe('executed'); + expect(counter.n).toBe(1); + }); + + it('still gates a destructive write when trusted', async () => { + const counter = { n: 0 }; + const rep = await runLoop( + writeThenAnswer('delete_order', { id: 1 }), + registryWith(destructiveCap(counter)), + 'delete order 1', + true, + { autonomy: 'trusted' }, // default deny-all gate + ); + expect(rep.outcome).toBe('not_executed'); + expect(counter.n).toBe(0); + }); + + it('fails closed if an approved write cannot be audited (no audit, no action)', async () => { + const counter = { n: 0 }; + const brokenSink: AuditSink = { + record() { + throw new Error('sink down'); + }, + }; + const rep = await runLoop( + writeThenAnswer(), + registryWith(writeCap(counter)), + 'create an order', + true, + { + autonomy: 'approved_writes', + approve: () => true, + audit: brokenSink, + }, + ); + expect(rep.outcome).toBe('not_executed'); + expect(rep.reason).toBe('audit_failed'); + expect(counter.n).toBe(0); + }); + + it('redacts sensitive arguments in the audit trail', async () => { + const audit = new MemoryAuditSink(); + const cap = capability( + 'create_account', + { + description: 'Create an account with credentials.', + parameters: params({ email: 'string', password: 'string' }), + }, + () => ({ ok: true }), + ); + await runLoop( + new FakeModel( + callResponse('c1', 'create_account', { email: 'a@b.com', password: 'hunter2' }), + finalResponse('Done.'), + ), + registryWith(cap), + 'make an account', + true, + { autonomy: 'trusted', audit }, + ); + const rec = audit.records.find((r) => r.capability === 'create_account'); + expect(rec?.arguments['password']).toBe('[redacted]'); + expect(rec?.arguments['email']).toBe('a@b.com'); + }); +}); + +describe('bulk confirmation', () => { + function bulkModel() { + return new FakeModel( + callResponse('c1', 'update_orders', { ids: [1, 2, 3] }), + finalResponse('Done.'), + ); + } + function bulkCap(counter: { n: number }) { + return capability( + 'update_orders', + { description: 'Update several orders at once.', parameters: params({ ids: 'integer[]' }) }, + () => { + counter.n += 1; + return { updated: 3 }; + }, + ); + } + + it('gates a trusted plain write when it touches multiple rows', async () => { + const counter = { n: 0 }; + const rep = await runLoop(bulkModel(), registryWith(bulkCap(counter)), 'update orders', true, { + autonomy: 'trusted', // policy would allow, but bulk forces a confirm + // default deny-all gate + }); + expect(rep.outcome).toBe('not_executed'); + expect(counter.n).toBe(0); + }); + + it('executes the bulk write once confirmed', async () => { + const counter = { n: 0 }; + const rep = await runLoop(bulkModel(), registryWith(bulkCap(counter)), 'update orders', true, { + autonomy: 'trusted', + approve: (req) => req.bulkCount === 3, + }); + expect(rep.outcome).toBe('executed'); + expect(counter.n).toBe(1); + }); +}); + +describe('ask() still refuses writes and audits the refusal', () => { + it('refuses and records a refused audit entry', async () => { + const counter = { n: 0 }; + const audit = new MemoryAuditSink(); + const rep = await runLoop( + writeThenAnswer('delete_order', { id: 1 }), + registryWith(destructiveCap(counter)), + 'delete order 1', + false, + { audit }, + ); + expect(rep.outcome).toBe('refused'); + expect(counter.n).toBe(0); + expect(audit.records.some((r) => r.decision === 'refused')).toBe(true); + }); +}); + +describe('row-level security', () => { + function scopedReadCap(counter: { n: number }) { + return capability( + 'get_my_orders', + { description: 'List orders belonging to the caller.', scope: 'user' }, + () => { + counter.n += 1; + return [{ id: 1 }]; + }, + ); + } + + const model = () => + new FakeModel(callResponse('c1', 'get_my_orders', {}), finalResponse('Here they are.')); + + it('refuses a scoped capability when the run has no principal (fail closed, §2.8)', async () => { + const counter = { n: 0 }; + const rep = await runLoop(model(), registryWith(scopedReadCap(counter)), 'my orders', false); + expect(rep.outcome).toBe('completed'); // errors-as-data, the loop continues + expect(rep.executed).toEqual([]); // the scoped call never reached user code + expect(counter.n).toBe(0); + }); + + it('runs a scoped capability once a principal is present', async () => { + const counter = { n: 0 }; + const opts: RunOptions = { principal: 'user-42' }; + const rep = await runLoop( + model(), + registryWith(scopedReadCap(counter)), + 'my orders', + false, + opts, + ); + expect(rep.outcome).toBe('completed'); + expect(rep.executed).toEqual(['get_my_orders']); + expect(counter.n).toBe(1); + }); +});