predicate-claw 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. package/.github/workflows/release.yml +76 -0
  2. package/.github/workflows/tests.yml +34 -0
  3. package/.markdownlint.yaml +5 -0
  4. package/.pre-commit-config.yaml +100 -0
  5. package/README.md +405 -0
  6. package/dist/src/adapter.d.ts +17 -0
  7. package/dist/src/adapter.js +36 -0
  8. package/dist/src/authority-client.d.ts +21 -0
  9. package/dist/src/authority-client.js +22 -0
  10. package/dist/src/circuit-breaker.d.ts +86 -0
  11. package/dist/src/circuit-breaker.js +174 -0
  12. package/dist/src/config.d.ts +8 -0
  13. package/dist/src/config.js +7 -0
  14. package/dist/src/control-plane-sync.d.ts +57 -0
  15. package/dist/src/control-plane-sync.js +99 -0
  16. package/dist/src/errors.d.ts +6 -0
  17. package/dist/src/errors.js +6 -0
  18. package/dist/src/index.d.ts +12 -0
  19. package/dist/src/index.js +12 -0
  20. package/dist/src/non-web-evidence.d.ts +46 -0
  21. package/dist/src/non-web-evidence.js +54 -0
  22. package/dist/src/openclaw-hooks.d.ts +27 -0
  23. package/dist/src/openclaw-hooks.js +54 -0
  24. package/dist/src/openclaw-plugin-api.d.ts +18 -0
  25. package/dist/src/openclaw-plugin-api.js +17 -0
  26. package/dist/src/provider.d.ts +48 -0
  27. package/dist/src/provider.js +154 -0
  28. package/dist/src/runtime-integration.d.ts +20 -0
  29. package/dist/src/runtime-integration.js +43 -0
  30. package/dist/src/web-evidence.d.ts +48 -0
  31. package/dist/src/web-evidence.js +49 -0
  32. package/dist/tests/adapter.test.d.ts +1 -0
  33. package/dist/tests/adapter.test.js +63 -0
  34. package/dist/tests/audit-event-e2e.test.d.ts +1 -0
  35. package/dist/tests/audit-event-e2e.test.js +209 -0
  36. package/dist/tests/authority-client.test.d.ts +1 -0
  37. package/dist/tests/authority-client.test.js +46 -0
  38. package/dist/tests/circuit-breaker.test.d.ts +1 -0
  39. package/dist/tests/circuit-breaker.test.js +200 -0
  40. package/dist/tests/control-plane-sync.test.d.ts +1 -0
  41. package/dist/tests/control-plane-sync.test.js +90 -0
  42. package/dist/tests/hack-vs-fix-demo.test.d.ts +1 -0
  43. package/dist/tests/hack-vs-fix-demo.test.js +36 -0
  44. package/dist/tests/jwks-rotation.test.d.ts +1 -0
  45. package/dist/tests/jwks-rotation.test.js +232 -0
  46. package/dist/tests/load-latency.test.d.ts +1 -0
  47. package/dist/tests/load-latency.test.js +175 -0
  48. package/dist/tests/multi-tenant-isolation.test.d.ts +1 -0
  49. package/dist/tests/multi-tenant-isolation.test.js +146 -0
  50. package/dist/tests/non-web-evidence.test.d.ts +1 -0
  51. package/dist/tests/non-web-evidence.test.js +139 -0
  52. package/dist/tests/openclaw-hooks.test.d.ts +1 -0
  53. package/dist/tests/openclaw-hooks.test.js +38 -0
  54. package/dist/tests/openclaw-plugin-api.test.d.ts +1 -0
  55. package/dist/tests/openclaw-plugin-api.test.js +40 -0
  56. package/dist/tests/provider.test.d.ts +1 -0
  57. package/dist/tests/provider.test.js +190 -0
  58. package/dist/tests/runtime-integration.test.d.ts +1 -0
  59. package/dist/tests/runtime-integration.test.js +57 -0
  60. package/dist/tests/web-evidence.test.d.ts +1 -0
  61. package/dist/tests/web-evidence.test.js +89 -0
  62. package/docs/MIGRATION_GUIDE.md +405 -0
  63. package/docs/OPERATIONAL_RUNBOOK.md +389 -0
  64. package/docs/PRODUCTION_READINESS.md +134 -0
  65. package/docs/SLO_THRESHOLDS.md +193 -0
  66. package/examples/README.md +171 -0
  67. package/examples/docker/Dockerfile.test +16 -0
  68. package/examples/docker/README.md +48 -0
  69. package/examples/docker/docker-compose.test.yml +16 -0
  70. package/examples/non-web-evidence-demo.ts +184 -0
  71. package/examples/openclaw-plugin-smoke/index.ts +30 -0
  72. package/examples/openclaw-plugin-smoke/openclaw.plugin.json +11 -0
  73. package/examples/openclaw-plugin-smoke/package.json +9 -0
  74. package/examples/openclaw_integration_example.py +41 -0
  75. package/examples/policy/README.md +165 -0
  76. package/examples/policy/approved-hosts.yaml +137 -0
  77. package/examples/policy/dev-workflow.yaml +206 -0
  78. package/examples/policy/policy.example.yaml +17 -0
  79. package/examples/policy/production-strict.yaml +97 -0
  80. package/examples/policy/sensitive-paths.yaml +114 -0
  81. package/examples/policy/source-trust.yaml +129 -0
  82. package/examples/policy/workspace-isolation.yaml +51 -0
  83. package/examples/runtime_registry_example.py +75 -0
  84. package/package.json +27 -0
  85. package/pyproject.toml +41 -0
  86. package/src/adapter.ts +45 -0
  87. package/src/authority-client.ts +50 -0
  88. package/src/circuit-breaker.ts +245 -0
  89. package/src/config.ts +15 -0
  90. package/src/control-plane-sync.ts +159 -0
  91. package/src/errors.ts +5 -0
  92. package/src/index.ts +12 -0
  93. package/src/non-web-evidence.ts +116 -0
  94. package/src/openclaw-hooks.ts +76 -0
  95. package/src/openclaw-plugin-api.ts +51 -0
  96. package/src/openclaw_predicate_provider/__init__.py +16 -0
  97. package/src/openclaw_predicate_provider/__main__.py +5 -0
  98. package/src/openclaw_predicate_provider/adapter.py +84 -0
  99. package/src/openclaw_predicate_provider/agentidentity_backend.py +78 -0
  100. package/src/openclaw_predicate_provider/cli.py +160 -0
  101. package/src/openclaw_predicate_provider/config.py +42 -0
  102. package/src/openclaw_predicate_provider/errors.py +13 -0
  103. package/src/openclaw_predicate_provider/integrations/__init__.py +5 -0
  104. package/src/openclaw_predicate_provider/integrations/openclaw_runtime.py +74 -0
  105. package/src/openclaw_predicate_provider/models.py +19 -0
  106. package/src/openclaw_predicate_provider/openclaw_hooks.py +75 -0
  107. package/src/openclaw_predicate_provider/provider.py +69 -0
  108. package/src/openclaw_predicate_provider/py.typed +1 -0
  109. package/src/openclaw_predicate_provider/sidecar.py +59 -0
  110. package/src/provider.ts +220 -0
  111. package/src/runtime-integration.ts +68 -0
  112. package/src/web-evidence.ts +95 -0
  113. package/tests/adapter.test.ts +76 -0
  114. package/tests/audit-event-e2e.test.ts +258 -0
  115. package/tests/authority-client.test.ts +52 -0
  116. package/tests/circuit-breaker.test.ts +266 -0
  117. package/tests/conftest.py +9 -0
  118. package/tests/control-plane-sync.test.ts +114 -0
  119. package/tests/hack-vs-fix-demo.test.ts +44 -0
  120. package/tests/jwks-rotation.test.ts +274 -0
  121. package/tests/load-latency.test.ts +214 -0
  122. package/tests/multi-tenant-isolation.test.ts +183 -0
  123. package/tests/non-web-evidence.test.ts +168 -0
  124. package/tests/openclaw-hooks.test.ts +46 -0
  125. package/tests/openclaw-plugin-api.test.ts +50 -0
  126. package/tests/provider.test.ts +227 -0
  127. package/tests/runtime-integration.test.ts +70 -0
  128. package/tests/test_adapter.py +46 -0
  129. package/tests/test_cli.py +26 -0
  130. package/tests/test_openclaw_hooks.py +53 -0
  131. package/tests/test_provider.py +59 -0
  132. package/tests/test_runtime_integration.py +77 -0
  133. package/tests/test_sidecar_client.py +198 -0
  134. package/tests/web-evidence.test.ts +113 -0
  135. package/tsconfig.json +14 -0
  136. package/vitest.config.ts +7 -0
