feat(harness): add server-authoritative approvals

This commit is contained in:
Wenxin Duan
2026-09-07 15:47:12 +08:00
parent ec0686cd56
commit 85739f58ed
6 changed files with 393 additions and 0 deletions
@@ -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`);
}
}
+3
View File
@@ -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/in-memory-event-store.js";
export * from "./errors/harness-error.js";
export * from "./security/redact.js";
export * from "./skills/skill-catalog.js";
export * from "./skills/skill-event-inference.js";
export * from "./tools/mock-ads-metrics.js";
+57
View File
@@ -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),
);
}