agentcert 0.2.6 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +25 -0
- package/dist/cli.js +40 -3
- package/dist/command-help.js +8 -0
- package/dist/conformance.js +115 -0
- package/dist/index.js +2 -0
- package/dist/sandbox.js +258 -0
- package/dist/schema-validator.js +114 -1
- package/dist/vendor/onegent-runtime/mock-procurement.d.ts +6 -0
- package/dist/vendor/onegent-runtime/mock-procurement.d.ts.map +1 -0
- package/dist/vendor/onegent-runtime/mock-procurement.js +33 -0
- package/dist/vendor/onegent-runtime/policies.d.ts +6 -0
- package/dist/vendor/onegent-runtime/policies.d.ts.map +1 -0
- package/dist/vendor/onegent-runtime/policies.js +109 -0
- package/dist/vendor/onegent-runtime/risk.d.ts +3 -0
- package/dist/vendor/onegent-runtime/risk.d.ts.map +1 -0
- package/dist/vendor/onegent-runtime/risk.js +54 -0
- package/dist/vendor/onegent-runtime/sandbox-adapter-kit.d.ts +45 -0
- package/dist/vendor/onegent-runtime/sandbox-adapter-kit.d.ts.map +1 -0
- package/dist/vendor/onegent-runtime/sandbox-adapter-kit.js +148 -0
- package/dist/vendor/onegent-runtime/sandbox-harness.d.ts +167 -0
- package/dist/vendor/onegent-runtime/sandbox-harness.d.ts.map +1 -0
- package/dist/vendor/onegent-runtime/sandbox-harness.js +729 -0
- package/dist/vendor/onegent-runtime/sandbox-hosted.d.ts +76 -0
- package/dist/vendor/onegent-runtime/sandbox-hosted.d.ts.map +1 -0
- package/dist/vendor/onegent-runtime/sandbox-hosted.js +129 -0
- package/dist/vendor/onegent-runtime/sdk.d.ts +29 -0
- package/dist/vendor/onegent-runtime/sdk.d.ts.map +1 -0
- package/dist/vendor/onegent-runtime/sdk.js +140 -0
- package/dist/vendor/onegent-runtime/service.d.ts +27 -0
- package/dist/vendor/onegent-runtime/service.d.ts.map +1 -0
- package/dist/vendor/onegent-runtime/service.js +421 -0
- package/dist/vendor/onegent-runtime/store.d.ts +16 -0
- package/dist/vendor/onegent-runtime/store.d.ts.map +1 -0
- package/dist/vendor/onegent-runtime/store.js +29 -0
- package/dist/vendor/onegent-runtime/types.d.ts +284 -0
- package/dist/vendor/onegent-runtime/types.d.ts.map +1 -0
- package/dist/vendor/onegent-runtime/types.js +1 -0
- package/package.json +2 -2
|
@@ -0,0 +1,729 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { chmod, mkdir, writeFile } from "node:fs/promises";
|
|
3
|
+
import { dirname, resolve } from "node:path";
|
|
4
|
+
import { createOnegentRuntime } from "./sdk.js";
|
|
5
|
+
export const SANDBOX_CERTIFICATION_SCHEMA_VERSION = "agentcert.sandbox_certification.v0.1";
|
|
6
|
+
export const SANDBOX_RUN_SCHEMA_VERSION = "agentcert.sandbox_run.v0.1";
|
|
7
|
+
const DEFAULT_LIMITS = {
|
|
8
|
+
maxActionsPerRun: 25,
|
|
9
|
+
maxAmountPerAction: 10_000,
|
|
10
|
+
maxTotalAmountPerRun: 25_000,
|
|
11
|
+
};
|
|
12
|
+
const DEFAULT_TENANT_TTL_MS = 60 * 60 * 1_000;
|
|
13
|
+
const DEFAULT_MAX_TENANT_TTL_MS = 24 * 60 * 60 * 1_000;
|
|
14
|
+
const DEFAULT_CLEANUP_INTERVAL_MS = 60 * 1_000;
|
|
15
|
+
const REQUIRE_SANDBOX_APPROVAL = {
|
|
16
|
+
id: "sandbox-all-actions-require-approval",
|
|
17
|
+
name: "All sandbox writes require approval",
|
|
18
|
+
description: "The certification harness requires an identified reviewer before any synthetic state mutation.",
|
|
19
|
+
actionTypes: ["SUBMIT", "PAY", "SEND", "UPDATE"],
|
|
20
|
+
effect: "REQUIRE_APPROVAL",
|
|
21
|
+
enabled: true,
|
|
22
|
+
};
|
|
23
|
+
class SandboxRejectedError extends Error {
|
|
24
|
+
code;
|
|
25
|
+
constructor(code, message) {
|
|
26
|
+
super(message);
|
|
27
|
+
this.code = code;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
// Low-level synthetic state lifecycle. Policy, approval, and limits remain in the harness.
|
|
31
|
+
export function createInMemorySandboxSystem(options) {
|
|
32
|
+
const allowedTargetSystems = Object.freeze(normalizedTargets(options.allowedTargetSystems));
|
|
33
|
+
const tenants = new Map();
|
|
34
|
+
const tenant = (tenantId) => {
|
|
35
|
+
const value = tenants.get(tenantId);
|
|
36
|
+
if (!value)
|
|
37
|
+
throw new Error(`Sandbox tenant ${tenantId} was not found.`);
|
|
38
|
+
return value;
|
|
39
|
+
};
|
|
40
|
+
return {
|
|
41
|
+
name: options.name ?? "agentcert-in-memory-sandbox",
|
|
42
|
+
safety: {
|
|
43
|
+
mode: "sandbox",
|
|
44
|
+
networkAccess: false,
|
|
45
|
+
syntheticDataOnly: true,
|
|
46
|
+
allowedTargetSystems,
|
|
47
|
+
},
|
|
48
|
+
createTenant: (input) => {
|
|
49
|
+
assertTenantId(input.id);
|
|
50
|
+
if (input.synthetic !== true)
|
|
51
|
+
throw new Error("Sandbox tenants must explicitly declare synthetic: true.");
|
|
52
|
+
if (tenants.has(input.id))
|
|
53
|
+
throw new Error(`Sandbox tenant ${input.id} already exists.`);
|
|
54
|
+
const seed = validatedSeed(input.seed ?? {});
|
|
55
|
+
tenants.set(input.id, { seed, state: structuredClone(seed) });
|
|
56
|
+
},
|
|
57
|
+
deleteTenant: (tenantId) => {
|
|
58
|
+
if (!tenants.delete(tenantId))
|
|
59
|
+
throw new Error(`Sandbox tenant ${tenantId} was not found.`);
|
|
60
|
+
},
|
|
61
|
+
resetTenant: (tenantId) => {
|
|
62
|
+
const value = tenant(tenantId);
|
|
63
|
+
value.state = structuredClone(value.seed);
|
|
64
|
+
},
|
|
65
|
+
seedTenant: (tenantId, input) => {
|
|
66
|
+
const value = tenant(tenantId);
|
|
67
|
+
const seed = validatedSeed(input);
|
|
68
|
+
value.seed = seed;
|
|
69
|
+
value.state = structuredClone(seed);
|
|
70
|
+
},
|
|
71
|
+
hasTenant: (tenantId) => tenants.has(tenantId),
|
|
72
|
+
snapshotTenant: (tenantId) => structuredClone(tenant(tenantId).state),
|
|
73
|
+
adapterForTenant: (tenantId) => {
|
|
74
|
+
tenant(tenantId);
|
|
75
|
+
const assertAction = (action) => {
|
|
76
|
+
if (action.workspaceId !== tenantId) {
|
|
77
|
+
throw new SandboxRejectedError("TENANT_MISMATCH", `Action workspace ${action.workspaceId} cannot access tenant ${tenantId}.`);
|
|
78
|
+
}
|
|
79
|
+
if (action.environment === "production") {
|
|
80
|
+
throw new SandboxRejectedError("PRODUCTION_DENIED", "The sandbox system refuses production actions.");
|
|
81
|
+
}
|
|
82
|
+
if (action.targetUrl) {
|
|
83
|
+
throw new SandboxRejectedError("NETWORK_DENIED", "The sandbox system denies network targets by default.");
|
|
84
|
+
}
|
|
85
|
+
if (!allowedTargetSystems.includes(action.targetSystem)) {
|
|
86
|
+
throw new SandboxRejectedError("TARGET_NOT_ALLOWED", `Target system ${action.targetSystem} is not allowlisted.`);
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
return {
|
|
90
|
+
name: `${options.name ?? "agentcert-in-memory-sandbox"}:${tenantId}`,
|
|
91
|
+
safety: { mode: "sandbox", networkAccess: false, allowedTargetSystems: [...allowedTargetSystems] },
|
|
92
|
+
execute: (action) => {
|
|
93
|
+
assertAction(action);
|
|
94
|
+
const value = tenant(tenantId);
|
|
95
|
+
const previousState = structuredClone(value.state[action.businessObjectId] ?? action.beforeState);
|
|
96
|
+
const observedState = structuredClone(action.proposedAfterState);
|
|
97
|
+
value.state[action.businessObjectId] = observedState;
|
|
98
|
+
return {
|
|
99
|
+
method: "LOCAL_ADAPTER",
|
|
100
|
+
targetSystem: action.targetSystem,
|
|
101
|
+
previousState,
|
|
102
|
+
observedState,
|
|
103
|
+
rollbackToken: `${tenantId}:${action.idempotencyKey}`,
|
|
104
|
+
};
|
|
105
|
+
},
|
|
106
|
+
rollback: (action, execution) => {
|
|
107
|
+
assertAction(action);
|
|
108
|
+
const restored = structuredClone(execution.previousState ?? action.beforeState);
|
|
109
|
+
tenant(tenantId).state[action.businessObjectId] = restored;
|
|
110
|
+
return { success: true, observedState: restored, message: "Tenant sandbox state restored to its pre-execution snapshot." };
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
// The harness is the only supported execution path for bounded sandbox runs.
|
|
117
|
+
export function createSandboxCertificationHarness(options) {
|
|
118
|
+
const system = options.system ?? createInMemorySandboxSystem({
|
|
119
|
+
allowedTargetSystems: options.allowedTargetSystems ?? [],
|
|
120
|
+
});
|
|
121
|
+
assertSandboxSystem(system);
|
|
122
|
+
const allowedTargetSystems = Object.freeze(normalizedTargets([...system.safety.allowedTargetSystems]));
|
|
123
|
+
const limits = Object.freeze(normalizeLimits(options.limits));
|
|
124
|
+
const now = options.now ?? (() => new Date());
|
|
125
|
+
const maxTenantTtlMs = positiveInteger(options.maxTenantTtlMs ?? DEFAULT_MAX_TENANT_TTL_MS, "maxTenantTtlMs");
|
|
126
|
+
const tenantTtlMs = boundedTenantTtl(options.tenantTtlMs ?? DEFAULT_TENANT_TTL_MS, maxTenantTtlMs);
|
|
127
|
+
const cleanupIntervalMs = positiveInteger(options.cleanupIntervalMs ?? DEFAULT_CLEANUP_INTERVAL_MS, "cleanupIntervalMs");
|
|
128
|
+
const runtime = createOnegentRuntime({
|
|
129
|
+
policyRules: [REQUIRE_SANDBOX_APPROVAL],
|
|
130
|
+
authorizationPolicy: {
|
|
131
|
+
name: "sandbox-tenant-authorization",
|
|
132
|
+
authorize: (action) => ({
|
|
133
|
+
allowed: allowedTargetSystems.includes(action.targetSystem),
|
|
134
|
+
grantedPermissions: allowedTargetSystems.includes(action.targetSystem) ? [...action.requestedPermissions] : [],
|
|
135
|
+
policyVersion: "agentcert.sandbox.v0.1",
|
|
136
|
+
reason: allowedTargetSystems.includes(action.targetSystem)
|
|
137
|
+
? "The target is within the sandbox allowlist."
|
|
138
|
+
: "The target is outside the sandbox allowlist.",
|
|
139
|
+
}),
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
const runs = new Set();
|
|
143
|
+
const tenantSwitches = new Map();
|
|
144
|
+
const tenantLeases = new Map();
|
|
145
|
+
let cleanupTimer;
|
|
146
|
+
let cleanupInFlight;
|
|
147
|
+
let closed = false;
|
|
148
|
+
let globalSwitch = { enabled: false, changedAt: now().toISOString() };
|
|
149
|
+
const assertOpen = () => {
|
|
150
|
+
if (closed)
|
|
151
|
+
throw new Error("Sandbox certification harness is closed.");
|
|
152
|
+
};
|
|
153
|
+
const removeTenant = async (tenantId) => {
|
|
154
|
+
if (await system.hasTenant(tenantId))
|
|
155
|
+
await system.deleteTenant(tenantId);
|
|
156
|
+
tenantLeases.delete(tenantId);
|
|
157
|
+
tenantSwitches.delete(tenantId);
|
|
158
|
+
};
|
|
159
|
+
const cleanupExpiredTenants = () => {
|
|
160
|
+
if (cleanupInFlight)
|
|
161
|
+
return cleanupInFlight;
|
|
162
|
+
cleanupInFlight = (async () => {
|
|
163
|
+
const cutoff = now().getTime();
|
|
164
|
+
const expired = [...tenantLeases.values()]
|
|
165
|
+
.filter((lease) => Date.parse(lease.expiresAt) <= cutoff)
|
|
166
|
+
.map((lease) => lease.tenantId);
|
|
167
|
+
const removed = [];
|
|
168
|
+
for (const tenantId of expired) {
|
|
169
|
+
await removeTenant(tenantId);
|
|
170
|
+
removed.push(tenantId);
|
|
171
|
+
}
|
|
172
|
+
return removed;
|
|
173
|
+
})().finally(() => { cleanupInFlight = undefined; });
|
|
174
|
+
return cleanupInFlight;
|
|
175
|
+
};
|
|
176
|
+
const assertActiveTenant = async (tenantId) => {
|
|
177
|
+
const lease = tenantLeases.get(tenantId);
|
|
178
|
+
if (lease && Date.parse(lease.expiresAt) <= now().getTime())
|
|
179
|
+
await cleanupExpiredTenants();
|
|
180
|
+
if (!tenantLeases.has(tenantId) || !(await system.hasTenant(tenantId))) {
|
|
181
|
+
throw new Error(`Sandbox tenant ${tenantId} was not found or its lease expired.`);
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
if (options.autoCleanup !== false) {
|
|
185
|
+
cleanupTimer = setInterval(() => { void cleanupExpiredTenants().catch(() => undefined); }, cleanupIntervalMs);
|
|
186
|
+
cleanupTimer.unref?.();
|
|
187
|
+
}
|
|
188
|
+
return {
|
|
189
|
+
limits,
|
|
190
|
+
createTenant: async (input) => {
|
|
191
|
+
assertOpen();
|
|
192
|
+
const effectiveTtlMs = boundedTenantTtl(input.ttlMs ?? tenantTtlMs, maxTenantTtlMs);
|
|
193
|
+
await system.createTenant(input);
|
|
194
|
+
const createdAt = now();
|
|
195
|
+
const lease = {
|
|
196
|
+
tenantId: input.id,
|
|
197
|
+
createdAt: createdAt.toISOString(),
|
|
198
|
+
expiresAt: new Date(createdAt.getTime() + effectiveTtlMs).toISOString(),
|
|
199
|
+
};
|
|
200
|
+
tenantLeases.set(input.id, lease);
|
|
201
|
+
return { ...lease };
|
|
202
|
+
},
|
|
203
|
+
deleteTenant: async (tenantId) => {
|
|
204
|
+
assertOpen();
|
|
205
|
+
if (!tenantLeases.has(tenantId) && !(await system.hasTenant(tenantId))) {
|
|
206
|
+
throw new Error(`Sandbox tenant ${tenantId} was not found.`);
|
|
207
|
+
}
|
|
208
|
+
await removeTenant(tenantId);
|
|
209
|
+
},
|
|
210
|
+
tenantLease: (tenantId) => {
|
|
211
|
+
const lease = tenantLeases.get(tenantId);
|
|
212
|
+
return lease ? { ...lease } : undefined;
|
|
213
|
+
},
|
|
214
|
+
renewTenant: async (tenantId, ttlMs) => {
|
|
215
|
+
assertOpen();
|
|
216
|
+
await assertActiveTenant(tenantId);
|
|
217
|
+
const effectiveTtlMs = boundedTenantTtl(ttlMs ?? tenantTtlMs, maxTenantTtlMs);
|
|
218
|
+
const previous = tenantLeases.get(tenantId);
|
|
219
|
+
const lease = { ...previous, expiresAt: new Date(now().getTime() + effectiveTtlMs).toISOString() };
|
|
220
|
+
tenantLeases.set(tenantId, lease);
|
|
221
|
+
return { ...lease };
|
|
222
|
+
},
|
|
223
|
+
cleanupExpiredTenants,
|
|
224
|
+
seedTenant: async (tenantId, seed) => { assertOpen(); await assertActiveTenant(tenantId); await system.seedTenant(tenantId, seed); },
|
|
225
|
+
resetTenant: async (tenantId) => { assertOpen(); await assertActiveTenant(tenantId); await system.resetTenant(tenantId); },
|
|
226
|
+
snapshotTenant: async (tenantId) => { assertOpen(); await assertActiveTenant(tenantId); return system.snapshotTenant(tenantId); },
|
|
227
|
+
startRun: async ({ tenantId, runId }) => {
|
|
228
|
+
assertOpen();
|
|
229
|
+
await assertActiveTenant(tenantId);
|
|
230
|
+
const effectiveRunId = runId?.trim() || `sandbox-run-${randomUUID()}`;
|
|
231
|
+
if (runs.has(effectiveRunId))
|
|
232
|
+
throw new Error(`Sandbox run ${effectiveRunId} already exists.`);
|
|
233
|
+
runs.add(effectiveRunId);
|
|
234
|
+
return createSandboxRun({
|
|
235
|
+
runId: effectiveRunId,
|
|
236
|
+
tenantId,
|
|
237
|
+
system,
|
|
238
|
+
allowedTargetSystems,
|
|
239
|
+
runtime,
|
|
240
|
+
limits,
|
|
241
|
+
now,
|
|
242
|
+
globalSwitch: () => globalSwitch,
|
|
243
|
+
tenantSwitch: () => tenantSwitches.get(tenantId),
|
|
244
|
+
});
|
|
245
|
+
},
|
|
246
|
+
setGlobalKillSwitch: (enabled, reason) => {
|
|
247
|
+
globalSwitch = killSwitch(enabled, reason, now);
|
|
248
|
+
return { ...globalSwitch };
|
|
249
|
+
},
|
|
250
|
+
setTenantKillSwitch: (tenantId, enabled, reason) => {
|
|
251
|
+
const next = killSwitch(enabled, reason, now);
|
|
252
|
+
tenantSwitches.set(tenantId, next);
|
|
253
|
+
return { ...next };
|
|
254
|
+
},
|
|
255
|
+
close: async () => {
|
|
256
|
+
if (closed)
|
|
257
|
+
return;
|
|
258
|
+
closed = true;
|
|
259
|
+
if (cleanupTimer)
|
|
260
|
+
clearInterval(cleanupTimer);
|
|
261
|
+
await cleanupInFlight?.catch(() => undefined);
|
|
262
|
+
for (const tenantId of [...tenantLeases.keys()])
|
|
263
|
+
await removeTenant(tenantId);
|
|
264
|
+
},
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
function createSandboxRun(options) {
|
|
268
|
+
const startedAt = options.now().toISOString();
|
|
269
|
+
const results = [];
|
|
270
|
+
const idempotency = new Map();
|
|
271
|
+
let attempted = 0;
|
|
272
|
+
let totalApprovedAmount = 0;
|
|
273
|
+
let completed = false;
|
|
274
|
+
const executeNew = async (input, actionOptions, idempotencyKey) => {
|
|
275
|
+
const actionStartedAt = options.now().toISOString();
|
|
276
|
+
attempted += 1;
|
|
277
|
+
let actionIntentId;
|
|
278
|
+
try {
|
|
279
|
+
assertRunPreflight({
|
|
280
|
+
input,
|
|
281
|
+
tenantId: options.tenantId,
|
|
282
|
+
system: options.system,
|
|
283
|
+
allowedTargetSystems: options.allowedTargetSystems,
|
|
284
|
+
limits: options.limits,
|
|
285
|
+
attempted,
|
|
286
|
+
totalApprovedAmount,
|
|
287
|
+
globalSwitch: options.globalSwitch(),
|
|
288
|
+
tenantSwitch: options.tenantSwitch(),
|
|
289
|
+
approval: actionOptions.approval,
|
|
290
|
+
});
|
|
291
|
+
const amount = input.amount ?? 0;
|
|
292
|
+
const review = options.runtime.captureAction({
|
|
293
|
+
...input,
|
|
294
|
+
idempotencyKey,
|
|
295
|
+
workspaceId: options.tenantId,
|
|
296
|
+
workflowId: input.workflowId ?? options.runId,
|
|
297
|
+
sourceAgentRunId: input.sourceAgentRunId ?? options.runId,
|
|
298
|
+
environment: input.environment ?? "demo",
|
|
299
|
+
});
|
|
300
|
+
actionIntentId = review.action.id;
|
|
301
|
+
const approval = actionOptions.approval;
|
|
302
|
+
if (!approval.approved) {
|
|
303
|
+
options.runtime.rejectAction(review.action, approval.reviewerId, approval.comment ?? "Rejected during sandbox certification.");
|
|
304
|
+
const packet = await options.runtime.writeAuditPacket(review.action);
|
|
305
|
+
return recordResult({
|
|
306
|
+
idempotencyKey,
|
|
307
|
+
actionIntentId,
|
|
308
|
+
status: "rejected",
|
|
309
|
+
rejectionCode: "APPROVAL_REJECTED",
|
|
310
|
+
message: "The identified reviewer rejected the sandbox action.",
|
|
311
|
+
startedAt: actionStartedAt,
|
|
312
|
+
completedAt: options.now().toISOString(),
|
|
313
|
+
auditPacket: packet,
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
options.runtime.approveAction(review.action, approval.reviewerId, approval.comment ?? "Approved for sandbox certification.");
|
|
317
|
+
totalApprovedAmount += amount;
|
|
318
|
+
const adapter = options.system.adapterForTenant(options.tenantId);
|
|
319
|
+
const execution = await options.runtime.executeAfterApproval(review.action, adapter);
|
|
320
|
+
const verification = options.runtime.verifyOutcome(review.action, execution);
|
|
321
|
+
let rollback;
|
|
322
|
+
let status = verification.success ? "verified" : "failed";
|
|
323
|
+
let message = verification.success ? "Sandbox execution matched the expected synthetic state." : "Sandbox verification detected a state mismatch.";
|
|
324
|
+
if (actionOptions.rollbackAfterVerification) {
|
|
325
|
+
rollback = await options.runtime.rollbackAfterExecution(review.action, adapter, actionOptions.rollbackReason ?? "Sandbox certification rollback requested.");
|
|
326
|
+
status = rollback.status === "ROLLED_BACK" ? "rolled_back" : "rollback_failed";
|
|
327
|
+
message = rollback.status === "ROLLED_BACK"
|
|
328
|
+
? "Sandbox execution verified and the pre-execution state was restored."
|
|
329
|
+
: "Sandbox rollback failed after verification.";
|
|
330
|
+
}
|
|
331
|
+
const packet = await options.runtime.writeAuditPacket(review.action);
|
|
332
|
+
return recordResult({
|
|
333
|
+
idempotencyKey,
|
|
334
|
+
actionIntentId,
|
|
335
|
+
status,
|
|
336
|
+
message,
|
|
337
|
+
startedAt: actionStartedAt,
|
|
338
|
+
completedAt: options.now().toISOString(),
|
|
339
|
+
execution,
|
|
340
|
+
verification,
|
|
341
|
+
rollback,
|
|
342
|
+
auditPacket: packet,
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
catch (error) {
|
|
346
|
+
if (error instanceof SandboxRejectedError) {
|
|
347
|
+
return recordResult(rejectedResult(idempotencyKey, actionStartedAt, options.now, error.code, error.message, actionIntentId));
|
|
348
|
+
}
|
|
349
|
+
return recordResult({
|
|
350
|
+
idempotencyKey,
|
|
351
|
+
actionIntentId,
|
|
352
|
+
status: "failed",
|
|
353
|
+
message: error instanceof Error ? error.message : String(error),
|
|
354
|
+
startedAt: actionStartedAt,
|
|
355
|
+
completedAt: options.now().toISOString(),
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
};
|
|
359
|
+
const recordResult = (result) => {
|
|
360
|
+
results.push(result);
|
|
361
|
+
return result;
|
|
362
|
+
};
|
|
363
|
+
return {
|
|
364
|
+
runId: options.runId,
|
|
365
|
+
tenantId: options.tenantId,
|
|
366
|
+
executeAction: async (input, actionOptions = {}) => {
|
|
367
|
+
if (completed)
|
|
368
|
+
throw new Error(`Sandbox run ${options.runId} is already complete.`);
|
|
369
|
+
const idempotencyKey = input.idempotencyKey?.trim();
|
|
370
|
+
if (!idempotencyKey) {
|
|
371
|
+
attempted += 1;
|
|
372
|
+
return recordResult(rejectedResult("missing", options.now().toISOString(), options.now, "IDEMPOTENCY_REQUIRED", "Sandbox actions require an explicit idempotency key."));
|
|
373
|
+
}
|
|
374
|
+
const fingerprint = sha256(canonicalJson({ input: { ...input, idempotencyKey }, options: actionOptions }));
|
|
375
|
+
const existing = idempotency.get(idempotencyKey);
|
|
376
|
+
if (existing) {
|
|
377
|
+
if (existing.fingerprint === fingerprint)
|
|
378
|
+
return existing.promise;
|
|
379
|
+
attempted += 1;
|
|
380
|
+
return recordResult(rejectedResult(idempotencyKey, options.now().toISOString(), options.now, "IDEMPOTENCY_CONFLICT", `Idempotency key ${idempotencyKey} was reused with different sandbox action content.`));
|
|
381
|
+
}
|
|
382
|
+
const promise = executeNew(input, actionOptions, idempotencyKey);
|
|
383
|
+
idempotency.set(idempotencyKey, { fingerprint, promise });
|
|
384
|
+
return promise;
|
|
385
|
+
},
|
|
386
|
+
complete: () => {
|
|
387
|
+
if (completed)
|
|
388
|
+
throw new Error(`Sandbox run ${options.runId} is already complete.`);
|
|
389
|
+
completed = true;
|
|
390
|
+
const summary = {
|
|
391
|
+
attempted,
|
|
392
|
+
verified: results.filter((item) => item.status === "verified").length,
|
|
393
|
+
rolledBack: results.filter((item) => item.status === "rolled_back").length,
|
|
394
|
+
rejected: results.filter((item) => item.status === "rejected").length,
|
|
395
|
+
failed: results.filter((item) => item.status === "failed" || item.status === "rollback_failed").length,
|
|
396
|
+
totalApprovedAmount,
|
|
397
|
+
};
|
|
398
|
+
return {
|
|
399
|
+
schemaVersion: SANDBOX_RUN_SCHEMA_VERSION,
|
|
400
|
+
kind: "agentcert.sandbox_run",
|
|
401
|
+
runId: options.runId,
|
|
402
|
+
tenantId: options.tenantId,
|
|
403
|
+
system: options.system.name,
|
|
404
|
+
startedAt,
|
|
405
|
+
completedAt: options.now().toISOString(),
|
|
406
|
+
safe: summary.failed === 0,
|
|
407
|
+
syntheticData: true,
|
|
408
|
+
networkAccess: false,
|
|
409
|
+
allowedTargetSystems: [...options.allowedTargetSystems],
|
|
410
|
+
limits: { ...options.limits },
|
|
411
|
+
summary,
|
|
412
|
+
actions: structuredClone(results),
|
|
413
|
+
disclaimer: "Synthetic sandbox evidence only. This report does not authorize production writes or prove a live integration is safe.",
|
|
414
|
+
};
|
|
415
|
+
},
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
// Actively exercises the safety contract instead of inferring compliance from configuration.
|
|
419
|
+
export async function runSandboxCertificationSuite(options = {}) {
|
|
420
|
+
const now = options.now ?? (() => new Date());
|
|
421
|
+
const targetSystem = options.targetSystem ?? options.system?.safety.allowedTargetSystems[0] ?? "SandboxCRM";
|
|
422
|
+
const system = options.system ?? createInMemorySandboxSystem({ allowedTargetSystems: [targetSystem] });
|
|
423
|
+
const harness = createSandboxCertificationHarness({
|
|
424
|
+
system,
|
|
425
|
+
limits: { maxActionsPerRun: 1, maxAmountPerAction: 5_000, maxTotalAmountPerRun: 5_000 },
|
|
426
|
+
now,
|
|
427
|
+
});
|
|
428
|
+
const suffix = randomUUID().slice(0, 8);
|
|
429
|
+
const unlistedTarget = `AgentCertUnlisted-${suffix}`;
|
|
430
|
+
const tenantA = `cert-a-${suffix}`;
|
|
431
|
+
const tenantB = `cert-b-${suffix}`;
|
|
432
|
+
const checks = [];
|
|
433
|
+
const runIds = [];
|
|
434
|
+
const seed = { "account-1": { tier: "standard", owner: "Synthetic Customer" } };
|
|
435
|
+
try {
|
|
436
|
+
await harness.createTenant({ id: tenantA, synthetic: true, seed });
|
|
437
|
+
await harness.createTenant({ id: tenantB, synthetic: true, seed: { "account-1": { tier: "restricted" } } });
|
|
438
|
+
}
|
|
439
|
+
catch (error) {
|
|
440
|
+
await harness.close();
|
|
441
|
+
throw error;
|
|
442
|
+
}
|
|
443
|
+
const check = async (id, message, operation) => {
|
|
444
|
+
try {
|
|
445
|
+
const evidence = await operation();
|
|
446
|
+
checks.push({ id, status: "passed", message, ...(evidence ? { evidence } : {}) });
|
|
447
|
+
}
|
|
448
|
+
catch (error) {
|
|
449
|
+
checks.push({ id, status: "failed", message: error instanceof Error ? error.message : String(error) });
|
|
450
|
+
}
|
|
451
|
+
};
|
|
452
|
+
await check("tenant-isolation", "Cross-tenant action context is rejected before state access.", async () => {
|
|
453
|
+
const run = await certificationRun(harness, tenantA, runIds, "tenant-isolation");
|
|
454
|
+
const result = await run.executeAction(referenceAction(targetSystem, { workspaceId: tenantB }));
|
|
455
|
+
expectRejection(result, "TENANT_MISMATCH");
|
|
456
|
+
return { rejectionCode: result.rejectionCode };
|
|
457
|
+
});
|
|
458
|
+
await check("synthetic-data-only", "Credential-like seed data is rejected.", async () => {
|
|
459
|
+
let rejected = false;
|
|
460
|
+
try {
|
|
461
|
+
await harness.createTenant({ id: `secret-${suffix}`, synthetic: true, seed: { record: { apiKey: "sk-proj-not-allowed-in-sandbox-fixture" } } });
|
|
462
|
+
}
|
|
463
|
+
catch {
|
|
464
|
+
rejected = true;
|
|
465
|
+
}
|
|
466
|
+
if (!rejected)
|
|
467
|
+
throw new Error("Credential-like seed data was accepted.");
|
|
468
|
+
});
|
|
469
|
+
await check("deny-network-egress", "Network targets are denied by default.", async () => {
|
|
470
|
+
const run = await certificationRun(harness, tenantA, runIds, "network");
|
|
471
|
+
const result = await run.executeAction(referenceAction(targetSystem, { targetUrl: "https://example.invalid/write" }));
|
|
472
|
+
expectRejection(result, "NETWORK_DENIED");
|
|
473
|
+
return { rejectionCode: result.rejectionCode };
|
|
474
|
+
});
|
|
475
|
+
await check("target-allowlist", "Non-allowlisted target systems are rejected.", async () => {
|
|
476
|
+
const run = await certificationRun(harness, tenantA, runIds, "target");
|
|
477
|
+
const result = await run.executeAction(referenceAction(unlistedTarget));
|
|
478
|
+
expectRejection(result, "TARGET_NOT_ALLOWED");
|
|
479
|
+
return { rejectionCode: result.rejectionCode };
|
|
480
|
+
});
|
|
481
|
+
await check("production-deny", "Production actions are rejected.", async () => {
|
|
482
|
+
const run = await certificationRun(harness, tenantA, runIds, "production");
|
|
483
|
+
const result = await run.executeAction(referenceAction(targetSystem, { environment: "production" }));
|
|
484
|
+
expectRejection(result, "PRODUCTION_DENIED");
|
|
485
|
+
return { rejectionCode: result.rejectionCode };
|
|
486
|
+
});
|
|
487
|
+
await check("approval-gate", "An identified human approval is required before mutation.", async () => {
|
|
488
|
+
const run = await certificationRun(harness, tenantA, runIds, "approval");
|
|
489
|
+
const result = await run.executeAction(referenceAction(targetSystem));
|
|
490
|
+
expectRejection(result, "APPROVAL_REQUIRED");
|
|
491
|
+
return { rejectionCode: result.rejectionCode };
|
|
492
|
+
});
|
|
493
|
+
await check("execution-limits", "Per-action amount and per-run action limits fail closed.", async () => {
|
|
494
|
+
const amountRun = await certificationRun(harness, tenantA, runIds, "amount-limit");
|
|
495
|
+
const amount = await amountRun.executeAction(referenceAction(targetSystem, { amount: 5_001 }), approved());
|
|
496
|
+
expectRejection(amount, "AMOUNT_LIMIT_EXCEEDED");
|
|
497
|
+
const actionRun = await certificationRun(harness, tenantA, runIds, "action-limit");
|
|
498
|
+
await actionRun.executeAction(referenceAction(targetSystem, { idempotencyKey: `first-${suffix}` }), approved());
|
|
499
|
+
const second = await actionRun.executeAction(referenceAction(targetSystem, { idempotencyKey: `second-${suffix}` }), approved());
|
|
500
|
+
expectRejection(second, "ACTION_LIMIT_EXCEEDED");
|
|
501
|
+
return { amount: amount.rejectionCode, action: second.rejectionCode };
|
|
502
|
+
});
|
|
503
|
+
await check("kill-switches", "Tenant and global kill switches stop new execution.", async () => {
|
|
504
|
+
harness.setTenantKillSwitch(tenantA, true, "Certification tenant stop.");
|
|
505
|
+
const tenantRun = await certificationRun(harness, tenantA, runIds, "tenant-kill");
|
|
506
|
+
const tenantResult = await tenantRun.executeAction(referenceAction(targetSystem), approved());
|
|
507
|
+
expectRejection(tenantResult, "TENANT_KILL_SWITCH");
|
|
508
|
+
harness.setTenantKillSwitch(tenantA, false);
|
|
509
|
+
harness.setGlobalKillSwitch(true, "Certification global stop.");
|
|
510
|
+
const globalRun = await certificationRun(harness, tenantA, runIds, "global-kill");
|
|
511
|
+
const globalResult = await globalRun.executeAction(referenceAction(targetSystem), approved());
|
|
512
|
+
expectRejection(globalResult, "GLOBAL_KILL_SWITCH");
|
|
513
|
+
harness.setGlobalKillSwitch(false);
|
|
514
|
+
return { tenant: tenantResult.rejectionCode, global: globalResult.rejectionCode };
|
|
515
|
+
});
|
|
516
|
+
await check("idempotent-execution", "Concurrent identical retries share one sandbox action result.", async () => {
|
|
517
|
+
const run = await certificationRun(harness, tenantA, runIds, "idempotency");
|
|
518
|
+
const input = referenceAction(targetSystem, { idempotencyKey: `concurrent-${suffix}` });
|
|
519
|
+
const [first, second] = await Promise.all([run.executeAction(input, approved()), run.executeAction(input, approved())]);
|
|
520
|
+
if (!first.actionIntentId || first.actionIntentId !== second.actionIntentId)
|
|
521
|
+
throw new Error("Concurrent retries did not share one action intent.");
|
|
522
|
+
const report = run.complete();
|
|
523
|
+
if (report.actions.length !== 1)
|
|
524
|
+
throw new Error(`Expected one recorded action, observed ${report.actions.length}.`);
|
|
525
|
+
return { actionIntentId: first.actionIntentId };
|
|
526
|
+
});
|
|
527
|
+
await check("verification-rollback-reset", "Expected state is verified, rollback restores the snapshot, and reset restores the seed.", async () => {
|
|
528
|
+
await harness.resetTenant(tenantA);
|
|
529
|
+
const run = await certificationRun(harness, tenantA, runIds, "rollback");
|
|
530
|
+
const result = await run.executeAction(referenceAction(targetSystem, { idempotencyKey: `rollback-${suffix}` }), {
|
|
531
|
+
...approved(),
|
|
532
|
+
rollbackAfterVerification: true,
|
|
533
|
+
});
|
|
534
|
+
if (result.status !== "rolled_back" || !result.verification?.success)
|
|
535
|
+
throw new Error("Verified execution did not roll back successfully.");
|
|
536
|
+
const afterRollback = await harness.snapshotTenant(tenantA);
|
|
537
|
+
if (canonicalJson(afterRollback) !== canonicalJson(seed))
|
|
538
|
+
throw new Error("Rollback did not restore the tenant seed state.");
|
|
539
|
+
const mutateRun = await certificationRun(harness, tenantA, runIds, "reset");
|
|
540
|
+
const mutation = await mutateRun.executeAction(referenceAction(targetSystem, { idempotencyKey: `reset-${suffix}` }), approved());
|
|
541
|
+
if (mutation.status !== "verified")
|
|
542
|
+
throw new Error("Reference mutation did not verify.");
|
|
543
|
+
await harness.resetTenant(tenantA);
|
|
544
|
+
const afterReset = await harness.snapshotTenant(tenantA);
|
|
545
|
+
if (canonicalJson(afterReset) !== canonicalJson(seed))
|
|
546
|
+
throw new Error("Reset did not restore the tenant seed state.");
|
|
547
|
+
return { rollback: result.status, reset: "restored" };
|
|
548
|
+
});
|
|
549
|
+
const passed = checks.filter((item) => item.status === "passed").length;
|
|
550
|
+
const failed = checks.length - passed;
|
|
551
|
+
const report = {
|
|
552
|
+
schemaVersion: SANDBOX_CERTIFICATION_SCHEMA_VERSION,
|
|
553
|
+
kind: "agentcert.sandbox_certification",
|
|
554
|
+
implementation: options.implementation ?? system.name,
|
|
555
|
+
generatedAt: now().toISOString(),
|
|
556
|
+
verdict: { passed: failed === 0, score: Math.round((passed / checks.length) * 100) },
|
|
557
|
+
summary: { passed, failed, total: checks.length },
|
|
558
|
+
checks,
|
|
559
|
+
runIds,
|
|
560
|
+
disclaimer: "This certification covers the synthetic sandbox contract only. It does not certify production systems, credentials, payments, email delivery, or vendor portals.",
|
|
561
|
+
};
|
|
562
|
+
await harness.close();
|
|
563
|
+
return report;
|
|
564
|
+
}
|
|
565
|
+
export async function writeSandboxReport(report, filePath) {
|
|
566
|
+
const absolutePath = resolve(filePath);
|
|
567
|
+
await mkdir(dirname(absolutePath), { recursive: true });
|
|
568
|
+
await writeFile(absolutePath, `${JSON.stringify(report, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
|
|
569
|
+
await chmod(absolutePath, 0o600);
|
|
570
|
+
return absolutePath;
|
|
571
|
+
}
|
|
572
|
+
function assertRunPreflight(input) {
|
|
573
|
+
if (input.globalSwitch.enabled)
|
|
574
|
+
throw new SandboxRejectedError("GLOBAL_KILL_SWITCH", input.globalSwitch.reason ?? "The global sandbox kill switch is enabled.");
|
|
575
|
+
if (input.tenantSwitch?.enabled)
|
|
576
|
+
throw new SandboxRejectedError("TENANT_KILL_SWITCH", input.tenantSwitch.reason ?? "The tenant sandbox kill switch is enabled.");
|
|
577
|
+
if (input.input.workspaceId && input.input.workspaceId !== input.tenantId) {
|
|
578
|
+
throw new SandboxRejectedError("TENANT_MISMATCH", `Action workspace ${input.input.workspaceId} does not match tenant ${input.tenantId}.`);
|
|
579
|
+
}
|
|
580
|
+
if (input.input.environment === "production")
|
|
581
|
+
throw new SandboxRejectedError("PRODUCTION_DENIED", "Production actions are not allowed in the certification sandbox.");
|
|
582
|
+
if (input.input.targetUrl)
|
|
583
|
+
throw new SandboxRejectedError("NETWORK_DENIED", "Network targets are denied by default in Sandbox Certification Harness v0.1.");
|
|
584
|
+
if (!input.allowedTargetSystems.includes(input.input.targetSystem)) {
|
|
585
|
+
throw new SandboxRejectedError("TARGET_NOT_ALLOWED", `Target system ${input.input.targetSystem} is not allowlisted.`);
|
|
586
|
+
}
|
|
587
|
+
if (input.attempted > input.limits.maxActionsPerRun) {
|
|
588
|
+
throw new SandboxRejectedError("ACTION_LIMIT_EXCEEDED", `Run action limit ${input.limits.maxActionsPerRun} was exceeded.`);
|
|
589
|
+
}
|
|
590
|
+
const amount = input.input.amount ?? 0;
|
|
591
|
+
if (!Number.isFinite(amount) || amount < 0)
|
|
592
|
+
throw new SandboxRejectedError("INVALID_AMOUNT", "Action amount must be a finite non-negative number.");
|
|
593
|
+
if (amount > input.limits.maxAmountPerAction) {
|
|
594
|
+
throw new SandboxRejectedError("AMOUNT_LIMIT_EXCEEDED", `Action amount ${amount} exceeds the sandbox limit ${input.limits.maxAmountPerAction}.`);
|
|
595
|
+
}
|
|
596
|
+
if (input.totalApprovedAmount + amount > input.limits.maxTotalAmountPerRun) {
|
|
597
|
+
throw new SandboxRejectedError("RUN_AMOUNT_LIMIT_EXCEEDED", `Run amount would exceed the sandbox limit ${input.limits.maxTotalAmountPerRun}.`);
|
|
598
|
+
}
|
|
599
|
+
if (!input.approval)
|
|
600
|
+
throw new SandboxRejectedError("APPROVAL_REQUIRED", "An explicit sandbox approval decision is required before execution.");
|
|
601
|
+
if (!input.approval.reviewerId?.trim())
|
|
602
|
+
throw new SandboxRejectedError("APPROVAL_REQUIRED", "The sandbox approval decision must identify the reviewer.");
|
|
603
|
+
}
|
|
604
|
+
function rejectedResult(idempotencyKey, startedAt, now, rejectionCode, message, actionIntentId) {
|
|
605
|
+
return { idempotencyKey, actionIntentId, status: "rejected", rejectionCode, message, startedAt, completedAt: now().toISOString() };
|
|
606
|
+
}
|
|
607
|
+
function normalizeLimits(input) {
|
|
608
|
+
const limits = { ...DEFAULT_LIMITS, ...input };
|
|
609
|
+
for (const [name, value] of Object.entries(limits)) {
|
|
610
|
+
if (!Number.isFinite(value) || value <= 0 || (name === "maxActionsPerRun" && !Number.isInteger(value))) {
|
|
611
|
+
throw new Error(`Sandbox limit ${name} must be a positive${name === "maxActionsPerRun" ? " integer" : " number"}.`);
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
return limits;
|
|
615
|
+
}
|
|
616
|
+
function positiveInteger(value, name) {
|
|
617
|
+
if (!Number.isSafeInteger(value) || value <= 0)
|
|
618
|
+
throw new Error(`Sandbox ${name} must be a positive integer.`);
|
|
619
|
+
return value;
|
|
620
|
+
}
|
|
621
|
+
function boundedTenantTtl(value, maxTenantTtlMs) {
|
|
622
|
+
const ttlMs = positiveInteger(value, "tenant ttlMs");
|
|
623
|
+
if (ttlMs > maxTenantTtlMs)
|
|
624
|
+
throw new Error(`Sandbox tenant ttlMs cannot exceed ${maxTenantTtlMs}.`);
|
|
625
|
+
return ttlMs;
|
|
626
|
+
}
|
|
627
|
+
function normalizedTargets(input) {
|
|
628
|
+
const targets = [...new Set(input.map((item) => item.trim()).filter(Boolean))];
|
|
629
|
+
if (targets.length === 0)
|
|
630
|
+
throw new Error("Sandbox systems require at least one allowed target system.");
|
|
631
|
+
return targets;
|
|
632
|
+
}
|
|
633
|
+
function assertSandboxSystem(system) {
|
|
634
|
+
if (system.safety.mode !== "sandbox" || system.safety.networkAccess !== false || system.safety.syntheticDataOnly !== true) {
|
|
635
|
+
throw new Error("Sandbox Certification Harness v0.1 accepts only synthetic, network-denied sandbox systems.");
|
|
636
|
+
}
|
|
637
|
+
normalizedTargets(system.safety.allowedTargetSystems);
|
|
638
|
+
}
|
|
639
|
+
function assertTenantId(tenantId) {
|
|
640
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(tenantId)) {
|
|
641
|
+
throw new Error("Sandbox tenant IDs must be 1-64 characters using letters, numbers, dot, underscore, or hyphen.");
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
function validatedSeed(input) {
|
|
645
|
+
assertSyntheticValue(input, "seed", new Set());
|
|
646
|
+
return structuredClone(input);
|
|
647
|
+
}
|
|
648
|
+
export function validateSyntheticSandboxSeed(input) {
|
|
649
|
+
return validatedSeed(input);
|
|
650
|
+
}
|
|
651
|
+
function assertSyntheticValue(value, path, seen) {
|
|
652
|
+
if (value === null || typeof value === "boolean" || typeof value === "number")
|
|
653
|
+
return;
|
|
654
|
+
if (typeof value === "string") {
|
|
655
|
+
if (/^(?:sk-(?:proj-)?|npm_|gh[pousr]_)[A-Za-z0-9_-]{16,}$/.test(value)) {
|
|
656
|
+
throw new Error(`Credential-like value is not allowed in synthetic sandbox data at ${path}.`);
|
|
657
|
+
}
|
|
658
|
+
return;
|
|
659
|
+
}
|
|
660
|
+
if (typeof value !== "object")
|
|
661
|
+
throw new Error(`Synthetic sandbox data must be JSON-compatible at ${path}.`);
|
|
662
|
+
if (seen.has(value))
|
|
663
|
+
throw new Error(`Synthetic sandbox data cannot contain cycles at ${path}.`);
|
|
664
|
+
seen.add(value);
|
|
665
|
+
if (Array.isArray(value)) {
|
|
666
|
+
value.forEach((item, index) => assertSyntheticValue(item, `${path}[${index}]`, seen));
|
|
667
|
+
}
|
|
668
|
+
else {
|
|
669
|
+
for (const [key, item] of Object.entries(value)) {
|
|
670
|
+
if (/(?:password|passwd|secret|token|api[_-]?key|credential|authorization|cookie)/i.test(key)) {
|
|
671
|
+
throw new Error(`Credential-like field ${path}.${key} is not allowed in synthetic sandbox data.`);
|
|
672
|
+
}
|
|
673
|
+
assertSyntheticValue(item, `${path}.${key}`, seen);
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
seen.delete(value);
|
|
677
|
+
}
|
|
678
|
+
function killSwitch(enabled, reason, now) {
|
|
679
|
+
return { enabled, reason: reason?.trim() || undefined, changedAt: now().toISOString() };
|
|
680
|
+
}
|
|
681
|
+
function canonicalJson(value) {
|
|
682
|
+
return JSON.stringify(sortValue(value));
|
|
683
|
+
}
|
|
684
|
+
function sortValue(value) {
|
|
685
|
+
if (Array.isArray(value))
|
|
686
|
+
return value.map(sortValue);
|
|
687
|
+
if (value && typeof value === "object") {
|
|
688
|
+
return Object.fromEntries(Object.entries(value)
|
|
689
|
+
.filter(([, item]) => item !== undefined)
|
|
690
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
691
|
+
.map(([key, item]) => [key, sortValue(item)]));
|
|
692
|
+
}
|
|
693
|
+
return value;
|
|
694
|
+
}
|
|
695
|
+
function sha256(value) {
|
|
696
|
+
return createHash("sha256").update(value).digest("hex");
|
|
697
|
+
}
|
|
698
|
+
function referenceAction(targetSystem, overrides = {}) {
|
|
699
|
+
return {
|
|
700
|
+
idempotencyKey: `cert-action-${randomUUID()}`,
|
|
701
|
+
sourceAgentName: "SandboxCertificationAgent",
|
|
702
|
+
actionType: "UPDATE",
|
|
703
|
+
targetSystem,
|
|
704
|
+
environment: "demo",
|
|
705
|
+
title: "Update synthetic account tier",
|
|
706
|
+
description: "Exercise the isolated AgentCert reference sandbox.",
|
|
707
|
+
businessObjectType: "account",
|
|
708
|
+
businessObjectId: "account-1",
|
|
709
|
+
amount: 100,
|
|
710
|
+
currency: "USD",
|
|
711
|
+
beforeState: { tier: "standard", owner: "Synthetic Customer" },
|
|
712
|
+
proposedAfterState: { tier: "enterprise", owner: "Synthetic Customer" },
|
|
713
|
+
fieldsChanged: [{ field: "tier", before: "standard", after: "enterprise" }],
|
|
714
|
+
...overrides,
|
|
715
|
+
};
|
|
716
|
+
}
|
|
717
|
+
function approved() {
|
|
718
|
+
return { approval: { approved: true, reviewerId: "sandbox-certifier@agentcert.local", comment: "Approved for deterministic certification." } };
|
|
719
|
+
}
|
|
720
|
+
async function certificationRun(harness, tenantId, runIds, label) {
|
|
721
|
+
const runId = `cert-${label}-${randomUUID().slice(0, 8)}`;
|
|
722
|
+
runIds.push(runId);
|
|
723
|
+
return harness.startRun({ tenantId, runId });
|
|
724
|
+
}
|
|
725
|
+
function expectRejection(result, code) {
|
|
726
|
+
if (result.status !== "rejected" || result.rejectionCode !== code) {
|
|
727
|
+
throw new Error(`Expected ${code}, observed ${result.status}/${result.rejectionCode ?? "none"}.`);
|
|
728
|
+
}
|
|
729
|
+
}
|