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/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";
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user