mandat 0.0.0-stage → 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.
- package/LICENSE +202 -0
- package/README.md +293 -3
- package/dist/approvals/approvals.d.ts +136 -0
- package/dist/approvals/approvals.js +262 -0
- package/dist/bridge.d.ts +38 -0
- package/dist/bridge.js +117 -0
- package/dist/cli/args.d.ts +9 -0
- package/dist/cli/args.js +39 -0
- package/dist/cli/bin.d.ts +2 -0
- package/dist/cli/bin.js +3 -0
- package/dist/cli/main.d.ts +1 -0
- package/dist/cli/main.js +649 -0
- package/dist/cli/templates.d.ts +91 -0
- package/dist/cli/templates.js +57 -0
- package/dist/client.d.ts +54 -0
- package/dist/client.js +88 -0
- package/dist/controlplane/config.d.ts +48 -0
- package/dist/controlplane/config.js +33 -0
- package/dist/controlplane/console/.claude-flow/neural/patterns.json +402 -0
- package/dist/controlplane/console/.claude-flow/neural/stats.json +6 -0
- package/dist/controlplane/console/.claude-flow/policy/ledger-anchors.json +3 -0
- package/dist/controlplane/console/.claude-flow/policy/state.json +54 -0
- package/dist/controlplane/console/app.css +93 -0
- package/dist/controlplane/console/app.js +330 -0
- package/dist/controlplane/console/index.html +70 -0
- package/dist/controlplane/server.d.ts +17 -0
- package/dist/controlplane/server.js +427 -0
- package/dist/guard/guard.d.ts +154 -0
- package/dist/guard/guard.js +290 -0
- package/dist/guard/pinning.d.ts +46 -0
- package/dist/guard/pinning.js +49 -0
- package/dist/guard/remote.d.ts +78 -0
- package/dist/guard/remote.js +185 -0
- package/dist/index.d.ts +43 -0
- package/dist/index.js +67 -0
- package/dist/ledger/export.d.ts +27 -0
- package/dist/ledger/export.js +169 -0
- package/dist/ledger/ledger.d.ts +117 -0
- package/dist/ledger/ledger.js +139 -0
- package/dist/ledger/redact.d.ts +13 -0
- package/dist/ledger/redact.js +73 -0
- package/dist/ledger/report.d.ts +71 -0
- package/dist/ledger/report.js +128 -0
- package/dist/mcp/protect.d.ts +8 -0
- package/dist/mcp/protect.js +68 -0
- package/dist/mcp/server.d.ts +49 -0
- package/dist/mcp/server.js +211 -0
- package/dist/oidc.d.ts +52 -0
- package/dist/oidc.js +194 -0
- package/dist/policy/engine.d.ts +135 -0
- package/dist/policy/engine.js +243 -0
- package/dist/policy/expr.d.ts +56 -0
- package/dist/policy/expr.js +506 -0
- package/dist/scan/findings.d.ts +32 -0
- package/dist/scan/findings.js +105 -0
- package/dist/scan/remote.d.ts +7 -0
- package/dist/scan/remote.js +225 -0
- package/dist/scan/static.d.ts +15 -0
- package/dist/scan/static.js +245 -0
- package/dist/store.d.ts +14 -0
- package/dist/store.js +40 -0
- package/dist/sts/jwt.d.ts +64 -0
- package/dist/sts/jwt.js +168 -0
- package/dist/sts/revocation.d.ts +38 -0
- package/dist/sts/revocation.js +52 -0
- package/dist/sts/sts.d.ts +83 -0
- package/dist/sts/sts.js +239 -0
- package/dist/util.d.ts +28 -0
- package/dist/util.js +137 -0
- package/package.json +47 -4
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { Guard, type GuardOptions } from "./guard/guard.ts";
|
|
2
|
+
export * from "./policy/engine.ts";
|
|
3
|
+
export { compileExpr, ExprError } from "./policy/expr.ts";
|
|
4
|
+
export * from "./guard/guard.ts";
|
|
5
|
+
export * from "./guard/pinning.ts";
|
|
6
|
+
export * from "./guard/remote.ts";
|
|
7
|
+
export * from "./mcp/server.ts";
|
|
8
|
+
export { protect } from "./mcp/protect.ts";
|
|
9
|
+
export * from "./sts/sts.ts";
|
|
10
|
+
export * from "./sts/jwt.ts";
|
|
11
|
+
export * from "./sts/revocation.ts";
|
|
12
|
+
export * from "./ledger/ledger.ts";
|
|
13
|
+
export * from "./ledger/redact.ts";
|
|
14
|
+
export * from "./ledger/export.ts";
|
|
15
|
+
export * from "./ledger/report.ts";
|
|
16
|
+
export * from "./approvals/approvals.ts";
|
|
17
|
+
export * from "./scan/findings.ts";
|
|
18
|
+
export { scanRemote } from "./scan/remote.ts";
|
|
19
|
+
export { scanConfigFile, scanServers, scanSource } from "./scan/static.ts";
|
|
20
|
+
export { createControlPlane, type ControlPlane } from "./controlplane/server.ts";
|
|
21
|
+
export { loadConfig, hashApiKey, type ControlPlaneConfig } from "./controlplane/config.ts";
|
|
22
|
+
export { Bridge, type BridgeOptions } from "./bridge.ts";
|
|
23
|
+
export { login, discover, refreshSession, currentIdToken, loadSession, saveSession, clearSession, pkcePair, OidcError, type Session, type LoginOptions, type OidcMetadata } from "./oidc.ts";
|
|
24
|
+
export { exchangeToken, devLogin, McpHttpClient, type ExchangeOptions, type TaskToken } from "./client.ts";
|
|
25
|
+
export interface CreateGuardOptions extends Partial<Pick<GuardOptions, "mode" | "approvalWaitMs" | "pinning" | "redaction" | "source" | "log">> {
|
|
26
|
+
/** Canonical URL of the protected MCP server (token audience). */
|
|
27
|
+
resource: string;
|
|
28
|
+
/** Mandat control plane base URL. */
|
|
29
|
+
controlPlane: string;
|
|
30
|
+
/** Control plane API key with the `service` role. */
|
|
31
|
+
apiKey: string;
|
|
32
|
+
/** Use a local policy file (hot-reloaded) instead of the control plane's policy. */
|
|
33
|
+
policyFile?: string;
|
|
34
|
+
/** Directory for the policy snapshot and optional local ledger copy. Default ".mandat". */
|
|
35
|
+
dataDir?: string;
|
|
36
|
+
/** Also keep a local hash-chained copy of this guard's records. */
|
|
37
|
+
localLedger?: boolean;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Creates a Guard wired to a Mandat control plane: JWKS + issuer discovered from AS metadata,
|
|
41
|
+
* revocations polled every 2 s, policy pulled with an offline snapshot, approvals and ledger over HTTP.
|
|
42
|
+
*/
|
|
43
|
+
export declare function createGuard(o: CreateGuardOptions): Promise<Guard>;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// Public API of the `mandat` package.
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { Guard } from "./guard/guard.js";
|
|
4
|
+
import { FilePolicy, HttpLedgerSink, RemoteApprovalBroker, RemotePolicy, RemoteRevocations } from "./guard/remote.js";
|
|
5
|
+
import { FileLedger } from "./ledger/ledger.js";
|
|
6
|
+
export * from "./policy/engine.js";
|
|
7
|
+
export { compileExpr, ExprError } from "./policy/expr.js";
|
|
8
|
+
export * from "./guard/guard.js";
|
|
9
|
+
export * from "./guard/pinning.js";
|
|
10
|
+
export * from "./guard/remote.js";
|
|
11
|
+
export * from "./mcp/server.js";
|
|
12
|
+
export { protect } from "./mcp/protect.js";
|
|
13
|
+
export * from "./sts/sts.js";
|
|
14
|
+
export * from "./sts/jwt.js";
|
|
15
|
+
export * from "./sts/revocation.js";
|
|
16
|
+
export * from "./ledger/ledger.js";
|
|
17
|
+
export * from "./ledger/redact.js";
|
|
18
|
+
export * from "./ledger/export.js";
|
|
19
|
+
export * from "./ledger/report.js";
|
|
20
|
+
export * from "./approvals/approvals.js";
|
|
21
|
+
export * from "./scan/findings.js";
|
|
22
|
+
export { scanRemote } from "./scan/remote.js";
|
|
23
|
+
export { scanConfigFile, scanServers, scanSource } from "./scan/static.js";
|
|
24
|
+
export { createControlPlane } from "./controlplane/server.js";
|
|
25
|
+
export { loadConfig, hashApiKey } from "./controlplane/config.js";
|
|
26
|
+
export { Bridge } from "./bridge.js";
|
|
27
|
+
export { login, discover, refreshSession, currentIdToken, loadSession, saveSession, clearSession, pkcePair, OidcError } from "./oidc.js";
|
|
28
|
+
export { exchangeToken, devLogin, McpHttpClient } from "./client.js";
|
|
29
|
+
/**
|
|
30
|
+
* Creates a Guard wired to a Mandat control plane: JWKS + issuer discovered from AS metadata,
|
|
31
|
+
* revocations polled every 2 s, policy pulled with an offline snapshot, approvals and ledger over HTTP.
|
|
32
|
+
*/
|
|
33
|
+
export async function createGuard(o) {
|
|
34
|
+
const cp = { url: o.controlPlane, apiKey: o.apiKey };
|
|
35
|
+
const dataDir = o.dataDir ?? ".mandat";
|
|
36
|
+
const meta = await fetch(`${o.controlPlane.replace(/\/$/, "")}/.well-known/oauth-authorization-server`, { signal: AbortSignal.timeout(5000) })
|
|
37
|
+
.then((r) => (r.ok ? r.json() : null))
|
|
38
|
+
.catch(() => null);
|
|
39
|
+
const issuer = meta?.issuer ?? o.controlPlane.replace(/\/$/, "");
|
|
40
|
+
const revocations = new RemoteRevocations(cp);
|
|
41
|
+
await revocations.refresh();
|
|
42
|
+
let policy;
|
|
43
|
+
if (o.policyFile) {
|
|
44
|
+
policy = new FilePolicy(o.policyFile);
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
const rp = new RemotePolicy(cp, { snapshotPath: join(dataDir, "policy.snapshot.json") });
|
|
48
|
+
await rp.refresh();
|
|
49
|
+
rp.current(); // throws if neither control plane nor snapshot is available
|
|
50
|
+
policy = rp;
|
|
51
|
+
}
|
|
52
|
+
return new Guard({
|
|
53
|
+
resource: o.resource,
|
|
54
|
+
issuer,
|
|
55
|
+
jwksUri: meta?.jwks_uri,
|
|
56
|
+
policy,
|
|
57
|
+
revocations,
|
|
58
|
+
approvals: new RemoteApprovalBroker(cp),
|
|
59
|
+
ledger: new HttpLedgerSink(cp, { local: o.localLedger ? new FileLedger(join(dataDir, "guard-ledger.jsonl")) : undefined }),
|
|
60
|
+
mode: o.mode,
|
|
61
|
+
approvalWaitMs: o.approvalWaitMs,
|
|
62
|
+
pinning: o.pinning,
|
|
63
|
+
redaction: o.redaction,
|
|
64
|
+
source: o.source,
|
|
65
|
+
log: o.log,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { ActionRecord } from "./ledger.ts";
|
|
2
|
+
export declare function toOcsf(r: ActionRecord): Record<string, unknown>;
|
|
3
|
+
export declare function describe(r: ActionRecord): string;
|
|
4
|
+
export declare function toOtelSpan(r: ActionRecord): Record<string, unknown>;
|
|
5
|
+
export interface OtlpExporterOptions {
|
|
6
|
+
/** Base OTLP/HTTP endpoint, e.g. http://localhost:4318 (".../v1/traces" is appended). */
|
|
7
|
+
endpoint: string;
|
|
8
|
+
headers?: Record<string, string>;
|
|
9
|
+
serviceName?: string;
|
|
10
|
+
batchSize?: number;
|
|
11
|
+
flushIntervalMs?: number;
|
|
12
|
+
maxQueue?: number;
|
|
13
|
+
}
|
|
14
|
+
/** Off the critical path: `push` only enqueues; a timer flushes. Failed batches are re-queued with backoff. */
|
|
15
|
+
export declare class OtlpExporter {
|
|
16
|
+
private queue;
|
|
17
|
+
private timer;
|
|
18
|
+
private flushing;
|
|
19
|
+
private failures;
|
|
20
|
+
private opts;
|
|
21
|
+
dropped: number;
|
|
22
|
+
constructor(opts: OtlpExporterOptions);
|
|
23
|
+
push(r: ActionRecord): void;
|
|
24
|
+
flush(): Promise<void>;
|
|
25
|
+
get backoffActive(): boolean;
|
|
26
|
+
close(): Promise<void>;
|
|
27
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// Ledger -> SIEM / observability formats:
|
|
2
|
+
// - OCSF 1.x "API Activity" (class 6003) events, type `agent.tool_call` in `metadata.labels`
|
|
3
|
+
// - OpenTelemetry spans following the GenAI semantic conventions (`execute_tool`) + `mandat.*` attributes,
|
|
4
|
+
// shipped over OTLP/HTTP JSON. Delivery is asynchronous, batched and retried (at-least-once).
|
|
5
|
+
import { sha256 } from "../util.js";
|
|
6
|
+
const OCSF_VERSION = "1.3.0";
|
|
7
|
+
export function toOcsf(r) {
|
|
8
|
+
const activity = r.tool?.risk === "read" ? 2 : r.tool?.risk === "destructive" ? 4 : r.tool?.risk === "write" ? 3 : 99;
|
|
9
|
+
const failed = r.outcome?.status === "error" || r.outcome?.status === "blocked";
|
|
10
|
+
const severity = r.decision?.final === "deny" ? 3 : r.tool?.risk === "destructive" || r.tool?.risk === "financial" ? 2 : 1;
|
|
11
|
+
return {
|
|
12
|
+
class_uid: 6003,
|
|
13
|
+
class_name: "API Activity",
|
|
14
|
+
category_uid: 6,
|
|
15
|
+
category_name: "Application Activity",
|
|
16
|
+
activity_id: activity,
|
|
17
|
+
type_uid: 6003 * 100 + activity,
|
|
18
|
+
time: Date.parse(r.ts),
|
|
19
|
+
severity_id: severity,
|
|
20
|
+
status_id: failed ? 2 : 1,
|
|
21
|
+
status: r.outcome?.status ?? r.decision?.final,
|
|
22
|
+
status_detail: r.outcome?.error ?? r.decision?.reasons?.join("; "),
|
|
23
|
+
message: describe(r),
|
|
24
|
+
metadata: {
|
|
25
|
+
version: OCSF_VERSION,
|
|
26
|
+
uid: r.id,
|
|
27
|
+
correlation_uid: r.task_id ?? undefined,
|
|
28
|
+
labels: [r.type, ...(r.tags ?? [])],
|
|
29
|
+
product: { name: "Mandat", vendor_name: "Mandat", version: "0.1.0" },
|
|
30
|
+
sequence: r.seq,
|
|
31
|
+
},
|
|
32
|
+
actor: {
|
|
33
|
+
user: r.principal ? { uid: r.principal.sub, email_addr: r.principal.email ?? undefined, org: r.principal.tenant ? { uid: r.principal.tenant } : undefined } : undefined,
|
|
34
|
+
app_name: r.agent?.id,
|
|
35
|
+
invoked_by: r.actor_chain?.join(" <- "),
|
|
36
|
+
authorizations: r.decision ? [{ decision: r.decision.final, policy: { uid: r.decision.policy_version, name: r.decision.rules.join(",") } }] : undefined,
|
|
37
|
+
},
|
|
38
|
+
api: r.tool ? { operation: r.tool.name, service: { name: r.tool.server ?? "unknown" }, request: { uid: r.id } } : undefined,
|
|
39
|
+
unmapped: {
|
|
40
|
+
mandat: {
|
|
41
|
+
record_hash: r.hash,
|
|
42
|
+
prev_hash: r.prev_hash,
|
|
43
|
+
effect: r.decision?.effect,
|
|
44
|
+
args: r.args,
|
|
45
|
+
approval: r.approval,
|
|
46
|
+
session: r.session,
|
|
47
|
+
token_jti: r.token_jti,
|
|
48
|
+
agent_version: r.agent?.version,
|
|
49
|
+
prompt_hash: r.agent?.prompt_hash,
|
|
50
|
+
details: r.details,
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
export function describe(r) {
|
|
56
|
+
if (r.type === "agent.tool_call") {
|
|
57
|
+
const who = r.principal?.sub ?? "?";
|
|
58
|
+
return `${r.agent?.id ?? "?"} on behalf of ${who} called ${r.tool?.name} -> ${r.decision?.final ?? "?"} (${r.decision?.rules.join(",")})`;
|
|
59
|
+
}
|
|
60
|
+
return `${r.type}${r.details ? " " + JSON.stringify(r.details) : ""}`;
|
|
61
|
+
}
|
|
62
|
+
function attr(key, v) {
|
|
63
|
+
if (v === undefined || v === null)
|
|
64
|
+
return null;
|
|
65
|
+
if (typeof v === "boolean")
|
|
66
|
+
return { key, value: { boolValue: v } };
|
|
67
|
+
if (typeof v === "number")
|
|
68
|
+
return { key, value: Number.isInteger(v) ? { intValue: String(v) } : { doubleValue: v } };
|
|
69
|
+
return { key, value: { stringValue: typeof v === "string" ? v : JSON.stringify(v) } };
|
|
70
|
+
}
|
|
71
|
+
export function toOtelSpan(r) {
|
|
72
|
+
const endNs = BigInt(Date.parse(r.ts)) * 1000000n;
|
|
73
|
+
const durNs = BigInt(Math.round((r.outcome?.duration_ms ?? 0) * 1e6));
|
|
74
|
+
const isTool = r.type === "agent.tool_call";
|
|
75
|
+
const attributes = [
|
|
76
|
+
attr("gen_ai.operation.name", isTool ? "execute_tool" : r.type),
|
|
77
|
+
attr("gen_ai.tool.name", r.tool?.name),
|
|
78
|
+
attr("gen_ai.agent.id", r.agent?.id),
|
|
79
|
+
attr("gen_ai.agent.name", r.agent?.id),
|
|
80
|
+
attr("gen_ai.agent.version", r.agent?.version),
|
|
81
|
+
attr("mcp.server.url", r.tool?.server),
|
|
82
|
+
attr("enduser.id", r.principal?.sub),
|
|
83
|
+
attr("mandat.record.id", r.id),
|
|
84
|
+
attr("mandat.record.hash", r.hash),
|
|
85
|
+
attr("mandat.record.type", r.type),
|
|
86
|
+
attr("mandat.decision.effect", r.decision?.effect),
|
|
87
|
+
attr("mandat.decision.final", r.decision?.final),
|
|
88
|
+
attr("mandat.decision.rules", r.decision?.rules?.join(",")),
|
|
89
|
+
attr("mandat.decision.latency_us", r.decision?.latency_us),
|
|
90
|
+
attr("mandat.policy.version", r.decision?.policy_version),
|
|
91
|
+
attr("mandat.principal.tenant", r.principal?.tenant),
|
|
92
|
+
attr("mandat.actor_chain", r.actor_chain?.join(",")),
|
|
93
|
+
attr("mandat.task.id", r.task_id),
|
|
94
|
+
attr("mandat.token.jti", r.token_jti),
|
|
95
|
+
attr("mandat.tool.risk", r.tool?.risk),
|
|
96
|
+
attr("mandat.session.tainted", r.session?.tainted),
|
|
97
|
+
attr("mandat.approval.id", r.approval?.id),
|
|
98
|
+
attr("mandat.approval.status", r.approval?.status),
|
|
99
|
+
attr("mandat.prompt.hash", r.agent?.prompt_hash),
|
|
100
|
+
].filter(Boolean);
|
|
101
|
+
return {
|
|
102
|
+
traceId: sha256(r.task_id ?? r.id).slice(0, 32),
|
|
103
|
+
spanId: sha256(r.id).slice(0, 16),
|
|
104
|
+
name: isTool ? `execute_tool ${r.tool?.name}` : r.type,
|
|
105
|
+
kind: isTool ? 3 /* CLIENT */ : 1 /* INTERNAL */,
|
|
106
|
+
startTimeUnixNano: String(endNs - durNs),
|
|
107
|
+
endTimeUnixNano: String(endNs),
|
|
108
|
+
attributes,
|
|
109
|
+
status: r.outcome?.status === "error" || r.decision?.final === "deny" ? { code: 2, message: r.outcome?.error ?? r.decision?.reasons?.join("; ") } : { code: 1 },
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
/** Off the critical path: `push` only enqueues; a timer flushes. Failed batches are re-queued with backoff. */
|
|
113
|
+
export class OtlpExporter {
|
|
114
|
+
queue = [];
|
|
115
|
+
timer;
|
|
116
|
+
flushing = false;
|
|
117
|
+
failures = 0;
|
|
118
|
+
opts;
|
|
119
|
+
dropped = 0;
|
|
120
|
+
constructor(opts) {
|
|
121
|
+
this.opts = { serviceName: "mandat", batchSize: 200, flushIntervalMs: 1000, maxQueue: 50_000, headers: {}, ...opts };
|
|
122
|
+
this.timer = setInterval(() => void this.flush(), this.opts.flushIntervalMs);
|
|
123
|
+
this.timer.unref();
|
|
124
|
+
}
|
|
125
|
+
push(r) {
|
|
126
|
+
if (this.queue.length >= this.opts.maxQueue) {
|
|
127
|
+
this.queue.shift();
|
|
128
|
+
this.dropped++;
|
|
129
|
+
}
|
|
130
|
+
this.queue.push(r);
|
|
131
|
+
}
|
|
132
|
+
async flush() {
|
|
133
|
+
if (this.flushing || !this.queue.length)
|
|
134
|
+
return;
|
|
135
|
+
this.flushing = true;
|
|
136
|
+
try {
|
|
137
|
+
while (this.queue.length) {
|
|
138
|
+
const batch = this.queue.slice(0, this.opts.batchSize);
|
|
139
|
+
const body = {
|
|
140
|
+
resourceSpans: [{
|
|
141
|
+
resource: { attributes: [attr("service.name", this.opts.serviceName)] },
|
|
142
|
+
scopeSpans: [{ scope: { name: "mandat", version: "0.1.0" }, spans: batch.map(toOtelSpan) }],
|
|
143
|
+
}],
|
|
144
|
+
};
|
|
145
|
+
const res = await fetch(this.opts.endpoint.replace(/\/$/, "") + "/v1/traces", {
|
|
146
|
+
method: "POST",
|
|
147
|
+
headers: { "content-type": "application/json", ...this.opts.headers },
|
|
148
|
+
body: JSON.stringify(body),
|
|
149
|
+
signal: AbortSignal.timeout(5000),
|
|
150
|
+
});
|
|
151
|
+
if (!res.ok)
|
|
152
|
+
throw new Error(`OTLP export failed: ${res.status}`);
|
|
153
|
+
this.queue.splice(0, batch.length);
|
|
154
|
+
this.failures = 0;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
this.failures++;
|
|
159
|
+
}
|
|
160
|
+
finally {
|
|
161
|
+
this.flushing = false;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
get backoffActive() { return this.failures > 0; }
|
|
165
|
+
async close() {
|
|
166
|
+
clearInterval(this.timer);
|
|
167
|
+
await this.flush();
|
|
168
|
+
}
|
|
169
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import type { Effect, Obligation, Risk } from "../policy/engine.ts";
|
|
2
|
+
export declare const GENESIS_HASH: string;
|
|
3
|
+
export type RecordType = "agent.tool_call" | "approval.requested" | "approval.decided" | "token.issued" | "revocation.created" | "revocation.lifted" | "policy.changed" | "tool.drift" | "agent.registered" | "agent.updated";
|
|
4
|
+
export interface ActionRecord {
|
|
5
|
+
id: string;
|
|
6
|
+
seq: number;
|
|
7
|
+
ts: string;
|
|
8
|
+
type: RecordType;
|
|
9
|
+
prev_hash: string;
|
|
10
|
+
hash: string;
|
|
11
|
+
/** Where the record was produced (guard instance / control plane). */
|
|
12
|
+
source?: string;
|
|
13
|
+
principal?: {
|
|
14
|
+
sub: string;
|
|
15
|
+
tenant?: string | null;
|
|
16
|
+
email?: string | null;
|
|
17
|
+
};
|
|
18
|
+
agent?: {
|
|
19
|
+
id: string;
|
|
20
|
+
version?: string | null;
|
|
21
|
+
prompt_hash?: string | null;
|
|
22
|
+
};
|
|
23
|
+
/** Outermost actor first: [current agent, delegating agent, ...]. The human is `principal`. */
|
|
24
|
+
actor_chain?: string[];
|
|
25
|
+
task_id?: string | null;
|
|
26
|
+
token_jti?: string | null;
|
|
27
|
+
session?: {
|
|
28
|
+
id?: string | null;
|
|
29
|
+
tainted?: boolean;
|
|
30
|
+
taint_sources?: string[];
|
|
31
|
+
};
|
|
32
|
+
tool?: {
|
|
33
|
+
name: string;
|
|
34
|
+
server?: string | null;
|
|
35
|
+
risk?: Risk | null;
|
|
36
|
+
definition_hash?: string | null;
|
|
37
|
+
};
|
|
38
|
+
args?: unknown;
|
|
39
|
+
decision?: {
|
|
40
|
+
effect: Effect;
|
|
41
|
+
final: "allow" | "deny" | "pending";
|
|
42
|
+
rules: string[];
|
|
43
|
+
reasons: string[];
|
|
44
|
+
obligations?: Obligation[];
|
|
45
|
+
policy_version?: string;
|
|
46
|
+
latency_us?: number;
|
|
47
|
+
mode?: "enforce" | "shadow";
|
|
48
|
+
};
|
|
49
|
+
approval?: {
|
|
50
|
+
id: string;
|
|
51
|
+
status: string;
|
|
52
|
+
approvers?: string[];
|
|
53
|
+
required?: number;
|
|
54
|
+
decided_at?: string | null;
|
|
55
|
+
};
|
|
56
|
+
outcome?: {
|
|
57
|
+
status: "success" | "error" | "blocked" | "pending";
|
|
58
|
+
error?: string;
|
|
59
|
+
duration_ms?: number;
|
|
60
|
+
};
|
|
61
|
+
tags?: string[];
|
|
62
|
+
/** Free-form details for non tool-call events (revocations, policy changes, drift). */
|
|
63
|
+
details?: Record<string, unknown>;
|
|
64
|
+
}
|
|
65
|
+
export type RecordInput = Omit<ActionRecord, "id" | "seq" | "ts" | "prev_hash" | "hash"> & {
|
|
66
|
+
ts?: string;
|
|
67
|
+
id?: string;
|
|
68
|
+
};
|
|
69
|
+
export interface LedgerSink {
|
|
70
|
+
append(rec: RecordInput): ActionRecord | void;
|
|
71
|
+
/** Deliver buffered records now (asynchronous sinks). */
|
|
72
|
+
flush?(): Promise<void>;
|
|
73
|
+
close?(): Promise<void> | void;
|
|
74
|
+
}
|
|
75
|
+
export declare function computeHash(rec: Omit<ActionRecord, "hash">): string;
|
|
76
|
+
export declare class FileLedger implements LedgerSink {
|
|
77
|
+
readonly path: string;
|
|
78
|
+
private head;
|
|
79
|
+
private seq;
|
|
80
|
+
private listeners;
|
|
81
|
+
constructor(path: string);
|
|
82
|
+
get headHash(): string;
|
|
83
|
+
get length(): number;
|
|
84
|
+
append(input: RecordInput): ActionRecord;
|
|
85
|
+
onAppend(fn: (r: ActionRecord) => void): () => void;
|
|
86
|
+
read(): ActionRecord[];
|
|
87
|
+
}
|
|
88
|
+
/** In-memory sink, handy in tests. */
|
|
89
|
+
export declare class MemoryLedger implements LedgerSink {
|
|
90
|
+
records: ActionRecord[];
|
|
91
|
+
append(input: RecordInput): ActionRecord;
|
|
92
|
+
}
|
|
93
|
+
export declare function readLedger(path: string): ActionRecord[];
|
|
94
|
+
export interface VerifyResult {
|
|
95
|
+
ok: boolean;
|
|
96
|
+
count: number;
|
|
97
|
+
head: string;
|
|
98
|
+
first_ts?: string;
|
|
99
|
+
last_ts?: string;
|
|
100
|
+
broken_at?: number;
|
|
101
|
+
error?: string;
|
|
102
|
+
}
|
|
103
|
+
export declare function verifyRecords(records: Iterable<ActionRecord | string>): VerifyResult;
|
|
104
|
+
export declare function verifyLedger(path: string): VerifyResult;
|
|
105
|
+
export interface LedgerQuery {
|
|
106
|
+
type?: string;
|
|
107
|
+
agent?: string;
|
|
108
|
+
sub?: string;
|
|
109
|
+
tool?: string;
|
|
110
|
+
task?: string;
|
|
111
|
+
effect?: string;
|
|
112
|
+
since?: string;
|
|
113
|
+
until?: string;
|
|
114
|
+
limit?: number;
|
|
115
|
+
order?: "asc" | "desc";
|
|
116
|
+
}
|
|
117
|
+
export declare function queryRecords(records: ActionRecord[], q: LedgerQuery): ActionRecord[];
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// Append-only, hash-chained audit ledger ("Action Records").
|
|
2
|
+
//
|
|
3
|
+
// hash_n = sha256( hash_{n-1} || "\n" || canonical_json(record_n without `hash`) )
|
|
4
|
+
//
|
|
5
|
+
// Any edit, deletion or reordering of a past line breaks every later hash; `verifyLedger`
|
|
6
|
+
// pinpoints the first broken record. One JSON object per line (JSONL).
|
|
7
|
+
import { appendFileSync, closeSync, existsSync, fstatSync, mkdirSync, openSync, readFileSync, readSync } from "node:fs";
|
|
8
|
+
import { dirname } from "node:path";
|
|
9
|
+
import { canonicalJson, randomId, sha256 } from "../util.js";
|
|
10
|
+
export const GENESIS_HASH = "0".repeat(64);
|
|
11
|
+
export function computeHash(rec) {
|
|
12
|
+
return sha256(rec.prev_hash + "\n" + canonicalJson(rec));
|
|
13
|
+
}
|
|
14
|
+
export class FileLedger {
|
|
15
|
+
path;
|
|
16
|
+
head = GENESIS_HASH;
|
|
17
|
+
seq = 0;
|
|
18
|
+
listeners = new Set();
|
|
19
|
+
constructor(path) {
|
|
20
|
+
this.path = path;
|
|
21
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
22
|
+
const last = readLastLine(path);
|
|
23
|
+
if (last) {
|
|
24
|
+
const rec = JSON.parse(last);
|
|
25
|
+
this.head = rec.hash;
|
|
26
|
+
this.seq = rec.seq;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
get headHash() { return this.head; }
|
|
30
|
+
get length() { return this.seq; }
|
|
31
|
+
append(input) {
|
|
32
|
+
const base = { ...input, id: input.id ?? randomId("rec_"), seq: this.seq + 1, ts: input.ts ?? new Date().toISOString(), prev_hash: this.head };
|
|
33
|
+
const clean = JSON.parse(JSON.stringify(base)); // drop undefined
|
|
34
|
+
const rec = { ...clean, hash: computeHash(clean) };
|
|
35
|
+
appendFileSync(this.path, JSON.stringify(rec) + "\n");
|
|
36
|
+
this.head = rec.hash;
|
|
37
|
+
this.seq = rec.seq;
|
|
38
|
+
for (const l of this.listeners) {
|
|
39
|
+
try {
|
|
40
|
+
l(rec);
|
|
41
|
+
}
|
|
42
|
+
catch { /* listener errors never break the ledger */ }
|
|
43
|
+
}
|
|
44
|
+
return rec;
|
|
45
|
+
}
|
|
46
|
+
onAppend(fn) {
|
|
47
|
+
this.listeners.add(fn);
|
|
48
|
+
return () => this.listeners.delete(fn);
|
|
49
|
+
}
|
|
50
|
+
read() {
|
|
51
|
+
return readLedger(this.path);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/** In-memory sink, handy in tests. */
|
|
55
|
+
export class MemoryLedger {
|
|
56
|
+
records = [];
|
|
57
|
+
append(input) {
|
|
58
|
+
const prev = this.records.at(-1);
|
|
59
|
+
const base = { ...input, id: input.id ?? randomId("rec_"), seq: (prev?.seq ?? 0) + 1, ts: input.ts ?? new Date().toISOString(), prev_hash: prev?.hash ?? GENESIS_HASH };
|
|
60
|
+
const clean = JSON.parse(JSON.stringify(base));
|
|
61
|
+
const rec = { ...clean, hash: computeHash(clean) };
|
|
62
|
+
this.records.push(rec);
|
|
63
|
+
return rec;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
export function readLedger(path) {
|
|
67
|
+
if (!existsSync(path))
|
|
68
|
+
return [];
|
|
69
|
+
return readFileSync(path, "utf8").split("\n").filter(Boolean).map((l) => JSON.parse(l));
|
|
70
|
+
}
|
|
71
|
+
export function verifyRecords(records) {
|
|
72
|
+
let prev = GENESIS_HASH;
|
|
73
|
+
let count = 0;
|
|
74
|
+
let first_ts;
|
|
75
|
+
let last_ts;
|
|
76
|
+
for (const item of records) {
|
|
77
|
+
count++;
|
|
78
|
+
let rec;
|
|
79
|
+
try {
|
|
80
|
+
rec = typeof item === "string" ? JSON.parse(item) : item;
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
return { ok: false, count, head: prev, broken_at: count, error: `Line ${count} is not valid JSON` };
|
|
84
|
+
}
|
|
85
|
+
const { hash, ...rest } = rec;
|
|
86
|
+
if (rec.seq !== count)
|
|
87
|
+
return { ok: false, count, head: prev, broken_at: count, error: `Record ${count} has seq ${rec.seq} (records missing or reordered)` };
|
|
88
|
+
if (rec.prev_hash !== prev)
|
|
89
|
+
return { ok: false, count, head: prev, broken_at: count, error: `Record ${count} prev_hash does not match previous record (chain broken)` };
|
|
90
|
+
if (computeHash(rest) !== hash)
|
|
91
|
+
return { ok: false, count, head: prev, broken_at: count, error: `Record ${count} content does not match its hash (tampered)` };
|
|
92
|
+
prev = hash;
|
|
93
|
+
first_ts ??= rec.ts;
|
|
94
|
+
last_ts = rec.ts;
|
|
95
|
+
}
|
|
96
|
+
return { ok: true, count, head: prev, first_ts, last_ts };
|
|
97
|
+
}
|
|
98
|
+
export function verifyLedger(path) {
|
|
99
|
+
if (!existsSync(path))
|
|
100
|
+
return { ok: true, count: 0, head: GENESIS_HASH };
|
|
101
|
+
return verifyRecords(readFileSync(path, "utf8").split("\n").filter(Boolean));
|
|
102
|
+
}
|
|
103
|
+
export function queryRecords(records, q) {
|
|
104
|
+
let out = records.filter((r) => (!q.type || r.type === q.type || (q.type.endsWith("*") && r.type.startsWith(q.type.slice(0, -1)))) &&
|
|
105
|
+
(!q.agent || r.agent?.id === q.agent || r.actor_chain?.includes(q.agent)) &&
|
|
106
|
+
(!q.sub || r.principal?.sub === q.sub) &&
|
|
107
|
+
(!q.tool || r.tool?.name === q.tool) &&
|
|
108
|
+
(!q.task || r.task_id === q.task) &&
|
|
109
|
+
(!q.effect || r.decision?.effect === q.effect || r.decision?.final === q.effect) &&
|
|
110
|
+
(!q.since || r.ts >= q.since) &&
|
|
111
|
+
(!q.until || r.ts <= q.until));
|
|
112
|
+
if (q.order !== "asc")
|
|
113
|
+
out = out.reverse();
|
|
114
|
+
return q.limit ? out.slice(0, q.limit) : out;
|
|
115
|
+
}
|
|
116
|
+
function readLastLine(path) {
|
|
117
|
+
if (!existsSync(path))
|
|
118
|
+
return null;
|
|
119
|
+
const fd = openSync(path, "r");
|
|
120
|
+
try {
|
|
121
|
+
const size = fstatSync(fd).size;
|
|
122
|
+
if (size === 0)
|
|
123
|
+
return null;
|
|
124
|
+
let chunk = 64 * 1024;
|
|
125
|
+
for (;;) {
|
|
126
|
+
const start = Math.max(0, size - chunk);
|
|
127
|
+
const buf = Buffer.alloc(size - start);
|
|
128
|
+
readSync(fd, buf, 0, buf.length, start);
|
|
129
|
+
const text = buf.toString("utf8").replace(/\n+$/, "");
|
|
130
|
+
const idx = text.lastIndexOf("\n");
|
|
131
|
+
if (idx >= 0 || start === 0)
|
|
132
|
+
return text.slice(idx + 1) || null;
|
|
133
|
+
chunk *= 4;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
finally {
|
|
137
|
+
closeSync(fd);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export interface RedactionOptions {
|
|
2
|
+
/** Extra key names (case-insensitive substrings) whose values are always removed. */
|
|
3
|
+
keys?: string[];
|
|
4
|
+
/** Built-in string patterns to mask. Default: all. */
|
|
5
|
+
patterns?: Array<"email" | "card" | "phone" | "iban" | "jwt" | "secret">;
|
|
6
|
+
/** Explicit arg paths to replace with a hash ("customer.email"). */
|
|
7
|
+
fields?: string[];
|
|
8
|
+
/** Metadata-only mode: drop all values, keep keys + a hash per value. */
|
|
9
|
+
metadataOnly?: boolean;
|
|
10
|
+
}
|
|
11
|
+
export declare function hashValue(v: unknown): string;
|
|
12
|
+
export declare function redactString(s: string, patterns: RedactionOptions["patterns"]): string;
|
|
13
|
+
export declare function redact(value: unknown, opts?: RedactionOptions): unknown;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// PII / secret redaction applied to tool arguments BEFORE they reach any log, ledger or approval message.
|
|
2
|
+
import { sha256 } from "../util.js";
|
|
3
|
+
// Substring match on key names, plus short names that only match exactly (so "pinned" stays visible).
|
|
4
|
+
const SECRET_KEYS = ["password", "passwd", "secret", "token", "apikey", "api_key", "api-key", "authorization", "cookie", "private_key"];
|
|
5
|
+
const SECRET_KEYS_EXACT = ["pin", "cvv", "cvc", "ssn"];
|
|
6
|
+
const PATTERNS = {
|
|
7
|
+
jwt: { re: /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g, fn: () => "[JWT]" },
|
|
8
|
+
secret: { re: /\b(?:sk|pk|rk|xox[abpr]|ghp|gho|github_pat|AKIA)[-_A-Za-z0-9]{12,}\b/g, fn: () => "[SECRET]" },
|
|
9
|
+
email: { re: /\b([A-Za-z0-9._%+-])[A-Za-z0-9._%+-]*@([A-Za-z0-9.-]+\.[A-Za-z]{2,})\b/g, fn: (_, first, dom) => `${first}***@${dom}` },
|
|
10
|
+
iban: { re: /\b[A-Z]{2}\d{2}(?:\s?[A-Z0-9]{4}){2,7}(?:\s?[A-Z0-9]{1,4})?\b/g, fn: () => "[IBAN]" },
|
|
11
|
+
card: {
|
|
12
|
+
re: /\b(?:\d[ -]?){13,19}\b/g,
|
|
13
|
+
fn: (m) => {
|
|
14
|
+
const d = m.replace(/\D/g, "");
|
|
15
|
+
return luhn(d) ? `[CARD ****${d.slice(-4)}]` : m;
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
phone: { re: /(?<![\w.])\+\d[\d\s().-]{8,16}\d\b/g, fn: () => "[PHONE]" },
|
|
19
|
+
};
|
|
20
|
+
function luhn(d) {
|
|
21
|
+
if (d.length < 13 || d.length > 19)
|
|
22
|
+
return false;
|
|
23
|
+
let sum = 0;
|
|
24
|
+
for (let i = 0; i < d.length; i++) {
|
|
25
|
+
let n = Number(d[d.length - 1 - i]);
|
|
26
|
+
if (i % 2 === 1) {
|
|
27
|
+
n *= 2;
|
|
28
|
+
if (n > 9)
|
|
29
|
+
n -= 9;
|
|
30
|
+
}
|
|
31
|
+
sum += n;
|
|
32
|
+
}
|
|
33
|
+
return sum % 10 === 0;
|
|
34
|
+
}
|
|
35
|
+
export function hashValue(v) {
|
|
36
|
+
return "sha256:" + sha256(JSON.stringify(v) ?? "null").slice(0, 16);
|
|
37
|
+
}
|
|
38
|
+
export function redactString(s, patterns) {
|
|
39
|
+
let out = s;
|
|
40
|
+
for (const name of patterns ?? Object.keys(PATTERNS)) {
|
|
41
|
+
const p = PATTERNS[name];
|
|
42
|
+
out = out.replace(p.re, p.fn);
|
|
43
|
+
}
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
46
|
+
export function redact(value, opts = {}) {
|
|
47
|
+
const keys = [...SECRET_KEYS, ...(opts.keys ?? [])].map((k) => k.toLowerCase());
|
|
48
|
+
const fields = new Set(opts.fields ?? []);
|
|
49
|
+
const walk = (v, path, depth) => {
|
|
50
|
+
if (fields.has(path))
|
|
51
|
+
return `[REDACTED ${hashValue(v)}]`;
|
|
52
|
+
if (depth > 12)
|
|
53
|
+
return "[TRUNCATED]";
|
|
54
|
+
if (opts.metadataOnly && (v === null || typeof v !== "object"))
|
|
55
|
+
return v === null ? null : `[${typeof v} ${hashValue(v)}]`;
|
|
56
|
+
if (typeof v === "string")
|
|
57
|
+
return redactString(v.length > 4000 ? v.slice(0, 4000) + "…[truncated]" : v, opts.patterns);
|
|
58
|
+
if (Array.isArray(v))
|
|
59
|
+
return v.slice(0, 200).map((x, i) => walk(x, path ? `${path}.${i}` : String(i), depth + 1));
|
|
60
|
+
if (v && typeof v === "object") {
|
|
61
|
+
const out = {};
|
|
62
|
+
for (const [k, x] of Object.entries(v)) {
|
|
63
|
+
const p = path ? `${path}.${k}` : k;
|
|
64
|
+
const lk = k.toLowerCase();
|
|
65
|
+
const secret = keys.some((s) => lk.includes(s)) || SECRET_KEYS_EXACT.includes(lk);
|
|
66
|
+
out[k] = secret && !fields.has(p) ? "[REDACTED]" : walk(x, p, depth + 1);
|
|
67
|
+
}
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
return v;
|
|
71
|
+
};
|
|
72
|
+
return walk(value, "", 0);
|
|
73
|
+
}
|