diff --git a/.claude/worktrees/zealous-feistel b/.claude/worktrees/zealous-feistel new file mode 160000 index 0000000..2760753 --- /dev/null +++ b/.claude/worktrees/zealous-feistel @@ -0,0 +1 @@ +Subproject commit 2760753c707c62036808691e4e8871831a177d5f diff --git a/docs/conformance/owasp-asi-mapping.md b/docs/conformance/owasp-asi-mapping.md index 318a688..e2a0bc6 100644 --- a/docs/conformance/owasp-asi-mapping.md +++ b/docs/conformance/owasp-asi-mapping.md @@ -5,6 +5,7 @@ **Status:** Certified (Phase 3 complete) **Companion fixture pack:** `packages/conformance-tests/fixtures/security/owasp-asi-conformance.v1.json` **Companion test file:** `packages/conformance-tests/src/owasp-asi-conformance.test.ts` +**Invariant-level breakdown:** [`runtime-enforcement-mapping.md`](./runtime-enforcement-mapping.md) — maps each fixture to the OWASP runtime-integrity invariant it exercises (transmission / authorization-at-execution / execution / intent) --- diff --git a/docs/conformance/runtime-enforcement-mapping.md b/docs/conformance/runtime-enforcement-mapping.md new file mode 100644 index 0000000..30d2ead --- /dev/null +++ b/docs/conformance/runtime-enforcement-mapping.md @@ -0,0 +1,130 @@ +# SINT Protocol — Runtime Enforcement Mapping (OWASP) + +**Document ID:** `sint-conformance-runtime-enforcement-v1` +**Schema version:** `2026-04-20` +**Status:** Draft addendum to `owasp-asi-mapping.md` +**Companion fixture pack:** `packages/conformance-tests/fixtures/security/owasp-asi-conformance.v1.json` +**Inbound context:** [OWASP/www-project-top-10-for-large-language-model-applications#802](https://github.com/OWASP/www-project-top-10-for-large-language-model-applications/issues/802) + +--- + +## Purpose + +`owasp-asi-mapping.md` maps SINT's enforcement checkpoints to each ASI01–ASI10 control. This addendum maps each SINT fixture vector to the **runtime integrity invariant** it exercises, using the four-layer decomposition converging in OWASP#802: + +1. **Transmission integrity** — authorization decisions, tokens, and evidence survive the wire without silent mutation +2. **Authorization integrity at execution** — `request_authorized == request_executed` at the moment the tool call is made +3. **Execution integrity** — the runtime environment performing the action matches what was authorized +4. **Intent integrity** *(upper layer — advisory, not runtime-enforceable in SINT's deterministic layer)* + +The distinction matters for conformance claims: implementations should claim coverage against a specific invariant, not the broader OWASP category. A goal-hijack detector (intent layer) is not the same conformance guarantee as a monotonic-delegation check (authorization layer). + +--- + +## Invariant → Fixture map + +### Layer 1 — Transmission integrity + +SINT's contribution: capability tokens are Ed25519-signed over RFC 8785 canonical JSON (recursive key sort, `signature` field stripped, `undefined` omitted, `null` preserved — see [ADR on canonical signing payload](https://github.com/sint-ai/sint-protocol/blob/main/docs/specs/sint-protocol-v1.0.md#411-canonical-signing-payload)). This means a token that leaves one service and arrives at another via any lossy transport (HTTP JSON body, Postgres JSONB, message queue) verifies byte-identically on both ends. + +| Fixture vector | Invariant it exercises | +|----------------|------------------------| +| `ASI03-attack-expired-token` | Transmission preserves `expiresAt`; expired tokens rejected regardless of transport | +| `ASI03-attack-subject-mismatch` | Transmission preserves `subject`; subject-bound tokens cannot be replayed by a different agent | +| `ASI03-safe-valid-token-correct-subject` | Round-trip signature verification succeeds after normal transport | + +**Not yet covered by fixture pack:** explicit JSONB-round-trip vectors (tokens with populated `modelConstraints`, `attestationRequirements`, `delegationChain`). Tracked in [#175](https://github.com/sint-ai/sint-protocol/issues/175). + +--- + +### Layer 2 — Authorization integrity at execution + +This is SINT's primary enforcement layer. `PolicyGateway.intercept()` re-validates the token on every tool call — synchronous, in the critical path, no LLM in the enforcement loop. The invariant: the authorization decision that arrives at the gateway is the decision that gets executed, with no widening, no drift, no async-dispatch divergence. + +| Fixture vector | Invariant it exercises | +|----------------|------------------------| +| `ASI02-attack-scope-mismatch-exec-with-filesystem-token` | Resource glob re-validated at execution; token for `mcp://filesystem/*` cannot authorize `mcp://exec/run` | +| `ASI02-attack-wrong-action-on-resource` | Action re-validated at execution; `call` token cannot authorize `subscribe` | +| `ASI02-safe-matching-scope-and-action` | Authorized scope is executed scope | +| `ASI07-attack-delegation-depth-exceeded` | Delegation chain is monotonically narrowing; depth cap prevents chain-based widening | +| `ASI07-safe-valid-delegation-chain` | Attenuated chain executes at the narrowed scope, not the parent scope | +| `ASI08-attack-rate-limit-exhaustion` | Rate-limit constraint on the token is enforced per call, not at session start | +| `ASI09-attack-revoked-token-bypass` | Revocation is checked pre-tier-assignment on every call | +| `ASI09-attack-t2-escalation-no-human` | T3 action requires explicit human approval; no path to auto-allow | +| `ASI09-safe-t1-auto-allow` | T1 scope is correctly bounded; oversight applies only where authorized | +| `ASI05-attack-exec-resource-escalates` | `mcp://exec/*` → `T3_COMMIT` regardless of token scope — authorization tier cannot be bypassed by presentation | + +**Core invariant claim:** for any fixture above, the authorized payload (token scope) and the executed payload (gateway decision input) are the same bytes. SINT does not re-serialize, re-parse, or reinterpret between validation and enforcement. + +--- + +### Layer 3 — Execution integrity + +The runtime environment actually performing the action matches what was authorized. Includes model-identity binding, execution-history-aware decisions, and memory-state integrity checks. + +| Fixture vector | Invariant it exercises | +|----------------|------------------------| +| `ASI04-attack-model-fingerprint-mismatch` | Model weights running at execution match the `modelFingerprintHash` in the token; substitution detected | +| `ASI04-attack-model-id-not-allowlisted` | Model ID allowlist enforced; unknown model instances rejected at execution | +| `ASI04-safe-fingerprint-match` | Authorized model fingerprint == executing model fingerprint | +| `ASI05-attack-write-then-exec-forbidden-combo` | Execution history is part of the authorization input; write→exec sequences force escalation | +| `ASI05-safe-read-no-forbidden-combo` | Clean history allows execution at nominal tier | +| `ASI06-attack-memory-privilege-claim` | Injected privilege claims in execution history rejected pre-decision | +| `ASI06-attack-history-repetition-anomaly` | Anomalous execution patterns flagged | +| `ASI06-safe-clean-history` | Normal execution context passes | +| `ASI08-attack-circuit-breaker-tripped` | Operator-tripped circuit halts execution regardless of token validity | +| `ASI10-attack-circuit-breaker-auto-trip-on-denials` | Auto-trip on consecutive denials halts rogue execution | +| `ASI10-attack-manual-operator-stop-button` | Manual stop (EU AI Act Art. 14(4)(e)) halts execution with no auto-recovery | +| `ASI10-safe-circuit-closed-normal-operation` | Circuit CLOSED → normal execution proceeds | + +--- + +### Layer 4 — Intent integrity (upper layer, advisory) + +Goal hijack detection, prompt injection defense, persona-anomaly scoring. These operate on natural-language content and emit heuristic signals. **SINT treats these as advisory signals, not deterministic authorization decisions** — they can deny at high confidence but they are not in the cryptographic enforcement path. + +This separation is load-bearing: the cryptographic layer below (layers 1–2) must remain deterministic and LLM-free to be conformance-testable. Intent-layer heuristics evolve; cryptographic invariants do not. + +| Fixture vector | Invariant it exercises | Enforcement class | +|----------------|------------------------|--------------------| +| `ASI01-attack-prompt-injection-ignore-previous` | Regex-layer detection of `ignore previous instructions` pattern family | Advisory → deny at confidence ≥ 0.6 | +| `ASI01-attack-role-override-you-are-now` | Role-override pattern family | Advisory → deny at confidence ≥ 0.6 | +| `ASI01-safe-clean-read-request` | No false-positive on clean params | Advisory → allow | +| `ASI05-attack-arg-injection` | Shell metacharacter + dangerous command keyword combination | Advisory → deny at high severity | + +**Conformance claim caveat:** any implementation claiming "ASI01 coverage" must specify whether the claim is cryptographic (deterministic) or heuristic (advisory). SINT's claim is heuristic with fail-open semantics — plugin errors do not block requests. Implementations that overclaim intent integrity as runtime-enforceable at the deterministic layer create a false sense of conformance. + +--- + +## Invariant-level conformance summary + +| Integrity invariant | SINT coverage class | Fixture vector count | +|---------------------|---------------------|----------------------| +| **Transmission integrity** | Ed25519 + RFC 8785 JCS (deterministic) | 3 | +| **Authorization integrity at execution** | Gateway re-validation (deterministic) | 10 | +| **Execution integrity** | Model fingerprint + history + circuit breaker (deterministic) | 12 | +| **Intent integrity** | Goal-hijack + arg-injection detectors (advisory, fail-open) | 4 | + +29 of 30 fixture vectors map cleanly onto invariants 1–3 (deterministic enforcement). The 4 intent-layer vectors are explicitly scoped as advisory to prevent conformance overclaiming. + +--- + +## Recommended use + +For implementers claiming ASI conformance via SINT fixtures: + +1. Claim against an **invariant**, not just a control ID. "`ASI02 coverage`" is ambiguous; "`ASI02 via authorization-integrity-at-execution, fixtures X/Y/Z`" is testable. +2. For the intent layer, state explicitly whether the coverage is **deterministic or advisory**. SINT's intent-layer coverage is advisory with fail-open semantics. +3. Transmission-integrity claims must include the specific serialization guarantee. "JCS with recursive key sort, `undefined` omitted, `null` preserved" is testable; "canonical JSON" is not. + +--- + +## Open items + +- **Transmission-integrity fixtures gap:** no current vectors for JSONB-round-trip-with-all-optional-fields-populated. Closing via [#175](https://github.com/sint-ai/sint-protocol/issues/175). +- **Intent-layer calibration data:** cross-reference with [HeadyZhang/agent-audit#5](https://github.com/HeadyZhang/agent-audit/issues/5) production defense rates (6% unicode, 4% indirect-injection) to anchor advisory-layer claims in observed data rather than synthetic fixtures. +- **Layer 1 crypto coverage expansion:** explicit fixtures for `undefined`/`null` interaction in `delegationChain.parentTokenId` (followup from #168 review). + +--- + +*Companion to `docs/conformance/owasp-asi-mapping.md`. Feedback via OWASP#802 or sint-ai/sint-protocol issues.* diff --git a/packages/degraded-connectivity/package.json b/packages/degraded-connectivity/package.json new file mode 100644 index 0000000..00de7c2 --- /dev/null +++ b/packages/degraded-connectivity/package.json @@ -0,0 +1,18 @@ +{ + "name": "@pshkv/degraded-connectivity", + "version": "0.3.0", + "type": "module", + "description": "Degraded-connectivity mode: offline T0/T1 autonomy with local buffer replay", + "main": "src/index.ts", + "scripts": { + "test": "vitest run" + }, + "dependencies": { + "@pshkv/core": "workspace:*", + "@pshkv/gate-policy-gateway": "workspace:*", + "@pshkv/world-model-provenance": "workspace:*" + }, + "devDependencies": { + "vitest": "^3.2.4" + } +} diff --git a/packages/degraded-connectivity/src/__tests__/connectivity-monitor.test.ts b/packages/degraded-connectivity/src/__tests__/connectivity-monitor.test.ts new file mode 100644 index 0000000..fd5381b --- /dev/null +++ b/packages/degraded-connectivity/src/__tests__/connectivity-monitor.test.ts @@ -0,0 +1,183 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { ConnectivityMonitor } from '../fallback/connectivity-monitor'; + +describe('ConnectivityMonitor', () => { + let monitor: ConnectivityMonitor; + + beforeEach(() => { + monitor = new ConnectivityMonitor(); + }); + + it('initializes as online', () => { + expect(monitor.getStatus()).toBe('online'); + expect(monitor.isOnline()).toBe(true); + }); + + it('records successful ping', () => { + monitor.recordPing(100, true); + + expect(monitor.isOnline()).toBe(true); + const metrics = monitor.getMetrics(); + expect(metrics.averageLatencyMs).toBe(100); + }); + + it('transitions to degraded on high latency', () => { + monitor.recordPing(6000, true); // Exceeds 5s threshold + + expect(monitor.isDegraded()).toBe(true); + }); + + it('transitions to offline on repeated failures', () => { + monitor.recordPing(100, false); + expect(monitor.getStatus()).toBe('degraded'); + + monitor.recordPing(100, false); + expect(monitor.getStatus()).toBe('degraded'); + + monitor.recordPing(100, false); // 3rd failure + expect(monitor.isOffline()).toBe(true); + }); + + it('recovers to online from offline', () => { + monitor.recordPing(100, false); + monitor.recordPing(100, false); + monitor.recordPing(100, false); + expect(monitor.isOffline()).toBe(true); + + monitor.recordPing(100, true); // Recovery + expect(monitor.isOnline()).toBe(true); + }); + + it('allows T0 always', () => { + monitor.recordPing(100, false); + monitor.recordPing(100, false); + monitor.recordPing(100, false); + + expect(monitor.canExecuteT0()).toBe(true); // Even when offline + }); + + it('allows T1 when online or degraded', () => { + expect(monitor.canExecuteT1()).toBe(true); // Online + + monitor.recordPing(6000, true); // Degraded + expect(monitor.canExecuteT1()).toBe(true); + + monitor.recordPing(100, false); + monitor.recordPing(100, false); + monitor.recordPing(100, false); // Offline + expect(monitor.canExecuteT1()).toBe(false); + }); + + it('tracks latency history', () => { + monitor.recordPing(100, true); + monitor.recordPing(200, true); + monitor.recordPing(150, true); + + const metrics = monitor.getMetrics(); + expect(metrics.averageLatencyMs).toBeCloseTo(150); // (100 + 200 + 150) / 3 + }); + + it('tracks downtime duration', () => { + monitor.recordPing(100, false); + monitor.recordPing(100, false); + monitor.recordPing(100, false); + + const metrics = monitor.getMetrics(); + expect(metrics.downtimeDurationMs).toBeGreaterThanOrEqual(0); + }); + + it('notifies listeners of status changes', () => { + const events: string[] = []; + + monitor.subscribe((event) => { + events.push(event.status); + }); + + monitor.recordPing(100, false); + monitor.recordPing(100, false); + monitor.recordPing(100, false); + + expect(events).toContain('degraded'); + expect(events).toContain('offline'); + }); + + it('unsubscribes listeners', () => { + const events: string[] = []; + + const listener = (event: any) => { + events.push(event.status); + }; + + monitor.subscribe(listener); + monitor.recordPing(100, false); + monitor.recordPing(100, false); + monitor.recordPing(100, false); + + const eventCount = events.length; + + monitor.unsubscribe(listener); + monitor.recordPing(100, true); // Back online + + expect(events.length).toBe(eventCount); // No new events + }); + + it('sets custom max latency', () => { + monitor.setMaxLatency(1000); + monitor.recordPing(800, true); + + expect(monitor.isOnline()).toBe(true); + + monitor.recordPing(1100, true); + expect(monitor.isDegraded()).toBe(true); + }); + + it('sets custom max failures threshold', () => { + monitor.setMaxFailures(2); + + monitor.recordPing(100, false); + expect(monitor.isDegraded()).toBe(true); + + monitor.recordPing(100, false); // 2nd failure triggers offline + expect(monitor.isOffline()).toBe(true); + }); + + it('resets state', () => { + monitor.recordPing(100, false); + monitor.recordPing(100, false); + monitor.recordPing(100, false); + + expect(monitor.isOffline()).toBe(true); + + monitor.reset(); + + expect(monitor.isOnline()).toBe(true); + expect(monitor.getAverageLatency()).toBe(0); + }); + + it('calculates average latency', () => { + monitor.recordPing(100, true); + monitor.recordPing(200, true); + monitor.recordPing(300, true); + + expect(monitor.getAverageLatency()).toBeCloseTo(200); + }); + + it('provides connectivity metrics', () => { + monitor.recordPing(150, true); + + const metrics = monitor.getMetrics(); + + expect(metrics.currentStatus).toBe('online'); + expect(metrics.averageLatencyMs).toBe(150); + expect(metrics.packetLossPercent).toBe(0); + }); + + it('resets ping counter on success', () => { + monitor.recordPing(100, false); // Failure 1 + monitor.recordPing(100, false); // Failure 2 + monitor.recordPing(100, true); // Success, resets counter + + monitor.recordPing(100, false); // Failure 1 again + expect(monitor.isDegraded()).toBe(true); // Only 1 failure + }); +}); diff --git a/packages/degraded-connectivity/src/__tests__/local-action-buffer.test.ts b/packages/degraded-connectivity/src/__tests__/local-action-buffer.test.ts new file mode 100644 index 0000000..15ec130 --- /dev/null +++ b/packages/degraded-connectivity/src/__tests__/local-action-buffer.test.ts @@ -0,0 +1,194 @@ +import { describe, it, expect } from 'vitest'; +import { LocalActionBuffer } from '../buffer/local-action-buffer'; + +describe('LocalActionBuffer', () => { + it('records T0 action', () => { + const buffer = new LocalActionBuffer(); + + const action = buffer.recordAction( + 'T0', + 'Read sensor', + true, + { sensorId: 1, value: 25 }, + true, + ); + + expect(action.id).toBeDefined(); + expect(action.tier).toBe('T0'); + expect(action.executedLocally).toBe(true); + expect(action.replayed).toBe(false); + }); + + it('records T1 action', () => { + const buffer = new LocalActionBuffer(); + + const action = buffer.recordAction( + 'T1', + 'Write config', + false, + null, + undefined, + ); + + expect(action.tier).toBe('T1'); + expect(action.executedLocally).toBe(false); + }); + + it('retrieves action by ID', () => { + const buffer = new LocalActionBuffer(); + + const action1 = buffer.recordAction('T0', 'Read 1', true, {}, true); + const action2 = buffer.recordAction('T1', 'Write 1', false, null, false); + + const retrieved = buffer.getActionById(action1.id); + expect(retrieved?.id).toBe(action1.id); + expect(retrieved?.tier).toBe('T0'); + }); + + it('marks action as replayed', () => { + const buffer = new LocalActionBuffer(); + + const action = buffer.recordAction('T0', 'Read', true, {}, true); + + const marked = buffer.markReplayed(action.id); + expect(marked).toBe(true); + expect(action.replayed).toBe(true); + }); + + it('gets pending actions', () => { + const buffer = new LocalActionBuffer(); + + buffer.recordAction('T0', 'Read 1', true, {}, true); + buffer.recordAction('T1', 'Write 1', false, null, false); + const action3 = buffer.recordAction('T0', 'Read 2', true, {}, true); + + buffer.markReplayed(action3.id); + + const pending = buffer.getPendingActions(); + expect(pending).toHaveLength(2); + }); + + it('filters by tier', () => { + const buffer = new LocalActionBuffer(); + + buffer.recordAction('T0', 'Read 1', true, {}, true); + buffer.recordAction('T0', 'Read 2', true, {}, true); + buffer.recordAction('T1', 'Write 1', false, null, false); + + const t0Actions = buffer.getTier0Actions(); + const t1Actions = buffer.getTier1Actions(); + + expect(t0Actions).toHaveLength(2); + expect(t1Actions).toHaveLength(1); + }); + + it('gets pending by tier', () => { + const buffer = new LocalActionBuffer(); + + const t0Action = buffer.recordAction('T0', 'Read', true, {}, true); + const t1Action = buffer.recordAction('T1', 'Write', false, null, false); + + buffer.markReplayed(t0Action.id); + + const pendingT0 = buffer.getPendingActionsByTier('T0'); + const pendingT1 = buffer.getPendingActionsByTier('T1'); + + expect(pendingT0).toHaveLength(0); + expect(pendingT1).toHaveLength(1); + }); + + it('calculates buffer stats', () => { + const buffer = new LocalActionBuffer(); + + buffer.recordAction('T0', 'Read 1', true, { val: 1 }, true); + buffer.recordAction('T0', 'Read 2', true, { val: 2 }, false); + buffer.recordAction('T1', 'Write 1', false, null, true); + + const stats = buffer.getStats(); + + expect(stats.totalActions).toBe(3); + expect(stats.succeededLocally).toBe(2); + expect(stats.failedLocally).toBe(1); + }); + + it('detects full buffer', () => { + const buffer = new LocalActionBuffer(); + buffer.setMaxActions(2); + + buffer.recordAction('T0', 'Read 1', true, {}, true); + buffer.recordAction('T0', 'Read 2', true, {}, true); + + expect(buffer.isBufferFull()).toBe(true); + }); + + it('auto-removes oldest on overflow', () => { + const buffer = new LocalActionBuffer(); + buffer.setMaxActions(2); + + const action1 = buffer.recordAction('T0', 'Read 1', true, {}, true); + buffer.recordAction('T0', 'Read 2', true, {}, true); + buffer.recordAction('T0', 'Read 3', true, {}, true); // Triggers removal + + const all = buffer.getAllActions(); + expect(all).toHaveLength(2); + expect(all.find((a) => a.id === action1.id)).toBeUndefined(); + }); + + it('clears replayed actions', () => { + const buffer = new LocalActionBuffer(); + + const action1 = buffer.recordAction('T0', 'Read 1', true, {}, true); + const action2 = buffer.recordAction('T1', 'Write 1', false, null, false); + + buffer.markReplayed(action1.id); + buffer.clearReplayed(); + + const all = buffer.getAllActions(); + expect(all).toHaveLength(1); + expect(all[0]!.id).toBe(action2.id); + }); + + it('clears all actions', () => { + const buffer = new LocalActionBuffer(); + + buffer.recordAction('T0', 'Read 1', true, {}, true); + buffer.recordAction('T1', 'Write 1', false, null, false); + + buffer.clear(); + + expect(buffer.getStats().totalActions).toBe(0); + }); + + it('sets custom buffer size limit', () => { + const buffer = new LocalActionBuffer(); + buffer.setMaxBufferSize(1000); + + const action = buffer.recordAction('T0', 'Read', true, { data: 'x'.repeat(500) }, true); + expect(action.id).toBeDefined(); + }); + + it('stores action results', () => { + const buffer = new LocalActionBuffer(); + + const result = { position: { x: 10, y: 20 }, status: 'ok' }; + const action = buffer.recordAction( + 'T0', + 'Get position', + true, + result, + true, + ); + + expect(action.result).toEqual(result); + }); + + it('tracks execution status', () => { + const buffer = new LocalActionBuffer(); + + const localAction = buffer.recordAction('T0', 'Read local', true, {}, true); + const remoteAction = buffer.recordAction('T1', 'Write remote', false, null, false); + + expect(localAction.executedLocally).toBe(true); + expect(remoteAction.executedLocally).toBe(false); + }); +}); diff --git a/packages/degraded-connectivity/src/__tests__/offline-autonomy.test.ts b/packages/degraded-connectivity/src/__tests__/offline-autonomy.test.ts new file mode 100644 index 0000000..d8abbca --- /dev/null +++ b/packages/degraded-connectivity/src/__tests__/offline-autonomy.test.ts @@ -0,0 +1,271 @@ +import { describe, it, expect } from 'vitest'; +import { OfflineAutonomyManager } from '../fallback/offline-autonomy'; + +describe('OfflineAutonomyManager', () => { + it('registers cached policy', () => { + const manager = new OfflineAutonomyManager(); + + const policy = manager.registerCachedPolicy( + 'policy-1', + 'T0', + ['read', 'sensor'], + 3600000, + ); + + expect(policy.policyId).toBe('policy-1'); + expect(policy.tier).toBe('T0'); + expect(policy.isValid).toBe(true); + }); + + it('allows T0 offline by default', () => { + const manager = new OfflineAutonomyManager(); + + const action = manager.makeOfflineDecision( + 'action-1', + 'T0', + 'Read sensor', + ); + + expect(action.decision).toBe('EXECUTE'); + expect(action.rationale).toContain('read-only'); + }); + + it('buffers T1 action with matching policy', () => { + const manager = new OfflineAutonomyManager(); + + manager.registerCachedPolicy( + 'policy-1', + 'T1', + ['write', 'config'], + 3600000, + ); + + const action = manager.makeOfflineDecision( + 'action-1', + 'T1', + 'Write config', + { type: 'write', target: 'config' }, + ); + + expect(action.decision).toBe('BUFFER'); + expect(action.policyApplied).toBe('policy-1'); + }); + + it('denies T1 without policy', () => { + const manager = new OfflineAutonomyManager(); + + const action = manager.makeOfflineDecision( + 'action-1', + 'T1', + 'Write config', + ); + + expect(action.decision).toBe('DENY'); + expect(action.rationale).toContain('No matching'); + }); + + it('disables T0 offline', () => { + const manager = new OfflineAutonomyManager(); + manager.allowT0Offline(false); + + const action = manager.makeOfflineDecision( + 'action-1', + 'T0', + 'Read sensor', + ); + + expect(action.decision).toBe('DENY'); + expect(action.rationale).toContain('disabled'); + }); + + it('disables T1 offline', () => { + const manager = new OfflineAutonomyManager(); + manager.allowT1Offline(false); + + manager.registerCachedPolicy( + 'policy-1', + 'T1', + ['write'], + 3600000, + ); + + const action = manager.makeOfflineDecision( + 'action-1', + 'T1', + 'Write config', + ); + + expect(action.decision).toBe('DENY'); + expect(action.rationale).toContain('disabled'); + }); + + it('checks T0/T1 offline status', () => { + const manager = new OfflineAutonomyManager(); + + expect(manager.isT0AllowedOffline()).toBe(true); + expect(manager.isT1AllowedOffline()).toBe(true); + + manager.allowT0Offline(false); + expect(manager.isT0AllowedOffline()).toBe(false); + }); + + it('gets policies by tier', () => { + const manager = new OfflineAutonomyManager(); + + manager.registerCachedPolicy('p1', 'T0', ['read'], 3600000); + manager.registerCachedPolicy('p2', 'T0', ['observe'], 3600000); + manager.registerCachedPolicy('p3', 'T1', ['write'], 3600000); + + const t0Policies = manager.getPoliciesForTier('T0'); + const t1Policies = manager.getPoliciesForTier('T1'); + + expect(t0Policies).toHaveLength(2); + expect(t1Policies).toHaveLength(1); + }); + + it('stores offline action history', () => { + const manager = new OfflineAutonomyManager(); + + manager.registerCachedPolicy('p1', 'T0', [], 3600000); + manager.registerCachedPolicy('p2', 'T1', [], 3600000); + + manager.makeOfflineDecision('a1', 'T0', 'Read'); + manager.makeOfflineDecision('a2', 'T1', 'Write'); + + const all = manager.getOfflineActionHistory(); + expect(all).toHaveLength(2); + }); + + it('filters action history by tier', () => { + const manager = new OfflineAutonomyManager(); + + manager.registerCachedPolicy('p1', 'T0', [], 3600000); + manager.registerCachedPolicy('p2', 'T1', [], 3600000); + + manager.makeOfflineDecision('a1', 'T0', 'Read'); + manager.makeOfflineDecision('a2', 'T0', 'Read'); + manager.makeOfflineDecision('a3', 'T1', 'Write'); + + const t0History = manager.getOfflineActionHistory('T0'); + const t1History = manager.getOfflineActionHistory('T1'); + + expect(t0History).toHaveLength(2); + expect(t1History).toHaveLength(1); + }); + + it('invalidates policy', () => { + const manager = new OfflineAutonomyManager(); + + manager.registerCachedPolicy('p1', 'T0', [], 3600000); + + const result = manager.invalidatePolicy('p1'); + expect(result).toBe(true); + + const policies = manager.getPoliciesForTier('T0'); + expect(policies).toHaveLength(0); + }); + + it('invalidates all policies for tier', () => { + const manager = new OfflineAutonomyManager(); + + manager.registerCachedPolicy('p1', 'T1', [], 3600000); + manager.registerCachedPolicy('p2', 'T1', [], 3600000); + manager.registerCachedPolicy('p3', 'T0', [], 3600000); + + const removed = manager.invalidatePoliciesForTier('T1'); + expect(removed).toBe(2); + + const t1Policies = manager.getPoliciesForTier('T1'); + expect(t1Policies).toHaveLength(0); + + const t0Policies = manager.getPoliciesForTier('T0'); + expect(t0Policies).toHaveLength(1); + }); + + it('counts valid policies', () => { + const manager = new OfflineAutonomyManager(); + + manager.registerCachedPolicy('p1', 'T0', [], 3600000); + manager.registerCachedPolicy('p2', 'T1', [], 3600000); + manager.registerCachedPolicy('p3', 'T0', [], 3600000); + + expect(manager.getValidPolicyCount()).toBe(3); + + manager.invalidatePolicy('p1'); + expect(manager.getValidPolicyCount()).toBe(2); + }); + + it('clears expired policies', () => { + const manager = new OfflineAutonomyManager(); + + // Create a policy and then manually expire it + manager.registerCachedPolicy('p1', 'T0', [], 3600000); + manager.invalidatePolicy('p1'); // Mark as invalid + manager.registerCachedPolicy('p2', 'T0', [], 3600000); + + const validCount = manager.getValidPolicyCount(); + expect(validCount).toBe(1); + }); + + it('sets max execution time', () => { + const manager = new OfflineAutonomyManager(); + + manager.setMaxExecutionTime(7200000); // 2 hours + + expect(manager.getMaxExecutionTime()).toBe(7200000); + }); + + it('checks if action can execute offline', () => { + const manager = new OfflineAutonomyManager(); + + expect(manager.canExecuteOfflineAction('T0')).toBe(true); + expect(manager.canExecuteOfflineAction('T1')).toBe(true); + + manager.allowT0Offline(false); + expect(manager.canExecuteOfflineAction('T0')).toBe(false); + }); + + it('matches policies by conditions', () => { + const manager = new OfflineAutonomyManager(); + + manager.registerCachedPolicy( + 'p1', + 'T1', + ['write', 'safety'], + 3600000, + ); + + // Matching conditions + const action1 = manager.makeOfflineDecision( + 'a1', + 'T1', + 'Write safety', + { type: 'write', category: 'safety' }, + ); + expect(action1.decision).toBe('BUFFER'); + + // Non-matching conditions + const action2 = manager.makeOfflineDecision( + 'a2', + 'T1', + 'Write other', + { type: 'write', category: 'other' }, + ); + expect(action2.decision).toBe('DENY'); + }); + + it('returns first valid policy without conditions', () => { + const manager = new OfflineAutonomyManager(); + + manager.registerCachedPolicy('p1', 'T1', ['write'], 3600000); + + const action = manager.makeOfflineDecision( + 'a1', + 'T1', + 'Write', + ); + + expect(action.decision).toBe('BUFFER'); + expect(action.policyApplied).toBe('p1'); + }); +}); diff --git a/packages/degraded-connectivity/src/buffer/local-action-buffer.ts b/packages/degraded-connectivity/src/buffer/local-action-buffer.ts new file mode 100644 index 0000000..cacabef --- /dev/null +++ b/packages/degraded-connectivity/src/buffer/local-action-buffer.ts @@ -0,0 +1,122 @@ +export type ActionTier = 'T0' | 'T1'; + +export interface BufferedAction { + id: string; + tier: ActionTier; + description: string; + timestamp: Date; + result?: unknown; + success?: boolean; + executedLocally: boolean; + replayed: boolean; +} + +export interface BufferStats { + totalActions: number; + pendingReplay: number; + succeededLocally: number; + failedLocally: number; + bufferedSize: number; // bytes +} + +export class LocalActionBuffer { + private buffer: BufferedAction[] = []; + private maxBufferSize = 10 * 1024 * 1024; // 10MB default + private maxActions = 1000; // Max 1000 actions + + recordAction( + tier: ActionTier, + description: string, + executedLocally: boolean, + result?: unknown, + success?: boolean, + ): BufferedAction { + if (this.buffer.length >= this.maxActions) { + // Remove oldest action + this.buffer.shift(); + } + + const action: BufferedAction = { + id: `action-${Date.now()}-${Math.random().toString(36).slice(2)}`, + tier, + description, + timestamp: new Date(), + result, + success, + executedLocally, + replayed: false, + }; + + this.buffer.push(action); + return action; + } + + markReplayed(actionId: string): boolean { + const action = this.buffer.find((a) => a.id === actionId); + if (!action) return false; + + action.replayed = true; + return true; + } + + getPendingActions(): BufferedAction[] { + return this.buffer.filter((a) => !a.replayed); + } + + getPendingActionsByTier(tier: ActionTier): BufferedAction[] { + return this.buffer.filter((a) => !a.replayed && a.tier === tier); + } + + getActionById(actionId: string): BufferedAction | undefined { + return this.buffer.find((a) => a.id === actionId); + } + + getAllActions(): BufferedAction[] { + return [...this.buffer]; + } + + clearReplayed(): void { + this.buffer = this.buffer.filter((a) => !a.replayed); + } + + getStats(): BufferStats { + const pending = this.buffer.filter((a) => !a.replayed); + const succeeded = this.buffer.filter((a) => a.success === true).length; + const failed = this.buffer.filter((a) => a.success === false).length; + + const bufferedSize = JSON.stringify(this.buffer).length; + + return { + totalActions: this.buffer.length, + pendingReplay: pending.length, + succeededLocally: succeeded, + failedLocally: failed, + bufferedSize, + }; + } + + isBufferFull(): boolean { + const size = JSON.stringify(this.buffer).length; + return size >= this.maxBufferSize || this.buffer.length >= this.maxActions; + } + + setMaxBufferSize(sizeBytes: number): void { + this.maxBufferSize = sizeBytes; + } + + setMaxActions(count: number): void { + this.maxActions = count; + } + + clear(): void { + this.buffer = []; + } + + getTier0Actions(): BufferedAction[] { + return this.buffer.filter((a) => a.tier === 'T0'); + } + + getTier1Actions(): BufferedAction[] { + return this.buffer.filter((a) => a.tier === 'T1'); + } +} diff --git a/packages/degraded-connectivity/src/fallback/connectivity-monitor.ts b/packages/degraded-connectivity/src/fallback/connectivity-monitor.ts new file mode 100644 index 0000000..22295e1 --- /dev/null +++ b/packages/degraded-connectivity/src/fallback/connectivity-monitor.ts @@ -0,0 +1,172 @@ +export type ConnectivityStatus = 'online' | 'degraded' | 'offline'; + +export interface ConnectivityEvent { + status: ConnectivityStatus; + timestamp: Date; + latencyMs?: number; + errorMessage?: string; +} + +export interface ConnectivityMetrics { + currentStatus: ConnectivityStatus; + lastSuccessfulPingMs: Date; + averageLatencyMs: number; + packetLossPercent: number; + downtimeDurationMs: number; +} + +export class ConnectivityMonitor { + private status: ConnectivityStatus = 'online'; + private lastPingMs = Date.now(); + private downtimeStartMs: number | null = null; + private latencyHistory: number[] = []; + private pingFailureCount = 0; + private maxLatencyMs = 5000; // 5s threshold for degraded + private maxFailures = 3; // 3 consecutive failures = offline + + private listeners: ((event: ConnectivityEvent) => void)[] = []; + + recordPing(latencyMs: number, success: boolean): void { + const now = Date.now(); + + if (success) { + this.latencyHistory.push(latencyMs); + if (this.latencyHistory.length > 100) { + this.latencyHistory.shift(); + } + + this.pingFailureCount = 0; + this.lastPingMs = now; + + // Transition from offline/degraded to online + if (this.status !== 'online') { + const wasOffline = this.status === 'offline'; + this.setStatus('online'); + + if (wasOffline && this.downtimeStartMs) { + const downtimeDurationMs = now - this.downtimeStartMs; + this._notifyListeners({ + status: 'online', + timestamp: new Date(), + latencyMs, + }); + this.downtimeStartMs = null; + } + } else if (latencyMs > this.maxLatencyMs) { + this.setStatus('degraded'); + } + } else { + this.pingFailureCount++; + + if (this.pingFailureCount >= this.maxFailures) { + this.setStatus('offline'); + } else if (this.status === 'online') { + this.setStatus('degraded'); + } + } + } + + getStatus(): ConnectivityStatus { + return this.status; + } + + getMetrics(): ConnectivityMetrics { + const avgLatency = + this.latencyHistory.length > 0 + ? this.latencyHistory.reduce((a, b) => a + b, 0) / + this.latencyHistory.length + : 0; + + const downtime = + this.downtimeStartMs !== null + ? Date.now() - this.downtimeStartMs + : 0; + + return { + currentStatus: this.status, + lastSuccessfulPingMs: new Date(this.lastPingMs), + averageLatencyMs: avgLatency, + packetLossPercent: this.pingFailureCount / this.maxFailures * 100, + downtimeDurationMs: downtime, + }; + } + + isOnline(): boolean { + return this.status === 'online'; + } + + isDegraded(): boolean { + return this.status === 'degraded'; + } + + isOffline(): boolean { + return this.status === 'offline'; + } + + canExecuteT0(): boolean { + // T0 can always execute (read-only) + return true; + } + + canExecuteT1(): boolean { + // T1 can execute if online or degraded (with buffering) + return this.status !== 'offline'; + } + + setMaxLatency(ms: number): void { + this.maxLatencyMs = ms; + } + + setMaxFailures(count: number): void { + this.maxFailures = count; + } + + subscribe(listener: (event: ConnectivityEvent) => void): void { + this.listeners.push(listener); + } + + unsubscribe(listener: (event: ConnectivityEvent) => void): void { + this.listeners = this.listeners.filter((l) => l !== listener); + } + + private setStatus(newStatus: ConnectivityStatus): void { + if (newStatus === this.status) return; + + const oldStatus = this.status; + this.status = newStatus; + + if (newStatus === 'offline' && !this.downtimeStartMs) { + this.downtimeStartMs = Date.now(); + } + + this._notifyListeners({ + status: newStatus, + timestamp: new Date(), + errorMessage: newStatus === 'offline' + ? `Connectivity lost after ${this.pingFailureCount} failures` + : undefined, + }); + } + + private _notifyListeners(event: ConnectivityEvent): void { + for (const listener of this.listeners) { + listener(event); + } + } + + reset(): void { + this.status = 'online'; + this.lastPingMs = Date.now(); + this.downtimeStartMs = null; + this.latencyHistory = []; + this.pingFailureCount = 0; + } + + getAverageLatency(): number { + if (this.latencyHistory.length === 0) return 0; + return ( + this.latencyHistory.reduce((a, b) => a + b, 0) / + this.latencyHistory.length + ); + } +} diff --git a/packages/degraded-connectivity/src/fallback/offline-autonomy.ts b/packages/degraded-connectivity/src/fallback/offline-autonomy.ts new file mode 100644 index 0000000..f6bd061 --- /dev/null +++ b/packages/degraded-connectivity/src/fallback/offline-autonomy.ts @@ -0,0 +1,226 @@ +export type OfflineDecision = 'EXECUTE' | 'BUFFER' | 'DENY'; + +export interface CachedPolicy { + policyId: string; + tier: 'T0' | 'T1'; + conditions: string[]; + lastUpdatedMs: Date; + expiryMs: Date; + isValid: boolean; +} + +export interface OfflineAction { + actionId: string; + tier: 'T0' | 'T1'; + description: string; + decision: OfflineDecision; + rationale: string; + timestamp: Date; + policyApplied?: string; +} + +export class OfflineAutonomyManager { + private cachedPolicies = new Map(); + private offlineActions: OfflineAction[] = []; + private t0ExecutionAllowed = true; + private t1ExecutionAllowed = true; + private maxExecutionTime = 3600000; // 1 hour offline max + + registerCachedPolicy( + policyId: string, + tier: 'T0' | 'T1', + conditions: string[], + expiryMs: number, + ): CachedPolicy { + const now = new Date(); + const expiresAt = new Date(now.getTime() + expiryMs); + + const policy: CachedPolicy = { + policyId, + tier, + conditions, + lastUpdatedMs: now, + expiryMs: expiresAt, + isValid: true, + }; + + this.cachedPolicies.set(policyId, policy); + return policy; + } + + makeOfflineDecision( + actionId: string, + tier: 'T0' | 'T1', + description: string, + conditions?: Record, + ): OfflineAction { + let decision: OfflineDecision = 'DENY'; + let rationale = 'No matching policy found'; + let appliedPolicy: string | undefined; + + if (tier === 'T0') { + if (this.t0ExecutionAllowed) { + decision = 'EXECUTE'; + rationale = 'T0 (read-only) allowed offline'; + } else { + rationale = 'T0 disabled in offline mode'; + } + } else if (tier === 'T1') { + if (!this.t1ExecutionAllowed) { + rationale = 'T1 disabled in offline mode'; + } else { + // Check cached policies + const matchingPolicy = this._findMatchingPolicy(tier, conditions); + if (matchingPolicy) { + decision = 'BUFFER'; + rationale = `Buffered for replay using policy ${matchingPolicy.policyId}`; + appliedPolicy = matchingPolicy.policyId; + } else { + rationale = 'No matching cached policy for T1 action'; + } + } + } + + const action: OfflineAction = { + actionId, + tier, + description, + decision, + rationale, + timestamp: new Date(), + policyApplied: appliedPolicy, + }; + + this.offlineActions.push(action); + return action; + } + + allowT0Offline(allow: boolean): void { + this.t0ExecutionAllowed = allow; + } + + allowT1Offline(allow: boolean): void { + this.t1ExecutionAllowed = allow; + } + + isT0AllowedOffline(): boolean { + return this.t0ExecutionAllowed; + } + + isT1AllowedOffline(): boolean { + return this.t1ExecutionAllowed; + } + + getPoliciesForTier(tier: 'T0' | 'T1'): CachedPolicy[] { + const now = new Date(); + const result: CachedPolicy[] = []; + + for (const policy of this.cachedPolicies.values()) { + if ( + policy.tier === tier && + policy.isValid && + policy.expiryMs > now + ) { + result.push(policy); + } + } + + return result; + } + + getOfflineActionHistory(tier?: 'T0' | 'T1'): OfflineAction[] { + if (!tier) { + return [...this.offlineActions]; + } + return this.offlineActions.filter((a) => a.tier === tier); + } + + invalidatePolicy(policyId: string): boolean { + const policy = this.cachedPolicies.get(policyId); + if (!policy) return false; + + policy.isValid = false; + return true; + } + + invalidatePoliciesForTier(tier: 'T0' | 'T1'): number { + let count = 0; + for (const policy of this.cachedPolicies.values()) { + if (policy.tier === tier) { + policy.isValid = false; + count++; + } + } + return count; + } + + getValidPolicyCount(): number { + const now = new Date(); + let count = 0; + for (const policy of this.cachedPolicies.values()) { + if (policy.isValid && policy.expiryMs > now) { + count++; + } + } + return count; + } + + canExecuteOfflineAction(tier: 'T0' | 'T1'): boolean { + return tier === 'T0' ? this.t0ExecutionAllowed : this.t1ExecutionAllowed; + } + + setMaxExecutionTime(ms: number): void { + this.maxExecutionTime = ms; + } + + getMaxExecutionTime(): number { + return this.maxExecutionTime; + } + + private _findMatchingPolicy( + tier: 'T0' | 'T1', + conditions?: Record, + ): CachedPolicy | undefined { + const now = new Date(); + + for (const policy of this.cachedPolicies.values()) { + if ( + policy.tier !== tier || + !policy.isValid || + policy.expiryMs <= now + ) { + continue; + } + + if (!conditions) { + return policy; // Return first valid policy + } + + // Check if conditions match (simple substring matching) + const conditionStr = JSON.stringify(conditions); + const allMatch = policy.conditions.every((cond) => + conditionStr.includes(cond), + ); + + if (allMatch) { + return policy; + } + } + + return undefined; + } + + clearExpiredPolicies(): number { + const now = new Date(); + let removed = 0; + + for (const [key, policy] of this.cachedPolicies.entries()) { + if (policy.expiryMs <= now) { + this.cachedPolicies.delete(key); + removed++; + } + } + + return removed; + } +} diff --git a/packages/degraded-connectivity/src/index.ts b/packages/degraded-connectivity/src/index.ts new file mode 100644 index 0000000..5cf3687 --- /dev/null +++ b/packages/degraded-connectivity/src/index.ts @@ -0,0 +1,20 @@ +export { + LocalActionBuffer, + type ActionTier, + type BufferedAction, + type BufferStats, +} from './buffer/local-action-buffer'; + +export { + ConnectivityMonitor, + type ConnectivityStatus, + type ConnectivityEvent, + type ConnectivityMetrics, +} from './fallback/connectivity-monitor'; + +export { + OfflineAutonomyManager, + type OfflineDecision, + type CachedPolicy, + type OfflineAction, +} from './fallback/offline-autonomy'; diff --git a/packages/degraded-connectivity/tsconfig.json b/packages/degraded-connectivity/tsconfig.json new file mode 100644 index 0000000..61e922e --- /dev/null +++ b/packages/degraded-connectivity/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist" + }, + "include": ["src"], + "exclude": ["dist", "node_modules", "**/*.test.ts"] +} diff --git a/packages/delegated-capability-chains/package.json b/packages/delegated-capability-chains/package.json new file mode 100644 index 0000000..abede35 --- /dev/null +++ b/packages/delegated-capability-chains/package.json @@ -0,0 +1,17 @@ +{ + "name": "@pshkv/delegated-capability-chains", + "version": "0.3.0", + "type": "module", + "description": "Delegated-capability chains with handoff receipts and quorum rules for multi-agent coordination", + "main": "src/index.ts", + "scripts": { + "test": "vitest run" + }, + "dependencies": { + "@pshkv/core": "workspace:*", + "@pshkv/gate-capability-tokens": "workspace:*" + }, + "devDependencies": { + "vitest": "^3.2.4" + } +} diff --git a/packages/delegated-capability-chains/src/__tests__/delegation-chain.test.ts b/packages/delegated-capability-chains/src/__tests__/delegation-chain.test.ts new file mode 100644 index 0000000..edcda89 --- /dev/null +++ b/packages/delegated-capability-chains/src/__tests__/delegation-chain.test.ts @@ -0,0 +1,290 @@ +import { describe, it, expect } from 'vitest'; +import { CapabilityDelegationChain, type CapabilityScope } from '../delegation/delegation-chain'; + +describe('CapabilityDelegationChain', () => { + const defaultScope: CapabilityScope = { + actions: ['read', 'write', 'execute'], + resources: ['resource-1', 'resource-2'], + maxDelegationDepth: 3, + timeoutMs: 3600000, + }; + + it('delegates capability to another agent', () => { + const chain = new CapabilityDelegationChain(); + + const delegation = chain.delegate( + 'orig-cap-1', + 'agent-1', + 'agent-2', + defaultScope, + 3600000, + ); + + expect(delegation.id).toBeDefined(); + expect(delegation.delegatedTo).toBe('agent-2'); + expect(delegation.delegationDepth).toBe(1); + }); + + it('attenuates capability scope on delegation', () => { + const chain = new CapabilityDelegationChain(); + + const parent = chain.delegate( + 'orig-cap-1', + 'agent-1', + 'agent-2', + defaultScope, + 3600000, + ); + + const attenuated: CapabilityScope = { + actions: ['read'], // Subset of parent + resources: ['resource-1'], + maxDelegationDepth: 2, + timeoutMs: 3600000, + }; + + const child = chain.delegate( + 'orig-cap-1', + 'agent-2', + 'agent-3', + attenuated, + 3600000, + parent, + ); + + expect(child.delegationDepth).toBe(2); + expect(child.scope.actions).toHaveLength(1); + }); + + it('prevents over-delegation beyond max depth', () => { + const chain = new CapabilityDelegationChain(); + + const scope: CapabilityScope = { + ...defaultScope, + maxDelegationDepth: 1, + }; + + const parent = chain.delegate('orig-1', 'a1', 'a2', scope, 3600000); + + expect(() => { + chain.delegate('orig-1', 'a2', 'a3', defaultScope, 3600000, parent); + }).toThrow('max depth'); + }); + + it('prevents delegating with expanded scope', () => { + const chain = new CapabilityDelegationChain(); + + const parent = chain.delegate('orig-1', 'a1', 'a2', defaultScope, 3600000); + + const expanded: CapabilityScope = { + actions: ['read', 'write', 'execute', 'delete'], // More than parent + resources: defaultScope.resources, + maxDelegationDepth: 3, + timeoutMs: 3600000, + }; + + expect(() => { + chain.delegate('orig-1', 'a2', 'a3', expanded, 3600000, parent); + }).toThrow('scope must be subset'); + }); + + it('records handoff receipt', () => { + const chain = new CapabilityDelegationChain(); + + const delegation = chain.delegate( + 'orig-1', + 'agent-1', + 'agent-2', + defaultScope, + 3600000, + ); + + const receipt = chain.recordHandoff( + delegation.id, + 'agent-1', + 'agent-2', + 'read-resource', + { value: 42 }, + ); + + expect(receipt.receiptId).toBeDefined(); + expect(receipt.fromAgent).toBe('agent-1'); + }); + + it('tracks handoff history', () => { + const chain = new CapabilityDelegationChain(); + + const delegation = chain.delegate( + 'orig-1', + 'agent-1', + 'agent-2', + defaultScope, + 3600000, + ); + + chain.recordHandoff(delegation.id, 'a1', 'a2', 'action-1'); + chain.recordHandoff(delegation.id, 'a2', 'a3', 'action-2'); + + const history = chain.getHandoffHistory(delegation.id); + expect(history).toHaveLength(2); + }); + + it('verifies capability validity', () => { + const chain = new CapabilityDelegationChain(); + + const delegation = chain.delegate( + 'orig-1', + 'a1', + 'a2', + defaultScope, + 3600000, + ); + + expect(chain.verifyCapability(delegation.id)).toBe(true); + }); + + it('revokes capability', () => { + const chain = new CapabilityDelegationChain(); + + const delegation = chain.delegate( + 'orig-1', + 'a1', + 'a2', + defaultScope, + 3600000, + ); + + const result = chain.revokeCapability(delegation.id); + expect(result).toBe(true); + expect(chain.verifyCapability(delegation.id)).toBe(false); + }); + + it('retrieves delegation by ID', () => { + const chain = new CapabilityDelegationChain(); + + const delegation = chain.delegate( + 'orig-1', + 'a1', + 'a2', + defaultScope, + 3600000, + ); + + const retrieved = chain.getDelegation(delegation.id); + expect(retrieved?.id).toBe(delegation.id); + expect(retrieved?.delegatedTo).toBe('a2'); + }); + + it('lists all delegations', () => { + const chain = new CapabilityDelegationChain(); + + chain.delegate('orig-1', 'a1', 'a2', defaultScope, 3600000); + chain.delegate('orig-2', 'a1', 'a3', defaultScope, 3600000); + chain.delegate('orig-3', 'a2', 'a4', defaultScope, 3600000); + + const all = chain.getAllDelegations(); + expect(all).toHaveLength(3); + }); + + it('returns chain hash', () => { + const chain = new CapabilityDelegationChain(); + + chain.delegate('orig-1', 'a1', 'a2', defaultScope, 3600000); + + const hash = chain.getChainHash(); + expect(hash).toBeDefined(); + expect(hash).not.toBe('0'); + }); + + it('verifies chain integrity', () => { + const chain = new CapabilityDelegationChain(); + + chain.delegate('orig-1', 'a1', 'a2', defaultScope, 3600000); + chain.delegate('orig-2', 'a2', 'a3', defaultScope, 3600000); + + expect(chain.verifyChainIntegrity()).toBe(true); + }); + + it('attenuates capability with subset scope', () => { + const chain = new CapabilityDelegationChain(); + + const original = chain.delegate( + 'orig-1', + 'a1', + 'a2', + defaultScope, + 3600000, + ); + + const reduced: CapabilityScope = { + actions: ['read'], + resources: ['resource-1'], + maxDelegationDepth: 2, + timeoutMs: 3600000, + }; + + const attenuated = chain.attenuateCapability(original.id, reduced); + + expect(attenuated).not.toBeNull(); + expect(attenuated!.scope.actions).toHaveLength(1); + expect(attenuated!.delegationDepth).toBe(2); + }); + + it('prevents attenuation with expanded scope', () => { + const chain = new CapabilityDelegationChain(); + + const scope: CapabilityScope = { + actions: ['read'], + resources: ['resource-1'], + maxDelegationDepth: 3, + timeoutMs: 3600000, + }; + + const original = chain.delegate('orig-1', 'a1', 'a2', scope, 3600000); + + const expanded: CapabilityScope = { + actions: ['read', 'write'], + resources: ['resource-1'], + maxDelegationDepth: 3, + timeoutMs: 3600000, + }; + + expect(() => { + chain.attenuateCapability(original.id, expanded); + }).toThrow('subset'); + }); + + it('lists all handoffs', () => { + const chain = new CapabilityDelegationChain(); + + const d1 = chain.delegate('orig-1', 'a1', 'a2', defaultScope, 3600000); + const d2 = chain.delegate('orig-2', 'a1', 'a3', defaultScope, 3600000); + + chain.recordHandoff(d1.id, 'a1', 'a2', 'act-1'); + chain.recordHandoff(d2.id, 'a1', 'a3', 'act-2'); + + const all = chain.getAllHandoffs(); + expect(all).toHaveLength(2); + }); + + it('tracks delegation depth correctly', () => { + const chain = new CapabilityDelegationChain(); + + const d1 = chain.delegate('orig', 'a1', 'a2', defaultScope, 3600000); + const d2 = chain.delegate('orig', 'a2', 'a3', defaultScope, 3600000, d1); + const d3 = chain.delegate('orig', 'a3', 'a4', defaultScope, 3600000, d2); + + expect(d1.delegationDepth).toBe(1); + expect(d2.delegationDepth).toBe(2); + expect(d3.delegationDepth).toBe(3); + }); + + it('maintains consistent scope hashing', () => { + const chain = new CapabilityDelegationChain(); + + const d1 = chain.delegate('orig-1', 'a1', 'a2', defaultScope, 3600000); + const d2 = chain.delegate('orig-2', 'a1', 'a2', defaultScope, 3600000); + + expect(d1.scopeHash).toBe(d2.scopeHash); // Same scope = same hash + }); +}); diff --git a/packages/delegated-capability-chains/src/__tests__/quorum-manager.test.ts b/packages/delegated-capability-chains/src/__tests__/quorum-manager.test.ts new file mode 100644 index 0000000..a8d6241 --- /dev/null +++ b/packages/delegated-capability-chains/src/__tests__/quorum-manager.test.ts @@ -0,0 +1,282 @@ +import { describe, it, expect } from 'vitest'; +import { QuorumManager, type QuorumRule } from '../quorum/quorum-manager'; + +describe('QuorumManager', () => { + const defaultRule: QuorumRule = { + minAgents: 3, + diversityRequired: false, + timeoutMs: 3600000, + }; + + it('creates quorum request', () => { + const manager = new QuorumManager(); + + const request = manager.createQuorumRequest('cap-1', defaultRule); + + expect(request.requestId).toBeDefined(); + expect(request.capabilityId).toBe('cap-1'); + expect(request.decision).toBe('PENDING'); + }); + + it('casts vote on quorum', () => { + const manager = new QuorumManager(); + + const request = manager.createQuorumRequest('cap-1', defaultRule); + + const result = manager.castVote( + request.requestId, + 'agent-1', + 'validator', + 'yes', + 'Approved', + ); + + expect(result).toBe(true); + expect(manager.getVotes(request.requestId)).toHaveLength(1); + }); + + it('rejects with any no vote', () => { + const manager = new QuorumManager(); + + const request = manager.createQuorumRequest('cap-1', defaultRule); + + manager.castVote(request.requestId, 'a1', 'validator', 'yes'); + manager.castVote(request.requestId, 'a2', 'validator', 'yes'); + manager.castVote(request.requestId, 'a3', 'validator', 'no'); + + expect(manager.getQuorumStatus(request.requestId)).toBe('REJECTED'); + }); + + it('approves with unanimous yes votes', () => { + const manager = new QuorumManager(); + + const request = manager.createQuorumRequest('cap-1', defaultRule); + + manager.castVote(request.requestId, 'a1', 'validator', 'yes'); + manager.castVote(request.requestId, 'a2', 'validator', 'yes'); + manager.castVote(request.requestId, 'a3', 'validator', 'yes'); + + expect(manager.getQuorumStatus(request.requestId)).toBe('APPROVED'); + }); + + it('allows abstain votes', () => { + const manager = new QuorumManager(); + + const request = manager.createQuorumRequest('cap-1', defaultRule); + + manager.castVote(request.requestId, 'a1', 'validator', 'yes'); + manager.castVote(request.requestId, 'a2', 'validator', 'abstain'); + manager.castVote(request.requestId, 'a3', 'validator', 'yes'); + manager.castVote(request.requestId, 'a4', 'validator', 'yes'); + + expect(manager.getQuorumStatus(request.requestId)).toBe('APPROVED'); + }); + + it('requires minimum agent count', () => { + const manager = new QuorumManager(); + + const request = manager.createQuorumRequest('cap-1', defaultRule); + + manager.castVote(request.requestId, 'a1', 'validator', 'yes'); + manager.castVote(request.requestId, 'a2', 'validator', 'yes'); + + expect(manager.getQuorumStatus(request.requestId)).toBe('PENDING'); + + manager.castVote(request.requestId, 'a3', 'validator', 'yes'); + + expect(manager.getQuorumStatus(request.requestId)).toBe('APPROVED'); + }); + + it('enforces diversity requirement', () => { + const manager = new QuorumManager(); + + const rule: QuorumRule = { + minAgents: 3, + diversityRequired: true, + diversityTypes: ['validator', 'auditor', 'observer'], + timeoutMs: 3600000, + }; + + const request = manager.createQuorumRequest('cap-1', rule); + + // Only validators and auditors + manager.castVote(request.requestId, 'a1', 'validator', 'yes'); + manager.castVote(request.requestId, 'a2', 'auditor', 'yes'); + manager.castVote(request.requestId, 'a3', 'validator', 'yes'); + + expect(manager.getQuorumStatus(request.requestId)).toBe('PENDING'); + + // Add observer + manager.castVote(request.requestId, 'a4', 'observer', 'yes'); + + expect(manager.getQuorumStatus(request.requestId)).toBe('APPROVED'); + }); + + it('calculates approval percentage', () => { + const manager = new QuorumManager(); + + const request = manager.createQuorumRequest('cap-1', { + minAgents: 2, + diversityRequired: false, + timeoutMs: 3600000, + }); + + manager.castVote(request.requestId, 'a1', 'validator', 'yes'); + manager.castVote(request.requestId, 'a2', 'validator', 'no'); + + const approval = manager.getApprovalPercentage(request.requestId); + expect(approval).toBe(50); + }); + + it('calculates rejection percentage', () => { + const manager = new QuorumManager(); + + const request = manager.createQuorumRequest('cap-1', { + minAgents: 2, + diversityRequired: false, + timeoutMs: 3600000, + }); + + manager.castVote(request.requestId, 'a1', 'validator', 'yes'); + manager.castVote(request.requestId, 'a2', 'validator', 'no'); + // Third vote is rejected since decision already made + + const rejection = manager.getRejectionPercentage(request.requestId); + expect(rejection).toBe(50); // 1 no out of 2 total votes + }); + + it('prevents votes on non-existent request', () => { + const manager = new QuorumManager(); + + const result = manager.castVote('fake-id', 'a1', 'validator', 'yes'); + expect(result).toBe(false); + }); + + it('prevents votes after decision', () => { + const manager = new QuorumManager(); + + const request = manager.createQuorumRequest('cap-1', defaultRule); + + manager.castVote(request.requestId, 'a1', 'validator', 'yes'); + manager.castVote(request.requestId, 'a2', 'validator', 'yes'); + manager.castVote(request.requestId, 'a3', 'validator', 'no'); + + // Should already be rejected + const result = manager.castVote( + request.requestId, + 'a4', + 'validator', + 'yes', + ); + expect(result).toBe(false); + }); + + it('lists all requests', () => { + const manager = new QuorumManager(); + + manager.createQuorumRequest('cap-1', defaultRule); + manager.createQuorumRequest('cap-2', defaultRule); + manager.createQuorumRequest('cap-3', defaultRule); + + const all = manager.getAllRequests(); + expect(all).toHaveLength(3); + }); + + it('filters pending requests', () => { + const manager = new QuorumManager(); + + const r1 = manager.createQuorumRequest('cap-1', defaultRule); + const r2 = manager.createQuorumRequest('cap-2', defaultRule); + + // Complete r1 with rejection + manager.castVote(r1.requestId, 'a1', 'validator', 'yes'); + manager.castVote(r1.requestId, 'a2', 'validator', 'yes'); + manager.castVote(r1.requestId, 'a3', 'validator', 'no'); + + const pending = manager.getPendingRequests(); + expect(pending).toHaveLength(1); + expect(pending[0]!.capabilityId).toBe('cap-2'); + }); + + it('retrieves request by ID', () => { + const manager = new QuorumManager(); + + const request = manager.createQuorumRequest('cap-1', defaultRule); + + const retrieved = manager.getRequest(request.requestId); + expect(retrieved?.capabilityId).toBe('cap-1'); + }); + + it('calculates diversity score', () => { + const manager = new QuorumManager(); + + const request = manager.createQuorumRequest('cap-1', defaultRule); + + manager.castVote(request.requestId, 'a1', 'validator', 'yes'); + manager.castVote(request.requestId, 'a2', 'auditor', 'yes'); + manager.castVote(request.requestId, 'a3', 'observer', 'yes'); + + const diversity = manager.getDiversityScore(request.requestId); + expect(diversity).toBe(3); + }); + + it('stores vote rationale', () => { + const manager = new QuorumManager(); + + const request = manager.createQuorumRequest('cap-1', defaultRule); + + manager.castVote( + request.requestId, + 'a1', + 'validator', + 'yes', + 'Passed security check', + ); + + const votes = manager.getVotes(request.requestId); + expect(votes[0]!.rationale).toBe('Passed security check'); + }); + + it('can execute capability when approved', () => { + const manager = new QuorumManager(); + + const request = manager.createQuorumRequest('cap-1', defaultRule); + + manager.castVote(request.requestId, 'a1', 'validator', 'yes'); + manager.castVote(request.requestId, 'a2', 'validator', 'yes'); + manager.castVote(request.requestId, 'a3', 'validator', 'yes'); + + expect(manager.canExecuteCapability(request.requestId)).toBe(true); + }); + + it('cannot execute when rejected', () => { + const manager = new QuorumManager(); + + const request = manager.createQuorumRequest('cap-1', defaultRule); + + manager.castVote(request.requestId, 'a1', 'validator', 'no'); + + expect(manager.canExecuteCapability(request.requestId)).toBe(false); + }); + + it('cannot execute when pending', () => { + const manager = new QuorumManager(); + + const request = manager.createQuorumRequest('cap-1', defaultRule); + + manager.castVote(request.requestId, 'a1', 'validator', 'yes'); + + expect(manager.canExecuteCapability(request.requestId)).toBe(false); + }); + + it('records vote timestamps', () => { + const manager = new QuorumManager(); + + const request = manager.createQuorumRequest('cap-1', defaultRule); + + manager.castVote(request.requestId, 'a1', 'validator', 'yes'); + + const votes = manager.getVotes(request.requestId); + expect(votes[0]!.timestamp).toBeInstanceOf(Date); + }); +}); diff --git a/packages/delegated-capability-chains/src/delegation/delegation-chain.ts b/packages/delegated-capability-chains/src/delegation/delegation-chain.ts new file mode 100644 index 0000000..2db2ac1 --- /dev/null +++ b/packages/delegated-capability-chains/src/delegation/delegation-chain.ts @@ -0,0 +1,227 @@ +import { createHash } from 'crypto'; + +export type CapabilityScope = { + actions: string[]; + resources: string[]; + maxDelegationDepth: number; + timeoutMs: number; +}; + +export interface DelegatedCapability { + id: string; + originalCapId: string; + delegatedFrom: string; + delegatedTo: string; + scope: CapabilityScope; + createdAt: Date; + expiresAt: Date; + delegationDepth: number; + scopeHash: string; + isValid: boolean; +} + +export interface HandoffReceipt { + receiptId: string; + capabilityId: string; + fromAgent: string; + toAgent: string; + timestamp: Date; + executedAction?: string; + result?: unknown; + scopeVerified: boolean; +} + +export class CapabilityDelegationChain { + private delegations = new Map(); + private handoffReceipts = new Map(); + private chain: string[] = []; // Hash chain of delegations + + delegate( + originalCapId: string, + delegatedFrom: string, + delegatedTo: string, + scope: CapabilityScope, + expirationMs: number, + parentDelegation?: DelegatedCapability, + ): DelegatedCapability { + let delegationDepth = 1; + + if (parentDelegation) { + if (parentDelegation.delegationDepth >= parentDelegation.scope.maxDelegationDepth) { + throw new Error( + `Cannot delegate: max depth ${parentDelegation.scope.maxDelegationDepth} reached`, + ); + } + delegationDepth = parentDelegation.delegationDepth + 1; + } + + // Scope must be subset of parent scope + if (parentDelegation) { + if (!this._isScopeSubset(scope, parentDelegation.scope)) { + throw new Error('Delegated scope must be subset of parent scope'); + } + } + + const now = new Date(); + const expiresAt = new Date(now.getTime() + expirationMs); + const scopeHash = this._hashScope(scope); + + const delegation: DelegatedCapability = { + id: `deleg-${Date.now()}-${Math.random().toString(36).slice(2)}`, + originalCapId, + delegatedFrom, + delegatedTo, + scope, + createdAt: now, + expiresAt, + delegationDepth, + scopeHash, + isValid: true, + }; + + this.delegations.set(delegation.id, delegation); + this.chain.push(scopeHash); + + return delegation; + } + + recordHandoff( + capabilityId: string, + fromAgent: string, + toAgent: string, + executedAction?: string, + result?: unknown, + ): HandoffReceipt { + const delegation = this.delegations.get(capabilityId); + if (!delegation) { + throw new Error(`Capability ${capabilityId} not found`); + } + + const receipt: HandoffReceipt = { + receiptId: `receipt-${Date.now()}-${Math.random().toString(36).slice(2)}`, + capabilityId, + fromAgent, + toAgent, + timestamp: new Date(), + executedAction, + result, + scopeVerified: true, + }; + + this.handoffReceipts.set(receipt.receiptId, receipt); + return receipt; + } + + getHandoffHistory(capabilityId: string): HandoffReceipt[] { + const results: HandoffReceipt[] = []; + for (const receipt of this.handoffReceipts.values()) { + if (receipt.capabilityId === capabilityId) { + results.push(receipt); + } + } + return results; + } + + getAllHandoffs(): HandoffReceipt[] { + return Array.from(this.handoffReceipts.values()); + } + + verifyCapability(capabilityId: string): boolean { + const delegation = this.delegations.get(capabilityId); + if (!delegation) return false; + + const now = new Date(); + if (delegation.expiresAt < now) { + delegation.isValid = false; + return false; + } + + return delegation.isValid; + } + + attenuateCapability( + capabilityId: string, + newScope: CapabilityScope, + ): DelegatedCapability | null { + const original = this.delegations.get(capabilityId); + if (!original) return null; + + if (!this._isScopeSubset(newScope, original.scope)) { + throw new Error('New scope must be subset of original scope'); + } + + return this.delegate( + original.originalCapId, + original.delegatedFrom, + original.delegatedTo, + newScope, + original.expiresAt.getTime() - Date.now(), + original, + ); + } + + revokeCapability(capabilityId: string): boolean { + const delegation = this.delegations.get(capabilityId); + if (!delegation) return false; + + delegation.isValid = false; + return true; + } + + getDelegation(capabilityId: string): DelegatedCapability | undefined { + return this.delegations.get(capabilityId); + } + + getAllDelegations(): DelegatedCapability[] { + return Array.from(this.delegations.values()); + } + + getChainHash(): string { + if (this.chain.length === 0) return '0'; + return createHash('sha256') + .update(this.chain.join(':')) + .digest('hex'); + } + + verifyChainIntegrity(): boolean { + // Verify all delegations are in valid order + for (const delegation of this.delegations.values()) { + if (!delegation.isValid) continue; + + const now = new Date(); + if (delegation.expiresAt < now) { + delegation.isValid = false; + return false; + } + } + return true; + } + + private _isScopeSubset( + child: CapabilityScope, + parent: CapabilityScope, + ): boolean { + // All actions in child must be in parent + const childActionsInParent = child.actions.every((a) => + parent.actions.includes(a), + ); + + // All resources in child must be in parent + const childResourcesInParent = child.resources.every((r) => + parent.resources.includes(r), + ); + + return childActionsInParent && childResourcesInParent; + } + + private _hashScope(scope: CapabilityScope): string { + const data = JSON.stringify({ + actions: scope.actions.sort(), + resources: scope.resources.sort(), + maxDelegationDepth: scope.maxDelegationDepth, + timeoutMs: scope.timeoutMs, + }); + + return createHash('sha256').update(data).digest('hex'); + } +} diff --git a/packages/delegated-capability-chains/src/index.ts b/packages/delegated-capability-chains/src/index.ts new file mode 100644 index 0000000..034b498 --- /dev/null +++ b/packages/delegated-capability-chains/src/index.ts @@ -0,0 +1,14 @@ +export { + CapabilityDelegationChain, + type DelegatedCapability, + type HandoffReceipt, + type CapabilityScope, +} from './delegation/delegation-chain'; + +export { + QuorumManager, + type QuorumDecision, + type QuorumRule, + type AgentVote, + type QuorumRequest, +} from './quorum/quorum-manager'; diff --git a/packages/delegated-capability-chains/src/quorum/quorum-manager.ts b/packages/delegated-capability-chains/src/quorum/quorum-manager.ts new file mode 100644 index 0000000..08b9645 --- /dev/null +++ b/packages/delegated-capability-chains/src/quorum/quorum-manager.ts @@ -0,0 +1,194 @@ +export type QuorumDecision = 'APPROVED' | 'REJECTED' | 'PENDING'; + +export interface QuorumRule { + minAgents: number; + diversityRequired: boolean; + diversityTypes?: string[]; + timeoutMs: number; +} + +export interface AgentVote { + agentId: string; + agentType: string; + capabilityId: string; + vote: 'yes' | 'no' | 'abstain'; + timestamp: Date; + rationale?: string; +} + +export interface QuorumRequest { + requestId: string; + capabilityId: string; + requiredAgents: number; + rule: QuorumRule; + createdAt: Date; + expiresAt: Date; + votes: AgentVote[]; + decision: QuorumDecision; + decidedAt?: Date; +} + +export class QuorumManager { + private requests = new Map(); + + createQuorumRequest( + capabilityId: string, + rule: QuorumRule, + ): QuorumRequest { + const now = new Date(); + const expiresAt = new Date(now.getTime() + rule.timeoutMs); + + const request: QuorumRequest = { + requestId: `quorum-${Date.now()}-${Math.random().toString(36).slice(2)}`, + capabilityId, + requiredAgents: rule.minAgents, + rule, + createdAt: now, + expiresAt, + votes: [], + decision: 'PENDING', + }; + + this.requests.set(request.requestId, request); + return request; + } + + castVote( + requestId: string, + agentId: string, + agentType: string, + vote: 'yes' | 'no' | 'abstain', + rationale?: string, + ): boolean { + const request = this.requests.get(requestId); + if (!request) return false; + + if (request.decision !== 'PENDING') { + return false; // Quorum already decided + } + + const now = new Date(); + if (request.expiresAt < now) { + request.decision = 'REJECTED'; + request.decidedAt = now; + return false; // Quorum expired + } + + const voteRecord: AgentVote = { + agentId, + agentType, + capabilityId: request.capabilityId, + vote, + timestamp: now, + rationale, + }; + + request.votes.push(voteRecord); + + // Check if quorum is reached + this._evaluateQuorum(request); + + return true; + } + + getQuorumStatus(requestId: string): QuorumDecision { + const request = this.requests.get(requestId); + return request ? request.decision : 'REJECTED'; + } + + getVotes(requestId: string): AgentVote[] { + const request = this.requests.get(requestId); + return request ? request.votes : []; + } + + getApprovalPercentage(requestId: string): number { + const request = this.requests.get(requestId); + if (!request || request.votes.length === 0) return 0; + + const approvals = request.votes.filter((v) => v.vote === 'yes').length; + return (approvals / request.votes.length) * 100; + } + + getRejectionPercentage(requestId: string): number { + const request = this.requests.get(requestId); + if (!request || request.votes.length === 0) return 0; + + const rejections = request.votes.filter((v) => v.vote === 'no').length; + return (rejections / request.votes.length) * 100; + } + + canExecuteCapability(requestId: string): boolean { + const request = this.requests.get(requestId); + return request ? request.decision === 'APPROVED' : false; + } + + getAllRequests(): QuorumRequest[] { + return Array.from(this.requests.values()); + } + + getPendingRequests(): QuorumRequest[] { + return Array.from(this.requests.values()).filter( + (r) => r.decision === 'PENDING', + ); + } + + getRequest(requestId: string): QuorumRequest | undefined { + return this.requests.get(requestId); + } + + getDiversityScore(requestId: string): number { + const request = this.requests.get(requestId); + if (!request || request.votes.length === 0) return 0; + + const types = new Set(request.votes.map((v) => v.agentType)); + return types.size; + } + + private _evaluateQuorum(request: QuorumRequest): void { + const now = new Date(); + + // Check if expired + if (request.expiresAt < now && request.decision === 'PENDING') { + request.decision = 'REJECTED'; + request.decidedAt = now; + return; + } + + const yesVotes = request.votes.filter((v) => v.vote === 'yes').length; + const noVotes = request.votes.filter((v) => v.vote === 'no').length; + + // Check if minimum required votes reached + const totalVotes = request.votes.length; + if (totalVotes < request.requiredAgents) { + return; // Still waiting for more votes + } + + // Check diversity if required + if (request.rule.diversityRequired && request.rule.diversityTypes) { + const agentTypes = new Set(request.votes.map((v) => v.agentType)); + const requiredDiversity = request.rule.diversityTypes.length; + + let diversityMet = 0; + for (const type of request.rule.diversityTypes) { + if (agentTypes.has(type)) { + diversityMet++; + } + } + + if (diversityMet < requiredDiversity) { + return; // Diversity not met, keep pending + } + } + + // Decide based on votes + if (noVotes > 0) { + request.decision = 'REJECTED'; + } else if (yesVotes >= request.requiredAgents) { + request.decision = 'APPROVED'; + } + + if (request.decision !== 'PENDING') { + request.decidedAt = now; + } + } +} diff --git a/packages/delegated-capability-chains/tsconfig.json b/packages/delegated-capability-chains/tsconfig.json new file mode 100644 index 0000000..61e922e --- /dev/null +++ b/packages/delegated-capability-chains/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist" + }, + "include": ["src"], + "exclude": ["dist", "node_modules", "**/*.test.ts"] +} diff --git a/packages/gate-capability-tokens/src/extensions/__tests__/accountability-token.test.ts b/packages/gate-capability-tokens/src/extensions/__tests__/accountability-token.test.ts new file mode 100644 index 0000000..0e1640c --- /dev/null +++ b/packages/gate-capability-tokens/src/extensions/__tests__/accountability-token.test.ts @@ -0,0 +1,124 @@ +import { describe, it, expect } from 'vitest'; +import { AccountabilityTokenManager } from '../accountability-token'; + +describe('AccountabilityTokenManager', () => { + const manager = new AccountabilityTokenManager(); + + it('creates accountability token', () => { + const token = manager.createToken('official-1', 'Police Chief', 'NYC'); + + expect(token.id).toBeDefined(); + expect(token.officialDID).toBe('official-1'); + expect(token.officialTitle).toBe('Police Chief'); + expect(token.jurisdiction).toBe('NYC'); + expect(token.riskScore).toBe(0); + }); + + it('records action', () => { + const token = manager.createToken('official-2', 'Judge', 'Cook County'); + manager.recordAction(token.id, 'governance', 'Case ruling', true); + + const history = manager.getActionHistory(token.id); + expect(history).toHaveLength(1); + expect(history[0]!.category).toBe('governance'); + expect(history[0]!.success).toBe(true); + }); + + it('filters actions by category', () => { + const token = manager.createToken('official-3', 'Fire Chief', 'LA'); + manager.recordAction(token.id, 'public-safety', 'Dispatch emergency', true); + manager.recordAction(token.id, 'infrastructure', 'Inspect facility', true); + manager.recordAction(token.id, 'public-safety', 'Rescue operation', true); + + const safetyActions = manager.getActionHistory(token.id, 'public-safety'); + expect(safetyActions).toHaveLength(2); + }); + + it('increases risk score for failed actions', () => { + const token = manager.createToken('official-4', 'Administrator', 'Boston'); + manager.recordAction(token.id, 'governance', 'Approved permit', true); + manager.recordAction(token.id, 'governance', 'Denied permit', false); + + const updated = manager.getToken(token.id)!; + expect(updated.riskScore).toBeGreaterThan(0); + }); + + it('increases risk score for high-risk actions', () => { + const token = manager.createToken('official-5', 'Security Director', 'DC'); + manager.recordAction(token.id, 'security', 'Approved access', true); + manager.recordAction(token.id, 'infrastructure', 'System shutdown', true); + + const updated = manager.getToken(token.id)!; + expect(updated.riskScore).toBeGreaterThan(0); + }); + + it('records judicial override', () => { + const token = manager.createToken('official-6', 'Mayor', 'Chicago'); + manager.recordJudicialOverride(token.id, 'Emergency powers', 'Judge Smith', 7); + + expect(manager.isOverridden(token.id)).toBe(true); + }); + + it('detects active overrides', () => { + const token = manager.createToken('official-7', 'Governor', 'Texas'); + manager.recordJudicialOverride(token.id, 'Disaster relief', 'Federal Judge', 30); + + expect(manager.isOverridden(token.id)).toBe(true); + }); + + it('exports public audit', () => { + const token = manager.createToken('official-8', 'City Manager', 'Seattle'); + manager.recordAction(token.id, 'governance', 'Policy change', true); + manager.recordAction(token.id, 'public-safety', 'Emergency declaration', true); + + const audit = manager.getPublicAudit(token.id); + expect(audit).toHaveLength(2); + expect(audit[0]!.category).toBe('governance'); + }); + + it('exports for transparency', () => { + const token = manager.createToken('official-9', 'Police Officer', 'Denver'); + manager.recordAction(token.id, 'security', 'Arrest', true); + manager.recordAction(token.id, 'security', 'Investigation', true); + + const export_data = manager.exportForTransparency(token.id); + expect(export_data.official).toBeDefined(); + expect(export_data.title).toBe('Police Officer'); + expect(export_data.actions).toBe(2); + }); + + it('verifies audit trail integrity', () => { + const token = manager.createToken('official-10', 'Clerk', 'Atlanta'); + manager.recordAction(token.id, 'governance', 'Record filing', true); + manager.recordAction(token.id, 'governance', 'Record update', true); + + expect(manager.verifyIntegrity(token.id)).toBe(true); + }); + + it('calculates risk score', () => { + const token = manager.createToken('official-11', 'Director', 'Miami'); + manager.recordAction(token.id, 'security', 'Authorization denied', false); + manager.recordAction(token.id, 'security', 'Investigation started', true); + + const risk = manager.calculateRiskScore(token.id); + expect(risk).toBeGreaterThan(0); + }); + + it('maintains 7 year retention policy', () => { + const token = manager.createToken('official-12', 'Auditor', 'Philadelphia'); + const now = new Date(); + const sevenYearsAgo = new Date(now.getTime() - 7 * 365 * 24 * 3600000); + + expect(token.createdAt > sevenYearsAgo).toBe(true); + }); + + it('handles multiple officials', () => { + const token1 = manager.createToken('official-13', 'Manager A', 'Zone A'); + const token2 = manager.createToken('official-14', 'Manager B', 'Zone B'); + + manager.recordAction(token1.id, 'governance', 'Action A', true); + manager.recordAction(token2.id, 'governance', 'Action B', false); + + expect(manager.getToken(token1.id)!.riskScore).toBeLessThan(manager.getToken(token2.id)!.riskScore); + }); +}); diff --git a/packages/gate-capability-tokens/src/extensions/accountability-token.ts b/packages/gate-capability-tokens/src/extensions/accountability-token.ts new file mode 100644 index 0000000..31cc796 --- /dev/null +++ b/packages/gate-capability-tokens/src/extensions/accountability-token.ts @@ -0,0 +1,187 @@ +import { createHash } from 'crypto'; + +export type ActionCategory = 'medical' | 'security' | 'public-safety' | 'infrastructure' | 'governance' | 'other'; + +export interface ActionRecord { + category: ActionCategory; + timestamp: Date; + details: string; + success: boolean; +} + +export interface JudicialOverride { + overrideId: string; + reason: string; + issuedBy: string; + issuedAt: Date; + expiresAt: Date; +} + +export interface AccountabilityToken { + id: string; + officialDID: string; + officialTitle: string; + jurisdiction: string; + createdAt: Date; + actionHistory: ActionRecord[]; + auditTrail: string[]; + judicialOverrides: JudicialOverride[]; + riskScore: number; +} + +export class AccountabilityTokenManager { + private tokens = new Map(); + private readonly RETENTION_YEARS = 7; + + createToken(officialDID: string, officialTitle: string, jurisdiction: string): AccountabilityToken { + const token: AccountabilityToken = { + id: `accountability-${Date.now()}-${Math.random().toString(36).slice(2)}`, + officialDID, + officialTitle, + jurisdiction, + createdAt: new Date(), + actionHistory: [], + auditTrail: [], + judicialOverrides: [], + riskScore: 0, + }; + + this.tokens.set(token.id, token); + return token; + } + + recordAction(tokenId: string, category: ActionCategory, details: string, success: boolean): void { + const token = this.tokens.get(tokenId); + if (!token) throw new Error(`Token ${tokenId} not found`); + + const action: ActionRecord = { + category, + timestamp: new Date(), + details, + success, + }; + + token.actionHistory.push(action); + + // Calculate risk score + const failurePoints = !success ? 5 : 0; + const highRiskPoints = ['security', 'public-safety', 'infrastructure'].includes(category) ? 3 : 0; + const overridePoints = token.judicialOverrides.filter((o) => o.expiresAt > new Date()).length * 10; + + token.riskScore += failurePoints + highRiskPoints + overridePoints; + + // Log to audit trail + const auditEntry = this.createAuditEntry(action, token); + token.auditTrail.push(auditEntry); + } + + getActionHistory(tokenId: string, category?: ActionCategory): ActionRecord[] { + const token = this.tokens.get(tokenId); + if (!token) return []; + + if (category) { + return token.actionHistory.filter((a) => a.category === category); + } + return token.actionHistory; + } + + recordJudicialOverride(tokenId: string, reason: string, issuedBy: string, durationDays: number = 30): void { + const token = this.tokens.get(tokenId); + if (!token) throw new Error(`Token ${tokenId} not found`); + + const override: JudicialOverride = { + overrideId: `override-${Date.now()}`, + reason, + issuedBy, + issuedAt: new Date(), + expiresAt: new Date(Date.now() + durationDays * 24 * 3600000), + }; + + token.judicialOverrides.push(override); + token.riskScore += 10; // Judicial overrides increase risk + } + + isOverridden(tokenId: string): boolean { + const token = this.tokens.get(tokenId); + if (!token) return false; + + return token.judicialOverrides.some((o) => o.expiresAt > new Date()); + } + + getPublicAudit(tokenId: string): Array<{ category: ActionCategory; timestamp: Date; success: boolean }> { + const token = this.tokens.get(tokenId); + if (!token) return []; + + return token.actionHistory.map((a) => ({ + category: a.category, + timestamp: a.timestamp, + success: a.success, + })); + } + + exportForTransparency(tokenId: string): { official: string; title: string; actions: number; riskScore: number } { + const token = this.tokens.get(tokenId); + if (!token) throw new Error(`Token ${tokenId} not found`); + + return { + official: this.anonymizeId(token.officialDID), + title: token.officialTitle, + actions: token.actionHistory.length, + riskScore: token.riskScore, + }; + } + + verifyIntegrity(tokenId: string): boolean { + const token = this.tokens.get(tokenId); + if (!token) return false; + + // Verify audit trail chain + for (let i = 1; i < token.auditTrail.length; i++) { + const prevHash = token.auditTrail[i - 1]!.substring(0, 16); + const currentEntry = token.auditTrail[i]!; + if (!currentEntry.includes(prevHash)) { + return false; + } + } + + return true; + } + + calculateRiskScore(tokenId: string): number { + const token = this.tokens.get(tokenId); + if (!token) return 0; + + let score = 0; + + // Factor 1: Failed actions + const failedActions = token.actionHistory.filter((a) => !a.success).length; + score += failedActions * 5; + + // Factor 2: High-risk action count + const highRiskActions = token.actionHistory.filter((a) => + ['security', 'public-safety', 'infrastructure'].includes(a.category), + ).length; + score += highRiskActions * 3; + + // Factor 3: Active judicial overrides + const activeOverrides = token.judicialOverrides.filter((o) => o.expiresAt > new Date()).length; + score += activeOverrides * 10; + + return score; + } + + private createAuditEntry(action: ActionRecord, token: AccountabilityToken): string { + const data = `${action.category}:${action.timestamp.toISOString()}:${action.success}:${token.officialDID}`; + const hash = createHash('sha256').update(data).digest('hex'); + const previousHash = token.auditTrail.length > 0 ? token.auditTrail[token.auditTrail.length - 1]!.substring(0, 16) : '0'; + return `${hash}:${previousHash}:${action.category}`; + } + + private anonymizeId(id: string): string { + return createHash('sha256').update(id).digest('hex').substring(0, 8); + } + + getToken(tokenId: string): AccountabilityToken | undefined { + return this.tokens.get(tokenId); + } +} diff --git a/packages/world-model-provenance/package.json b/packages/world-model-provenance/package.json new file mode 100644 index 0000000..41c4870 --- /dev/null +++ b/packages/world-model-provenance/package.json @@ -0,0 +1,18 @@ +{ + "name": "@pshkv/world-model-provenance", + "version": "0.3.0", + "type": "module", + "description": "World-model provenance with staleness thresholds and assumption ledger", + "main": "src/index.ts", + "scripts": { + "test": "vitest run" + }, + "dependencies": { + "@pshkv/core": "workspace:*", + "@pshkv/gate-capability-tokens": "workspace:*", + "@pshkv/gate-policy-gateway": "workspace:*" + }, + "devDependencies": { + "vitest": "^3.2.4" + } +} diff --git a/packages/world-model-provenance/src/__tests__/assumption-ledger.test.ts b/packages/world-model-provenance/src/__tests__/assumption-ledger.test.ts new file mode 100644 index 0000000..b7734bb --- /dev/null +++ b/packages/world-model-provenance/src/__tests__/assumption-ledger.test.ts @@ -0,0 +1,272 @@ +import { describe, it, expect } from 'vitest'; +import { AssumptionLedger } from '../ledger/assumption-ledger'; + +describe('AssumptionLedger', () => { + it('creates assumption with defaults', () => { + const ledger = new AssumptionLedger(); + + const assumption = ledger.createAssumption( + 'physics', + 'gravity = 9.81 m/s²', + 0.95, + 3600000, // 1 hour expiry + 'sensor-calibration', + 'world-state-1', + ); + + expect(assumption.id).toBeDefined(); + expect(assumption.category).toBe('physics'); + expect(assumption.confidence).toBe(0.95); + expect(assumption.status).toBe('active'); + }); + + it('clamps confidence to 0-1', () => { + const ledger = new AssumptionLedger(); + + const assumption = ledger.createAssumption( + 'sensors', + 'camera calibrated', + 1.5, // should clamp to 1.0 + 3600000, + 'manual', + 'world-state-1', + ); + + expect(assumption.confidence).toBe(1); + }); + + it('validates assumption and updates status', () => { + const ledger = new AssumptionLedger(); + + const assumption = ledger.createAssumption( + 'environment', + 'room temperature stable', + 0.8, + 3600000, + 'sensor-check', + 'world-state-1', + ); + + const result = ledger.validateAssumption( + assumption.id, + true, + 'hash-abc123', + 'validator-1', + ); + + expect(result).toBe(true); + + const updated = ledger.getAssumption(assumption.id); + expect(updated!.status).toBe('validated'); + }); + + it('marks assumption as invalidated', () => { + const ledger = new AssumptionLedger(); + + const assumption = ledger.createAssumption( + 'agent-state', + 'robot in safe zone', + 0.9, + 3600000, + 'position-sensor', + 'world-state-1', + ); + + ledger.validateAssumption(assumption.id, false, 'hash-xyz789', 'validator-2'); + + expect(ledger.checkAssumptionValidity(assumption.id)).toBe(false); + }); + + it('tracks assumption validity', () => { + const ledger = new AssumptionLedger(); + + const assumption = ledger.createAssumption( + 'physics', + 'friction coefficient 0.3', + 0.85, + 3600000, + 'empirical-test', + 'world-state-1', + ); + + expect(ledger.checkAssumptionValidity(assumption.id)).toBe(true); + }); + + it('filters assumptions by category', () => { + const ledger = new AssumptionLedger(); + + ledger.createAssumption( + 'physics', + 'assumption 1', + 0.9, + 3600000, + 'method-1', + 'world-state-1', + ); + ledger.createAssumption( + 'physics', + 'assumption 2', + 0.8, + 3600000, + 'method-2', + 'world-state-1', + ); + ledger.createAssumption( + 'sensors', + 'assumption 3', + 0.85, + 3600000, + 'method-3', + 'world-state-1', + ); + + const physicsAssumptions = ledger.getAssumptionsByCategory('physics'); + expect(physicsAssumptions).toHaveLength(2); + + const sensorAssumptions = ledger.getAssumptionsByCategory('sensors'); + expect(sensorAssumptions).toHaveLength(1); + }); + + it('calculates average confidence per category', () => { + const ledger = new AssumptionLedger(); + + ledger.createAssumption( + 'physics', + 'assumption 1', + 0.9, + 3600000, + 'method-1', + 'world-state-1', + ); + ledger.createAssumption( + 'physics', + 'assumption 2', + 0.8, + 3600000, + 'method-2', + 'world-state-1', + ); + + const avgConfidence = ledger.getAverageConfidence('physics'); + expect(avgConfidence).toBeCloseTo(0.85); // (0.9 + 0.8) / 2 + }); + + it('maintains hash chain integrity', () => { + const ledger = new AssumptionLedger(); + + const assumption1 = ledger.createAssumption( + 'physics', + 'assumption 1', + 0.9, + 3600000, + 'method-1', + 'world-state-1', + ); + + const assumption2 = ledger.createAssumption( + 'sensors', + 'assumption 2', + 0.85, + 3600000, + 'method-2', + 'world-state-1', + ); + + ledger.validateAssumption(assumption1.id, true, 'hash-1', 'validator-1'); + ledger.validateAssumption(assumption2.id, true, 'hash-2', 'validator-2'); + + const isValid = ledger.verifyLedgerIntegrity(); + expect(isValid).toBe(true); + }); + + it('exports ledger entries', () => { + const ledger = new AssumptionLedger(); + + const assumption = ledger.createAssumption( + 'physics', + 'test assumption', + 0.9, + 3600000, + 'test-method', + 'world-state-1', + ); + + ledger.validateAssumption(assumption.id, true, 'hash-abc', 'validator-1'); + + const exported = ledger.exportLedger(); + expect(exported.length).toBeGreaterThan(0); + expect(exported[0]!.assumption.id).toBe(assumption.id); + }); + + it('returns validation history', () => { + const ledger = new AssumptionLedger(); + + const assumption = ledger.createAssumption( + 'environment', + 'test', + 0.8, + 3600000, + 'method', + 'world-state-1', + ); + + ledger.validateAssumption(assumption.id, true, 'hash-1', 'validator-1'); + ledger.validateAssumption(assumption.id, true, 'hash-2', 'validator-1'); + + const history = ledger.getValidationHistory(assumption.id); + expect(history).toHaveLength(2); + }); + + it('counts validations per assumption', () => { + const ledger = new AssumptionLedger(); + + const assumption = ledger.createAssumption( + 'agent-state', + 'test', + 0.75, + 3600000, + 'method', + 'world-state-1', + ); + + ledger.validateAssumption(assumption.id, true, 'hash-1', 'v1'); + ledger.validateAssumption(assumption.id, true, 'hash-2', 'v2'); + ledger.validateAssumption(assumption.id, false, 'hash-3', 'v3'); + + const count = ledger.getValidationCount(assumption.id); + expect(count).toBe(3); + }); + + it('handles non-existent assumptions gracefully', () => { + const ledger = new AssumptionLedger(); + + const result = ledger.validateAssumption( + 'fake-id', + true, + 'hash', + 'validator', + ); + expect(result).toBe(false); + + const retrieved = ledger.getAssumption('fake-id'); + expect(retrieved).toBeUndefined(); + }); + + it('detects expired assumptions', () => { + const ledger = new AssumptionLedger(); + + const assumption = ledger.createAssumption( + 'physics', + 'assumption', + 0.9, + -1000, // Already expired + 'method', + 'world-state-1', + ); + + expect(ledger.checkAssumptionValidity(assumption.id)).toBe(false); + + const retrieved = ledger.getAssumption(assumption.id); + expect(retrieved!.status).toBe('expired'); + }); +}); diff --git a/packages/world-model-provenance/src/__tests__/simulator-validator.test.ts b/packages/world-model-provenance/src/__tests__/simulator-validator.test.ts new file mode 100644 index 0000000..f29603c --- /dev/null +++ b/packages/world-model-provenance/src/__tests__/simulator-validator.test.ts @@ -0,0 +1,214 @@ +import { describe, it, expect } from 'vitest'; +import { SimulatorValidator } from '../simulation/simulator-validator'; + +describe('SimulatorValidator', () => { + it('records simulation result', () => { + const validator = new SimulatorValidator(); + + const result = validator.recordSimulation( + 'physics', + { x: 10, y: 20 }, + 0.95, + 25, + ); + + expect(result.simulationId).toBeDefined(); + expect(result.type).toBe('physics'); + expect(result.confidenceScore).toBe(0.95); + expect(result.executionTimeMs).toBe(25); + }); + + it('clamps confidence to 0-1', () => { + const validator = new SimulatorValidator(); + + const result = validator.recordSimulation('physics', 100, 1.5, 10); + expect(result.confidenceScore).toBe(1); + + const result2 = validator.recordSimulation('physics', 100, -0.5, 10); + expect(result2.confidenceScore).toBe(0); + }); + + it('validates simulation with perfect match', () => { + const validator = new SimulatorValidator(); + + const sim = validator.recordSimulation( + 'physics', + { distance: 50 }, + 0.9, + 15, + ); + + const validation = validator.validateSimulation(sim.simulationId, { + distance: 50, + }); + + expect(validation!.matchScore).toBe(1); + expect(validation!.withinTolerance).toBe(true); + }); + + it('detects simulation mismatch', () => { + const validator = new SimulatorValidator(); + + const sim = validator.recordSimulation('physics', 100, 0.9, 20); + + const validation = validator.validateSimulation(sim.simulationId, 50); + + expect(validation!.matchScore).toBeLessThanOrEqual(0.5); + expect(validation!.withinTolerance).toBe(false); + }); + + it('applies tolerance thresholds', () => { + const validator = new SimulatorValidator(); + + // Physics has 5% tolerance + const sim1 = validator.recordSimulation('physics', 100, 0.9, 10); + const v1 = validator.validateSimulation(sim1.simulationId, 103); // 3% error + expect(v1!.withinTolerance).toBe(true); + + const v2 = validator.validateSimulation( + sim1.simulationId, + 107, // 7% error + ); + expect(v2!.withinTolerance).toBe(false); + }); + + it('calculates simulator reliability', () => { + const validator = new SimulatorValidator(); + + for (let i = 0; i < 5; i++) { + const sim = validator.recordSimulation('sensor-fusion', 100, 0.85, 5); + validator.validateSimulation(sim.simulationId, 100); // Perfect match + } + + const isReliable = validator.isSimulatorReliable('sensor-fusion'); + expect(isReliable).toBe(false); // Only 5 samples, need 10+ + }); + + it('tracks reliability over time', () => { + const validator = new SimulatorValidator(); + + // Record 10 successful simulations + for (let i = 0; i < 10; i++) { + const sim = validator.recordSimulation('outcome-prediction', 100, 0.85, 10); + validator.validateSimulation(sim.simulationId, 100); + } + + const calibration = validator.getCalibration('outcome-prediction'); + expect(calibration!.sampleSize).toBe(10); + expect(calibration!.successRate).toBe(1); // All perfect matches + }); + + it('enforces tier requirements for simulation usage', () => { + const validator = new SimulatorValidator(); + + const sim = validator.recordSimulation('physics', 50, 0.95, 12); + + // Simulate successful validations + for (let i = 0; i < 15; i++) { + validator.validateSimulation(sim.simulationId, 50); + } + + // T0 always works + expect(validator.canRelyOnSimulation(sim.simulationId, 'T0')).toBe(true); + + // T3 requires high success rate + expect(validator.canRelyOnSimulation(sim.simulationId, 'T3')).toBe(true); + }); + + it('requires high confidence for high tiers', () => { + const validator = new SimulatorValidator(); + + const lowConfidenceSim = validator.recordSimulation( + 'physics', + 100, + 0.5, // Low confidence + 10, + ); + + // Populate validation history + for (let i = 0; i < 15; i++) { + validator.validateSimulation(lowConfidenceSim.simulationId, 100); + } + + // Should not be reliable for T2/T3 due to low confidence + expect( + validator.canRelyOnSimulation(lowConfidenceSim.simulationId, 'T2'), + ).toBe(false); + }); + + it('retrieves validation result', () => { + const validator = new SimulatorValidator(); + + const sim = validator.recordSimulation('safety-check', 100, 0.95, 5); + const validation = validator.validateSimulation(sim.simulationId, 102); + + const retrieved = validator.getValidationResult(sim.simulationId); + expect(retrieved).toEqual(validation); + }); + + it('tracks validation history per type', () => { + const validator = new SimulatorValidator(); + + for (let i = 0; i < 5; i++) { + const sim = validator.recordSimulation('physics', i * 10, 0.9, 10); + validator.validateSimulation(sim.simulationId, i * 10); + } + + const history = validator.getValidationHistory('physics'); + expect(history).toHaveLength(5); + }); + + it('handles invalid simulation IDs gracefully', () => { + const validator = new SimulatorValidator(); + + const validation = validator.validateSimulation('fake-id', 100); + expect(validation).toBeNull(); + + const result = validator.getValidationResult('fake-id'); + expect(result).toBeUndefined(); + }); + + it('sets custom tolerance', () => { + const validator = new SimulatorValidator(); + + validator.setTolerance('physics', 0.2); // 20% tolerance + + const retrieved = validator.getTolerance('physics'); + expect(retrieved).toBe(0.2); + }); + + it('gets success rate per type', () => { + const validator = new SimulatorValidator(); + + // Record simulations with mixed success + const sim1 = validator.recordSimulation('sensor-fusion', 100, 0.85, 5); + const sim2 = validator.recordSimulation('sensor-fusion', 200, 0.85, 6); + + validator.validateSimulation(sim1.simulationId, 100); // Perfect + validator.validateSimulation(sim2.simulationId, 250); // Poor + + const rate = validator.getSuccessRate('sensor-fusion'); + expect(rate).toBeGreaterThan(0); + expect(rate).toBeLessThan(1); + }); + + it('clamps custom tolerance to 0-1', () => { + const validator = new SimulatorValidator(); + + validator.setTolerance('physics', 1.5); + expect(validator.getTolerance('physics')).toBe(1); + + validator.setTolerance('physics', -0.5); + expect(validator.getTolerance('physics')).toBe(0); + }); + + it('returns undefined calibration for uninitialized type', () => { + const validator = new SimulatorValidator(); + + const calibration = validator.getCalibration('physics'); + expect(calibration).toBeUndefined(); + + const isReliable = validator.isSimulatorReliable('physics'); + expect(isReliable).toBe(false); + }); +}); diff --git a/packages/world-model-provenance/src/__tests__/staleness-threshold.test.ts b/packages/world-model-provenance/src/__tests__/staleness-threshold.test.ts new file mode 100644 index 0000000..7f7f26b --- /dev/null +++ b/packages/world-model-provenance/src/__tests__/staleness-threshold.test.ts @@ -0,0 +1,149 @@ +import { describe, it, expect } from 'vitest'; +import { StalenessThresholdManager } from '../staleness/staleness-threshold'; + +describe('StalenessThresholdManager', () => { + it('creates world state with multiple sources', () => { + const manager = new StalenessThresholdManager(); + const now = Date.now(); + + const sources = new Map([ + ['sensor', { lastUpdateMs: now - 50 }], + ['simulator', { lastUpdateMs: now - 200 }], + ]); + + const state = manager.registerWorldState('state-1', sources); + + expect(state.stateId).toBe('state-1'); + expect(state.sources.size).toBe(2); + expect(state.isSafe).toBe(true); + expect(state.riskLevel).toBe('low'); + }); + + it('detects stale sources', () => { + const manager = new StalenessThresholdManager(); + const now = Date.now(); + + const sources = new Map([ + ['sensor', { lastUpdateMs: now - 120 }], // 120ms age, threshold 100ms = STALE, freshness ~0.4 = HIGH + ]); + + const state = manager.registerWorldState('state-2', sources); + const sensorMetric = state.sources.get('sensor'); + + expect(sensorMetric!.isStale).toBe(true); + expect(state.riskLevel).toBe('high'); + }); + + it('calculates freshness score', () => { + const manager = new StalenessThresholdManager(); + const now = Date.now(); + + const sources = new Map([ + ['sensor', { lastUpdateMs: now - 50 }], // 50% of threshold + ]); + + const state = manager.registerWorldState('state-3', sources); + const metric = state.sources.get('sensor')!; + + expect(metric.freshnessScore).toBeGreaterThan(0.5); + expect(metric.freshnessScore).toBeLessThanOrEqual(1); + }); + + it('enforces tier requirements', () => { + const manager = new StalenessThresholdManager(); + const now = Date.now(); + + const freshSources = new Map([ + ['sensor', { lastUpdateMs: now - 50 }], + ['simulator', { lastUpdateMs: now - 200 }], + ]); + + const state = manager.registerWorldState('state-4', freshSources); + + expect(manager.canActOnTier(state.stateId, 'T0')).toBe(true); + expect(manager.canActOnTier(state.stateId, 'T1')).toBe(true); + expect(manager.canActOnTier(state.stateId, 'T2')).toBe(true); + }); + + it('requires all sources fresh for T3', () => { + const manager = new StalenessThresholdManager(); + const now = Date.now(); + + const staleSources = new Map([ + ['sensor', { lastUpdateMs: now - 200 }], + ]); + + const state = manager.registerWorldState('state-5', staleSources); + + expect(manager.canActOnTier(state.stateId, 'T3')).toBe(false); + }); + + it('custom thresholds override defaults', () => { + const manager = new StalenessThresholdManager({ + sensor: 500, // Override default 100ms + }); + + const now = Date.now(); + const sources = new Map([['sensor', { lastUpdateMs: now - 300 }]]); + + const state = manager.registerWorldState('state-6', sources); + const metric = state.sources.get('sensor')!; + + expect(metric.isStale).toBe(false); // 300ms < 500ms threshold + }); + + it('tracks minimum freshness', () => { + const manager = new StalenessThresholdManager(); + const now = Date.now(); + + const sources = new Map([ + ['sensor', { lastUpdateMs: now - 90 }], + ['simulator', { lastUpdateMs: now - 800 }], + ]); + + const state = manager.registerWorldState('state-7', sources); + const minFreshness = manager.getMinimumFreshness(state.stateId); + + expect(minFreshness).toBeLessThan(0.5); // simulator is stale + }); + + it('detects critical risk', () => { + const manager = new StalenessThresholdManager(); + const now = Date.now(); + + const sources = new Map([ + ['sensor', { lastUpdateMs: now - 500 }], + ]); + + const state = manager.registerWorldState('state-8', sources); + + expect(state.riskLevel).toBe('critical'); + expect(state.isSafe).toBe(false); + }); + + it('allows T0 on any staleness', () => { + const manager = new StalenessThresholdManager(); + const now = Date.now(); + + const veryOldSources = new Map([ + ['sensor', { lastUpdateMs: now - 10000 }], + ]); + + const state = manager.registerWorldState('state-9', veryOldSources); + + expect(manager.canActOnTier(state.stateId, 'T0')).toBe(true); + }); + + it('requires sensor fresh for T1', () => { + const manager = new StalenessThresholdManager(); + const now = Date.now(); + + const sources = new Map([ + ['sensor', { lastUpdateMs: now - 200 }], + ]); + + const state = manager.registerWorldState('state-10', sources); + + expect(manager.canActOnTier(state.stateId, 'T1')).toBe(false); + }); +}); diff --git a/packages/world-model-provenance/src/index.ts b/packages/world-model-provenance/src/index.ts new file mode 100644 index 0000000..67fba7f --- /dev/null +++ b/packages/world-model-provenance/src/index.ts @@ -0,0 +1,23 @@ +export { + StalenessThresholdManager, + type StalenessMetric, + type WorldState, + type DataSource, +} from './staleness/staleness-threshold'; + +export { + AssumptionLedger, + type Assumption, + type AssumptionValidation, + type LedgerEntry, + type AssumptionCategory, + type AssumptionStatus, +} from './ledger/assumption-ledger'; + +export { + SimulatorValidator, + type SimulationResult, + type ValidationResult, + type SimulatorCalibration, + type SimulationType, +} from './simulation/simulator-validator'; diff --git a/packages/world-model-provenance/src/ledger/assumption-ledger.ts b/packages/world-model-provenance/src/ledger/assumption-ledger.ts new file mode 100644 index 0000000..86b1eaa --- /dev/null +++ b/packages/world-model-provenance/src/ledger/assumption-ledger.ts @@ -0,0 +1,190 @@ +import { createHash } from 'crypto'; + +export type AssumptionCategory = + | 'physics' + | 'sensors' + | 'actuators' + | 'environment' + | 'agent-state' + | 'human-intent'; + +export type AssumptionStatus = 'active' | 'validated' | 'invalidated' | 'expired'; + +export interface Assumption { + id: string; + category: AssumptionCategory; + description: string; + confidence: number; // 0-1 + createdAt: Date; + expiresAt: Date; + status: AssumptionStatus; + validationMethod: string; + sourceWorldState: string; +} + +export interface AssumptionValidation { + assumptionId: string; + timestamp: Date; + isValid: boolean; + evidenceHash: string; + validator: string; +} + +export interface LedgerEntry { + entryId: string; + assumption: Assumption; + validations: AssumptionValidation[]; + ledgerHash: string; + previousHash: string; +} + +export class AssumptionLedger { + private assumptions = new Map(); + private validations = new Map(); + private ledgerChain: LedgerEntry[] = []; + private lastHash = '0'; + + createAssumption( + category: AssumptionCategory, + description: string, + confidence: number, + expirationMs: number, + validationMethod: string, + sourceWorldState: string, + ): Assumption { + const id = `assumption-${Date.now()}-${Math.random().toString(36).slice(2)}`; + const now = new Date(); + const expiresAt = new Date(now.getTime() + expirationMs); + + const assumption: Assumption = { + id, + category, + description, + confidence: Math.min(1, Math.max(0, confidence)), + createdAt: now, + expiresAt, + status: 'active', + validationMethod, + sourceWorldState, + }; + + this.assumptions.set(id, assumption); + this.validations.set(id, []); + + return assumption; + } + + validateAssumption( + assumptionId: string, + isValid: boolean, + evidenceHash: string, + validator: string, + ): boolean { + const assumption = this.assumptions.get(assumptionId); + if (!assumption) return false; + + const validation: AssumptionValidation = { + assumptionId, + timestamp: new Date(), + isValid, + evidenceHash, + validator, + }; + + const validations = this.validations.get(assumptionId) || []; + validations.push(validation); + this.validations.set(assumptionId, validations); + + // Update assumption status based on validation + if (!isValid) { + assumption.status = 'invalidated'; + } else { + assumption.status = 'validated'; + } + + this._appendToLedger(assumption, validation); + return true; + } + + checkAssumptionValidity(assumptionId: string): boolean { + const assumption = this.assumptions.get(assumptionId); + if (!assumption) return false; + + const now = new Date(); + if (assumption.expiresAt < now) { + assumption.status = 'expired'; + return false; + } + + if (assumption.status === 'invalidated') return false; + + return true; + } + + getAssumptionsByCategory(category: AssumptionCategory): Assumption[] { + const results: Assumption[] = []; + for (const assumption of this.assumptions.values()) { + if (assumption.category === category) { + results.push(assumption); + } + } + return results; + } + + getValidationCount(assumptionId: string): number { + return (this.validations.get(assumptionId) || []).length; + } + + getValidationHistory(assumptionId: string): AssumptionValidation[] { + return this.validations.get(assumptionId) || []; + } + + getAverageConfidence(category: AssumptionCategory): number { + const assumptions = this.getAssumptionsByCategory(category); + if (assumptions.length === 0) return 0; + + const sum = assumptions.reduce((acc, a) => acc + a.confidence, 0); + return sum / assumptions.length; + } + + verifyLedgerIntegrity(): boolean { + let currentHash = '0'; + + for (const entry of this.ledgerChain) { + if (entry.previousHash !== currentHash) { + return false; + } + currentHash = entry.ledgerHash; + } + + this.lastHash = currentHash; + return true; + } + + exportLedger(): LedgerEntry[] { + return [...this.ledgerChain]; + } + + private _appendToLedger( + assumption: Assumption, + validation: AssumptionValidation, + ): void { + const entryData = `${assumption.id}:${validation.timestamp.toISOString()}:${validation.isValid}:${validation.validator}`; + const ledgerHash = createHash('sha256').update(entryData).digest('hex'); + + const entry: LedgerEntry = { + entryId: `entry-${Date.now()}`, + assumption, + validations: [validation], + ledgerHash, + previousHash: this.lastHash, + }; + + this.ledgerChain.push(entry); + this.lastHash = ledgerHash; + } + + getAssumption(assumptionId: string): Assumption | undefined { + return this.assumptions.get(assumptionId); + } +} diff --git a/packages/world-model-provenance/src/simulation/simulator-validator.ts b/packages/world-model-provenance/src/simulation/simulator-validator.ts new file mode 100644 index 0000000..e664f82 --- /dev/null +++ b/packages/world-model-provenance/src/simulation/simulator-validator.ts @@ -0,0 +1,202 @@ +export type SimulationType = 'physics' | 'sensor-fusion' | 'outcome-prediction' | 'safety-check'; + +export interface SimulationResult { + simulationId: string; + type: SimulationType; + predictedOutcome: unknown; + confidenceScore: number; // 0-1 + executionTimeMs: number; + timestamp: Date; +} + +export interface ValidationResult { + simulationId: string; + actualOutcome: unknown; + predictedOutcome: unknown; + matchScore: number; // 0-1, higher = better match + withinTolerance: boolean; + toleranceThreshold: number; + validatedAt: Date; +} + +export interface SimulatorCalibration { + type: SimulationType; + successRate: number; + averageError: number; + lastCalibrationMs: Date; + sampleSize: number; +} + +export class SimulatorValidator { + private simulations = new Map(); + private validations = new Map(); + private calibrations = new Map(); + + private tolerances: Record = { + physics: 0.05, // 5% tolerance + 'sensor-fusion': 0.1, // 10% tolerance + 'outcome-prediction': 0.15, // 15% tolerance + 'safety-check': 0.05, // 5% tolerance (strict) + }; + + recordSimulation( + type: SimulationType, + predictedOutcome: unknown, + confidenceScore: number, + executionTimeMs: number, + ): SimulationResult { + const simulationId = `sim-${Date.now()}-${Math.random().toString(36).slice(2)}`; + + const result: SimulationResult = { + simulationId, + type, + predictedOutcome, + confidenceScore: Math.min(1, Math.max(0, confidenceScore)), + executionTimeMs, + timestamp: new Date(), + }; + + this.simulations.set(simulationId, result); + return result; + } + + validateSimulation( + simulationId: string, + actualOutcome: unknown, + ): ValidationResult | null { + const simulation = this.simulations.get(simulationId); + if (!simulation) return null; + + const matchScore = this._calculateMatchScore( + simulation.predictedOutcome, + actualOutcome, + ); + const tolerance = this.tolerances[simulation.type]; + const withinTolerance = matchScore >= 1 - tolerance; + + const validation: ValidationResult = { + simulationId, + actualOutcome, + predictedOutcome: simulation.predictedOutcome, + matchScore, + withinTolerance, + toleranceThreshold: tolerance, + validatedAt: new Date(), + }; + + this.validations.set(simulationId, validation); + + // Update calibration + this._updateCalibration(simulation.type, matchScore); + + return validation; + } + + getValidationResult(simulationId: string): ValidationResult | undefined { + return this.validations.get(simulationId); + } + + isSimulatorReliable(type: SimulationType): boolean { + const calibration = this.calibrations.get(type); + if (!calibration) return false; + + // Reliable if success rate > 80% with at least 10 samples + return calibration.successRate > 0.8 && calibration.sampleSize >= 10; + } + + getCalibration(type: SimulationType): SimulatorCalibration | undefined { + return this.calibrations.get(type); + } + + setTolerance(type: SimulationType, tolerance: number): void { + this.tolerances[type] = Math.min(1, Math.max(0, tolerance)); + } + + getTolerance(type: SimulationType): number { + return this.tolerances[type]; + } + + getSuccessRate(type: SimulationType): number { + const calibration = this.calibrations.get(type); + return calibration ? calibration.successRate : 0; + } + + getValidationHistory(type: SimulationType): ValidationResult[] { + const results: ValidationResult[] = []; + for (const validation of this.validations.values()) { + const sim = this.simulations.get(validation.simulationId); + if (sim && sim.type === type) { + results.push(validation); + } + } + return results; + } + + canRelyOnSimulation(simulationId: string, tier: 'T0' | 'T1' | 'T2' | 'T3'): boolean { + const simulation = this.simulations.get(simulationId); + if (!simulation) return false; + + const calibration = this.calibrations.get(simulation.type); + if (!calibration) return false; + + // T0 can use simulation always (read-only) + if (tier === 'T0') return true; + + // T1 needs > 70% success rate + if (tier === 'T1') return calibration.successRate > 0.7; + + // T2 needs > 85% success rate and confidence > 0.8 + if (tier === 'T2') { + return calibration.successRate > 0.85 && simulation.confidenceScore > 0.8; + } + + // T3 needs > 95% success rate and high confidence + if (tier === 'T3') { + return calibration.successRate > 0.95 && simulation.confidenceScore > 0.9; + } + + return false; + } + + private _calculateMatchScore( + predicted: unknown, + actual: unknown, + ): number { + if (typeof predicted === 'number' && typeof actual === 'number') { + const diff = Math.abs(predicted - actual); + const max = Math.max(Math.abs(predicted), Math.abs(actual), 1); + return Math.max(0, 1 - diff / max); + } + + if (JSON.stringify(predicted) === JSON.stringify(actual)) { + return 1; + } + + return 0; + } + + private _updateCalibration(type: SimulationType, matchScore: number): void { + let calibration = this.calibrations.get(type); + + if (!calibration) { + calibration = { + type, + successRate: matchScore, + averageError: 1 - matchScore, + lastCalibrationMs: new Date(), + sampleSize: 1, + }; + } else { + const oldSuccessCount = calibration.successRate * calibration.sampleSize; + const newSampleSize = calibration.sampleSize + 1; + calibration.successRate = (oldSuccessCount + matchScore) / newSampleSize; + calibration.averageError = + (calibration.averageError * calibration.sampleSize + (1 - matchScore)) / + newSampleSize; + calibration.sampleSize = newSampleSize; + calibration.lastCalibrationMs = new Date(); + } + + this.calibrations.set(type, calibration); + } +} diff --git a/packages/world-model-provenance/src/staleness/staleness-threshold.ts b/packages/world-model-provenance/src/staleness/staleness-threshold.ts new file mode 100644 index 0000000..c4ed2fa --- /dev/null +++ b/packages/world-model-provenance/src/staleness/staleness-threshold.ts @@ -0,0 +1,150 @@ +export type DataSource = 'sensor' | 'simulator' | 'learned' | 'external' | 'inferred'; + +export interface StalenessMetric { + source: DataSource; + lastUpdateMs: number; + maxStalenessMs: number; + isStale: boolean; + freshnessScore: number; +} + +export interface WorldState { + stateId: string; + timestamp: number; + sources: Map; + isSafe: boolean; + riskLevel: 'low' | 'medium' | 'high' | 'critical'; +} + +export class StalenessThresholdManager { + private readonly DEFAULT_THRESHOLDS: Record = { + sensor: 100, // 100ms for real sensors + simulator: 500, // 500ms for simulator predictions + learned: 1000, // 1s for ML models + external: 2000, // 2s for external APIs + inferred: 3000, // 3s for inferred state + }; + + private thresholds: Record; + private worldStates = new Map(); + + constructor(customThresholds?: Partial>) { + this.thresholds = { ...this.DEFAULT_THRESHOLDS, ...customThresholds }; + } + + registerWorldState( + stateId: string, + sources: Map, + ): WorldState { + const now = Date.now(); + const metricsMap = new Map(); + + let maxRiskLevel: 'low' | 'medium' | 'high' | 'critical' = 'low'; + + for (const [source, { lastUpdateMs }] of sources) { + const ageMs = now - lastUpdateMs; + const maxStaleness = this.thresholds[source]; + const isStale = ageMs > maxStaleness; + + // Freshness score: 1.0 = fresh, 0.0 = stale + const freshnessScore = Math.max(0, 1 - ageMs / (maxStaleness * 2)); + + if (isStale && freshnessScore < 0.3) { + maxRiskLevel = 'critical'; + } else if (isStale) { + maxRiskLevel = 'high'; + } else if (freshnessScore < 0.5) { + maxRiskLevel = 'medium'; + } + + metricsMap.set(source, { + source, + lastUpdateMs, + maxStalenessMs: maxStaleness, + isStale, + freshnessScore, + }); + } + + const worldState: WorldState = { + stateId, + timestamp: now, + sources: metricsMap, + isSafe: maxRiskLevel !== 'critical', + riskLevel: maxRiskLevel, + }; + + this.worldStates.set(stateId, worldState); + return worldState; + } + + getWorldState(stateId: string): WorldState | undefined { + return this.worldStates.get(stateId); + } + + isFresh(stateId: string, source: DataSource): boolean { + const state = this.worldStates.get(stateId); + if (!state) return false; + + const metric = state.sources.get(source); + return metric ? !metric.isStale : false; + } + + getAllSourcesFresh(stateId: string): boolean { + const state = this.worldStates.get(stateId); + if (!state) return false; + + for (const metric of state.sources.values()) { + if (metric.isStale) return false; + } + return true; + } + + getMinimumFreshness(stateId: string): number { + const state = this.worldStates.get(stateId); + if (!state) return 0; + + let minFreshness = 1; + for (const metric of state.sources.values()) { + minFreshness = Math.min(minFreshness, metric.freshnessScore); + } + return minFreshness; + } + + setThreshold(source: DataSource, thresholdMs: number): void { + this.thresholds[source] = thresholdMs; + } + + canActOnTier(stateId: string, tier: 'T0' | 'T1' | 'T2' | 'T3'): boolean { + const state = this.worldStates.get(stateId); + if (!state) return false; + + // T0 can act with any freshness (read-only) + if (tier === 'T0') return true; + + // T1 needs at least one sensor + if (tier === 'T1') { + const sensor = state.sources.get('sensor'); + return sensor ? !sensor.isStale : false; + } + + // T2 needs sensors and simulator fresh + if (tier === 'T2') { + const sensor = state.sources.get('sensor'); + const simulator = state.sources.get('simulator'); + return ( + sensor && + !sensor.isStale && + simulator && + !simulator.isStale + ); + } + + // T3 needs all sources fresh + if (tier === 'T3') { + return state.isSafe && this.getAllSourcesFresh(stateId); + } + + return false; + } +} diff --git a/packages/world-model-provenance/tsconfig.json b/packages/world-model-provenance/tsconfig.json new file mode 100644 index 0000000..61e922e --- /dev/null +++ b/packages/world-model-provenance/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist" + }, + "include": ["src"], + "exclude": ["dist", "node_modules", "**/*.test.ts"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5faa824..e369aa5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -697,6 +697,35 @@ importers: specifier: ^3.0.0 version: 3.2.4(@types/node@22.19.15)(jsdom@25.0.1)(tsx@4.21.0) + packages/degraded-connectivity: + dependencies: + '@pshkv/core': + specifier: workspace:* + version: link:../core + '@pshkv/gate-policy-gateway': + specifier: workspace:* + version: link:../policy-gateway + '@pshkv/world-model-provenance': + specifier: workspace:* + version: link:../world-model-provenance + devDependencies: + vitest: + specifier: ^3.2.4 + version: 3.2.4(@types/node@22.19.15)(jsdom@25.0.1)(tsx@4.21.0) + + packages/delegated-capability-chains: + dependencies: + '@pshkv/core': + specifier: workspace:* + version: link:../core + '@pshkv/gate-capability-tokens': + specifier: workspace:* + version: link:../capability-tokens + devDependencies: + vitest: + specifier: ^3.2.4 + version: 3.2.4(@types/node@22.19.15)(jsdom@25.0.1)(tsx@4.21.0) + packages/engine-capsule-sandbox: dependencies: '@noble/hashes': @@ -958,6 +987,22 @@ importers: specifier: ^3.0.0 version: 3.2.4(@types/node@22.19.15)(jsdom@25.0.1)(tsx@4.21.0) + packages/world-model-provenance: + dependencies: + '@pshkv/core': + specifier: workspace:* + version: link:../core + '@pshkv/gate-capability-tokens': + specifier: workspace:* + version: link:../capability-tokens + '@pshkv/gate-policy-gateway': + specifier: workspace:* + version: link:../policy-gateway + devDependencies: + vitest: + specifier: ^3.2.4 + version: 3.2.4(@types/node@22.19.15)(jsdom@25.0.1)(tsx@4.21.0) + sdks/typescript: devDependencies: typescript: