halo-record 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 +204 -0
- package/README.md +109 -0
- package/dist/anchor.d.ts +22 -0
- package/dist/anchor.js +91 -0
- package/dist/canon.d.ts +8 -0
- package/dist/canon.js +99 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +15 -0
- package/dist/integrations/claudeAgent.d.ts +29 -0
- package/dist/integrations/claudeAgent.js +44 -0
- package/dist/integrations/common.d.ts +53 -0
- package/dist/integrations/common.js +134 -0
- package/dist/integrations/langchain.d.ts +26 -0
- package/dist/integrations/langchain.js +49 -0
- package/dist/integrations/mcp.d.ts +31 -0
- package/dist/integrations/mcp.js +92 -0
- package/dist/integrations/openaiAgents.d.ts +26 -0
- package/dist/integrations/openaiAgents.js +41 -0
- package/dist/integrations/vercel.d.ts +38 -0
- package/dist/integrations/vercel.js +85 -0
- package/dist/record.d.ts +55 -0
- package/dist/record.js +222 -0
- package/dist/redact.d.ts +11 -0
- package/dist/redact.js +75 -0
- package/dist/verify.d.ts +13 -0
- package/dist/verify.js +103 -0
- package/dist/witness.d.ts +8 -0
- package/dist/witness.js +36 -0
- package/halo-record.schema.json +146 -0
- package/package.json +31 -0
package/dist/record.js
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/* Build and append Halo Runtime Records (Schema v0.1).
|
|
2
|
+
Port of the Python implementation (record.py), including the symmetric
|
|
3
|
+
input/outcome redaction. Records written here verify with the Python
|
|
4
|
+
verifier and anchor to the same witness — the chain format is language-
|
|
5
|
+
independent. */
|
|
6
|
+
import { randomUUID } from "node:crypto";
|
|
7
|
+
import { appendFileSync, existsSync, readFileSync } from "node:fs";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { homedir } from "node:os";
|
|
10
|
+
import { GENESIS_PREV, computeHash, inputHash } from "./canon.js";
|
|
11
|
+
import { redactText, scan, topSeverity } from "./redact.js";
|
|
12
|
+
export const SCHEMA_VERSION = "0.1";
|
|
13
|
+
export const ACTION_TYPES = new Set(["tool_call", "agent_message", "read", "write", "network"]);
|
|
14
|
+
export const CATEGORIES = new Set(["security", "safety", "reliability", "privacy"]);
|
|
15
|
+
/* Where a record came from — "captured" (Halo saw the call at the trust
|
|
16
|
+
boundary; strongest) vs "ingested" (built from telemetry the vendor already
|
|
17
|
+
emits; weaker). Same table as the Python package. */
|
|
18
|
+
export const SOURCES = {
|
|
19
|
+
recorder: { adapter: "recorder", via: "Halo recorder (native)", capture: "captured" },
|
|
20
|
+
mcp: { adapter: "mcp", via: "MCP interceptor", capture: "captured" },
|
|
21
|
+
langchain: { adapter: "langchain", via: "LangChain / LangGraph", capture: "captured" },
|
|
22
|
+
openai_agents: { adapter: "openai_agents", via: "OpenAI Agents SDK", capture: "captured" },
|
|
23
|
+
vercel_ai: { adapter: "vercel_ai", via: "Vercel AI SDK", capture: "captured" },
|
|
24
|
+
claude_agent_sdk: { adapter: "claude_agent_sdk", via: "Claude Agent SDK", capture: "captured" },
|
|
25
|
+
otel: { adapter: "otel", via: "OpenTelemetry GenAI spans", capture: "ingested" },
|
|
26
|
+
litellm: { adapter: "litellm", via: "LiteLLM gateway", capture: "ingested" },
|
|
27
|
+
langfuse: { adapter: "langfuse", via: "Langfuse traces", capture: "ingested" },
|
|
28
|
+
gateway: { adapter: "gateway", via: "LLM gateway / proxy log", capture: "ingested" },
|
|
29
|
+
};
|
|
30
|
+
/* Unknown ids fall back to "ingested" — the conservative tier, so an
|
|
31
|
+
unrecognized origin is never overstated as boundary-captured. */
|
|
32
|
+
export function normalizeSource(source) {
|
|
33
|
+
if (source == null)
|
|
34
|
+
return null;
|
|
35
|
+
if (typeof source === "string") {
|
|
36
|
+
return { ...(SOURCES[source] ?? { adapter: source, via: source, capture: "ingested" }) };
|
|
37
|
+
}
|
|
38
|
+
const src = { ...source };
|
|
39
|
+
src.capture ??= "ingested";
|
|
40
|
+
src.via ??= src.adapter ?? "unknown";
|
|
41
|
+
return src;
|
|
42
|
+
}
|
|
43
|
+
function now() {
|
|
44
|
+
return new Date().toISOString();
|
|
45
|
+
}
|
|
46
|
+
function normSubject(subject) {
|
|
47
|
+
if (subject == null)
|
|
48
|
+
return null;
|
|
49
|
+
if (typeof subject === "string")
|
|
50
|
+
return { id: subject };
|
|
51
|
+
return subject;
|
|
52
|
+
}
|
|
53
|
+
/* Construct a v0.1 record (without integrity.hash filled in). tool_input is
|
|
54
|
+
hashed and, by default, a redacted summary is stored; raw arguments never
|
|
55
|
+
enter the record. Outcome summaries are redacted and scanned symmetrically
|
|
56
|
+
with input. */
|
|
57
|
+
export function build(actionType, category, opts = {}) {
|
|
58
|
+
const { tool, toolInput, sessionId = "local", agent, scope, decision = "allowed", approver, outcome: outcomeIn, ts, subject, source, summaries = true, } = opts;
|
|
59
|
+
let findings = opts.findings ?? null;
|
|
60
|
+
if (!ACTION_TYPES.has(actionType)) {
|
|
61
|
+
throw new RangeError("action.type must be one of " + [...ACTION_TYPES].sort().join(", "));
|
|
62
|
+
}
|
|
63
|
+
if (!CATEGORIES.has(category)) {
|
|
64
|
+
throw new RangeError("action.category must be one of " + [...CATEGORIES].sort().join(", "));
|
|
65
|
+
}
|
|
66
|
+
const action = { type: actionType, category };
|
|
67
|
+
if (tool !== undefined)
|
|
68
|
+
action["tool"] = tool;
|
|
69
|
+
if (scope !== undefined || decision !== undefined) {
|
|
70
|
+
const auth = { decision };
|
|
71
|
+
if (scope !== undefined)
|
|
72
|
+
auth["scope"] = scope;
|
|
73
|
+
if (approver !== undefined)
|
|
74
|
+
auth["approver"] = approver;
|
|
75
|
+
action["authorization"] = auth;
|
|
76
|
+
}
|
|
77
|
+
if (toolInput !== undefined) {
|
|
78
|
+
const inp = { hash: inputHash(toolInput) };
|
|
79
|
+
if (summaries)
|
|
80
|
+
inp["summary"] = redactText(stringify(toolInput)).slice(0, 200);
|
|
81
|
+
action["input"] = inp;
|
|
82
|
+
}
|
|
83
|
+
// Normalize the outcome up front so its summary is redacted before it is
|
|
84
|
+
// sealed/served and so it can be scanned for secrets alongside the input.
|
|
85
|
+
let outcome = null;
|
|
86
|
+
let outcomeSummaryRaw = null;
|
|
87
|
+
if (outcomeIn != null) {
|
|
88
|
+
outcome = { ...outcomeIn };
|
|
89
|
+
if ("summary" in outcome && outcome["summary"] != null) {
|
|
90
|
+
outcomeSummaryRaw = String(outcome["summary"]);
|
|
91
|
+
}
|
|
92
|
+
if (!summaries) {
|
|
93
|
+
delete outcome["summary"];
|
|
94
|
+
}
|
|
95
|
+
else if (outcomeSummaryRaw !== null) {
|
|
96
|
+
outcome["summary"] = redactText(outcomeSummaryRaw).slice(0, 200);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (findings == null) {
|
|
100
|
+
findings = [];
|
|
101
|
+
if (toolInput !== undefined)
|
|
102
|
+
findings.push(...scan(stringify(toolInput)));
|
|
103
|
+
if (outcomeSummaryRaw !== null)
|
|
104
|
+
findings.push(...scan(outcomeSummaryRaw));
|
|
105
|
+
}
|
|
106
|
+
const record = {
|
|
107
|
+
schema_version: SCHEMA_VERSION,
|
|
108
|
+
record_id: randomUUID(),
|
|
109
|
+
session_id: sessionId,
|
|
110
|
+
ts: ts ?? now(),
|
|
111
|
+
agent: agent ?? { id: "unknown", name: "unknown" },
|
|
112
|
+
action,
|
|
113
|
+
severity: topSeverity(findings),
|
|
114
|
+
findings,
|
|
115
|
+
integrity: { alg: "sha-256", canon: "rfc8785", prev_hash: "", hash: "" },
|
|
116
|
+
};
|
|
117
|
+
const subj = normSubject(subject);
|
|
118
|
+
if (subj !== null)
|
|
119
|
+
record["subject"] = subj;
|
|
120
|
+
const src = normalizeSource(source);
|
|
121
|
+
if (src !== null)
|
|
122
|
+
record["source"] = src;
|
|
123
|
+
if (outcome !== null)
|
|
124
|
+
record["outcome"] = outcome;
|
|
125
|
+
return record;
|
|
126
|
+
}
|
|
127
|
+
function stringify(value) {
|
|
128
|
+
if (typeof value === "string")
|
|
129
|
+
return value;
|
|
130
|
+
try {
|
|
131
|
+
return JSON.stringify(value);
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
return String(value);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
function expandHome(p) {
|
|
138
|
+
return p.startsWith("~") ? join(homedir(), p.slice(1)) : p;
|
|
139
|
+
}
|
|
140
|
+
/* Append-only writer that maintains the hash chain in a JSONL file.
|
|
141
|
+
|
|
142
|
+
append() is fully synchronous, so within a Node process parallel tool calls
|
|
143
|
+
cannot interleave mid-append (the event loop runs the read-hash/write block
|
|
144
|
+
atomically). The chain head is cached after the first read so appending
|
|
145
|
+
stays O(1) per record instead of re-scanning the file. One Recorder
|
|
146
|
+
instance should own a given log file per process. */
|
|
147
|
+
export class Recorder {
|
|
148
|
+
path;
|
|
149
|
+
#lastHash = null;
|
|
150
|
+
constructor(path) {
|
|
151
|
+
this.path = expandHome(path);
|
|
152
|
+
}
|
|
153
|
+
lastHash() {
|
|
154
|
+
if (this.#lastHash !== null)
|
|
155
|
+
return this.#lastHash;
|
|
156
|
+
if (!existsSync(this.path))
|
|
157
|
+
return GENESIS_PREV;
|
|
158
|
+
const lines = readFileSync(this.path, "utf8").split("\n").filter((l) => l.trim());
|
|
159
|
+
if (lines.length === 0)
|
|
160
|
+
return GENESIS_PREV;
|
|
161
|
+
try {
|
|
162
|
+
const rec = JSON.parse(lines[lines.length - 1]);
|
|
163
|
+
const integ = (rec["integrity"] ?? {});
|
|
164
|
+
return integ["hash"] || GENESIS_PREV;
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
return GENESIS_PREV;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
append(record) {
|
|
171
|
+
const prev = this.lastHash();
|
|
172
|
+
const integ = (record["integrity"] ??= {});
|
|
173
|
+
integ["prev_hash"] = prev;
|
|
174
|
+
integ["hash"] = computeHash(record, prev);
|
|
175
|
+
appendFileSync(this.path, JSON.stringify(record) + "\n", "utf8");
|
|
176
|
+
this.#lastHash = integ["hash"];
|
|
177
|
+
return record;
|
|
178
|
+
}
|
|
179
|
+
/* Convenience: build + append in one call. */
|
|
180
|
+
record(actionType, category, opts = {}) {
|
|
181
|
+
return this.append(build(actionType, category, opts));
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
/* Routes each record to a per-subject log, each its own hash chain. */
|
|
185
|
+
export class TenantRecorder {
|
|
186
|
+
directory;
|
|
187
|
+
default;
|
|
188
|
+
recorders = new Map();
|
|
189
|
+
constructor(directory, opts = {}) {
|
|
190
|
+
this.directory = expandHome(directory);
|
|
191
|
+
this.default = opts.default ?? "_local";
|
|
192
|
+
}
|
|
193
|
+
static safe(name) {
|
|
194
|
+
const cleaned = String(name)
|
|
195
|
+
.split("")
|
|
196
|
+
.map((c) => (/[a-zA-Z0-9\-_.]/.test(c) ? c : "_"))
|
|
197
|
+
.join("")
|
|
198
|
+
.replace(/^[._]+|[._]+$/g, "");
|
|
199
|
+
return cleaned || "tenant";
|
|
200
|
+
}
|
|
201
|
+
subjectId(record) {
|
|
202
|
+
const subj = record["subject"];
|
|
203
|
+
if (subj && typeof subj === "object" && subj["id"]) {
|
|
204
|
+
return String(subj["id"]);
|
|
205
|
+
}
|
|
206
|
+
return this.default;
|
|
207
|
+
}
|
|
208
|
+
pathFor(subjectId) {
|
|
209
|
+
return join(this.directory, TenantRecorder.safe(subjectId) + ".jsonl");
|
|
210
|
+
}
|
|
211
|
+
recorderFor(subjectId) {
|
|
212
|
+
let r = this.recorders.get(subjectId);
|
|
213
|
+
if (!r) {
|
|
214
|
+
r = new Recorder(this.pathFor(subjectId));
|
|
215
|
+
this.recorders.set(subjectId, r);
|
|
216
|
+
}
|
|
217
|
+
return r;
|
|
218
|
+
}
|
|
219
|
+
append(record) {
|
|
220
|
+
return this.recorderFor(this.subjectId(record)).append(record);
|
|
221
|
+
}
|
|
222
|
+
}
|
package/dist/redact.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export type Severity = "CRITICAL" | "HIGH" | "MEDIUM" | "LOW" | "INFO";
|
|
2
|
+
export interface Finding {
|
|
3
|
+
type: string;
|
|
4
|
+
severity: Severity;
|
|
5
|
+
sample: string;
|
|
6
|
+
}
|
|
7
|
+
export declare const SEVERITY_RANK: Record<string, number>;
|
|
8
|
+
export declare function redactSample(ftype: string, value: unknown): string;
|
|
9
|
+
export declare function redactText(text: unknown): string;
|
|
10
|
+
export declare function scan(text: unknown): Finding[];
|
|
11
|
+
export declare function topSeverity(findings: Finding[]): Severity;
|
package/dist/redact.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/* Sensitive-pattern detection and redaction. Pattern-for-pattern port of the
|
|
2
|
+
Python implementation (redact.py) so both recorders flag and redact the
|
|
3
|
+
same content the same way. */
|
|
4
|
+
const PATTERNS = [
|
|
5
|
+
["api_key", "CRITICAL", /(?:sk-[a-zA-Z0-9]{20,}|AKIA[0-9A-Z]{16}|ghp_[a-zA-Z0-9]{36}|xox[baprs]-[a-zA-Z0-9-]{10,})/g],
|
|
6
|
+
["private_key", "CRITICAL", /-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/g],
|
|
7
|
+
["db_conn", "CRITICAL", /(?:postgres|mysql|mongodb(?:\+srv)?|redis):\/\/[^\s"'<>]+/g],
|
|
8
|
+
["credit_card", "HIGH", /\b(?:4[0-9]{12}(?:[0-9]{3})?|5[1-5][0-9]{14}|3[47][0-9]{13}|6(?:011|5[0-9]{2})[0-9]{12})\b/g],
|
|
9
|
+
["ssn", "HIGH", /\b\d{3}-\d{2}-\d{4}\b/g],
|
|
10
|
+
["email", "MEDIUM", /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/g],
|
|
11
|
+
["ip_internal", "MEDIUM", /\b(?:10\.\d{1,3}\.\d{1,3}\.\d{1,3}|192\.168\.\d{1,3}\.\d{1,3})\b/g],
|
|
12
|
+
["bearer_token", "HIGH", /Bearer\s+[a-zA-Z0-9\-_.]{20,}/g],
|
|
13
|
+
];
|
|
14
|
+
export const SEVERITY_RANK = {
|
|
15
|
+
CRITICAL: 4, HIGH: 3, MEDIUM: 2, LOW: 1, INFO: 0,
|
|
16
|
+
};
|
|
17
|
+
export function redactSample(ftype, value) {
|
|
18
|
+
const v = String(value);
|
|
19
|
+
if (ftype === "email") {
|
|
20
|
+
const m = v.match(/^([A-Za-z0-9._%+-])[A-Za-z0-9._%+-]*(@.+)$/);
|
|
21
|
+
return m ? m[1] + "****" + m[2] : "****";
|
|
22
|
+
}
|
|
23
|
+
if (ftype === "db_conn")
|
|
24
|
+
return v.replace(/:\/\/([^:/@]+):[^@]+@/, "://$1:****@");
|
|
25
|
+
if (ftype === "bearer_token")
|
|
26
|
+
return "Bearer ****";
|
|
27
|
+
if (ftype === "private_key")
|
|
28
|
+
return "-----BEGIN PRIVATE KEY----- ****";
|
|
29
|
+
if (ftype === "api_key")
|
|
30
|
+
return v.length > 4 ? v.slice(0, 4) + "****" : "****";
|
|
31
|
+
if (ftype === "credit_card") {
|
|
32
|
+
const digits = v.replace(/\D/g, "");
|
|
33
|
+
return digits.length >= 4 ? "****" + digits.slice(-4) : "****";
|
|
34
|
+
}
|
|
35
|
+
if (ftype === "ssn")
|
|
36
|
+
return v.length >= 4 ? "***-**-" + v.slice(-4) : "****";
|
|
37
|
+
if (ftype === "ip_internal") {
|
|
38
|
+
const parts = v.split(".");
|
|
39
|
+
return parts.length === 4 ? [parts[0], parts[1], "*", "*"].join(".") : "****";
|
|
40
|
+
}
|
|
41
|
+
return "****";
|
|
42
|
+
}
|
|
43
|
+
export function redactText(text) {
|
|
44
|
+
let out = String(text);
|
|
45
|
+
for (const [name, , pattern] of PATTERNS) {
|
|
46
|
+
out = out.replace(new RegExp(pattern.source, pattern.flags), (m) => redactSample(name, m));
|
|
47
|
+
}
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
/* Return a list of redacted findings for any sensitive patterns in `text`. */
|
|
51
|
+
export function scan(text) {
|
|
52
|
+
const findings = [];
|
|
53
|
+
const s = String(text);
|
|
54
|
+
for (const [name, severity, pattern] of PATTERNS) {
|
|
55
|
+
const matches = s.match(new RegExp(pattern.source, pattern.flags));
|
|
56
|
+
if (matches && matches.length) {
|
|
57
|
+
findings.push({
|
|
58
|
+
type: name,
|
|
59
|
+
severity,
|
|
60
|
+
sample: redactSample(name, String(matches[0]).slice(0, 120)),
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return findings;
|
|
65
|
+
}
|
|
66
|
+
export function topSeverity(findings) {
|
|
67
|
+
if (!findings || findings.length === 0)
|
|
68
|
+
return "INFO";
|
|
69
|
+
let best = findings[0];
|
|
70
|
+
for (const f of findings.slice(1)) {
|
|
71
|
+
if ((SEVERITY_RANK[f.severity] ?? 0) > (SEVERITY_RANK[best.severity] ?? 0))
|
|
72
|
+
best = f;
|
|
73
|
+
}
|
|
74
|
+
return best.severity;
|
|
75
|
+
}
|
package/dist/verify.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { HaloRecord } from "./record.ts";
|
|
2
|
+
type Schema = Record<string, any>;
|
|
3
|
+
export declare function loadSchema(): Schema;
|
|
4
|
+
export declare function validateRecord(record: HaloRecord, schema?: Schema): string[];
|
|
5
|
+
export interface VerifyResult {
|
|
6
|
+
ok: boolean;
|
|
7
|
+
count: number;
|
|
8
|
+
problems: string[];
|
|
9
|
+
}
|
|
10
|
+
export declare function verifyRecords(records: HaloRecord[], schema?: Schema): VerifyResult;
|
|
11
|
+
export declare function readLog(path: string): HaloRecord[];
|
|
12
|
+
export declare function verifyLog(path: string, schema?: Schema): VerifyResult;
|
|
13
|
+
export {};
|
package/dist/verify.js
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/* Conformance verification: schema validation + hash-chain integrity.
|
|
2
|
+
Port of the Python verifier (verify.py); dependency-free. */
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
import { join, dirname } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { GENESIS_PREV, computeHash } from "./canon.js";
|
|
7
|
+
const SCHEMA_PATH = join(dirname(fileURLToPath(import.meta.url)), "..", "halo-record.schema.json");
|
|
8
|
+
export function loadSchema() {
|
|
9
|
+
return JSON.parse(readFileSync(SCHEMA_PATH, "utf8"));
|
|
10
|
+
}
|
|
11
|
+
function typeOk(node, t) {
|
|
12
|
+
switch (t) {
|
|
13
|
+
case "object": return node !== null && typeof node === "object" && !Array.isArray(node);
|
|
14
|
+
case "array": return Array.isArray(node);
|
|
15
|
+
case "string": return typeof node === "string";
|
|
16
|
+
case "number": return typeof node === "number" && typeof node !== "boolean";
|
|
17
|
+
case "integer": return typeof node === "number" && Number.isInteger(node);
|
|
18
|
+
case "boolean": return typeof node === "boolean";
|
|
19
|
+
case "null": return node === null;
|
|
20
|
+
default: return true;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
function validate(node, schema, path, errors) {
|
|
24
|
+
if ("const" in schema && !deepEqual(node, schema["const"])) {
|
|
25
|
+
errors.push(`${path}: expected const ${JSON.stringify(schema["const"])}, got ${JSON.stringify(node)}`);
|
|
26
|
+
}
|
|
27
|
+
if ("enum" in schema && !schema["enum"].some((v) => deepEqual(node, v))) {
|
|
28
|
+
errors.push(`${path}: ${JSON.stringify(node)} not in enum ${JSON.stringify(schema["enum"])}`);
|
|
29
|
+
}
|
|
30
|
+
const t = schema["type"];
|
|
31
|
+
if (t) {
|
|
32
|
+
if ((t === "number" || t === "integer") && typeof node === "boolean") {
|
|
33
|
+
errors.push(`${path}: expected ${t}, got boolean`);
|
|
34
|
+
}
|
|
35
|
+
else if (!typeOk(node, t)) {
|
|
36
|
+
errors.push(`${path}: expected ${t}, got ${Array.isArray(node) ? "array" : node === null ? "null" : typeof node}`);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
if (node !== null && typeof node === "object" && !Array.isArray(node)) {
|
|
41
|
+
const obj = node;
|
|
42
|
+
for (const req of (schema["required"] ?? [])) {
|
|
43
|
+
if (!(req in obj))
|
|
44
|
+
errors.push(`${path}: missing required field ${JSON.stringify(req)}`);
|
|
45
|
+
}
|
|
46
|
+
const props = (schema["properties"] ?? {});
|
|
47
|
+
for (const [key, val] of Object.entries(obj)) {
|
|
48
|
+
if (key in props)
|
|
49
|
+
validate(val, props[key], `${path}.${key}`, errors);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
if (Array.isArray(node) && "items" in schema) {
|
|
53
|
+
node.forEach((item, i) => validate(item, schema["items"], `${path}[${i}]`, errors));
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
function deepEqual(a, b) {
|
|
57
|
+
if (a === b)
|
|
58
|
+
return true;
|
|
59
|
+
if (typeof a !== typeof b || a === null || b === null)
|
|
60
|
+
return false;
|
|
61
|
+
if (typeof a !== "object")
|
|
62
|
+
return false;
|
|
63
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
64
|
+
}
|
|
65
|
+
export function validateRecord(record, schema) {
|
|
66
|
+
const s = schema ?? loadSchema();
|
|
67
|
+
const errors = [];
|
|
68
|
+
validate(record, s, "record", errors);
|
|
69
|
+
return errors;
|
|
70
|
+
}
|
|
71
|
+
/* Verify a list of already-parsed records: schema + chain. */
|
|
72
|
+
export function verifyRecords(records, schema) {
|
|
73
|
+
const s = schema ?? loadSchema();
|
|
74
|
+
const problems = [];
|
|
75
|
+
let prevHash = GENESIS_PREV;
|
|
76
|
+
records.forEach((record, idx) => {
|
|
77
|
+
const n = idx + 1;
|
|
78
|
+
for (const err of validateRecord(record, s))
|
|
79
|
+
problems.push(`record ${n}: schema: ${err}`);
|
|
80
|
+
const integ = (record["integrity"] ?? {});
|
|
81
|
+
const declaredPrev = integ["prev_hash"];
|
|
82
|
+
const declaredHash = integ["hash"];
|
|
83
|
+
if (declaredPrev !== prevHash) {
|
|
84
|
+
problems.push(`record ${n}: chain: prev_hash ${declaredPrev} does not match expected ${prevHash}`);
|
|
85
|
+
}
|
|
86
|
+
const recomputed = computeHash(record, prevHash);
|
|
87
|
+
if (declaredHash !== recomputed) {
|
|
88
|
+
problems.push(`record ${n}: chain: hash ${declaredHash} does not match recomputed ${recomputed}`);
|
|
89
|
+
}
|
|
90
|
+
prevHash = declaredHash || recomputed;
|
|
91
|
+
});
|
|
92
|
+
return { ok: problems.length === 0, count: records.length, problems };
|
|
93
|
+
}
|
|
94
|
+
export function readLog(path) {
|
|
95
|
+
return readFileSync(path, "utf8")
|
|
96
|
+
.split("\n")
|
|
97
|
+
.filter((ln) => ln.trim())
|
|
98
|
+
.map((ln) => JSON.parse(ln));
|
|
99
|
+
}
|
|
100
|
+
/* Verify a JSONL chain file. */
|
|
101
|
+
export function verifyLog(path, schema) {
|
|
102
|
+
return verifyRecords(readLog(path), schema);
|
|
103
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type Checkpoint } from "./anchor.ts";
|
|
2
|
+
import type { HaloRecord } from "./record.ts";
|
|
3
|
+
export declare function anchorRemote(witnessUrl: string, key: string, records: HaloRecord[], opts?: {
|
|
4
|
+
timeoutMs?: number;
|
|
5
|
+
}): Promise<Checkpoint>;
|
|
6
|
+
export declare function fetchCheckpoints(witnessUrl: string, subject?: string | null, opts?: {
|
|
7
|
+
timeoutMs?: number;
|
|
8
|
+
}): Promise<Checkpoint[]>;
|
package/dist/witness.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/* Client for the hosted Halo witness. The checkpoint is computed LOCALLY and
|
|
2
|
+
only {subject, count, head, chain_root} is sent — record contents never
|
|
3
|
+
leave the vendor. Same wire protocol as the Python client (witness.py). */
|
|
4
|
+
import { checkpoint } from "./anchor.js";
|
|
5
|
+
/* Anchor a chain's current head to a hosted Halo witness. Returns the
|
|
6
|
+
witness's receipt (the stored checkpoint with the server's timestamp). */
|
|
7
|
+
export async function anchorRemote(witnessUrl, key, records, opts = {}) {
|
|
8
|
+
const cp = checkpoint(records);
|
|
9
|
+
const body = JSON.stringify({
|
|
10
|
+
subject: cp.subject,
|
|
11
|
+
count: cp.count,
|
|
12
|
+
head: cp.head,
|
|
13
|
+
chain_root: cp.chain_root,
|
|
14
|
+
});
|
|
15
|
+
const resp = await fetch(witnessUrl.replace(/\/+$/, "") + "/anchor", {
|
|
16
|
+
method: "POST",
|
|
17
|
+
headers: { "Content-Type": "application/json", Authorization: "Bearer " + key },
|
|
18
|
+
body,
|
|
19
|
+
signal: AbortSignal.timeout(opts.timeoutMs ?? 10_000),
|
|
20
|
+
});
|
|
21
|
+
if (!resp.ok)
|
|
22
|
+
throw new Error(`witness anchor failed: HTTP ${resp.status}`);
|
|
23
|
+
const out = (await resp.json());
|
|
24
|
+
return out.receipt ?? out;
|
|
25
|
+
}
|
|
26
|
+
/* Fetch the witness's independently held checkpoints for a subject. */
|
|
27
|
+
export async function fetchCheckpoints(witnessUrl, subject, opts = {}) {
|
|
28
|
+
let url = witnessUrl.replace(/\/+$/, "") + "/v1/checkpoints";
|
|
29
|
+
if (subject != null)
|
|
30
|
+
url += "?" + new URLSearchParams({ subject }).toString();
|
|
31
|
+
const resp = await fetch(url, { signal: AbortSignal.timeout(opts.timeoutMs ?? 10_000) });
|
|
32
|
+
if (!resp.ok)
|
|
33
|
+
throw new Error(`witness fetch failed: HTTP ${resp.status}`);
|
|
34
|
+
const out = (await resp.json());
|
|
35
|
+
return out.checkpoints ?? [];
|
|
36
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://halo.dev/spec/v0.1/halo-record.schema.json",
|
|
4
|
+
"title": "Halo Runtime Record",
|
|
5
|
+
"description": "An append-only, tamper-evident record of a single AI-agent action. Schema v0.1.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"required": ["schema_version", "record_id", "session_id", "ts", "action", "integrity"],
|
|
8
|
+
"properties": {
|
|
9
|
+
"schema_version": { "type": "string", "const": "0.1" },
|
|
10
|
+
"record_id": { "type": "string", "description": "Unique identifier for this record (UUID recommended)." },
|
|
11
|
+
"session_id": { "type": "string", "description": "Links the record to the triggering session/conversation." },
|
|
12
|
+
"parent_id": { "type": "string", "description": "The record this one was caused by, for provenance/chaining." },
|
|
13
|
+
"ts": { "type": "string", "description": "RFC 3339 UTC timestamp." },
|
|
14
|
+
"subject": {
|
|
15
|
+
"type": "object",
|
|
16
|
+
"description": "The tenant/customer context this action belongs to — the segmentation key. Distinct from principal (who operated): subject is whose engagement the action serves. Used to keep each customer's records in their own chain and report, so one is never exposed to another.",
|
|
17
|
+
"properties": {
|
|
18
|
+
"id": { "type": "string" },
|
|
19
|
+
"name": { "type": "string" }
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
"principal": {
|
|
23
|
+
"type": "object",
|
|
24
|
+
"description": "Identities on whose behalf the action ran (up to four layers).",
|
|
25
|
+
"properties": {
|
|
26
|
+
"human_id": { "type": "string" },
|
|
27
|
+
"creator_id": { "type": "string" },
|
|
28
|
+
"service_account": { "type": "string" },
|
|
29
|
+
"role_scope": { "type": "string" }
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
"agent": {
|
|
33
|
+
"type": "object",
|
|
34
|
+
"properties": {
|
|
35
|
+
"id": { "type": "string" },
|
|
36
|
+
"name": { "type": "string" },
|
|
37
|
+
"version": { "type": "string" },
|
|
38
|
+
"model": { "type": "string" },
|
|
39
|
+
"model_version": { "type": "string" }
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
"mcp": {
|
|
43
|
+
"type": "object",
|
|
44
|
+
"properties": {
|
|
45
|
+
"host": { "type": "string" },
|
|
46
|
+
"client": { "type": "string" },
|
|
47
|
+
"server": { "type": "string" },
|
|
48
|
+
"server_version": { "type": "string" }
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
"action": {
|
|
52
|
+
"type": "object",
|
|
53
|
+
"required": ["type", "category"],
|
|
54
|
+
"properties": {
|
|
55
|
+
"type": { "type": "string", "enum": ["tool_call", "agent_message", "read", "write", "network"] },
|
|
56
|
+
"category": { "type": "string", "enum": ["security", "safety", "reliability", "privacy"] },
|
|
57
|
+
"tool": { "type": "string" },
|
|
58
|
+
"authorization": {
|
|
59
|
+
"type": "object",
|
|
60
|
+
"properties": {
|
|
61
|
+
"decision": { "type": "string", "enum": ["allowed", "denied", "human_approved"] },
|
|
62
|
+
"scope": { "type": "string" },
|
|
63
|
+
"approver": { "type": "string" }
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
"input": {
|
|
67
|
+
"type": "object",
|
|
68
|
+
"description": "Redacted summary plus a hash of the full canonical arguments. Raw arguments MUST NOT appear.",
|
|
69
|
+
"properties": {
|
|
70
|
+
"summary": { "type": "string" },
|
|
71
|
+
"hash": { "type": "string" }
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
"outcome": {
|
|
77
|
+
"type": "object",
|
|
78
|
+
"properties": {
|
|
79
|
+
"status": { "type": "string", "enum": ["ok", "error", "denied"] },
|
|
80
|
+
"summary": { "type": "string" },
|
|
81
|
+
"hash": { "type": "string" }
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
"findings": {
|
|
85
|
+
"type": "array",
|
|
86
|
+
"items": {
|
|
87
|
+
"type": "object",
|
|
88
|
+
"required": ["type", "severity"],
|
|
89
|
+
"properties": {
|
|
90
|
+
"type": { "type": "string", "description": "e.g. api_key, private_key, db_conn, credit_card, ssn, email, ip_internal, bearer_token" },
|
|
91
|
+
"severity": { "type": "string", "enum": ["CRITICAL", "HIGH", "MEDIUM", "LOW", "INFO"] },
|
|
92
|
+
"sample": { "type": "string", "description": "Redacted excerpt. Unredacted value MUST NOT appear." }
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
"severity": { "type": "string", "enum": ["CRITICAL", "HIGH", "MEDIUM", "LOW", "INFO"] },
|
|
97
|
+
"threats": {
|
|
98
|
+
"type": "array",
|
|
99
|
+
"items": {
|
|
100
|
+
"type": "object",
|
|
101
|
+
"required": ["type"],
|
|
102
|
+
"properties": {
|
|
103
|
+
"type": { "type": "string", "description": "e.g. prompt_injection_direct, prompt_injection_indirect, policy_violation, tool_misuse" },
|
|
104
|
+
"ref": { "type": "string" }
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
"data": {
|
|
109
|
+
"type": "object",
|
|
110
|
+
"properties": {
|
|
111
|
+
"region": { "type": "string" },
|
|
112
|
+
"cross_region": { "type": "number" },
|
|
113
|
+
"purpose": { "type": "string" },
|
|
114
|
+
"pii_types": { "type": "array", "items": { "type": "string" } }
|
|
115
|
+
}
|
|
116
|
+
},
|
|
117
|
+
"retention": {
|
|
118
|
+
"type": "object",
|
|
119
|
+
"properties": {
|
|
120
|
+
"policy": { "type": "string" },
|
|
121
|
+
"expires": { "type": "string" }
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
"jurisdiction": { "type": "string" },
|
|
125
|
+
"framework_tags": { "type": "array", "items": { "type": "string" } },
|
|
126
|
+
"integrity": {
|
|
127
|
+
"type": "object",
|
|
128
|
+
"required": ["alg", "canon", "prev_hash", "hash"],
|
|
129
|
+
"properties": {
|
|
130
|
+
"alg": { "type": "string", "enum": ["sha-256"] },
|
|
131
|
+
"canon": { "type": "string", "enum": ["rfc8785"] },
|
|
132
|
+
"prev_hash": { "type": "string", "description": "Hex SHA-256 of the previous record; 64 zeros for the first record." },
|
|
133
|
+
"hash": { "type": "string", "description": "Hex SHA-256 of this record (excluding integrity.hash), canonicalized per RFC 8785." }
|
|
134
|
+
}
|
|
135
|
+
},
|
|
136
|
+
"signature": {
|
|
137
|
+
"type": "object",
|
|
138
|
+
"required": ["alg", "sig"],
|
|
139
|
+
"properties": {
|
|
140
|
+
"alg": { "type": "string", "enum": ["ecdsa-p256", "ed25519"] },
|
|
141
|
+
"sig": { "type": "string" },
|
|
142
|
+
"key_id": { "type": "string" }
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "halo-record",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Tamper-evident, hash-chained Runtime Records for AI agents: the TypeScript recorder. Chain-format compatible with the Python halo-record package.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"default": "./dist/index.js"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"dist",
|
|
16
|
+
"halo-record.schema.json"
|
|
17
|
+
],
|
|
18
|
+
"scripts": {
|
|
19
|
+
"test": "node --test test/core.test.ts test/integrations.test.ts",
|
|
20
|
+
"typecheck": "tsc --noEmit",
|
|
21
|
+
"build": "tsc"
|
|
22
|
+
},
|
|
23
|
+
"engines": {
|
|
24
|
+
"node": ">=20"
|
|
25
|
+
},
|
|
26
|
+
"license": "Apache-2.0",
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"@types/node": "^24.13.1",
|
|
29
|
+
"typescript": "^5.8.3"
|
|
30
|
+
}
|
|
31
|
+
}
|