fix(audit): preserve numeric decision cursors (#122619)

This commit is contained in:
Josh Avant
2026-08-12 13:08:25 -05:00
committed by GitHub
parent 5e85947a15
commit 1b36f42653
28 changed files with 252 additions and 51 deletions
@@ -1 +1 @@
{"contentHash":"3197bbee115b315bb7ffaec2f495abba5bc9981a90751760da6c430be3ee5445","entrypoint":"agent-harness-runtime","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime"}
{"contentHash":"db7aab74b421a7ed47dac3fd71ca80190d2df3102a1a890b0d72323ed155d11d","entrypoint":"agent-harness-runtime","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"bc2750304f9959144c04143e8f5b14ca56bae3f8ec1bdfb27d05ddbe77e01d53","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"}
{"contentHash":"f2752fd306b35b124b0cd32b732711be0f9831f10d13f9fcaacd4ecb39cce9e3","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"362c2fc4b9048470c4857d542f7593bb5a8a8484d4c2176f4c9e560af642db7e","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"}
{"contentHash":"6fc88598c472783c5cd49a44a55c7ea918438eb540837772d68bbe6928e68495","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"}
@@ -1 +1 @@
{"contentHash":"bf8fa1e5fbb43f0a5fc3b891b93a848684414d78cb88c8f75b73491e4e46b0c7","entrypoint":"channel-entry-contract","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract"}
{"contentHash":"c9cc79970dbfef3cf73c2abaa966daa4206b25cc87d43562a32eb44934d6bb0c","entrypoint":"channel-entry-contract","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract"}
@@ -1 +1 @@
{"contentHash":"b3dc5322d5837138fa4b857d5ca0f18cef4c1bcebb10874bef30f42c91e04465","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"}
{"contentHash":"dc48f51312725277ee6004c6bfe43d42d16dae0e7e0fe9371a66f4b7a859df76","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"}
@@ -1 +1 @@
{"contentHash":"ad3ff6b5e1b3c11bf4e03599ec3724697869e5d342f5597c835ae1dd45b7feff","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"}
{"contentHash":"0dff4add5bfb93c182a0c0b3f3ee471e7fbf69d9d0e1f6a342668451a4620c18","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"}
@@ -1 +1 @@
{"contentHash":"d2bfeec50ac2ee0abbf987a58b004820060f1d3d5634834e781d125da7e7a930","entrypoint":"channel-plugin-common","importSpecifier":"openclaw/plugin-sdk/channel-plugin-common"}
{"contentHash":"d2a7bb51e14d60bcc1f623f9fabf8baa3d4d2b253f8a643b331e10e20c0ca9ac","entrypoint":"channel-plugin-common","importSpecifier":"openclaw/plugin-sdk/channel-plugin-common"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"d24c93b08546f841c3f60d03383214671a3bd8e479ff2c1aa3d7a934ef1e0d28","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"}
{"contentHash":"15b6006cb8edd215e6df21c5c8250a25635661b6633b0a858c50ed019554aa50","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"a910e8c530f0dc757c11a62e9b6898dffb5d0274de0311728badfdae657eb1bc","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"}
{"contentHash":"751c9fe121607e061b2eb3e1c57fc854d650ab583873979746cf25637c551669","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"}
@@ -1 +1 @@
{"contentHash":"4ba70d6eaf72efab607d4b2f9783df5bafdf21ab43a1f4a2947a48a829ed0337","entrypoint":"inbound-reply-dispatch","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch"}
{"contentHash":"5824cd4a9e5b2242177cfa79040d2bba2eb22b90cb1d2cf0d257d2088b47a6f3","entrypoint":"inbound-reply-dispatch","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch"}
@@ -1 +1 @@
{"contentHash":"db01fbcef6813a72f89535130a49036afdf998d88de3bb29e2e5d7c62d6c95eb","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"}
{"contentHash":"8307497b019c13f8ce6d3159a07a4df516d6f84b7219b2ab1666ceeb3481fa61","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"80ebabbad4a9bc7b8b6eacc806fe3d94d468f3650dee8b97e4cf2883d025d08f","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"}
{"contentHash":"52e9b62ba55034c21c4673e2acba9c6a367d75c46e92cff698f2a6720008c17e","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"7766933d97e904bf753203045d712d8423cd33f8e018aedb39b5cdc8ac0b8a5f","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"}
{"contentHash":"0da095103f904be5e77d287c93a739b33f192c96e0f5334571ad46245a9ecba5","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"}
@@ -1 +1 @@
{"contentHash":"17b9e593a0f1aebd5e985c34cbfc6a6df8963f8e73b25cfb9ca756a0e0918b77","entrypoint":"provider-catalog-runtime","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime"}
{"contentHash":"244eeb2fb3601d42e421c46f46e21432c954ddfdacc928fd6a9baa6a9ef6deba","entrypoint":"provider-catalog-runtime","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"86096091c7e63bac10baec86c07cf7d9256e1ba1433a5dbb60b86df81e53ca11","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"}
{"contentHash":"b9c11e37dd3647431225b8fdd97d6402074a741c80f2fa3e54745bade5fcc6b7","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"}
@@ -1 +1 @@
{"contentHash":"ee451a45f1687cede02fb776e87a630ed21fe485e1e5af1e36281161351d8278","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"}
{"contentHash":"307513192612557813825c129772eb642c0f4aac97ba538938dd4f6e9efd60d9","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"}
@@ -13,6 +13,7 @@ scenario:
- A real trusted agent exec request records an exact execution binding, is denied by an approval-capable Gateway client, and a conflicting later allow cannot replace the first answer.
- Text and JSON audit output report the denial reason, enforced state, authoritative durable owner, bounded policy references, and remediation.
- Receipt output omits raw command, tool-call, and reviewer-device details and creates no generic duplicate.
- A positive numeric compatibility cursor resumes the bounded decision receipt page and returns an opaque successor accepted by the same Gateway handler.
- A replacement Gateway process returns byte-equivalent decision inspection JSON.
docsRefs:
- docs/gateway/audit.md
+13
View File
@@ -0,0 +1,13 @@
import { parseStrictPositiveInteger } from "@openclaw/normalization-core/number-coercion";
/** Parse the digit-only positive cursor grammar shared by audit CLI and Gateway paging. */
export function parsePositiveAuditCursor(cursor: string | undefined): number | undefined | null {
if (cursor === undefined) {
return undefined;
}
const trimmed = cursor.trim();
if (!/^\d+$/.test(trimmed)) {
return null;
}
return parseStrictPositiveInteger(trimmed) ?? null;
}
+30 -2
View File
@@ -37,7 +37,9 @@ function databaseOptions() {
return { env: { OPENCLAW_STATE_DIR: tempDirs.make("openclaw-decision-facts-") } };
}
function seedExecutionContext(database: ReturnType<typeof databaseOptions>): void {
function seedExecutionContext(
database: ReturnType<typeof databaseOptions>,
): ExecutionIdentityContextV1 {
let envelope: ExecutionIdentityAdmissionEnvelope | undefined;
const clear = configureExecutionIdentityAdmissionSink((work) => {
if (work.kind === "capture") {
@@ -78,6 +80,7 @@ function seedExecutionContext(database: ReturnType<typeof databaseOptions>): voi
) {
throw new Error(`unexpected execution context: ${JSON.stringify(stored)}`);
}
return stored;
}
function receipt(id: string, occurredAt = 100): DecisionReceiptV1 {
@@ -193,7 +196,7 @@ describe("execution decision facts", () => {
it("pages equal-time facts by a bounded row key", () => {
const database = databaseOptions();
seedExecutionContext(database);
const context = seedExecutionContext(database);
for (const id of ["same-time-a", "same-time-b", "same-time-c"]) {
recordExecutionDecisionFact(receipt(id, 100), { ...database, now: 100 });
}
@@ -215,6 +218,31 @@ describe("execution decision facts", () => {
database,
}).receipts.map((item) => item.receiptId),
).toEqual(["same-time-b", "same-time-c"]);
for (const decisionCursor of ["1", "001"]) {
const legacyPage = presentExecutionDecisionReceipts({
context,
decisionCursor,
decisionLimit: 1,
options: { ...database, now: 100 },
});
expect(legacyPage.decisions.map((item) => item.receiptId)).toEqual(["same-time-a"]);
expect(legacyPage.nextDecisionCursor).toMatch(/^g:/);
}
const legacyPage = presentExecutionDecisionReceipts({
context,
decisionCursor: "1",
decisionLimit: 1,
options: { ...database, now: 100 },
});
expect(
presentExecutionDecisionReceipts({
context,
decisionCursor: legacyPage.nextDecisionCursor,
decisionLimit: 2,
options: { ...database, now: 100 },
}).decisions.map((item) => item.receiptId),
).toEqual(["same-time-b", "same-time-c"]);
});
it("bounds aggregated missing evidence at the result protocol boundary", () => {
+4
View File
@@ -326,6 +326,7 @@ function retainedDecisionFactMetadata(params: {
contextId: string;
now: number;
after?: ExecutionDecisionFactCursor;
offset?: number;
limit: number;
}): ExecutionDecisionMetadataRow[] {
const boundary = params.after
@@ -377,6 +378,7 @@ function retainedDecisionFactMetadata(params: {
])
.orderBy("occurred_at", "asc")
.orderBy("receipt_id", "asc")
.$if(params.offset !== undefined, (query) => query.offset(params.offset!))
.limit(params.limit),
).rows;
}
@@ -522,6 +524,7 @@ export function hasExecutionDecisionFactsForRun(params: {
export function pageExecutionDecisionFactsForContext(params: {
context: ExecutionDecisionContext;
after?: ExecutionDecisionFactCursor;
offset?: number;
limit: number;
now?: number;
database?: OpenClawStateDatabaseOptions;
@@ -536,6 +539,7 @@ export function pageExecutionDecisionFactsForContext(params: {
contextId: params.context.contextId,
now: params.now ?? Date.now(),
after: params.after,
offset: params.offset,
limit: params.limit + 1,
});
const pageMetadata = metadataRows.slice(0, params.limit);
+31 -7
View File
@@ -9,6 +9,7 @@ import {
summarizeOperatorApprovalReceiptsForRun,
} from "../gateway/operator-approval-store.js";
import type { OpenClawStateDatabaseOptions } from "../state/openclaw-state-db.js";
import { parsePositiveAuditCursor } from "./audit-cursor.js";
import {
pageExecutionDecisionFactsForContext,
summarizeExecutionDecisionFactsForContext,
@@ -18,10 +19,14 @@ type ExecutionDecisionReadOptions = OpenClawStateDatabaseOptions & { now?: numbe
const MAX_AGGREGATE_MISSING_EVIDENCE = 16;
const MISSING_EVIDENCE_TRUNCATED = "decision.missing_evidence_truncated";
type DecisionCursor = {
stage: "approval" | "generic";
after?: { occurredAt: number; rowId: number };
};
type DecisionCursor =
| {
stage: "approval" | "generic";
after?: { occurredAt: number; rowId: number };
}
| {
offset: number;
};
export class ExecutionDecisionCursorError extends Error {
constructor(message = "invalid execution decision cursor") {
@@ -34,6 +39,10 @@ function parseDecisionCursor(value: string | undefined): DecisionCursor | undefi
if (value === undefined) {
return undefined;
}
const offset = parsePositiveAuditCursor(value);
if (offset !== null && offset !== undefined) {
return { offset };
}
const match = /^([ag]):(0|[1-9]\d*):(0|[1-9]\d*)$/.exec(value);
if (!match) {
return null;
@@ -129,6 +138,10 @@ export function presentExecutionDecisionReceipts(params: {
}
const limit = params.decisionLimit ?? 50;
const now = params.options.now ?? Date.now();
// Numeric cursors are the shipped aggregate offset. Resolve its owner span
// once, then let the canonical bounded owner pagers emit opaque successors.
const opaqueCursor = cursor && "stage" in cursor ? cursor : undefined;
const legacyOffset = cursor && "offset" in cursor ? cursor.offset - 1 : undefined;
const approvalSummary = summarizeOperatorApprovalReceiptsForRun({
context: {
contextId: params.context.contextId,
@@ -137,6 +150,7 @@ export function presentExecutionDecisionReceipts(params: {
},
nowMs: now,
databaseOptions: params.options,
exactCount: legacyOffset !== undefined,
});
const genericSummary = summarizeExecutionDecisionFactsForContext({
context: params.context,
@@ -146,6 +160,10 @@ export function presentExecutionDecisionReceipts(params: {
const decisions: DecisionReceiptV1[] = [];
let remainingLimit = limit;
let nextDecisionCursor: string | undefined;
const approvalOffset =
legacyOffset !== undefined && legacyOffset < approvalSummary.count ? legacyOffset : undefined;
const genericOffset =
legacyOffset === undefined ? undefined : Math.max(0, legacyOffset - approvalSummary.count);
if (cursor === undefined && remainingLimit > 0) {
decisions.push(admissionDecision(params.context));
@@ -154,7 +172,11 @@ export function presentExecutionDecisionReceipts(params: {
nextDecisionCursor = formatDecisionCursor("approval");
}
}
if (remainingLimit > 0 && cursor?.stage !== "generic") {
if (
remainingLimit > 0 &&
opaqueCursor?.stage !== "generic" &&
(legacyOffset === undefined || approvalOffset !== undefined)
) {
let page;
try {
page = pageOperatorApprovalReceiptsForRun({
@@ -163,7 +185,8 @@ export function presentExecutionDecisionReceipts(params: {
executionId: params.context.executionId,
runId: params.context.runId,
},
after: cursor?.stage === "approval" ? cursor.after : undefined,
after: opaqueCursor?.stage === "approval" ? opaqueCursor.after : undefined,
offset: approvalOffset,
limit: remainingLimit,
nowMs: now,
databaseOptions: params.options,
@@ -189,7 +212,8 @@ export function presentExecutionDecisionReceipts(params: {
try {
page = pageExecutionDecisionFactsForContext({
context: params.context,
after: cursor?.stage === "generic" ? cursor.after : undefined,
after: opaqueCursor?.stage === "generic" ? opaqueCursor.after : undefined,
offset: genericOffset,
limit: remainingLimit,
now,
database: params.options,
@@ -1005,6 +1005,12 @@ describe("execution identity context storage", () => {
).toMatchObject({
decisions: [{ decision: { reasonCode: "operator_approval_denied_by_reviewer" } }],
});
expect(
inspectExecutionIdentityRun(
{ runId: "run-denied-receipt", decisionCursor: "1", decisionLimit: 1 },
{ ...database, now: 300 },
).decisions,
).toMatchObject([{ decision: { reasonCode: "operator_approval_denied_by_reviewer" } }]);
});
it("keeps a corrupt approval unknown before its decision page is returned", () => {
+44
View File
@@ -565,6 +565,50 @@ describe("audit run explanation", () => {
});
});
it("routes the shared explain cursor by selector and grammar", async () => {
callGateway.mockResolvedValue({
schemaVersion: 1,
run: { runId: "run-1", executionId: "execution-1", status: "known" },
identity: {
state: "unknown",
reasonCode: "execution_not_found",
missingEvidence: ["identity.context"],
remediation: [],
},
decisions: [],
coverage: { state: "unknown", missingEvidence: ["identity.context"] },
});
for (const [options, params] of [
[
{ explain: true, runId: "run-1", cursor: "a:2000:42" },
{ runId: "run-1", executionLimit: 50, decisionCursor: "a:2000:42", decisionLimit: 50 },
],
[
{ explain: true, executionId: "execution-1", cursor: "1" },
{ executionId: "execution-1", decisionCursor: "1", decisionLimit: 50 },
],
[
{ explain: true, runId: "run-1", cursor: "001" },
{
runId: "run-1",
executionLimit: 50,
executionCursor: "001",
decisionCursor: "001",
decisionLimit: 50,
},
],
[
{ explain: true, executionId: "execution-1", cursor: "g:2000:42" },
{ executionId: "execution-1", decisionCursor: "g:2000:42", decisionLimit: 50 },
],
] as const) {
callGateway.mockClear();
await auditListCommand(options, runtime);
expect(callGateway).toHaveBeenCalledWith({ method: "audit.run.inspect", params });
}
});
it("renders expired identity as unsupported without context fields or decisions", async () => {
callGateway.mockResolvedValue({
schemaVersion: 1,
+16 -8
View File
@@ -16,6 +16,7 @@ import type {
PrincipalRefV1,
} from "../../packages/gateway-protocol/src/index.js";
import { sanitizeTerminalText } from "../../packages/terminal-core/src/safe-text.js";
import { parsePositiveAuditCursor } from "../audit/audit-cursor.js";
import { parseAbsoluteTimeMs } from "../cron/parse.js";
import { callGateway } from "../gateway/call.js";
import { type RuntimeEnv, writeRuntimeJson } from "../runtime.js";
@@ -472,17 +473,24 @@ export async function auditListCommand(
);
}
const decisionLimit = parseAuditDecisionLimit(options.limit);
const result = await queryAuditRunInspection({
...(executionId
? { executionId }
const cursor = options.cursor;
const numericCursor = parsePositiveAuditCursor(cursor);
const runExecutionCursor =
numericCursor !== undefined && numericCursor !== null ? cursor : undefined;
const decisionPage = {
decisionLimit,
...(cursor ? { decisionCursor: cursor } : {}),
};
const result = await queryAuditRunInspection(
executionId
? { executionId, ...decisionPage }
: {
runId: runId!,
executionLimit: Math.min(decisionLimit, MAX_AUDIT_EXECUTION_LIMIT),
...(options.cursor ? { executionCursor: options.cursor } : {}),
}),
decisionLimit,
...(options.cursor ? { decisionCursor: options.cursor } : {}),
});
...(runExecutionCursor ? { executionCursor: runExecutionCursor } : {}),
...decisionPage,
},
);
if (options.json) {
writeRuntimeJson(runtime, result);
return;
+16 -3
View File
@@ -795,6 +795,7 @@ function terminalApprovalReceiptMetadataRows(params: {
runId: string;
nowMs: number;
after?: OperatorApprovalReceiptCursor;
offset?: number;
limit: number;
}): OperatorApprovalReceiptMetadataRow[] {
const boundary = params.after
@@ -825,6 +826,7 @@ function terminalApprovalReceiptMetadataRows(params: {
)
.orderBy("operator_approvals.resolved_at_ms", "asc")
.orderBy("operator_approvals.approval_id", "asc")
.$if(params.offset !== undefined, (query) => query.offset(params.offset!))
.limit(params.limit);
const metadata = (query: typeof ordered) =>
query
@@ -963,6 +965,7 @@ export function summarizeOperatorApprovalReceiptsForRun(params: {
context: OperatorApprovalReceiptContext;
nowMs?: number;
databaseOptions?: OpenClawStateDatabaseOptions;
exactCount?: boolean;
}): {
count: number;
coverageState?: "enforced" | "unknown";
@@ -981,13 +984,21 @@ export function summarizeOperatorApprovalReceiptsForRun(params: {
nowMs: params.nowMs ?? Date.now(),
limit: OPERATOR_APPROVAL_RECEIPT_SUMMARY_MAX_ROWS + 1,
});
const count = metadataRows.length;
if (count === 0) {
const boundedCount = metadataRows.length;
const count = params.exactCount
? (executeSqliteQueryTakeFirstSync(
db,
terminalApprovalsForRunQuery(stateDb, params.context.runId, params.nowMs ?? Date.now())
.clearSelect()
.select((eb) => eb.fn.countAll<number>().as("count")),
)?.count ?? 0)
: boundedCount;
if (boundedCount === 0) {
return { count: 0, missingEvidence: [] };
}
// Whole-set coverage stays conservative without decoding an unbounded
// collection on the Gateway event loop.
if (count > OPERATOR_APPROVAL_RECEIPT_SUMMARY_MAX_ROWS) {
if (boundedCount > OPERATOR_APPROVAL_RECEIPT_SUMMARY_MAX_ROWS) {
return {
count,
coverageState: "unknown" as const,
@@ -1039,6 +1050,7 @@ export function summarizeOperatorApprovalReceiptsForRun(params: {
export function pageOperatorApprovalReceiptsForRun(params: {
context: OperatorApprovalReceiptContext;
after?: OperatorApprovalReceiptCursor;
offset?: number;
limit: number;
nowMs?: number;
databaseOptions?: OpenClawStateDatabaseOptions;
@@ -1055,6 +1067,7 @@ export function pageOperatorApprovalReceiptsForRun(params: {
runId: params.context.runId,
nowMs: params.nowMs ?? Date.now(),
after: params.after,
offset: params.offset,
limit: params.limit + 1,
});
const pageMetadata = metadataRows.slice(0, params.limit);
+44
View File
@@ -290,6 +290,45 @@ describe("audit gateway methods", () => {
executionId: "execution-1",
decisionLimit: 20,
});
await runAuditHandler("audit.run.inspect", {
runId: "run-1",
executionCursor: "1",
decisionCursor: "1",
decisionLimit: 25,
});
expect(inspectExecutionIdentityRun).toHaveBeenLastCalledWith({
runId: "run-1",
executionOffset: 1,
executionLimit: 50,
decisionCursor: "1",
decisionLimit: 25,
});
await runAuditHandler("audit.run.inspect", {
runId: "run-1",
executionCursor: "001",
decisionCursor: "001",
decisionLimit: 25,
});
expect(inspectExecutionIdentityRun).toHaveBeenLastCalledWith({
runId: "run-1",
executionOffset: 1,
executionLimit: 50,
decisionCursor: "001",
decisionLimit: 25,
});
await runAuditHandler("audit.run.inspect", {
executionId: "execution-1",
decisionCursor: "1",
decisionLimit: 20,
});
expect(inspectExecutionIdentityRun).toHaveBeenLastCalledWith({
executionId: "execution-1",
decisionCursor: "1",
decisionLimit: 20,
});
});
it("rejects malformed run inspection before storage access", async () => {
@@ -299,6 +338,11 @@ describe("audit gateway methods", () => {
expect(
await runAuditHandler("audit.run.inspect", { runId: "run-1", decisionCursor: "0" }),
).toHaveBeenCalledWith(false, undefined, expect.any(Object));
for (const decisionCursor of ["-1", "1.5", "1a", "a:1:2x", "9007199254740992"]) {
expect(
await runAuditHandler("audit.run.inspect", { runId: "run-1", decisionCursor }),
).toHaveBeenCalledWith(false, undefined, expect.any(Object));
}
expect(
await runAuditHandler("audit.run.inspect", {
runId: "run-1",
+4 -15
View File
@@ -9,6 +9,7 @@ import {
validateAuditListParams,
validateAuditRunInspectParams,
} from "../../../packages/gateway-protocol/src/index.js";
import { parsePositiveAuditCursor } from "../../audit/audit-cursor.js";
import { listAuditEvents } from "../../audit/audit-event-store.js";
import type {
AgentRunAuditEventRecord,
@@ -26,18 +27,6 @@ import { assertValidParams } from "./validation.js";
const DEFAULT_AUDIT_LIST_LIMIT = 100;
const MAX_AUDIT_LIST_LIMIT = 500;
function parsePositiveCursor(cursor: string | undefined): number | undefined | null {
if (cursor === undefined) {
return undefined;
}
const trimmed = cursor.trim();
if (!/^\d+$/.test(trimmed)) {
return null;
}
const parsed = Number(trimmed);
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : null;
}
/** Preserve the shipped audit.list result shape for run/tool-only clients. */
function mapLegacyAuditEvent(
event: AgentRunAuditEventRecord | ToolActionAuditEventRecord,
@@ -74,7 +63,7 @@ function invalidRangeOrCursor(params: { cursor?: string; after?: number; before?
cursor?: number;
invalid: boolean;
} {
const cursor = parsePositiveCursor(params.cursor);
const cursor = parsePositiveAuditCursor(params.cursor);
return {
...(cursor !== undefined && cursor !== null ? { cursor } : {}),
invalid:
@@ -171,9 +160,9 @@ export const auditHandlers: GatewayRequestHandlers = {
typeof params.runId !== "string" ||
(params.executionCursor === decisionCursor &&
decisionCursor !== undefined &&
isExecutionDecisionCursor(decisionCursor))
(decisionCursor.startsWith("a:") || decisionCursor.startsWith("g:")))
? undefined
: parsePositiveCursor(params.executionCursor);
: parsePositiveAuditCursor(params.executionCursor);
if (
(decisionCursor !== undefined && !isExecutionDecisionCursor(decisionCursor)) ||
executionOffset === null
@@ -300,6 +300,31 @@ async function runProof(options: ProducerOptions): Promise<string> {
"pre-restart decision inspection",
);
const receipt = requireDeniedApproval(before);
const firstPage = parseJson<AuditRunInspectResult>(
await gateway.runCli(["audit", "--run", runId, "--explain", "--limit", "1", "--json"]),
"first decision page",
);
if (firstPage.nextDecisionCursor?.startsWith("a:") !== true) {
throw new Error("first decision page omitted its opaque approval cursor");
}
const legacyResume = parseJson<AuditRunInspectResult>(
await gateway.runCli(["audit", "--run", runId, "--explain", "--cursor", "001", "--json"]),
"legacy numeric decision continuation",
);
requireDeniedApproval(legacyResume);
const opaqueResume = parseJson<AuditRunInspectResult>(
await gateway.runCli([
"audit",
"--run",
runId,
"--explain",
"--cursor",
firstPage.nextDecisionCursor,
"--json",
]),
"opaque decision continuation",
);
requireDeniedApproval(opaqueResume);
const serialized = JSON.stringify(before);
const toolCallRef = readApprovalToolCallRef(gateway, approvalId);
if (serialized.includes(commandSentinel) || serialized.includes(toolCallRef)) {
@@ -336,6 +361,8 @@ async function runProof(options: ProducerOptions): Promise<string> {
firstAnswerPreserved: true,
agentCompletionObserved: true,
genericDuplicateAbsent: true,
numericDecisionContinuation: true,
opaqueDecisionContinuation: true,
byteEquivalentAfterRestart: true,
redaction: { command: true, toolCall: true },
resultSha256: sha256(serialized),