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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
159 changes: 159 additions & 0 deletions packages/js/src/approval.ts
Original file line number Diff line number Diff line change
@@ -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<boolean>;
}

/** A plain function form of {@link ApprovalHandler}. */
export type ApprovalFn = (request: ApprovalRequest) => boolean | Promise<boolean>;

/** 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<boolean> {
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;
}
94 changes: 94 additions & 0 deletions packages/js/src/audit.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
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<void>;
}

/** 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<string, unknown>): Record<string, unknown> {
const out: Record<string, unknown> = {};
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<string, unknown> = {};
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));
}
4 changes: 4 additions & 0 deletions packages/js/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Loading
Loading