feat(harness): add server-authoritative approvals
This commit is contained in:
@@ -0,0 +1,24 @@
|
|||||||
|
export type ApprovalStatus = "approved" | "denied" | "expired" | "cancelled";
|
||||||
|
|
||||||
|
export type ApprovalRequest = {
|
||||||
|
approvalId: string;
|
||||||
|
sessionId: string;
|
||||||
|
runId: string;
|
||||||
|
toolCallId: string;
|
||||||
|
toolName: string;
|
||||||
|
arguments: unknown;
|
||||||
|
riskLevel: "low" | "medium" | "high";
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ApprovalResolution = {
|
||||||
|
approvalId: string;
|
||||||
|
status: ApprovalStatus;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface ApprovalBroker {
|
||||||
|
request(input: ApprovalRequest, signal?: AbortSignal): Promise<ApprovalResolution>;
|
||||||
|
approve(approvalId: string): ApprovalResolution;
|
||||||
|
deny(approvalId: string): ApprovalResolution;
|
||||||
|
cancelForSession(sessionId: string): number;
|
||||||
|
cancelForRun(runId: string): number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import type { EventStore } from "../events/event-store.js";
|
||||||
|
import { HarnessError } from "../errors/harness-error.js";
|
||||||
|
import { redactAndLimit } from "../security/redact.js";
|
||||||
|
import type {
|
||||||
|
ApprovalBroker,
|
||||||
|
ApprovalRequest,
|
||||||
|
ApprovalResolution,
|
||||||
|
ApprovalStatus,
|
||||||
|
} from "./approval-broker.js";
|
||||||
|
|
||||||
|
type PendingApproval = {
|
||||||
|
request: ApprovalRequest;
|
||||||
|
promise: Promise<ApprovalResolution>;
|
||||||
|
resolve: (resolution: ApprovalResolution) => void;
|
||||||
|
timer: ReturnType<typeof setTimeout>;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
abortListener?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type InMemoryApprovalBrokerOptions = {
|
||||||
|
eventStore: EventStore;
|
||||||
|
timeoutMs?: number;
|
||||||
|
maxArgumentBytes?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class InMemoryApprovalBroker implements ApprovalBroker {
|
||||||
|
readonly #eventStore: EventStore;
|
||||||
|
readonly #timeoutMs: number;
|
||||||
|
readonly #maxArgumentBytes: number;
|
||||||
|
readonly #pending = new Map<string, PendingApproval>();
|
||||||
|
readonly #resolved = new Map<string, ApprovalResolution>();
|
||||||
|
|
||||||
|
public constructor(options: InMemoryApprovalBrokerOptions) {
|
||||||
|
this.#eventStore = options.eventStore;
|
||||||
|
this.#timeoutMs = options.timeoutMs ?? 30_000;
|
||||||
|
this.#maxArgumentBytes = options.maxArgumentBytes ?? 16_384;
|
||||||
|
assertPositiveInteger(this.#timeoutMs, "timeoutMs");
|
||||||
|
assertPositiveInteger(this.#maxArgumentBytes, "maxArgumentBytes");
|
||||||
|
}
|
||||||
|
|
||||||
|
public request(input: ApprovalRequest, signal?: AbortSignal): Promise<ApprovalResolution> {
|
||||||
|
if (this.#pending.has(input.approvalId) || this.#resolved.has(input.approvalId)) {
|
||||||
|
throw new HarnessError("APPROVAL_DUPLICATE", `Approval already exists: ${input.approvalId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let resolvePromise!: (resolution: ApprovalResolution) => void;
|
||||||
|
const promise = new Promise<ApprovalResolution>((resolve) => {
|
||||||
|
resolvePromise = resolve;
|
||||||
|
});
|
||||||
|
const expiresAt = new Date(Date.now() + this.#timeoutMs).toISOString();
|
||||||
|
const pending: PendingApproval = {
|
||||||
|
request: input,
|
||||||
|
promise,
|
||||||
|
resolve: resolvePromise,
|
||||||
|
timer: setTimeout(() => this.#resolve(input.approvalId, "expired"), this.#timeoutMs),
|
||||||
|
...(signal === undefined ? {} : { signal }),
|
||||||
|
};
|
||||||
|
this.#pending.set(input.approvalId, pending);
|
||||||
|
this.#eventStore.append(input.sessionId, {
|
||||||
|
type: "approval.required",
|
||||||
|
runId: input.runId,
|
||||||
|
payload: {
|
||||||
|
approvalId: input.approvalId,
|
||||||
|
toolCallId: input.toolCallId,
|
||||||
|
toolName: input.toolName,
|
||||||
|
arguments: redactAndLimit(input.arguments, this.#maxArgumentBytes),
|
||||||
|
riskLevel: input.riskLevel,
|
||||||
|
expiresAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (signal !== undefined) {
|
||||||
|
const abortListener = (): void => {
|
||||||
|
this.#resolve(input.approvalId, "cancelled");
|
||||||
|
};
|
||||||
|
pending.abortListener = abortListener;
|
||||||
|
signal.addEventListener("abort", abortListener, { once: true });
|
||||||
|
if (signal.aborted) abortListener();
|
||||||
|
}
|
||||||
|
return promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
public approve(approvalId: string): ApprovalResolution {
|
||||||
|
return this.#decide(approvalId, "approved");
|
||||||
|
}
|
||||||
|
|
||||||
|
public deny(approvalId: string): ApprovalResolution {
|
||||||
|
return this.#decide(approvalId, "denied");
|
||||||
|
}
|
||||||
|
|
||||||
|
public cancelForSession(sessionId: string): number {
|
||||||
|
return this.#cancelWhere((request) => request.sessionId === sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public cancelForRun(runId: string): number {
|
||||||
|
return this.#cancelWhere((request) => request.runId === runId);
|
||||||
|
}
|
||||||
|
|
||||||
|
#decide(approvalId: string, status: "approved" | "denied"): ApprovalResolution {
|
||||||
|
const prior = this.#resolved.get(approvalId);
|
||||||
|
if (prior !== undefined) {
|
||||||
|
if (prior.status === status) return prior;
|
||||||
|
throw new HarnessError(
|
||||||
|
"APPROVAL_ALREADY_RESOLVED",
|
||||||
|
`Approval ${approvalId} was already resolved as ${prior.status}`,
|
||||||
|
{ approvalId, status: prior.status },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!this.#pending.has(approvalId)) {
|
||||||
|
throw new HarnessError("APPROVAL_NOT_FOUND", `Unknown approval: ${approvalId}`);
|
||||||
|
}
|
||||||
|
return this.#resolve(approvalId, status)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
#cancelWhere(predicate: (request: ApprovalRequest) => boolean): number {
|
||||||
|
const approvalIds = [...this.#pending.values()]
|
||||||
|
.filter((pending) => predicate(pending.request))
|
||||||
|
.map((pending) => pending.request.approvalId);
|
||||||
|
for (const approvalId of approvalIds) this.#resolve(approvalId, "cancelled");
|
||||||
|
return approvalIds.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
#resolve(approvalId: string, status: ApprovalStatus): ApprovalResolution | undefined {
|
||||||
|
const pending = this.#pending.get(approvalId);
|
||||||
|
if (pending === undefined) return this.#resolved.get(approvalId);
|
||||||
|
|
||||||
|
this.#pending.delete(approvalId);
|
||||||
|
clearTimeout(pending.timer);
|
||||||
|
if (pending.signal !== undefined && pending.abortListener !== undefined) {
|
||||||
|
pending.signal.removeEventListener("abort", pending.abortListener);
|
||||||
|
}
|
||||||
|
const resolution = { approvalId, status } satisfies ApprovalResolution;
|
||||||
|
this.#resolved.set(approvalId, resolution);
|
||||||
|
this.#eventStore.append(pending.request.sessionId, {
|
||||||
|
type: "approval.resolved",
|
||||||
|
runId: pending.request.runId,
|
||||||
|
payload: resolution,
|
||||||
|
});
|
||||||
|
pending.resolve(resolution);
|
||||||
|
return resolution;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertPositiveInteger(value: number, name: string): void {
|
||||||
|
if (!Number.isSafeInteger(value) || value < 1) {
|
||||||
|
throw new RangeError(`${name} must be a positive integer`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
|
export * from "./approvals/approval-broker.js";
|
||||||
|
export * from "./approvals/in-memory-approval-broker.js";
|
||||||
export * from "./events/event-store.js";
|
export * from "./events/event-store.js";
|
||||||
export * from "./events/in-memory-event-store.js";
|
export * from "./events/in-memory-event-store.js";
|
||||||
export * from "./errors/harness-error.js";
|
export * from "./errors/harness-error.js";
|
||||||
|
export * from "./security/redact.js";
|
||||||
export * from "./skills/skill-catalog.js";
|
export * from "./skills/skill-catalog.js";
|
||||||
export * from "./skills/skill-event-inference.js";
|
export * from "./skills/skill-event-inference.js";
|
||||||
export * from "./tools/mock-ads-metrics.js";
|
export * from "./tools/mock-ads-metrics.js";
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
const REDACTED = "[REDACTED]";
|
||||||
|
|
||||||
|
export type TruncatedValue = {
|
||||||
|
type: "truncated";
|
||||||
|
byteLength: number;
|
||||||
|
maxBytes: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function redactAndLimit(value: unknown, maxBytes: number): unknown {
|
||||||
|
if (!Number.isSafeInteger(maxBytes) || maxBytes < 1) {
|
||||||
|
throw new RangeError("maxBytes must be a positive integer");
|
||||||
|
}
|
||||||
|
|
||||||
|
const redacted = redact(value, new WeakSet<object>());
|
||||||
|
const serialized = JSON.stringify(redacted) ?? "null";
|
||||||
|
const byteLength = Buffer.byteLength(serialized, "utf8");
|
||||||
|
if (byteLength <= maxBytes) return redacted;
|
||||||
|
return { type: "truncated", byteLength, maxBytes } satisfies TruncatedValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
function redact(value: unknown, ancestors: WeakSet<object>): unknown {
|
||||||
|
if (
|
||||||
|
value === null ||
|
||||||
|
typeof value === "string" ||
|
||||||
|
typeof value === "boolean" ||
|
||||||
|
typeof value === "number"
|
||||||
|
) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (typeof value === "bigint") return value.toString();
|
||||||
|
if (typeof value === "undefined") return null;
|
||||||
|
if (typeof value === "function" || typeof value === "symbol") return "[UNSERIALIZABLE]";
|
||||||
|
if (value instanceof Date) return value.toISOString();
|
||||||
|
if (ancestors.has(value)) return "[CIRCULAR]";
|
||||||
|
|
||||||
|
ancestors.add(value);
|
||||||
|
try {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.map((entry) => redact(entry, ancestors));
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = Object.entries(value).map(([key, entry]) => [
|
||||||
|
key,
|
||||||
|
isSensitiveKey(key) ? REDACTED : redact(entry, ancestors),
|
||||||
|
]);
|
||||||
|
return Object.fromEntries(entries);
|
||||||
|
} finally {
|
||||||
|
ancestors.delete(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSensitiveKey(key: string): boolean {
|
||||||
|
const normalized = key.toLowerCase().replaceAll(/[^a-z0-9]/g, "");
|
||||||
|
return ["apikey", "authorization", "token", "secret", "password", "credential"].some(
|
||||||
|
(suffix) => normalized === suffix || normalized.endsWith(suffix),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import * as harness from "../src/index.js";
|
||||||
|
|
||||||
|
type Resolution = { approvalId: string; status: string };
|
||||||
|
type Request = {
|
||||||
|
approvalId: string;
|
||||||
|
sessionId: string;
|
||||||
|
runId: string;
|
||||||
|
toolCallId: string;
|
||||||
|
toolName: string;
|
||||||
|
arguments: unknown;
|
||||||
|
riskLevel: "low" | "medium" | "high";
|
||||||
|
};
|
||||||
|
type Broker = {
|
||||||
|
request(input: Request, signal?: AbortSignal): Promise<Resolution>;
|
||||||
|
approve(approvalId: string): Resolution;
|
||||||
|
deny(approvalId: string): Resolution;
|
||||||
|
cancelForSession(sessionId: string): number;
|
||||||
|
cancelForRun(runId: string): number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function createBroker(timeoutMs = 1_000): {
|
||||||
|
broker: Broker;
|
||||||
|
store: { listAfter(sessionId: string): { events: Array<{ type: string; payload: unknown }> } };
|
||||||
|
} {
|
||||||
|
const Store = Reflect.get(harness, "InMemoryEventStore") as new () => {
|
||||||
|
listAfter(sessionId: string): { events: Array<{ type: string; payload: unknown }> };
|
||||||
|
};
|
||||||
|
const Broker = Reflect.get(harness, "InMemoryApprovalBroker") as unknown as
|
||||||
|
| (new (options: Record<string, unknown>) => Broker)
|
||||||
|
| undefined;
|
||||||
|
expect(Broker, "InMemoryApprovalBroker must be exported").toBeDefined();
|
||||||
|
const store = new Store();
|
||||||
|
return { broker: new Broker!({ eventStore: store, timeoutMs }), store };
|
||||||
|
}
|
||||||
|
|
||||||
|
function request(overrides: Partial<Request> = {}): Request {
|
||||||
|
return {
|
||||||
|
approvalId: "ap_1",
|
||||||
|
sessionId: "ses_1",
|
||||||
|
runId: "run_1",
|
||||||
|
toolCallId: "call_1",
|
||||||
|
toolName: "save_report_draft",
|
||||||
|
arguments: { body: "report", apiKey: "never-log-me" },
|
||||||
|
riskLevel: "medium",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("InMemoryApprovalBroker", () => {
|
||||||
|
it("resolves an approved request and treats the same repeated decision as idempotent", async () => {
|
||||||
|
const { broker, store } = createBroker();
|
||||||
|
const waiting = broker.request(request());
|
||||||
|
|
||||||
|
const first = broker.approve("ap_1");
|
||||||
|
const second = broker.approve("ap_1");
|
||||||
|
|
||||||
|
await expect(waiting).resolves.toEqual({ approvalId: "ap_1", status: "approved" });
|
||||||
|
expect(second).toEqual(first);
|
||||||
|
expect(store.listAfter("ses_1").events.map((event) => event.type)).toEqual([
|
||||||
|
"approval.required",
|
||||||
|
"approval.resolved",
|
||||||
|
]);
|
||||||
|
expect(JSON.stringify(store.listAfter("ses_1"))).not.toContain("never-log-me");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a conflicting second decision", async () => {
|
||||||
|
const { broker } = createBroker();
|
||||||
|
const waiting = broker.request(request());
|
||||||
|
broker.approve("ap_1");
|
||||||
|
await waiting;
|
||||||
|
|
||||||
|
expect(() => broker.deny("ap_1")).toThrowError(
|
||||||
|
expect.objectContaining({ code: "APPROVAL_ALREADY_RESOLVED" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("expires pending approval once when its deadline passes", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const { broker, store } = createBroker(50);
|
||||||
|
const waiting = broker.request(request());
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(50);
|
||||||
|
|
||||||
|
await expect(waiting).resolves.toEqual({ approvalId: "ap_1", status: "expired" });
|
||||||
|
expect(store.listAfter("ses_1").events.map((event) => event.type)).toEqual([
|
||||||
|
"approval.required",
|
||||||
|
"approval.resolved",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cancels on abort and removes the abort listener after resolution", async () => {
|
||||||
|
const { broker, store } = createBroker();
|
||||||
|
const controller = new AbortController();
|
||||||
|
const waiting = broker.request(request(), controller.signal);
|
||||||
|
|
||||||
|
controller.abort();
|
||||||
|
|
||||||
|
await expect(waiting).resolves.toEqual({ approvalId: "ap_1", status: "cancelled" });
|
||||||
|
expect(store.listAfter("ses_1").events).toHaveLength(2);
|
||||||
|
controller.abort();
|
||||||
|
expect(store.listAfter("ses_1").events).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cancels all pending approvals for a session or run", async () => {
|
||||||
|
const { broker } = createBroker();
|
||||||
|
const first = broker.request(request());
|
||||||
|
const second = broker.request(request({ approvalId: "ap_2", runId: "run_2", toolCallId: "call_2" }));
|
||||||
|
const third = broker.request(request({ approvalId: "ap_3", sessionId: "ses_2", toolCallId: "call_3" }));
|
||||||
|
|
||||||
|
expect(broker.cancelForRun("run_2")).toBe(1);
|
||||||
|
expect(broker.cancelForSession("ses_1")).toBe(1);
|
||||||
|
expect(broker.cancelForSession("ses_2")).toBe(1);
|
||||||
|
await expect(Promise.all([first, second, third])).resolves.toEqual([
|
||||||
|
{ approvalId: "ap_1", status: "cancelled" },
|
||||||
|
{ approvalId: "ap_2", status: "cancelled" },
|
||||||
|
{ approvalId: "ap_3", status: "cancelled" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import * as harness from "../src/index.js";
|
||||||
|
|
||||||
|
describe("redactAndLimit", () => {
|
||||||
|
it("recursively redacts sensitive keys without mutating the source", () => {
|
||||||
|
const redactAndLimit = Reflect.get(harness, "redactAndLimit") as
|
||||||
|
| ((value: unknown, maxBytes: number) => unknown)
|
||||||
|
| undefined;
|
||||||
|
expect(redactAndLimit, "redactAndLimit must be exported").toBeDefined();
|
||||||
|
const source = {
|
||||||
|
apiKey: "sk-secret",
|
||||||
|
nested: [{ Authorization: "Bearer secret" }, { refresh_token: "refresh" }],
|
||||||
|
safe: "visible",
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(redactAndLimit!(source, 10_000)).toEqual({
|
||||||
|
apiKey: "[REDACTED]",
|
||||||
|
nested: [{ Authorization: "[REDACTED]" }, { refresh_token: "[REDACTED]" }],
|
||||||
|
safe: "visible",
|
||||||
|
});
|
||||||
|
expect(source.apiKey).toBe("sk-secret");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("replaces oversized serialized values with a typed byte summary", () => {
|
||||||
|
const redactAndLimit = Reflect.get(harness, "redactAndLimit") as
|
||||||
|
| ((value: unknown, maxBytes: number) => unknown)
|
||||||
|
| undefined;
|
||||||
|
const result = redactAndLimit?.({ text: "中".repeat(100) }, 32);
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
type: "truncated",
|
||||||
|
maxBytes: 32,
|
||||||
|
});
|
||||||
|
expect(Reflect.get(result as object, "byteLength")).toBeGreaterThan(32);
|
||||||
|
expect(JSON.stringify(result)).not.toContain("中");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user