@shieldaai/agent-guard-scenarios 0.1.0-alpha.1 → 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 +7 -3
- package/dist/catalog.d.ts +2 -2
- package/dist/catalog.js +15 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +129 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -11,11 +11,15 @@ 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
17
|
|
|
18
|
-
The catalog version identifies the executed acceptance checks independently of the package version; compare reports using their `catalogVersion`. Version `1.
|
|
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.
|
|
19
23
|
|
|
20
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.
|
|
21
25
|
|
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;
|
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,6 +71,20 @@ 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({
|
package/dist/index.d.ts
CHANGED
|
@@ -6,7 +6,7 @@ export type { ScenarioDefinition, ScenarioId } from './catalog.js';
|
|
|
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
11
|
/** Time limit per variant in milliseconds; defaults to 5000. Timeout does not cancel adapter work. */
|
|
12
12
|
readonly caseTimeoutMs?: number;
|
package/dist/index.js
CHANGED
|
@@ -560,6 +560,135 @@ const cases = {
|
|
|
560
560
|
c.assert(failed.guard.snapshot(failedTask).spent === 6 && failed.guard.snapshot(failedTask).concurrent === 0, 'recovery retains both admitted charges and releases concurrency');
|
|
561
561
|
},
|
|
562
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');
|
|
690
|
+
},
|
|
691
|
+
},
|
|
563
692
|
};
|
|
564
693
|
/**
|
|
565
694
|
* Run the catalog fixtures against AgentGuard or the supplied adapter.
|
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",
|
|
@@ -47,6 +47,6 @@
|
|
|
47
47
|
"prepublishOnly": "node ../../scripts/release-check.mjs"
|
|
48
48
|
},
|
|
49
49
|
"dependencies": {
|
|
50
|
-
"@shieldaai/agent-guard": "0.1.0-alpha.
|
|
50
|
+
"@shieldaai/agent-guard": "0.1.0-alpha.2"
|
|
51
51
|
}
|
|
52
52
|
}
|