@@ -0,0 +1,18 @@
1
+ interface RegisteredTool {
2
+ name: string;
3
+ description?: string;
4
+ parameters?: unknown;
5
+ execute: (id: string, params: Record<string, unknown>) => Promise<unknown>;
6
+ }
7
+ interface OpenClawPluginApi {
8
+ registerTool(tool: RegisteredTool, options?: {
9
+ optional?: boolean;
10
+ }): void;
11
+ }
12
+ interface PredicateToolHandlers {
13
+ executeCmdRun(args: Record<string, unknown>): Promise<unknown>;
14
+ executeFsReadFile(args: Record<string, unknown>): Promise<unknown>;
15
+ executeHttpRequest(args: Record<string, unknown>): Promise<unknown>;
16
+ }
17
+ export declare function registerOpenClawPredicateTools(api: OpenClawPluginApi, handlers: PredicateToolHandlers): void;
18
+ export {};
@@ -0,0 +1,17 @@
1
+ export function registerOpenClawPredicateTools(api, handlers) {
2
+ api.registerTool({
3
+ name: "predicate_cmd_run",
4
+ description: "Guarded shell execution routed through Predicate Authority.",
5
+ execute: async (_id, params) => handlers.executeCmdRun(params),
6
+ }, { optional: true });
7
+ api.registerTool({
8
+ name: "predicate_fs_read_file",
9
+ description: "Guarded file read routed through Predicate Authority.",
10
+ execute: async (_id, params) => handlers.executeFsReadFile(params),
11
+ }, { optional: true });
12
+ api.registerTool({
13
+ name: "predicate_http_request",
14
+ description: "Guarded HTTP request routed through Predicate Authority.",
15
+ execute: async (_id, params) => handlers.executeHttpRequest(params),
16
+ }, { optional: true });
17
+ }
@@ -0,0 +1,48 @@
1
+ import { type AuthorityAdapter } from "./authority-client.js";
2
+ import { type ProviderConfig } from "./config.js";
3
+ export interface GuardRequest {
4
+ action: string;
5
+ resource: string;
6
+ args: Record<string, unknown>;
7
+ context?: Record<string, unknown>;
8
+ }
9
+ export interface GuardedProviderOptions {
10
+ principal: string;
11
+ config?: Partial<ProviderConfig>;
12
+ authorityClient?: AuthorityAdapter;
13
+ telemetry?: GuardTelemetry;
14
+ auditExporter?: DecisionAuditExporter;
15
+ }
16
+ export interface DecisionTelemetryEvent {
17
+ principal: string;
18
+ action: string;
19
+ resource: string;
20
+ outcome: "allow" | "deny" | "error";
21
+ reason?: string;
22
+ mandateId?: string;
23
+ sessionId?: string;
24
+ tenantId?: string;
25
+ userId?: string;
26
+ traceId?: string;
27
+ source?: string;
28
+ timestamp: string;
29
+ }
30
+ export interface GuardTelemetry {
31
+ onDecision?(event: DecisionTelemetryEvent): void;
32
+ }
33
+ export interface DecisionAuditExporter {
34
+ exportDecision(event: DecisionTelemetryEvent): Promise<void> | void;
35
+ }
36
+ export declare class GuardedProvider {
37
+ private readonly principal;
38
+ private readonly config;
39
+ private readonly authorityClient;
40
+ private readonly telemetry?;
41
+ private readonly auditExporter?;
42
+ constructor(options: GuardedProviderOptions);
43
+ static intentHash(args: Record<string, unknown>): string;
44
+ authorize(request: GuardRequest): Promise<string | null>;
45
+ guardOrThrow(request: GuardRequest): Promise<string | null>;
46
+ private emitDecisionEvent;
47
+ }
48
+ export { ActionDeniedError, SidecarUnavailableError } from "./errors.js";
@@ -0,0 +1,154 @@
1
+ import crypto from "node:crypto";
2
+ import { createDefaultAuthorityAdapter, } from "./authority-client.js";
3
+ import { defaultProviderConfig } from "./config.js";
4
+ import { ActionDeniedError, SidecarUnavailableError } from "./errors.js";
5
+ export class GuardedProvider {
6
+ principal;
7
+ config;
8
+ authorityClient;
9
+ telemetry;
10
+ auditExporter;
11
+ constructor(options) {
12
+ this.principal = options.principal;
13
+ this.config = { ...defaultProviderConfig, ...(options.config ?? {}) };
14
+ this.authorityClient =
15
+ options.authorityClient ?? createDefaultAuthorityAdapter(this.config);
16
+ this.telemetry = options.telemetry;
17
+ this.auditExporter = options.auditExporter;
18
+ }
19
+ static intentHash(args) {
20
+ const encoded = stableJson(args);
21
+ return crypto.createHash("sha256").update(encoded).digest("hex");
22
+ }
23
+ async authorize(request) {
24
+ const wireRequest = {
25
+ principal: this.principal,
26
+ action: request.action,
27
+ resource: request.resource,
28
+ intent_hash: GuardedProvider.intentHash(request.args),
29
+ labels: labelsFromContext(request.context),
30
+ };
31
+ try {
32
+ const decision = await this.authorityClient.authorize(wireRequest);
33
+ if (decision.allow) {
34
+ await this.emitDecisionEvent({
35
+ principal: this.principal,
36
+ action: request.action,
37
+ resource: redactResource(request.action, request.resource),
38
+ outcome: "allow",
39
+ reason: decision.reason,
40
+ mandateId: decision.mandateId,
41
+ ...contextFields(request.context),
42
+ timestamp: new Date().toISOString(),
43
+ });
44
+ return decision.mandateId ?? null;
45
+ }
46
+ await this.emitDecisionEvent({
47
+ principal: this.principal,
48
+ action: request.action,
49
+ resource: redactResource(request.action, request.resource),
50
+ outcome: "deny",
51
+ reason: decision.reason ?? "denied_by_policy",
52
+ mandateId: decision.mandateId,
53
+ ...contextFields(request.context),
54
+ timestamp: new Date().toISOString(),
55
+ });
56
+ throw new ActionDeniedError(decision.reason ?? "denied_by_policy");
57
+ }
58
+ catch (error) {
59
+ if (error instanceof ActionDeniedError) {
60
+ throw error;
61
+ }
62
+ if (error instanceof SidecarUnavailableError) {
63
+ await this.emitDecisionEvent({
64
+ principal: this.principal,
65
+ action: request.action,
66
+ resource: redactResource(request.action, request.resource),
67
+ outcome: "error",
68
+ reason: error.message,
69
+ ...contextFields(request.context),
70
+ timestamp: new Date().toISOString(),
71
+ });
72
+ throw error;
73
+ }
74
+ await this.emitDecisionEvent({
75
+ principal: this.principal,
76
+ action: request.action,
77
+ resource: redactResource(request.action, request.resource),
78
+ outcome: "error",
79
+ reason: "Predicate sidecar unavailable",
80
+ ...contextFields(request.context),
81
+ timestamp: new Date().toISOString(),
82
+ });
83
+ throw new SidecarUnavailableError("Predicate sidecar unavailable");
84
+ }
85
+ }
86
+ async guardOrThrow(request) {
87
+ try {
88
+ return await this.authorize(request);
89
+ }
90
+ catch (error) {
91
+ if (error instanceof SidecarUnavailableError &&
92
+ this.config.failClosed === false) {
93
+ return null;
94
+ }
95
+ throw error;
96
+ }
97
+ }
98
+ async emitDecisionEvent(event) {
99
+ this.telemetry?.onDecision?.(event);
100
+ if (!this.auditExporter) {
101
+ return;
102
+ }
103
+ try {
104
+ await this.auditExporter.exportDecision(event);
105
+ }
106
+ catch {
107
+ // Best-effort export; authorization path must not fail on telemetry sink issues.
108
+ }
109
+ }
110
+ }
111
+ function labelsFromContext(context) {
112
+ if (!context)
113
+ return [];
114
+ const labels = [];
115
+ const source = context.source;
116
+ if (typeof source === "string" && source.length > 0) {
117
+ labels.push(`source:${source}`);
118
+ }
119
+ return labels;
120
+ }
121
+ function contextFields(context) {
122
+ return {
123
+ sessionId: typeof context?.session_id === "string" ? context.session_id : undefined,
124
+ tenantId: typeof context?.tenant_id === "string" ? context.tenant_id : undefined,
125
+ userId: typeof context?.user_id === "string" ? context.user_id : undefined,
126
+ traceId: typeof context?.trace_id === "string" ? context.trace_id : undefined,
127
+ source: typeof context?.source === "string" ? context.source : undefined,
128
+ };
129
+ }
130
+ function redactResource(action, resource) {
131
+ if (action === "fs.read" || action === "fs.write") {
132
+ const lowered = resource.toLowerCase();
133
+ if (lowered.includes("/.ssh/") ||
134
+ lowered.includes("/etc/") ||
135
+ lowered.includes("id_rsa") ||
136
+ lowered.includes("credentials")) {
137
+ return "[REDACTED]";
138
+ }
139
+ }
140
+ return resource;
141
+ }
142
+ function stableJson(value) {
143
+ if (Array.isArray(value)) {
144
+ return `[${value.map((v) => stableJson(v)).join(",")}]`;
145
+ }
146
+ if (value && typeof value === "object") {
147
+ const entries = Object.entries(value).sort(([a], [b]) => a.localeCompare(b));
148
+ return `{${entries
149
+ .map(([k, v]) => `${JSON.stringify(k)}:${stableJson(v)}`)
150
+ .join(",")}}`;
151
+ }
152
+ return JSON.stringify(value);
153
+ }
154
+ export { ActionDeniedError, SidecarUnavailableError } from "./errors.js";
@@ -0,0 +1,20 @@
1
+ import { HookEnvelope, type OpenClawHooks } from "./openclaw-hooks.js";
2
+ type ToolHandler = (args: Record<string, unknown>) => Promise<unknown>;
3
+ export interface ToolRegistry {
4
+ get(toolName: string): ToolHandler;
5
+ set(toolName: string, handler: ToolHandler): void;
6
+ }
7
+ export interface RuntimeIntegratorOptions {
8
+ hooks: Pick<OpenClawHooks, "onCmdRun" | "onFsRead" | "onHttpRequest">;
9
+ contextBuilder?: (toolName: string, args: Record<string, unknown>) => HookEnvelope;
10
+ }
11
+ export declare class OpenClawRuntimeIntegrator {
12
+ private readonly hooks;
13
+ private readonly contextBuilder;
14
+ constructor(options: RuntimeIntegratorOptions);
15
+ register(registry: ToolRegistry): void;
16
+ private wrapCmdRun;
17
+ private wrapFsRead;
18
+ private wrapHttpRequest;
19
+ }
20
+ export {};
@@ -0,0 +1,43 @@
1
+ import { HookEnvelope } from "./openclaw-hooks.js";
2
+ export class OpenClawRuntimeIntegrator {
3
+ hooks;
4
+ contextBuilder;
5
+ constructor(options) {
6
+ this.hooks = options.hooks;
7
+ this.contextBuilder = options.contextBuilder ?? defaultContextBuilder;
8
+ }
9
+ register(registry) {
10
+ this.wrapCmdRun(registry);
11
+ this.wrapFsRead(registry);
12
+ this.wrapHttpRequest(registry);
13
+ }
14
+ wrapCmdRun(registry) {
15
+ const original = registry.get("cmd.run");
16
+ registry.set("cmd.run", async (args) => {
17
+ const envelope = this.contextBuilder("cmd.run", args);
18
+ return this.hooks.onCmdRun(envelope, original);
19
+ });
20
+ }
21
+ wrapFsRead(registry) {
22
+ const original = registry.get("fs.readFile");
23
+ registry.set("fs.readFile", async (args) => {
24
+ const envelope = this.contextBuilder("fs.readFile", args);
25
+ return this.hooks.onFsRead(envelope, original);
26
+ });
27
+ }
28
+ wrapHttpRequest(registry) {
29
+ const original = registry.get("http.request");
30
+ registry.set("http.request", async (args) => {
31
+ const envelope = this.contextBuilder("http.request", args);
32
+ return this.hooks.onHttpRequest(envelope, original);
33
+ });
34
+ }
35
+ }
36
+ function defaultContextBuilder(toolName, args) {
37
+ return new HookEnvelope({
38
+ toolName,
39
+ args,
40
+ sessionId: "unknown-session",
41
+ source: "unknown-source",
42
+ });
43
+ }
@@ -0,0 +1,48 @@
1
+ import { type RuntimeSnapshotLike, type StateEvidence, type WebStateSnapshot } from "@predicatesystems/authority";
2
+ /**
3
+ * Runtime context captured from OpenClaw web agent execution environment.
4
+ * Maps to ts-predicate-authority WebStateSnapshot contract.
5
+ */
6
+ export interface WebRuntimeContext {
7
+ url?: string;
8
+ title?: string;
9
+ domHtml?: string;
10
+ domHash?: string;
11
+ visibleText?: string;
12
+ visibleTextHash?: string;
13
+ eventId?: string;
14
+ observedAt?: string;
15
+ dominantGroupKey?: string;
16
+ snapshotTimestamp?: string;
17
+ confidence?: number;
18
+ confidenceReasons?: string[];
19
+ }
20
+ /**
21
+ * Provider interface for web state evidence capture.
22
+ * Implementations should capture browser/DOM state from the agent runtime.
23
+ */
24
+ export interface WebEvidenceProvider {
25
+ captureWebSnapshot(): Promise<WebStateSnapshot>;
26
+ }
27
+ /**
28
+ * OpenClaw-specific web evidence provider.
29
+ * Captures web state from the agent runtime and maps to TS SDK contract.
30
+ */
31
+ export declare class OpenClawWebEvidenceProvider implements WebEvidenceProvider {
32
+ private readonly capture;
33
+ constructor(capture: () => Promise<WebRuntimeContext> | WebRuntimeContext);
34
+ captureWebSnapshot(): Promise<WebStateSnapshot>;
35
+ }
36
+ /**
37
+ * Build StateEvidence from an OpenClaw web evidence provider.
38
+ */
39
+ export declare function buildWebEvidenceFromProvider(provider: WebEvidenceProvider, options?: {
40
+ schemaVersion?: string;
41
+ }): Promise<StateEvidence>;
42
+ /**
43
+ * Convenience adapter for predicate-runtime snapshot output.
44
+ * Maps RuntimeSnapshotLike to StateEvidence directly.
45
+ */
46
+ export declare function buildWebEvidenceFromRuntimeSnapshot(snapshot: RuntimeSnapshotLike, options?: {
47
+ schemaVersion?: string;
48
+ }): StateEvidence;
@@ -0,0 +1,49 @@
1
+ import crypto from "node:crypto";
2
+ import { buildWebStateEvidence, buildWebStateEvidenceFromRuntimeSnapshot, } from "@predicatesystems/authority";
3
+ /**
4
+ * OpenClaw-specific web evidence provider.
5
+ * Captures web state from the agent runtime and maps to TS SDK contract.
6
+ */
7
+ export class OpenClawWebEvidenceProvider {
8
+ capture;
9
+ constructor(capture) {
10
+ this.capture = capture;
11
+ }
12
+ async captureWebSnapshot() {
13
+ const runtime = await this.capture();
14
+ return {
15
+ url: runtime.url,
16
+ title: runtime.title,
17
+ dom_hash: runtime.domHash ?? sha256(runtime.domHtml ?? ""),
18
+ visible_text_hash: runtime.visibleTextHash ?? sha256(runtime.visibleText ?? ""),
19
+ event_id: runtime.eventId,
20
+ observed_at: runtime.observedAt ?? new Date().toISOString(),
21
+ dominant_group_key: runtime.dominantGroupKey,
22
+ snapshot_timestamp: runtime.snapshotTimestamp,
23
+ confidence: runtime.confidence,
24
+ confidence_reasons: runtime.confidenceReasons,
25
+ };
26
+ }
27
+ }
28
+ /**
29
+ * Build StateEvidence from an OpenClaw web evidence provider.
30
+ */
31
+ export async function buildWebEvidenceFromProvider(provider, options) {
32
+ const snapshot = await provider.captureWebSnapshot();
33
+ return buildWebStateEvidence({
34
+ snapshot,
35
+ schemaVersion: options?.schemaVersion ?? "v1",
36
+ });
37
+ }
38
+ /**
39
+ * Convenience adapter for predicate-runtime snapshot output.
40
+ * Maps RuntimeSnapshotLike to StateEvidence directly.
41
+ */
42
+ export function buildWebEvidenceFromRuntimeSnapshot(snapshot, options) {
43
+ return buildWebStateEvidenceFromRuntimeSnapshot(snapshot, {
44
+ schemaVersion: options?.schemaVersion ?? "v1",
45
+ });
46
+ }
47
+ function sha256(input) {
48
+ return crypto.createHash("sha256").update(input).digest("hex");
49
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,63 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { ToolAdapter } from "../src/adapter.js";
3
+ import { ActionDeniedError } from "../src/errors.js";
4
+ describe("ToolAdapter", () => {
5
+ it("maps cmd.run to shell.execute", async () => {
6
+ const seen = [];
7
+ const adapter = new ToolAdapter({
8
+ guardOrThrow: async ({ action, resource }) => {
9
+ seen.push({ action, resource });
10
+ return "mnd_test";
11
+ },
12
+ });
13
+ const result = await adapter.runShell({
14
+ args: { command: "echo hi" },
15
+ context: { source: "trusted_ui" },
16
+ execute: async (args) => args,
17
+ });
18
+ expect(result).toEqual({ command: "echo hi" });
19
+ expect(seen).toEqual([{ action: "shell.execute", resource: "echo hi" }]);
20
+ });
21
+ it("bubbles deny errors", async () => {
22
+ const adapter = new ToolAdapter({
23
+ guardOrThrow: async () => {
24
+ throw new ActionDeniedError("denied_by_policy");
25
+ },
26
+ });
27
+ await expect(adapter.readFile({
28
+ args: { path: "/etc/passwd" },
29
+ context: { source: "untrusted_dm" },
30
+ execute: async (args) => args,
31
+ })).rejects.toBeInstanceOf(ActionDeniedError);
32
+ });
33
+ it("maps fs.readFile to fs.read", async () => {
34
+ const seen = [];
35
+ const adapter = new ToolAdapter({
36
+ guardOrThrow: async ({ action, resource }) => {
37
+ seen.push({ action, resource });
38
+ return "mnd_test";
39
+ },
40
+ });
41
+ await adapter.readFile({
42
+ args: { path: "/tmp/demo.txt" },
43
+ context: { source: "trusted_ui" },
44
+ execute: async (args) => args,
45
+ });
46
+ expect(seen).toEqual([{ action: "fs.read", resource: "/tmp/demo.txt" }]);
47
+ });
48
+ it("maps http.request to net.http", async () => {
49
+ const seen = [];
50
+ const adapter = new ToolAdapter({
51
+ guardOrThrow: async ({ action, resource }) => {
52
+ seen.push({ action, resource });
53
+ return "mnd_test";
54
+ },
55
+ });
56
+ await adapter.httpRequest({
57
+ args: { url: "https://example.com" },
58
+ context: { source: "trusted_ui" },
59
+ execute: async (args) => args,
60
+ });
61
+ expect(seen).toEqual([{ action: "net.http", resource: "https://example.com" }]);
62
+ });
63
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,209 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { GuardedProvider, ActionDeniedError, } from "../src/provider.js";
3
+ /**
4
+ * End-to-end audit event visibility tests.
5
+ *
6
+ * These tests verify that decision events flow correctly from the provider
7
+ * through to audit exporters in a format compatible with control-plane
8
+ * audit pipelines.
9
+ */
10
+ describe("audit event visibility (e2e)", () => {
11
+ it("exports allow decisions with full context to audit sink", async () => {
12
+ const exportedEvents = [];
13
+ const mockClient = {
14
+ authorize: vi.fn().mockResolvedValue({
15
+ allow: true,
16
+ reason: "policy_matched",
17
+ mandateId: "mandate-12345",
18
+ }),
19
+ };
20
+ const auditExporter = {
21
+ exportDecision: async (event) => {
22
+ exportedEvents.push(event);
23
+ },
24
+ };
25
+ const provider = new GuardedProvider({
26
+ principal: "agent:e2e-test-agent",
27
+ authorityClient: mockClient,
28
+ auditExporter,
29
+ });
30
+ await provider.authorize({
31
+ action: "shell.execute",
32
+ resource: "npm install lodash",
33
+ args: { cmd: "npm install lodash" },
34
+ context: {
35
+ tenant_id: "tenant-prod",
36
+ session_id: "sess-e2e-001",
37
+ user_id: "user-developer",
38
+ trace_id: "trace-e2e-xyz",
39
+ source: "trusted_ui",
40
+ },
41
+ });
42
+ // Verify event was exported
43
+ expect(exportedEvents).toHaveLength(1);
44
+ const event = exportedEvents[0];
45
+ // Verify control-plane compatible fields
46
+ expect(event.principal).toBe("agent:e2e-test-agent");
47
+ expect(event.action).toBe("shell.execute");
48
+ expect(event.resource).toBe("npm install lodash");
49
+ expect(event.outcome).toBe("allow");
50
+ expect(event.reason).toBe("policy_matched");
51
+ expect(event.mandateId).toBe("mandate-12345");
52
+ // Verify tenant/session context
53
+ expect(event.tenantId).toBe("tenant-prod");
54
+ expect(event.sessionId).toBe("sess-e2e-001");
55
+ expect(event.userId).toBe("user-developer");
56
+ expect(event.traceId).toBe("trace-e2e-xyz");
57
+ expect(event.source).toBe("trusted_ui");
58
+ // Verify timestamp is ISO format
59
+ expect(event.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T/);
60
+ });
61
+ it("exports deny decisions with redacted sensitive resources", async () => {
62
+ const exportedEvents = [];
63
+ const mockClient = {
64
+ authorize: vi.fn().mockResolvedValue({
65
+ allow: false,
66
+ reason: "sensitive_path_blocked",
67
+ }),
68
+ };
69
+ const auditExporter = {
70
+ exportDecision: async (event) => {
71
+ exportedEvents.push(event);
72
+ },
73
+ };
74
+ const provider = new GuardedProvider({
75
+ principal: "agent:sensitive-test",
76
+ authorityClient: mockClient,
77
+ auditExporter,
78
+ });
79
+ await expect(provider.authorize({
80
+ action: "fs.read",
81
+ resource: "/home/user/.ssh/id_rsa",
82
+ args: { path: "/home/user/.ssh/id_rsa" },
83
+ context: { tenant_id: "tenant-sec" },
84
+ })).rejects.toThrow(ActionDeniedError);
85
+ expect(exportedEvents).toHaveLength(1);
86
+ const event = exportedEvents[0];
87
+ expect(event.outcome).toBe("deny");
88
+ expect(event.reason).toBe("sensitive_path_blocked");
89
+ // Resource should be redacted for sensitive paths
90
+ expect(event.resource).toBe("[REDACTED]");
91
+ });
92
+ it("exports error events when sidecar is unavailable", async () => {
93
+ const exportedEvents = [];
94
+ const mockClient = {
95
+ authorize: vi.fn().mockRejectedValue(new Error("Connection refused")),
96
+ };
97
+ const auditExporter = {
98
+ exportDecision: async (event) => {
99
+ exportedEvents.push(event);
100
+ },
101
+ };
102
+ const provider = new GuardedProvider({
103
+ principal: "agent:error-test",
104
+ authorityClient: mockClient,
105
+ auditExporter,
106
+ });
107
+ await expect(provider.authorize({
108
+ action: "net.http",
109
+ resource: "https://api.example.com",
110
+ args: { url: "https://api.example.com" },
111
+ context: { tenant_id: "tenant-error" },
112
+ })).rejects.toThrow();
113
+ expect(exportedEvents).toHaveLength(1);
114
+ const event = exportedEvents[0];
115
+ expect(event.outcome).toBe("error");
116
+ expect(event.tenantId).toBe("tenant-error");
117
+ });
118
+ it("handles audit exporter failures gracefully (best-effort)", async () => {
119
+ let callCount = 0;
120
+ const mockClient = {
121
+ authorize: vi.fn().mockResolvedValue({ allow: true }),
122
+ };
123
+ const failingExporter = {
124
+ exportDecision: async () => {
125
+ callCount++;
126
+ throw new Error("Audit sink unavailable");
127
+ },
128
+ };
129
+ const provider = new GuardedProvider({
130
+ principal: "agent:failing-audit",
131
+ authorityClient: mockClient,
132
+ auditExporter: failingExporter,
133
+ });
134
+ // Should not throw even though exporter fails
135
+ const result = await provider.authorize({
136
+ action: "fs.read",
137
+ resource: "/workspace/safe-file.txt",
138
+ args: { path: "/workspace/safe-file.txt" },
139
+ });
140
+ // Authorization should succeed despite audit failure
141
+ expect(result).toBeNull(); // No mandate ID returned in this case
142
+ expect(callCount).toBe(1); // Exporter was called
143
+ });
144
+ it("chains multiple decision events with consistent trace context", async () => {
145
+ const exportedEvents = [];
146
+ const mockClient = {
147
+ authorize: vi
148
+ .fn()
149
+ .mockResolvedValueOnce({ allow: true, mandateId: "m1" })
150
+ .mockResolvedValueOnce({ allow: true, mandateId: "m2" })
151
+ .mockResolvedValueOnce({ allow: false, reason: "rate_limited" }),
152
+ };
153
+ const auditExporter = {
154
+ exportDecision: async (event) => {
155
+ exportedEvents.push(event);
156
+ },
157
+ };
158
+ const provider = new GuardedProvider({
159
+ principal: "agent:chained-ops",
160
+ authorityClient: mockClient,
161
+ auditExporter,
162
+ });
163
+ const sharedContext = {
164
+ tenant_id: "tenant-chain",
165
+ session_id: "sess-chain",
166
+ trace_id: "trace-chain-root",
167
+ };
168
+ // First operation - allowed
169
+ await provider.authorize({
170
+ action: "fs.read",
171
+ resource: "/workspace/config.json",
172
+ args: { path: "/workspace/config.json" },
173
+ context: sharedContext,
174
+ });
175
+ // Second operation - allowed
176
+ await provider.authorize({
177
+ action: "net.http",
178
+ resource: "https://api.internal/fetch",
179
+ args: { url: "https://api.internal/fetch" },
180
+ context: sharedContext,
181
+ });
182
+ // Third operation - denied
183
+ try {
184
+ await provider.authorize({
185
+ action: "shell.execute",
186
+ resource: "curl external.com",
187
+ args: { cmd: "curl external.com" },
188
+ context: sharedContext,
189
+ });
190
+ }
191
+ catch {
192
+ // Expected denial
193
+ }
194
+ expect(exportedEvents).toHaveLength(3);
195
+ // All events should share the same trace context
196
+ for (const event of exportedEvents) {
197
+ expect(event.tenantId).toBe("tenant-chain");
198
+ expect(event.sessionId).toBe("sess-chain");
199
+ expect(event.traceId).toBe("trace-chain-root");
200
+ }
201
+ // Verify outcomes
202
+ expect(exportedEvents[0].outcome).toBe("allow");
203
+ expect(exportedEvents[0].mandateId).toBe("m1");
204
+ expect(exportedEvents[1].outcome).toBe("allow");
205
+ expect(exportedEvents[1].mandateId).toBe("m2");
206
+ expect(exportedEvents[2].outcome).toBe("deny");
207
+ expect(exportedEvents[2].reason).toBe("rate_limited");
208
+ });
209
+ });
@@ -0,0 +1 @@
1
+ export {};