@shieldaai/agent-guard-scenarios 0.1.0-alpha.0 → 0.1.0-alpha.2
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 +10 -2
- package/dist/catalog.d.ts +3 -3
- package/dist/catalog.js +16 -2
- package/dist/index.d.ts +8 -6
- package/dist/index.js +167 -22
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -11,12 +11,20 @@ console.table(report.results);
|
|
|
11
11
|
console.log(scenarioCatalog);
|
|
12
12
|
```
|
|
13
13
|
|
|
14
|
-
Run selected scenarios with `runConformance({ scenarioIds: ['S05', 'S08'] })`. The default suite executes malicious and benign variants of all
|
|
14
|
+
Run selected scenarios with `runConformance({ scenarioIds: ['S05', 'S08'] })`. The default suite executes malicious and benign variants of all twelve scenarios. It checks handler invocations and synthetic effects, not merely decision objects. The built-in fixtures use no credentials, model calls, network services or destructive operations. A custom adapter is responsible for its own I/O.
|
|
15
15
|
|
|
16
|
-
The scenarios cover unauthorized tools/resources, delegation, protected administration, sensitive export, action-bound approval, metadata drift, outbound secrets/canaries, revocation/control failures, tenants and
|
|
16
|
+
The scenarios cover unauthorized tools/resources, delegation, protected administration, sensitive export, action-bound approval, metadata drift, outbound secrets/canaries, revocation/control failures, tenants, budgets, normalized typed arguments and resource-version checks. The [scenario specification](https://github.com/ShieldaAI/agent-guard/blob/main/docs/scenarios.md) explains each boundary.
|
|
17
|
+
|
|
18
|
+
The catalog version identifies the executed acceptance checks independently of the package version; compare reports using their `catalogVersion`. Version `1.2.0` retains S01–S10 and adds S11 and S12, for twelve scenario identifiers and twenty-four paired cases. A pass from an older catalog does not establish coverage of newly added assertions.
|
|
19
|
+
|
|
20
|
+
S11 checks that the trusted `ToolDefinition.parse` rejects wrong types, unknown or missing fields, invalid ranges and invalid or asynchronous parser results before resource resolution. The benign case verifies that normalized arguments reach resolution, both policy evaluations, approval and execution; later mutations of caller or parser-owned objects cannot change the approved effect. The core does not interpret `inputSchema` automatically: the host must supply a suitable synchronous parser.
|
|
21
|
+
|
|
22
|
+
S12 binds the resolver's `resourceVersion` to the immutable prepared action and approval. A synthetic trusted handler compares the approved version with current state immediately before its write. Version forgery and approval transfer must fail before dispatch; a genuine stale version enters the handler, consumes its attempt and produces no write. The benign pair approves and writes two successive versions separately. Real services must implement the comparison atomically with their side effect. This scenario does not claim the core locks or refreshes external resources.
|
|
17
23
|
|
|
18
24
|
To exercise a compatible implementation, supply `createGuard(options)` in the options object. The factory must honor the provided clock and secret configuration and return the structural `GuardLike` API. Denials use the exported `GuardError` and its documented reason codes. Each fixture receives an isolated instance; no shared factory state is required.
|
|
19
25
|
|
|
26
|
+
Each malicious or benign variant has a five-second asynchronous timeout. Override it with `caseTimeoutMs`, a positive integer up to `2147483647`. A timed-out variant receives a failed result and the runner continues with the next variant. The timer cannot interrupt blocking JavaScript or cancel unfinished adapter work; an adapter may retain resources or continue running after its result is reported. Use process isolation if the implementation under test needs a hard execution limit.
|
|
27
|
+
|
|
20
28
|
A passing report applies to these fixtures in one process. It is not a security certification, injection-detector benchmark, MCP transport test or proof that a production agent cannot bypass its integration. Errors after actual handler invocation are tested separately from denials that must prevent invocation.
|
|
21
29
|
|
|
22
30
|
Apache-2.0. Copyright 2026 ShieldaAI contributors.
|
package/dist/catalog.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/** Version of the synthetic acceptance cases, independent of package releases. */
|
|
2
|
-
export declare const SCENARIO_CATALOG_VERSION: '1.
|
|
3
|
-
export type ScenarioId = 'S01' | 'S02' | 'S03' | 'S04' | 'S05' | 'S06' | 'S07' | 'S08' | 'S09' | 'S10';
|
|
2
|
+
export declare const SCENARIO_CATALOG_VERSION: '1.2.0';
|
|
3
|
+
export type ScenarioId = 'S01' | 'S02' | 'S03' | 'S04' | 'S05' | 'S06' | 'S07' | 'S08' | 'S09' | 'S10' | 'S11' | 'S12';
|
|
4
4
|
export interface ScenarioDefinition {
|
|
5
5
|
readonly id: ScenarioId;
|
|
6
6
|
readonly title: string;
|
|
@@ -9,5 +9,5 @@ export interface ScenarioDefinition {
|
|
|
9
9
|
readonly benign: string;
|
|
10
10
|
readonly limitations: readonly string[];
|
|
11
11
|
}
|
|
12
|
-
/**
|
|
12
|
+
/** Read-only scenario descriptions. Call runConformance() to run the fixtures. */
|
|
13
13
|
export declare const scenarioCatalog: readonly ScenarioDefinition[];
|
package/dist/catalog.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/** Version of the synthetic acceptance cases, independent of package releases. */
|
|
2
|
-
export const SCENARIO_CATALOG_VERSION = '1.
|
|
2
|
+
export const SCENARIO_CATALOG_VERSION = '1.2.0';
|
|
3
3
|
const definitions = [
|
|
4
4
|
{
|
|
5
5
|
id: 'S01', title: 'Deny unknown tools and unauthorized resources',
|
|
@@ -71,8 +71,22 @@ const definitions = [
|
|
|
71
71
|
benign: 'Authorized work releases concurrency, reconciles cost and cooperates with cancellation.',
|
|
72
72
|
limitations: ['Quotas are local and nondurable; the provider must enforce declared upper costs and handlers must honor cancellation.'],
|
|
73
73
|
},
|
|
74
|
+
{
|
|
75
|
+
id: 'S11', title: 'Validate and bind normalized typed arguments',
|
|
76
|
+
boundary: 'A trusted synchronous parser runs before resolution; its immutable normalized output reaches policy, approval and the handler.',
|
|
77
|
+
malicious: ['Wrong field types', 'Unknown fields', 'Missing required fields', 'Out-of-range values', 'Invalid parser output', 'Asynchronous parser'],
|
|
78
|
+
benign: 'One approved write uses the normalized arguments even after caller and parser-owned objects change.',
|
|
79
|
+
limitations: ['The host supplies the parser; descriptive inputSchema metadata alone does not validate core arguments.'],
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
id: 'S12', title: 'Bind approvals to resource versions and compare at use time',
|
|
83
|
+
boundary: 'The immutable action includes the resolved version; a trusted handler atomically compares it before applying the effect.',
|
|
84
|
+
malicious: ['Forged prepared resource version', 'Approval transferred to a new version', 'Resource changed after approval', 'Replay of a stale admitted attempt'],
|
|
85
|
+
benign: 'An unchanged approved version is written once; a subsequent version requires a fresh preparation and approval.',
|
|
86
|
+
limitations: ['The core does not fetch or lock external state. A stale attempt enters the handler, which must enforce the comparison atomically with the real effect.'],
|
|
87
|
+
},
|
|
74
88
|
];
|
|
75
|
-
/**
|
|
89
|
+
/** Read-only scenario descriptions. Call runConformance() to run the fixtures. */
|
|
76
90
|
export const scenarioCatalog = Object.freeze(definitions.map((entry) => Object.freeze({
|
|
77
91
|
...entry,
|
|
78
92
|
malicious: Object.freeze([...entry.malicious]),
|
package/dist/index.d.ts
CHANGED
|
@@ -2,14 +2,16 @@ import { AgentGuard, type GuardOptions } from '@shieldaai/agent-guard';
|
|
|
2
2
|
import { SCENARIO_CATALOG_VERSION, type ScenarioId } from './catalog.js';
|
|
3
3
|
export { SCENARIO_CATALOG_VERSION, scenarioCatalog } from './catalog.js';
|
|
4
4
|
export type { ScenarioDefinition, ScenarioId } from './catalog.js';
|
|
5
|
-
/**
|
|
5
|
+
/** Methods an adapter must provide to run the conformance suite. */
|
|
6
6
|
export type GuardLike = Pick<AgentGuard, 'registerTool' | 'approveTool' | 'createTask' | 'delegateTask' | 'revokeTask' | 'setPolicy' | 'prepare' | 'approve' | 'execute' | 'snapshot'>;
|
|
7
7
|
export type ConformanceGuardFactory = (options: GuardOptions) => GuardLike;
|
|
8
8
|
export interface ConformanceOptions {
|
|
9
|
-
/** Omit to execute all
|
|
9
|
+
/** Omit to execute all twelve scenarios. Each selected scenario runs both variants. */
|
|
10
10
|
readonly scenarioIds?: readonly ScenarioId[];
|
|
11
|
+
/** Time limit per variant in milliseconds; defaults to 5000. Timeout does not cancel adapter work. */
|
|
12
|
+
readonly caseTimeoutMs?: number;
|
|
11
13
|
/**
|
|
12
|
-
*
|
|
14
|
+
* Return a new guard for each call. Defaults to the public AgentGuard implementation.
|
|
13
15
|
* Compatible adapters must preserve these APIs and throw this core package's GuardError
|
|
14
16
|
* with the documented reason codes. The provided clock and secret configuration must be honored.
|
|
15
17
|
*/
|
|
@@ -35,8 +37,8 @@ export interface ConformanceReport {
|
|
|
35
37
|
readonly results: readonly ConformanceCaseResult[];
|
|
36
38
|
}
|
|
37
39
|
/**
|
|
38
|
-
* Run
|
|
39
|
-
*
|
|
40
|
-
*
|
|
40
|
+
* Run the catalog fixtures against AgentGuard or the supplied adapter.
|
|
41
|
+
* The default implementation uses in-memory effects only. A custom adapter is
|
|
42
|
+
* responsible for its own I/O; the runner cannot isolate or cancel it.
|
|
41
43
|
*/
|
|
42
44
|
export declare function runConformance(options?: ConformanceOptions): Promise<ConformanceReport>;
|
package/dist/index.js
CHANGED
|
@@ -28,11 +28,11 @@ function deferred() {
|
|
|
28
28
|
const promise = new Promise((done) => { resolve = done; });
|
|
29
29
|
return { promise, resolve };
|
|
30
30
|
}
|
|
31
|
-
async function bounded(promise, label) {
|
|
31
|
+
async function bounded(promise, label, timeoutMs = 1500) {
|
|
32
32
|
let timer;
|
|
33
33
|
try {
|
|
34
34
|
return await Promise.race([promise, new Promise((_, reject) => {
|
|
35
|
-
timer = setTimeout(() => reject(new CheckFailure(label)),
|
|
35
|
+
timer = setTimeout(() => reject(new CheckFailure(label)), timeoutMs);
|
|
36
36
|
})]);
|
|
37
37
|
}
|
|
38
38
|
finally {
|
|
@@ -341,20 +341,23 @@ const cases = {
|
|
|
341
341
|
const prepared = await f.guard.prepare(task, { tool: 'delete_record', args: { resource: RECORD_A } });
|
|
342
342
|
const approvalId = f.approve(prepared);
|
|
343
343
|
wait = true;
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
344
|
+
await c.denied(async () => {
|
|
345
|
+
const pending = f.guard.execute(prepared, { approvalId });
|
|
346
|
+
// Observe early settlement immediately, including when testing a broken implementation.
|
|
347
|
+
const observed = pending.then(() => undefined, () => undefined);
|
|
348
|
+
try {
|
|
349
|
+
await bounded(Promise.race([entered.promise, observed.then(() => { throw new CheckFailure('execution settled before policy barrier'); })]), 'policy barrier not reached');
|
|
350
|
+
if (change === 'revoke')
|
|
351
|
+
f.guard.revokeTask(task);
|
|
352
|
+
else
|
|
353
|
+
f.guard.setPolicy(() => 'allow');
|
|
354
|
+
}
|
|
355
|
+
finally {
|
|
356
|
+
release.resolve();
|
|
357
|
+
}
|
|
358
|
+
return pending;
|
|
359
|
+
}, `${change} during asynchronous policy`, change === 'revoke' ? 'TASK_REVOKED' : 'POLICY_CHANGED');
|
|
360
|
+
c.assert(f.ledger.length === 0, `${change} during asynchronous policy leaves the ledger empty`);
|
|
358
361
|
}
|
|
359
362
|
const f = fixture(c);
|
|
360
363
|
f.remove();
|
|
@@ -485,6 +488,13 @@ const cases = {
|
|
|
485
488
|
await f.invoke(task, 'read_document', { resource: GUIDE });
|
|
486
489
|
const snapshot = f.guard.snapshot(task);
|
|
487
490
|
c.assert(snapshot.calls === 2 && snapshot.concurrent === 0 && snapshot.spent === 2 && snapshot.reserved === 0, 'completed work reconciles usage and releases slots');
|
|
491
|
+
const omitted = fixture(c);
|
|
492
|
+
omitted.read({ maxCost: 3 });
|
|
493
|
+
const omittedTask = omitted.task(readGrant(), { budget: omitted.budget({ maxCost: 6 }) });
|
|
494
|
+
await omitted.invoke(omittedTask, 'read_document', { resource: GUIDE });
|
|
495
|
+
await omitted.invoke(omittedTask, 'read_document', { resource: GUIDE });
|
|
496
|
+
const omittedSnapshot = omitted.guard.snapshot(omittedTask);
|
|
497
|
+
c.assert(omittedSnapshot.calls === 2 && omittedSnapshot.spent === 6 && omittedSnapshot.reserved === 0 && omittedSnapshot.concurrent === 0, 'omitted actual cost charges the full declared maximum');
|
|
488
498
|
const cancel = fixture(c);
|
|
489
499
|
const entered = deferred();
|
|
490
500
|
let observed = false;
|
|
@@ -532,11 +542,11 @@ const cases = {
|
|
|
532
542
|
c.assert(deadlineObserved && deadline.guard.snapshot(deadlineTask).concurrent === 0, 'deadline signals cooperative cancellation and releases slot');
|
|
533
543
|
const failed = fixture(c);
|
|
534
544
|
let first = true;
|
|
535
|
-
failed.read({ execute: () => { c.handlerInvocations += 1; if (first) {
|
|
545
|
+
failed.read({ maxCost: 3, execute: () => { c.handlerInvocations += 1; if (first) {
|
|
536
546
|
first = false;
|
|
537
547
|
throw new Error('synthetic failure');
|
|
538
548
|
} return { value: 'recovered' }; } });
|
|
539
|
-
const failedTask = failed.task(readGrant(), { budget: failed.budget({ maxConcurrent: 1 }) });
|
|
549
|
+
const failedTask = failed.task(readGrant(), { budget: failed.budget({ maxConcurrent: 1, maxCost: 9 }) });
|
|
540
550
|
let rejected = false;
|
|
541
551
|
try {
|
|
542
552
|
await failed.invoke(failedTask, 'read_document', { resource: GUIDE });
|
|
@@ -545,16 +555,151 @@ const cases = {
|
|
|
545
555
|
rejected = true;
|
|
546
556
|
}
|
|
547
557
|
c.assert(rejected && failed.guard.snapshot(failedTask).calls === 1 && failed.guard.snapshot(failedTask).concurrent === 0, 'failed handler counts attempt and releases concurrency');
|
|
558
|
+
c.assert(failed.guard.snapshot(failedTask).spent === 3 && failed.guard.snapshot(failedTask).reserved === 0, 'failed handler retains the full declared charge');
|
|
548
559
|
c.assert(await failed.invoke(failedTask, 'read_document', { resource: GUIDE }) === 'recovered', 'next authorized call can use released slot');
|
|
560
|
+
c.assert(failed.guard.snapshot(failedTask).spent === 6 && failed.guard.snapshot(failedTask).concurrent === 0, 'recovery retains both admitted charges and releases concurrency');
|
|
561
|
+
},
|
|
562
|
+
},
|
|
563
|
+
S11: {
|
|
564
|
+
async malicious(c) {
|
|
565
|
+
const f = fixture(c);
|
|
566
|
+
let resolutions = 0;
|
|
567
|
+
f.register({ name: 'typed_write', description: 'Write a typed synthetic record',
|
|
568
|
+
parse(input) {
|
|
569
|
+
const value = fields(input);
|
|
570
|
+
if (Object.keys(value).some(key => !['resource', 'quantity'].includes(key)) ||
|
|
571
|
+
typeof value.resource !== 'string' || !value.resource.trim() ||
|
|
572
|
+
typeof value.quantity !== 'number' || !Number.isSafeInteger(value.quantity) || value.quantity < 1 || value.quantity > 10) {
|
|
573
|
+
throw new Error(SECRET);
|
|
574
|
+
}
|
|
575
|
+
return { resource: value.resource.trim(), quantity: value.quantity };
|
|
576
|
+
},
|
|
577
|
+
resolve(args) { resolutions++; return { resourceId: stringField(args, 'resource'), tenantId: 'alpha' }; },
|
|
578
|
+
execute: args => c.effect(args),
|
|
579
|
+
});
|
|
580
|
+
const task = f.task([{ tool: 'typed_write', resources: [RECORD_A] }]);
|
|
581
|
+
for (const args of [
|
|
582
|
+
{ resource: RECORD_A, quantity: '1' }, { resource: RECORD_A, quantity: 1, command: 'unexpected' },
|
|
583
|
+
{ quantity: 1 }, { resource: RECORD_A, quantity: 0 }, { resource: RECORD_A, quantity: 1.5 },
|
|
584
|
+
])
|
|
585
|
+
await c.denied(() => f.invoke(task, 'typed_write', args), 'invalid typed arguments', 'ARGUMENT_INVALID');
|
|
586
|
+
c.assert(resolutions === 0, 'invalid typed arguments never reach resource resolution');
|
|
587
|
+
for (const parse of [() => ({ unsafe: undefined }), () => Promise.resolve(null)]) {
|
|
588
|
+
f.register({ name: 'invalid_parser', description: 'Invalid trusted parser fixture', parse,
|
|
589
|
+
resolve: () => { resolutions++; return { resourceId: RECORD_A, tenantId: 'alpha' }; }, execute: args => c.effect(args) });
|
|
590
|
+
const parserTask = f.task([{ tool: 'invalid_parser', resources: [RECORD_A] }]);
|
|
591
|
+
await c.denied(() => f.invoke(parserTask, 'invalid_parser', null), 'invalid parser result', 'ARGUMENT_INVALID');
|
|
592
|
+
}
|
|
593
|
+
c.assert(resolutions === 0 && c.handlerInvocations === 0, 'parser failures prevent resolution and effects');
|
|
594
|
+
},
|
|
595
|
+
async benign(c) {
|
|
596
|
+
const observed = [];
|
|
597
|
+
const f = fixture(c, { policy: action => { observed.push(action.args); return 'allow'; } });
|
|
598
|
+
let parses = 0;
|
|
599
|
+
let normalized;
|
|
600
|
+
f.register({ name: 'typed_write', description: 'Write a normalized synthetic record', requiresApproval: true,
|
|
601
|
+
parse(input) {
|
|
602
|
+
parses++;
|
|
603
|
+
const value = fields(input);
|
|
604
|
+
if (typeof value.resource !== 'string' || !Number.isSafeInteger(value.quantity))
|
|
605
|
+
throw new Error('invalid fixture');
|
|
606
|
+
normalized = { resource: value.resource.trim(), quantity: Number(value.quantity) };
|
|
607
|
+
return normalized;
|
|
608
|
+
},
|
|
609
|
+
resolve(args) { return { resourceId: stringField(args, 'resource'), tenantId: 'alpha' }; },
|
|
610
|
+
execute(args, { action }) {
|
|
611
|
+
c.assert(args === action.args && Object.isFrozen(args), 'handler receives the immutable approved arguments');
|
|
612
|
+
f.ledger.push(`${action.resourceId}:${fields(args).quantity}`);
|
|
613
|
+
return c.effect(args);
|
|
614
|
+
},
|
|
615
|
+
});
|
|
616
|
+
const task = f.task([{ tool: 'typed_write', resources: [RECORD_A] }]);
|
|
617
|
+
const input = { resource: ` ${RECORD_A} `, quantity: 3 };
|
|
618
|
+
const prepared = await f.guard.prepare(task, { tool: 'typed_write', args: input });
|
|
619
|
+
c.assert(prepared.action.resourceId === RECORD_A && fields(prepared.action.args).resource === RECORD_A, 'normalized resource is resolved and bound');
|
|
620
|
+
const approvalId = f.approve(prepared);
|
|
621
|
+
input.resource = RECORD_B;
|
|
622
|
+
input.quantity = 9;
|
|
623
|
+
normalized.resource = RECORD_B;
|
|
624
|
+
normalized.quantity = 8;
|
|
625
|
+
const value = await f.guard.execute(prepared, { approvalId });
|
|
626
|
+
c.assert(fields(value).resource === RECORD_A && fields(value).quantity === 3, 'caller and parser mutations do not alter the approved write');
|
|
627
|
+
c.assert(observed.length === 2 && observed.every(args => fields(args).resource === RECORD_A && fields(args).quantity === 3), 'preparation and execution policy see normalized arguments');
|
|
628
|
+
c.assert(parses === 1 && c.handlerInvocations === 1 && f.ledger[0] === `${RECORD_A}:3`, 'one parser pass and one normalized approved effect');
|
|
629
|
+
},
|
|
630
|
+
},
|
|
631
|
+
S12: {
|
|
632
|
+
async malicious(c) {
|
|
633
|
+
const f = fixture(c);
|
|
634
|
+
let version = 'v1';
|
|
635
|
+
f.register({ name: 'versioned_write', description: 'Compare and write one synthetic record', requiresApproval: true,
|
|
636
|
+
resolve: () => ({ resourceId: RECORD_A, tenantId: 'alpha', resourceVersion: version }),
|
|
637
|
+
execute(_args, { action }) {
|
|
638
|
+
c.handlerInvocations++;
|
|
639
|
+
if (action.resourceVersion !== version)
|
|
640
|
+
throw new Error('synthetic version conflict');
|
|
641
|
+
f.ledger.push(`write:${version}`);
|
|
642
|
+
return { value: version };
|
|
643
|
+
},
|
|
644
|
+
});
|
|
645
|
+
const task = f.task([{ tool: 'versioned_write', resources: [RECORD_A] }]);
|
|
646
|
+
const old = await f.guard.prepare(task, { tool: 'versioned_write', args: null });
|
|
647
|
+
const approvalId = f.approve(old);
|
|
648
|
+
c.assert(old.action.resourceVersion === 'v1' && Object.isFrozen(old.action), 'resolved version is immutable approved action data');
|
|
649
|
+
await c.denied(() => f.guard.execute({ ...old, action: { ...old.action, resourceVersion: 'v2' } }, { approvalId }), 'forged resource version', 'INVALID_PREPARED');
|
|
650
|
+
version = 'v2';
|
|
651
|
+
const fresh = await f.guard.prepare(task, { tool: 'versioned_write', args: null });
|
|
652
|
+
c.assert(fresh.action.resourceVersion === 'v2' && fresh.digest !== old.digest, 'new resource version receives a distinct prepared invocation');
|
|
653
|
+
await c.denied(() => f.guard.execute(fresh, { approvalId }), 'approval transfer to new version', 'APPROVAL_INVALID');
|
|
654
|
+
let error;
|
|
655
|
+
try {
|
|
656
|
+
await f.guard.execute(old, { approvalId });
|
|
657
|
+
}
|
|
658
|
+
catch (caught) {
|
|
659
|
+
error = caught;
|
|
660
|
+
}
|
|
661
|
+
c.assert(error instanceof GuardError && error.code === 'TOOL_FAILED', 'stale resource compare fails with a sanitized handler error');
|
|
662
|
+
c.denialCodes.push('TOOL_FAILED');
|
|
663
|
+
c.assert(c.handlerInvocations === 1 && f.ledger.length === 0, 'stale attempt enters the handler but produces no effect');
|
|
664
|
+
await c.denied(() => f.guard.execute(old, { approvalId }), 'stale attempt cannot replay', 'ACTION_USED');
|
|
665
|
+
c.assert(f.guard.snapshot(task).calls === 1, 'stale admitted attempt still consumes one call');
|
|
666
|
+
},
|
|
667
|
+
async benign(c) {
|
|
668
|
+
const f = fixture(c);
|
|
669
|
+
let version = 'v1';
|
|
670
|
+
f.register({ name: 'versioned_write', description: 'Compare and write one synthetic record', requiresApproval: true,
|
|
671
|
+
resolve: () => ({ resourceId: RECORD_A, tenantId: 'alpha', resourceVersion: version }),
|
|
672
|
+
execute(_args, { action }) {
|
|
673
|
+
c.handlerInvocations++;
|
|
674
|
+
if (action.resourceVersion !== version)
|
|
675
|
+
throw new Error('synthetic version conflict');
|
|
676
|
+
f.ledger.push(`write:${version}`);
|
|
677
|
+
version = version === 'v1' ? 'v2' : 'v3';
|
|
678
|
+
return { value: { version } };
|
|
679
|
+
},
|
|
680
|
+
});
|
|
681
|
+
const task = f.task([{ tool: 'versioned_write', resources: [RECORD_A] }]);
|
|
682
|
+
for (const expected of ['v1', 'v2']) {
|
|
683
|
+
const prepared = await f.guard.prepare(task, { tool: 'versioned_write', args: null });
|
|
684
|
+
c.assert(prepared.action.resourceVersion === expected, 'preparation binds the current resource version');
|
|
685
|
+
const approvalId = f.approve(prepared);
|
|
686
|
+
await f.guard.execute(prepared, { approvalId });
|
|
687
|
+
}
|
|
688
|
+
c.assert(c.handlerInvocations === 2 && f.ledger.join(',') === 'write:v1,write:v2', 'each reviewed version produces exactly one intended effect');
|
|
689
|
+
c.assert(version === 'v3' && f.guard.snapshot(task).calls === 2, 'two independent approvals advance the synthetic resource twice');
|
|
549
690
|
},
|
|
550
691
|
},
|
|
551
692
|
};
|
|
552
693
|
/**
|
|
553
|
-
* Run
|
|
554
|
-
*
|
|
555
|
-
*
|
|
694
|
+
* Run the catalog fixtures against AgentGuard or the supplied adapter.
|
|
695
|
+
* The default implementation uses in-memory effects only. A custom adapter is
|
|
696
|
+
* responsible for its own I/O; the runner cannot isolate or cancel it.
|
|
556
697
|
*/
|
|
557
698
|
export async function runConformance(options = {}) {
|
|
699
|
+
const caseTimeoutMs = options.caseTimeoutMs === undefined ? 5000 : options.caseTimeoutMs;
|
|
700
|
+
if (!Number.isSafeInteger(caseTimeoutMs) || caseTimeoutMs < 1 || caseTimeoutMs > 2_147_483_647) {
|
|
701
|
+
throw new TypeError('caseTimeoutMs must be an integer from 1 through 2147483647');
|
|
702
|
+
}
|
|
558
703
|
const selected = options.scenarioIds ?? scenarioCatalog.map((scenario) => scenario.id);
|
|
559
704
|
if (selected.length === 0 || new Set(selected).size !== selected.length || selected.some((id) => !Object.hasOwn(cases, id))) {
|
|
560
705
|
throw new TypeError('scenarioIds must be a nonempty list of distinct catalog identifiers');
|
|
@@ -565,7 +710,7 @@ export async function runConformance(options = {}) {
|
|
|
565
710
|
const checks = new Checks(options.createGuard);
|
|
566
711
|
let failure;
|
|
567
712
|
try {
|
|
568
|
-
await cases[scenarioId][variant](checks);
|
|
713
|
+
await bounded(cases[scenarioId][variant](checks), 'case exceeded its asynchronous timeout', caseTimeoutMs);
|
|
569
714
|
}
|
|
570
715
|
catch (error) {
|
|
571
716
|
failure = error instanceof CheckFailure ? error.message
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shieldaai/agent-guard-scenarios",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.2",
|
|
4
4
|
"description": "Executable protective scenarios and conformance fixtures for agent tool executors.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -43,9 +43,10 @@
|
|
|
43
43
|
],
|
|
44
44
|
"scripts": {
|
|
45
45
|
"build": "tsc -p tsconfig.json",
|
|
46
|
+
"prepack": "node ../../scripts/build.mjs",
|
|
46
47
|
"prepublishOnly": "node ../../scripts/release-check.mjs"
|
|
47
48
|
},
|
|
48
49
|
"dependencies": {
|
|
49
|
-
"@shieldaai/agent-guard": "0.1.0-alpha.
|
|
50
|
+
"@shieldaai/agent-guard": "0.1.0-alpha.2"
|
|
50
51
|
}
|
|
51
52
|
}
|