kcp-harness 0.3.0 → 0.4.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/dist/audit-reader.d.ts +68 -0
- package/dist/audit-reader.js +150 -0
- package/dist/audit.d.ts +3 -0
- package/dist/audit.js +1 -0
- package/dist/cli.js +65 -1
- package/dist/config.d.ts +4 -0
- package/dist/config.js +2 -0
- package/dist/dashboard/server.d.ts +30 -0
- package/dist/dashboard/server.js +145 -0
- package/dist/dashboard/tail.d.ts +18 -0
- package/dist/dashboard/tail.js +77 -0
- package/dist/dashboard/ui.d.ts +1 -0
- package/dist/dashboard/ui.js +193 -0
- package/dist/export.d.ts +28 -0
- package/dist/export.js +226 -0
- package/dist/governor.d.ts +3 -1
- package/dist/governor.js +18 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/package.json +1 -1
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { AuditEvent, AuditEventType } from "./audit.js";
|
|
2
|
+
/** Filter criteria for querying audit events. */
|
|
3
|
+
export interface AuditFilter {
|
|
4
|
+
/** Filter by session ID. */
|
|
5
|
+
sessionId?: string;
|
|
6
|
+
/** Filter by event type(s). */
|
|
7
|
+
type?: AuditEventType | AuditEventType[];
|
|
8
|
+
/** Include events from this ISO date (inclusive). */
|
|
9
|
+
from?: string;
|
|
10
|
+
/** Include events until this ISO date (inclusive). */
|
|
11
|
+
to?: string;
|
|
12
|
+
/** Filter by outcome. */
|
|
13
|
+
outcome?: AuditEvent["outcome"];
|
|
14
|
+
}
|
|
15
|
+
/** Aggregate statistics from the audit log. */
|
|
16
|
+
export interface AuditSummary {
|
|
17
|
+
/** Total sessions observed. */
|
|
18
|
+
sessions: number;
|
|
19
|
+
/** Total events. */
|
|
20
|
+
events: number;
|
|
21
|
+
/** Events targeting governed domains. */
|
|
22
|
+
governed: number;
|
|
23
|
+
/** Events that were blocked. */
|
|
24
|
+
blocked: number;
|
|
25
|
+
/** Budget exceeded events. */
|
|
26
|
+
budgetExceeded: number;
|
|
27
|
+
/** Temporal drift events. */
|
|
28
|
+
drifts: number;
|
|
29
|
+
/** Signature-blocked events. */
|
|
30
|
+
signatureBlocked: number;
|
|
31
|
+
/** Date range of the log. */
|
|
32
|
+
dateRange: {
|
|
33
|
+
first: string;
|
|
34
|
+
last: string;
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
/** Session summary in the index. */
|
|
38
|
+
export interface SessionEntry {
|
|
39
|
+
id: string;
|
|
40
|
+
startedAt: string;
|
|
41
|
+
endedAt?: string;
|
|
42
|
+
events: number;
|
|
43
|
+
governed: number;
|
|
44
|
+
blocked: number;
|
|
45
|
+
}
|
|
46
|
+
/** Index of all sessions in the audit log. */
|
|
47
|
+
export interface SessionIndex {
|
|
48
|
+
sessions: SessionEntry[];
|
|
49
|
+
}
|
|
50
|
+
/** Streaming JSONL audit log reader. */
|
|
51
|
+
export declare class AuditReader {
|
|
52
|
+
private readonly path;
|
|
53
|
+
constructor(path: string);
|
|
54
|
+
/** Stream all events, optionally filtered. */
|
|
55
|
+
stream(filter?: AuditFilter): AsyncIterable<AuditEvent>;
|
|
56
|
+
/** Read all events into memory (for small-to-medium logs). */
|
|
57
|
+
readAll(filter?: AuditFilter): Promise<AuditEvent[]>;
|
|
58
|
+
/** Get aggregate statistics. */
|
|
59
|
+
summarize(filter?: AuditFilter): Promise<AuditSummary>;
|
|
60
|
+
/** Get events grouped by session. */
|
|
61
|
+
sessionIndex(): Promise<SessionIndex>;
|
|
62
|
+
/** Check if the audit log file exists. */
|
|
63
|
+
exists(): boolean;
|
|
64
|
+
/** Get file size in bytes. */
|
|
65
|
+
size(): number;
|
|
66
|
+
/** Get the path to the audit log. */
|
|
67
|
+
getPath(): string;
|
|
68
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// Audit reader — streaming JSONL reader with query capabilities.
|
|
2
|
+
//
|
|
3
|
+
// Reads the append-only audit log and provides filtering, summarization,
|
|
4
|
+
// and session indexing. This is the read counterpart to audit.ts's writer.
|
|
5
|
+
// Used by the compliance export (export.ts) and the dashboard (future).
|
|
6
|
+
import { createReadStream, existsSync, statSync } from "node:fs";
|
|
7
|
+
import { createInterface } from "node:readline";
|
|
8
|
+
/** Streaming JSONL audit log reader. */
|
|
9
|
+
export class AuditReader {
|
|
10
|
+
path;
|
|
11
|
+
constructor(path) {
|
|
12
|
+
this.path = path;
|
|
13
|
+
}
|
|
14
|
+
/** Stream all events, optionally filtered. */
|
|
15
|
+
async *stream(filter) {
|
|
16
|
+
if (!existsSync(this.path))
|
|
17
|
+
return;
|
|
18
|
+
const rl = createInterface({
|
|
19
|
+
input: createReadStream(this.path, "utf-8"),
|
|
20
|
+
crlfDelay: Infinity,
|
|
21
|
+
});
|
|
22
|
+
for await (const line of rl) {
|
|
23
|
+
const trimmed = line.trim();
|
|
24
|
+
if (!trimmed)
|
|
25
|
+
continue;
|
|
26
|
+
try {
|
|
27
|
+
const event = JSON.parse(trimmed);
|
|
28
|
+
if (matchesFilter(event, filter)) {
|
|
29
|
+
yield event;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
// Skip malformed lines
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/** Read all events into memory (for small-to-medium logs). */
|
|
38
|
+
async readAll(filter) {
|
|
39
|
+
const events = [];
|
|
40
|
+
for await (const event of this.stream(filter)) {
|
|
41
|
+
events.push(event);
|
|
42
|
+
}
|
|
43
|
+
return events;
|
|
44
|
+
}
|
|
45
|
+
/** Get aggregate statistics. */
|
|
46
|
+
async summarize(filter) {
|
|
47
|
+
const sessionIds = new Set();
|
|
48
|
+
let events = 0;
|
|
49
|
+
let governed = 0;
|
|
50
|
+
let blocked = 0;
|
|
51
|
+
let budgetExceeded = 0;
|
|
52
|
+
let drifts = 0;
|
|
53
|
+
let signatureBlocked = 0;
|
|
54
|
+
let first = "";
|
|
55
|
+
let last = "";
|
|
56
|
+
for await (const event of this.stream(filter)) {
|
|
57
|
+
events++;
|
|
58
|
+
sessionIds.add(event.sessionId);
|
|
59
|
+
if (event.timestamp) {
|
|
60
|
+
if (!first || event.timestamp < first)
|
|
61
|
+
first = event.timestamp;
|
|
62
|
+
if (!last || event.timestamp > last)
|
|
63
|
+
last = event.timestamp;
|
|
64
|
+
}
|
|
65
|
+
if (event.classification?.governed)
|
|
66
|
+
governed++;
|
|
67
|
+
if (event.outcome === "blocked")
|
|
68
|
+
blocked++;
|
|
69
|
+
if (event.type === "budget_exceeded")
|
|
70
|
+
budgetExceeded++;
|
|
71
|
+
if (event.type === "temporal_drift")
|
|
72
|
+
drifts++;
|
|
73
|
+
if (event.outcome === "blocked" && event.signature?.status && event.signature.status !== "verified") {
|
|
74
|
+
signatureBlocked++;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return {
|
|
78
|
+
sessions: sessionIds.size,
|
|
79
|
+
events,
|
|
80
|
+
governed,
|
|
81
|
+
blocked,
|
|
82
|
+
budgetExceeded,
|
|
83
|
+
drifts,
|
|
84
|
+
signatureBlocked,
|
|
85
|
+
dateRange: { first, last },
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
/** Get events grouped by session. */
|
|
89
|
+
async sessionIndex() {
|
|
90
|
+
const sessions = new Map();
|
|
91
|
+
for await (const event of this.stream()) {
|
|
92
|
+
let entry = sessions.get(event.sessionId);
|
|
93
|
+
if (!entry) {
|
|
94
|
+
entry = {
|
|
95
|
+
id: event.sessionId,
|
|
96
|
+
startedAt: event.timestamp,
|
|
97
|
+
events: 0,
|
|
98
|
+
governed: 0,
|
|
99
|
+
blocked: 0,
|
|
100
|
+
};
|
|
101
|
+
sessions.set(event.sessionId, entry);
|
|
102
|
+
}
|
|
103
|
+
entry.events++;
|
|
104
|
+
if (event.timestamp < entry.startedAt)
|
|
105
|
+
entry.startedAt = event.timestamp;
|
|
106
|
+
if (!entry.endedAt || event.timestamp > entry.endedAt)
|
|
107
|
+
entry.endedAt = event.timestamp;
|
|
108
|
+
if (event.classification?.governed)
|
|
109
|
+
entry.governed++;
|
|
110
|
+
if (event.outcome === "blocked")
|
|
111
|
+
entry.blocked++;
|
|
112
|
+
if (event.type === "session_end")
|
|
113
|
+
entry.endedAt = event.timestamp;
|
|
114
|
+
}
|
|
115
|
+
return { sessions: Array.from(sessions.values()) };
|
|
116
|
+
}
|
|
117
|
+
/** Check if the audit log file exists. */
|
|
118
|
+
exists() {
|
|
119
|
+
return existsSync(this.path);
|
|
120
|
+
}
|
|
121
|
+
/** Get file size in bytes. */
|
|
122
|
+
size() {
|
|
123
|
+
if (!this.exists())
|
|
124
|
+
return 0;
|
|
125
|
+
return statSync(this.path).size;
|
|
126
|
+
}
|
|
127
|
+
/** Get the path to the audit log. */
|
|
128
|
+
getPath() {
|
|
129
|
+
return this.path;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
/** Check if an event matches the filter criteria. */
|
|
133
|
+
function matchesFilter(event, filter) {
|
|
134
|
+
if (!filter)
|
|
135
|
+
return true;
|
|
136
|
+
if (filter.sessionId && event.sessionId !== filter.sessionId)
|
|
137
|
+
return false;
|
|
138
|
+
if (filter.type) {
|
|
139
|
+
const types = Array.isArray(filter.type) ? filter.type : [filter.type];
|
|
140
|
+
if (!types.includes(event.type))
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
if (filter.from && event.timestamp < filter.from)
|
|
144
|
+
return false;
|
|
145
|
+
if (filter.to && event.timestamp > filter.to)
|
|
146
|
+
return false;
|
|
147
|
+
if (filter.outcome && event.outcome !== filter.outcome)
|
|
148
|
+
return false;
|
|
149
|
+
return true;
|
|
150
|
+
}
|
package/dist/audit.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { SignatureResult } from "kcp-agent";
|
|
1
2
|
import type { Classification } from "./classifier.js";
|
|
2
3
|
import type { GovernanceDecision } from "./governor.js";
|
|
3
4
|
import type { LedgerSnapshot } from "./budget-ledger.js";
|
|
@@ -39,6 +40,8 @@ export interface AuditEvent {
|
|
|
39
40
|
movedUnits?: number;
|
|
40
41
|
newPlanAsOf?: string;
|
|
41
42
|
};
|
|
43
|
+
/** Manifest signature verification result. */
|
|
44
|
+
signature?: SignatureResult;
|
|
42
45
|
}
|
|
43
46
|
/** Append-only audit log writer. */
|
|
44
47
|
export declare class AuditLog {
|
package/dist/audit.js
CHANGED
package/dist/cli.js
CHANGED
|
@@ -14,6 +14,8 @@ import { dirname, join } from "node:path";
|
|
|
14
14
|
import { loadConfig } from "./config.js";
|
|
15
15
|
import { serveProxy } from "./proxy.js";
|
|
16
16
|
import { generate, listAgents } from "./integrations/generate.js";
|
|
17
|
+
import { exportEvidence } from "./export.js";
|
|
18
|
+
import { DashboardServer } from "./dashboard/server.js";
|
|
17
19
|
const USAGE = `kcp-harness — KCP Compliance Harness
|
|
18
20
|
|
|
19
21
|
Usage:
|
|
@@ -22,6 +24,8 @@ Usage:
|
|
|
22
24
|
kcp-harness check [--config harness.yaml] Validate configuration
|
|
23
25
|
kcp-harness integrate <agent> [options] Generate agent integration files
|
|
24
26
|
kcp-harness integrate --list List supported agents
|
|
27
|
+
kcp-harness export [options] Export compliance evidence
|
|
28
|
+
kcp-harness dashboard [options] Launch compliance dashboard
|
|
25
29
|
kcp-harness --version Show version
|
|
26
30
|
kcp-harness --help Show this help
|
|
27
31
|
|
|
@@ -32,7 +36,7 @@ governance for any agent. It intercepts tool calls, classifies them
|
|
|
32
36
|
as knowledge-navigation or pass-through, and routes governed calls
|
|
33
37
|
through the kcp-agent planner before execution.
|
|
34
38
|
`;
|
|
35
|
-
const VERSION = "0.
|
|
39
|
+
const VERSION = "0.4.0";
|
|
36
40
|
const TEMPLATE = `# kcp-harness configuration
|
|
37
41
|
version: "1.0"
|
|
38
42
|
|
|
@@ -57,6 +61,9 @@ governance:
|
|
|
57
61
|
# currency: USDC
|
|
58
62
|
# context_budget: 50000
|
|
59
63
|
# env: prod
|
|
64
|
+
# signature_required: true
|
|
65
|
+
# trusted_keys:
|
|
66
|
+
# - "./keys/manifest-key.pem"
|
|
60
67
|
|
|
61
68
|
downstream:
|
|
62
69
|
# - name: "filesystem"
|
|
@@ -161,6 +168,63 @@ async function main() {
|
|
|
161
168
|
}
|
|
162
169
|
break;
|
|
163
170
|
}
|
|
171
|
+
case "export": {
|
|
172
|
+
const config = existsSync(configPath) ? loadConfig(configPath) : null;
|
|
173
|
+
const auditPath = getFlag(args, "--audit") ?? config?.audit.path ?? ".kcp-harness/audit.jsonl";
|
|
174
|
+
const outDir = getFlag(args, "--out") ?? "evidence";
|
|
175
|
+
const format = (getFlag(args, "--format") ?? "both");
|
|
176
|
+
const org = getFlag(args, "--org");
|
|
177
|
+
const from = getFlag(args, "--from");
|
|
178
|
+
const to = getFlag(args, "--to");
|
|
179
|
+
if (!existsSync(auditPath)) {
|
|
180
|
+
process.stderr.write(`[kcp-harness] audit log not found: ${auditPath}\n`);
|
|
181
|
+
process.exit(1);
|
|
182
|
+
}
|
|
183
|
+
try {
|
|
184
|
+
const result = await exportEvidence({
|
|
185
|
+
auditPath,
|
|
186
|
+
outputDir: outDir,
|
|
187
|
+
format,
|
|
188
|
+
organization: org,
|
|
189
|
+
dateRange: from || to ? { from: from ?? "", to: to ?? "" } : undefined,
|
|
190
|
+
});
|
|
191
|
+
process.stderr.write(`[kcp-harness] exported ${result.files.length} files to ${result.outputDir}\n`);
|
|
192
|
+
process.stderr.write(`[kcp-harness] ${result.summary.events} events, ${result.summary.sessions} sessions\n`);
|
|
193
|
+
for (const f of result.files) {
|
|
194
|
+
process.stdout.write(` ${f}\n`);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
catch (e) {
|
|
198
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
199
|
+
process.stderr.write(`[kcp-harness] export error: ${msg}\n`);
|
|
200
|
+
process.exit(1);
|
|
201
|
+
}
|
|
202
|
+
break;
|
|
203
|
+
}
|
|
204
|
+
case "dashboard": {
|
|
205
|
+
const config = existsSync(configPath) ? loadConfig(configPath) : null;
|
|
206
|
+
const auditPath = getFlag(args, "--audit") ?? config?.audit.path ?? ".kcp-harness/audit.jsonl";
|
|
207
|
+
const port = Number(getFlag(args, "--port") ?? "3847");
|
|
208
|
+
const host = getFlag(args, "--host") ?? "127.0.0.1";
|
|
209
|
+
if (!existsSync(auditPath)) {
|
|
210
|
+
process.stderr.write(`[kcp-harness] audit log not found: ${auditPath}\n`);
|
|
211
|
+
process.stderr.write(`[kcp-harness] the dashboard reads from the audit log — run the proxy first\n`);
|
|
212
|
+
process.exit(1);
|
|
213
|
+
}
|
|
214
|
+
const dashboard = new DashboardServer({ auditPath, port, host });
|
|
215
|
+
await dashboard.start();
|
|
216
|
+
process.stderr.write(`[kcp-harness] dashboard running at ${dashboard.getAddress()}\n`);
|
|
217
|
+
process.stderr.write(`[kcp-harness] watching ${auditPath} for live updates\n`);
|
|
218
|
+
// Keep running until SIGINT/SIGTERM
|
|
219
|
+
const shutdown = async () => {
|
|
220
|
+
process.stderr.write(`\n[kcp-harness] shutting down dashboard\n`);
|
|
221
|
+
await dashboard.stop();
|
|
222
|
+
process.exit(0);
|
|
223
|
+
};
|
|
224
|
+
process.on("SIGINT", shutdown);
|
|
225
|
+
process.on("SIGTERM", shutdown);
|
|
226
|
+
break;
|
|
227
|
+
}
|
|
164
228
|
default:
|
|
165
229
|
process.stderr.write(`[kcp-harness] unknown command: ${command}\n\n`);
|
|
166
230
|
process.stdout.write(USAGE);
|
package/dist/config.d.ts
CHANGED
|
@@ -28,6 +28,10 @@ export interface GovernancePolicy {
|
|
|
28
28
|
strict?: boolean;
|
|
29
29
|
/** Environment for federation context selection (dev/test/staging/prod). */
|
|
30
30
|
env?: string;
|
|
31
|
+
/** Require verified signatures on all manifests (default: false). */
|
|
32
|
+
signature_required?: boolean;
|
|
33
|
+
/** Trusted public keys for signature verification (paths, URLs, or inline). */
|
|
34
|
+
trusted_keys?: string[];
|
|
31
35
|
}
|
|
32
36
|
/** A downstream MCP server to proxy tool calls to. */
|
|
33
37
|
export interface DownstreamConfig {
|
package/dist/config.js
CHANGED
|
@@ -60,6 +60,8 @@ function parsePolicy(raw) {
|
|
|
60
60
|
max_units: p["max_units"] === undefined ? DEFAULT_POLICY.max_units : Number(p["max_units"]),
|
|
61
61
|
strict: p["strict"] === true,
|
|
62
62
|
env: p["env"] === undefined ? undefined : String(p["env"]),
|
|
63
|
+
signature_required: p["signature_required"] === true,
|
|
64
|
+
trusted_keys: Array.isArray(p["trusted_keys"]) ? p["trusted_keys"].map(String) : undefined,
|
|
63
65
|
};
|
|
64
66
|
}
|
|
65
67
|
function parseBudget(raw) {
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/** Dashboard server options. */
|
|
2
|
+
export interface DashboardOptions {
|
|
3
|
+
/** Path to the audit JSONL log. */
|
|
4
|
+
auditPath: string;
|
|
5
|
+
/** HTTP port (default: 3847). */
|
|
6
|
+
port: number;
|
|
7
|
+
/** Bind host (default: 127.0.0.1 — localhost only). */
|
|
8
|
+
host: string;
|
|
9
|
+
}
|
|
10
|
+
/** Compliance dashboard HTTP server. */
|
|
11
|
+
export declare class DashboardServer {
|
|
12
|
+
private server;
|
|
13
|
+
private tail;
|
|
14
|
+
private sseClients;
|
|
15
|
+
private readonly reader;
|
|
16
|
+
private readonly options;
|
|
17
|
+
constructor(options: DashboardOptions);
|
|
18
|
+
/** Start the dashboard server. */
|
|
19
|
+
start(): Promise<void>;
|
|
20
|
+
/** Stop the dashboard server. */
|
|
21
|
+
stop(): Promise<void>;
|
|
22
|
+
/** Get the server address. */
|
|
23
|
+
getAddress(): string;
|
|
24
|
+
private handleRequest;
|
|
25
|
+
private serveUI;
|
|
26
|
+
private serveSummary;
|
|
27
|
+
private serveSessions;
|
|
28
|
+
private serveEvents;
|
|
29
|
+
private serveSSE;
|
|
30
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// Dashboard server — HTTP API + static UI over the audit log.
|
|
2
|
+
//
|
|
3
|
+
// Serves the compliance dashboard on localhost. No external dependencies
|
|
4
|
+
// — uses Node's built-in http module. Binds to 127.0.0.1 by default
|
|
5
|
+
// for security (no network exposure).
|
|
6
|
+
import { createServer } from "node:http";
|
|
7
|
+
import { AuditReader } from "../audit-reader.js";
|
|
8
|
+
import { AuditTail } from "./tail.js";
|
|
9
|
+
import { renderDashboard } from "./ui.js";
|
|
10
|
+
/** Compliance dashboard HTTP server. */
|
|
11
|
+
export class DashboardServer {
|
|
12
|
+
server = null;
|
|
13
|
+
tail = null;
|
|
14
|
+
sseClients = new Set();
|
|
15
|
+
reader;
|
|
16
|
+
options;
|
|
17
|
+
constructor(options) {
|
|
18
|
+
this.options = options;
|
|
19
|
+
this.reader = new AuditReader(options.auditPath);
|
|
20
|
+
}
|
|
21
|
+
/** Start the dashboard server. */
|
|
22
|
+
async start() {
|
|
23
|
+
this.server = createServer((req, res) => this.handleRequest(req, res));
|
|
24
|
+
// Start tailing for SSE
|
|
25
|
+
this.tail = new AuditTail(this.options.auditPath);
|
|
26
|
+
this.tail.on("line", (event) => {
|
|
27
|
+
const data = `data: ${JSON.stringify(event)}\n\n`;
|
|
28
|
+
for (const client of this.sseClients) {
|
|
29
|
+
try {
|
|
30
|
+
client.write(data);
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
this.sseClients.delete(client);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
this.tail.start();
|
|
38
|
+
return new Promise((resolve) => {
|
|
39
|
+
this.server.listen(this.options.port, this.options.host, () => {
|
|
40
|
+
resolve();
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
/** Stop the dashboard server. */
|
|
45
|
+
async stop() {
|
|
46
|
+
this.tail?.stop();
|
|
47
|
+
for (const client of this.sseClients) {
|
|
48
|
+
try {
|
|
49
|
+
client.end();
|
|
50
|
+
}
|
|
51
|
+
catch { }
|
|
52
|
+
}
|
|
53
|
+
this.sseClients.clear();
|
|
54
|
+
return new Promise((resolve) => {
|
|
55
|
+
if (this.server) {
|
|
56
|
+
this.server.close(() => resolve());
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
resolve();
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
/** Get the server address. */
|
|
64
|
+
getAddress() {
|
|
65
|
+
return `http://${this.options.host}:${this.options.port}`;
|
|
66
|
+
}
|
|
67
|
+
async handleRequest(req, res) {
|
|
68
|
+
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
|
|
69
|
+
const path = url.pathname;
|
|
70
|
+
// CORS headers for localhost
|
|
71
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
72
|
+
res.setHeader("Access-Control-Allow-Methods", "GET");
|
|
73
|
+
try {
|
|
74
|
+
switch (path) {
|
|
75
|
+
case "/":
|
|
76
|
+
return this.serveUI(res);
|
|
77
|
+
case "/api/summary":
|
|
78
|
+
return await this.serveSummary(url, res);
|
|
79
|
+
case "/api/sessions":
|
|
80
|
+
return await this.serveSessions(res);
|
|
81
|
+
case "/api/events":
|
|
82
|
+
return await this.serveEvents(url, res);
|
|
83
|
+
case "/api/events/stream":
|
|
84
|
+
return this.serveSSE(req, res);
|
|
85
|
+
default:
|
|
86
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
87
|
+
res.end(JSON.stringify({ error: "not found" }));
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
catch (e) {
|
|
91
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
92
|
+
res.end(JSON.stringify({ error: e instanceof Error ? e.message : String(e) }));
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
serveUI(res) {
|
|
96
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
97
|
+
res.end(renderDashboard());
|
|
98
|
+
}
|
|
99
|
+
async serveSummary(url, res) {
|
|
100
|
+
const filter = parseFilterParams(url);
|
|
101
|
+
const summary = await this.reader.summarize(filter);
|
|
102
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
103
|
+
res.end(JSON.stringify(summary));
|
|
104
|
+
}
|
|
105
|
+
async serveSessions(res) {
|
|
106
|
+
const index = await this.reader.sessionIndex();
|
|
107
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
108
|
+
res.end(JSON.stringify(index));
|
|
109
|
+
}
|
|
110
|
+
async serveEvents(url, res) {
|
|
111
|
+
const filter = parseFilterParams(url);
|
|
112
|
+
const events = await this.reader.readAll(filter);
|
|
113
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
114
|
+
res.end(JSON.stringify(events));
|
|
115
|
+
}
|
|
116
|
+
serveSSE(req, res) {
|
|
117
|
+
res.writeHead(200, {
|
|
118
|
+
"Content-Type": "text/event-stream",
|
|
119
|
+
"Cache-Control": "no-cache",
|
|
120
|
+
"Connection": "keep-alive",
|
|
121
|
+
});
|
|
122
|
+
res.write("retry: 5000\n\n");
|
|
123
|
+
this.sseClients.add(res);
|
|
124
|
+
req.on("close", () => {
|
|
125
|
+
this.sseClients.delete(res);
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
/** Parse filter parameters from URL query string. */
|
|
130
|
+
function parseFilterParams(url) {
|
|
131
|
+
const session = url.searchParams.get("session") ?? undefined;
|
|
132
|
+
const type = url.searchParams.get("type") ?? undefined;
|
|
133
|
+
const from = url.searchParams.get("from") ?? undefined;
|
|
134
|
+
const to = url.searchParams.get("to") ?? undefined;
|
|
135
|
+
const outcome = url.searchParams.get("outcome") ?? undefined;
|
|
136
|
+
if (!session && !type && !from && !to && !outcome)
|
|
137
|
+
return undefined;
|
|
138
|
+
return {
|
|
139
|
+
sessionId: session,
|
|
140
|
+
type: type,
|
|
141
|
+
from,
|
|
142
|
+
to,
|
|
143
|
+
outcome: outcome,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { AuditEvent } from "../audit.js";
|
|
2
|
+
type TailHandler = (event: AuditEvent) => void;
|
|
3
|
+
/** Tails an append-only JSONL audit log, emitting new events. */
|
|
4
|
+
export declare class AuditTail {
|
|
5
|
+
private readonly path;
|
|
6
|
+
private handlers;
|
|
7
|
+
private offset;
|
|
8
|
+
private running;
|
|
9
|
+
constructor(path: string);
|
|
10
|
+
/** Register a handler for new events. */
|
|
11
|
+
on(_event: "line", handler: TailHandler): void;
|
|
12
|
+
/** Start tailing from the current end of file. */
|
|
13
|
+
start(): void;
|
|
14
|
+
/** Stop tailing. */
|
|
15
|
+
stop(): void;
|
|
16
|
+
private readNew;
|
|
17
|
+
}
|
|
18
|
+
export {};
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// Audit log tail — watch for new events appended to the JSONL log.
|
|
2
|
+
//
|
|
3
|
+
// Uses fs.watch + readline to tail the audit log and emit parsed events.
|
|
4
|
+
// Powers the SSE live-update endpoint in the dashboard.
|
|
5
|
+
import { createReadStream, watchFile, unwatchFile, statSync } from "node:fs";
|
|
6
|
+
import { createInterface } from "node:readline";
|
|
7
|
+
/** Tails an append-only JSONL audit log, emitting new events. */
|
|
8
|
+
export class AuditTail {
|
|
9
|
+
path;
|
|
10
|
+
handlers = [];
|
|
11
|
+
offset = 0;
|
|
12
|
+
running = false;
|
|
13
|
+
constructor(path) {
|
|
14
|
+
this.path = path;
|
|
15
|
+
}
|
|
16
|
+
/** Register a handler for new events. */
|
|
17
|
+
on(_event, handler) {
|
|
18
|
+
this.handlers.push(handler);
|
|
19
|
+
}
|
|
20
|
+
/** Start tailing from the current end of file. */
|
|
21
|
+
start() {
|
|
22
|
+
if (this.running)
|
|
23
|
+
return;
|
|
24
|
+
this.running = true;
|
|
25
|
+
try {
|
|
26
|
+
this.offset = statSync(this.path).size;
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
this.offset = 0;
|
|
30
|
+
}
|
|
31
|
+
watchFile(this.path, { interval: 1000 }, () => {
|
|
32
|
+
this.readNew();
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
/** Stop tailing. */
|
|
36
|
+
stop() {
|
|
37
|
+
this.running = false;
|
|
38
|
+
unwatchFile(this.path);
|
|
39
|
+
}
|
|
40
|
+
readNew() {
|
|
41
|
+
if (!this.running)
|
|
42
|
+
return;
|
|
43
|
+
let currentSize;
|
|
44
|
+
try {
|
|
45
|
+
currentSize = statSync(this.path).size;
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
if (currentSize <= this.offset)
|
|
51
|
+
return;
|
|
52
|
+
const stream = createReadStream(this.path, {
|
|
53
|
+
encoding: "utf-8",
|
|
54
|
+
start: this.offset,
|
|
55
|
+
});
|
|
56
|
+
const rl = createInterface({ input: stream, crlfDelay: Infinity });
|
|
57
|
+
let newOffset = this.offset;
|
|
58
|
+
rl.on("line", (line) => {
|
|
59
|
+
newOffset += Buffer.byteLength(line, "utf-8") + 1; // +1 for newline
|
|
60
|
+
const trimmed = line.trim();
|
|
61
|
+
if (!trimmed)
|
|
62
|
+
return;
|
|
63
|
+
try {
|
|
64
|
+
const event = JSON.parse(trimmed);
|
|
65
|
+
for (const handler of this.handlers) {
|
|
66
|
+
handler(event);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
// Skip malformed lines
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
rl.on("close", () => {
|
|
74
|
+
this.offset = newOffset;
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function renderDashboard(): string;
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
// Dashboard UI — single-page HTML/CSS/JS served as a template string.
|
|
2
|
+
//
|
|
3
|
+
// No build step, no framework, no external dependencies. The dashboard
|
|
4
|
+
// fetches data from the API endpoints and renders it in the browser.
|
|
5
|
+
export function renderDashboard() {
|
|
6
|
+
return `<!DOCTYPE html>
|
|
7
|
+
<html lang="en">
|
|
8
|
+
<head>
|
|
9
|
+
<meta charset="utf-8">
|
|
10
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
11
|
+
<title>KCP Harness — Compliance Dashboard</title>
|
|
12
|
+
<style>
|
|
13
|
+
:root {
|
|
14
|
+
--bg: #0f1117; --surface: #1a1d27; --border: #2a2d3a;
|
|
15
|
+
--text: #e1e4ed; --muted: #8b8fa3; --accent: #e94560;
|
|
16
|
+
--green: #4ade80; --red: #f87171; --yellow: #fbbf24; --blue: #60a5fa;
|
|
17
|
+
}
|
|
18
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
19
|
+
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: var(--bg); color: var(--text); }
|
|
20
|
+
.container { max-width: 1200px; margin: 0 auto; padding: 20px; }
|
|
21
|
+
header { display: flex; align-items: center; gap: 16px; margin-bottom: 24px; border-bottom: 1px solid var(--border); padding-bottom: 16px; }
|
|
22
|
+
header h1 { font-size: 20px; font-weight: 600; }
|
|
23
|
+
header .badge { font-size: 11px; background: var(--accent); color: white; padding: 2px 8px; border-radius: 10px; }
|
|
24
|
+
.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 12px; margin-bottom: 24px; }
|
|
25
|
+
.stat { background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 16px; }
|
|
26
|
+
.stat .label { font-size: 12px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.5px; }
|
|
27
|
+
.stat .value { font-size: 28px; font-weight: 700; margin-top: 4px; }
|
|
28
|
+
.stat .value.green { color: var(--green); }
|
|
29
|
+
.stat .value.red { color: var(--red); }
|
|
30
|
+
.stat .value.yellow { color: var(--yellow); }
|
|
31
|
+
.stat .value.blue { color: var(--blue); }
|
|
32
|
+
.panel { background: var(--surface); border: 1px solid var(--border); border-radius: 8px; margin-bottom: 16px; }
|
|
33
|
+
.panel-header { padding: 12px 16px; border-bottom: 1px solid var(--border); display: flex; justify-content: space-between; align-items: center; }
|
|
34
|
+
.panel-header h2 { font-size: 14px; font-weight: 600; }
|
|
35
|
+
.panel-body { padding: 0; max-height: 500px; overflow-y: auto; }
|
|
36
|
+
table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
|
37
|
+
th { text-align: left; padding: 8px 16px; color: var(--muted); font-weight: 500; font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; position: sticky; top: 0; background: var(--surface); }
|
|
38
|
+
td { padding: 8px 16px; border-top: 1px solid var(--border); }
|
|
39
|
+
.outcome { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 600; }
|
|
40
|
+
.outcome-approved { background: rgba(74,222,128,0.15); color: var(--green); }
|
|
41
|
+
.outcome-blocked { background: rgba(248,113,113,0.15); color: var(--red); }
|
|
42
|
+
.outcome-pass-through { background: rgba(96,165,250,0.15); color: var(--blue); }
|
|
43
|
+
.outcome-error { background: rgba(251,191,36,0.15); color: var(--yellow); }
|
|
44
|
+
.type-badge { font-size: 11px; color: var(--muted); }
|
|
45
|
+
.live-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--green); display: inline-block; animation: pulse 2s infinite; }
|
|
46
|
+
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } }
|
|
47
|
+
.filters { display: flex; gap: 8px; align-items: center; }
|
|
48
|
+
.filters select, .filters input { background: var(--bg); border: 1px solid var(--border); color: var(--text); padding: 4px 8px; border-radius: 4px; font-size: 12px; }
|
|
49
|
+
.sessions-list { list-style: none; }
|
|
50
|
+
.sessions-list li { padding: 10px 16px; border-top: 1px solid var(--border); display: flex; justify-content: space-between; align-items: center; }
|
|
51
|
+
.sessions-list li:first-child { border-top: none; }
|
|
52
|
+
.session-id { font-family: monospace; font-size: 12px; color: var(--blue); cursor: pointer; }
|
|
53
|
+
.session-meta { font-size: 11px; color: var(--muted); }
|
|
54
|
+
footer { text-align: center; color: var(--muted); font-size: 11px; margin-top: 24px; padding: 16px; }
|
|
55
|
+
</style>
|
|
56
|
+
</head>
|
|
57
|
+
<body>
|
|
58
|
+
<div class="container">
|
|
59
|
+
<header>
|
|
60
|
+
<h1>KCP Harness</h1>
|
|
61
|
+
<span class="badge">Compliance Dashboard</span>
|
|
62
|
+
<span style="margin-left:auto;font-size:12px;color:var(--muted)"><span class="live-dot"></span> Live</span>
|
|
63
|
+
</header>
|
|
64
|
+
|
|
65
|
+
<div class="stats" id="stats">
|
|
66
|
+
<div class="stat"><div class="label">Sessions</div><div class="value blue" id="s-sessions">-</div></div>
|
|
67
|
+
<div class="stat"><div class="label">Events</div><div class="value" id="s-events">-</div></div>
|
|
68
|
+
<div class="stat"><div class="label">Governed</div><div class="value green" id="s-governed">-</div></div>
|
|
69
|
+
<div class="stat"><div class="label">Blocked</div><div class="value red" id="s-blocked">-</div></div>
|
|
70
|
+
<div class="stat"><div class="label">Budget Exceeded</div><div class="value yellow" id="s-budget">-</div></div>
|
|
71
|
+
<div class="stat"><div class="label">Drifts</div><div class="value yellow" id="s-drifts">-</div></div>
|
|
72
|
+
<div class="stat"><div class="label">Sig. Blocked</div><div class="value red" id="s-sig">-</div></div>
|
|
73
|
+
</div>
|
|
74
|
+
|
|
75
|
+
<div class="panel">
|
|
76
|
+
<div class="panel-header">
|
|
77
|
+
<h2>Sessions</h2>
|
|
78
|
+
</div>
|
|
79
|
+
<div class="panel-body">
|
|
80
|
+
<ul class="sessions-list" id="sessions-list"></ul>
|
|
81
|
+
</div>
|
|
82
|
+
</div>
|
|
83
|
+
|
|
84
|
+
<div class="panel">
|
|
85
|
+
<div class="panel-header">
|
|
86
|
+
<h2>Event Log</h2>
|
|
87
|
+
<div class="filters">
|
|
88
|
+
<select id="f-type"><option value="">All types</option>
|
|
89
|
+
<option value="tool_call">tool_call</option>
|
|
90
|
+
<option value="session_start">session_start</option>
|
|
91
|
+
<option value="session_end">session_end</option>
|
|
92
|
+
<option value="budget_spend">budget_spend</option>
|
|
93
|
+
<option value="budget_exceeded">budget_exceeded</option>
|
|
94
|
+
<option value="temporal_drift">temporal_drift</option>
|
|
95
|
+
</select>
|
|
96
|
+
<select id="f-outcome"><option value="">All outcomes</option>
|
|
97
|
+
<option value="approved">approved</option>
|
|
98
|
+
<option value="blocked">blocked</option>
|
|
99
|
+
<option value="pass-through">pass-through</option>
|
|
100
|
+
<option value="error">error</option>
|
|
101
|
+
</select>
|
|
102
|
+
</div>
|
|
103
|
+
</div>
|
|
104
|
+
<div class="panel-body">
|
|
105
|
+
<table>
|
|
106
|
+
<thead><tr><th>Time</th><th>Session</th><th>Type</th><th>Tool</th><th>Outcome</th><th>Reason</th></tr></thead>
|
|
107
|
+
<tbody id="events-body"></tbody>
|
|
108
|
+
</table>
|
|
109
|
+
</div>
|
|
110
|
+
</div>
|
|
111
|
+
|
|
112
|
+
<footer>kcp-harness compliance dashboard · all data local · no external connections</footer>
|
|
113
|
+
</div>
|
|
114
|
+
|
|
115
|
+
<script>
|
|
116
|
+
const API = '';
|
|
117
|
+
let allEvents = [];
|
|
118
|
+
|
|
119
|
+
async function load() {
|
|
120
|
+
const [summary, sessions, events] = await Promise.all([
|
|
121
|
+
fetch(API + '/api/summary').then(r => r.json()),
|
|
122
|
+
fetch(API + '/api/sessions').then(r => r.json()),
|
|
123
|
+
fetch(API + '/api/events').then(r => r.json()),
|
|
124
|
+
]);
|
|
125
|
+
updateStats(summary);
|
|
126
|
+
renderSessions(sessions.sessions || []);
|
|
127
|
+
allEvents = events;
|
|
128
|
+
renderEvents(events);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function updateStats(s) {
|
|
132
|
+
document.getElementById('s-sessions').textContent = s.sessions ?? 0;
|
|
133
|
+
document.getElementById('s-events').textContent = s.events ?? 0;
|
|
134
|
+
document.getElementById('s-governed').textContent = s.governed ?? 0;
|
|
135
|
+
document.getElementById('s-blocked').textContent = s.blocked ?? 0;
|
|
136
|
+
document.getElementById('s-budget').textContent = s.budgetExceeded ?? 0;
|
|
137
|
+
document.getElementById('s-drifts').textContent = s.drifts ?? 0;
|
|
138
|
+
document.getElementById('s-sig').textContent = s.signatureBlocked ?? 0;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function renderSessions(sessions) {
|
|
142
|
+
const ul = document.getElementById('sessions-list');
|
|
143
|
+
if (!sessions.length) { ul.innerHTML = '<li style="color:var(--muted)">No sessions yet</li>'; return; }
|
|
144
|
+
ul.innerHTML = sessions.map(s =>
|
|
145
|
+
'<li><div><span class="session-id" onclick="filterSession(\\'' + s.id + '\\')">' + s.id.slice(0,12) + '</span>' +
|
|
146
|
+
'<div class="session-meta">' + s.events + ' events, ' + s.governed + ' governed, ' + s.blocked + ' blocked</div></div>' +
|
|
147
|
+
'<div class="session-meta">' + new Date(s.startedAt).toLocaleString() + '</div></li>'
|
|
148
|
+
).join('');
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function renderEvents(events) {
|
|
152
|
+
const type = document.getElementById('f-type').value;
|
|
153
|
+
const outcome = document.getElementById('f-outcome').value;
|
|
154
|
+
let filtered = events;
|
|
155
|
+
if (type) filtered = filtered.filter(e => e.type === type);
|
|
156
|
+
if (outcome) filtered = filtered.filter(e => e.outcome === outcome);
|
|
157
|
+
const last200 = filtered.slice(-200);
|
|
158
|
+
const tbody = document.getElementById('events-body');
|
|
159
|
+
tbody.innerHTML = last200.map(e =>
|
|
160
|
+
'<tr><td style="font-size:11px;white-space:nowrap">' + new Date(e.timestamp).toLocaleTimeString() + '</td>' +
|
|
161
|
+
'<td style="font-family:monospace;font-size:11px">' + (e.sessionId||'').slice(0,8) + '</td>' +
|
|
162
|
+
'<td><span class="type-badge">' + e.type + '</span></td>' +
|
|
163
|
+
'<td>' + (e.toolCall?.name || '-') + '</td>' +
|
|
164
|
+
'<td><span class="outcome outcome-' + e.outcome + '">' + e.outcome + '</span></td>' +
|
|
165
|
+
'<td style="font-size:11px;max-width:300px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + (e.governance?.reason || e.error || e.type) + '</td></tr>'
|
|
166
|
+
).join('');
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function filterSession(id) {
|
|
170
|
+
const filtered = allEvents.filter(e => e.sessionId === id);
|
|
171
|
+
renderEvents(filtered);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
document.getElementById('f-type').onchange = () => renderEvents(allEvents);
|
|
175
|
+
document.getElementById('f-outcome').onchange = () => renderEvents(allEvents);
|
|
176
|
+
|
|
177
|
+
// SSE live updates
|
|
178
|
+
const sse = new EventSource(API + '/api/events/stream');
|
|
179
|
+
sse.onmessage = (msg) => {
|
|
180
|
+
try {
|
|
181
|
+
const event = JSON.parse(msg.data);
|
|
182
|
+
allEvents.push(event);
|
|
183
|
+
renderEvents(allEvents);
|
|
184
|
+
fetch(API + '/api/summary').then(r => r.json()).then(updateStats);
|
|
185
|
+
} catch {}
|
|
186
|
+
};
|
|
187
|
+
sse.onerror = () => {};
|
|
188
|
+
|
|
189
|
+
load();
|
|
190
|
+
</script>
|
|
191
|
+
</body>
|
|
192
|
+
</html>`;
|
|
193
|
+
}
|
package/dist/export.d.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { type AuditSummary } from "./audit-reader.js";
|
|
2
|
+
/** Export configuration. */
|
|
3
|
+
export interface ExportOptions {
|
|
4
|
+
/** Path to the audit JSONL log. */
|
|
5
|
+
auditPath: string;
|
|
6
|
+
/** Output directory for evidence bundles. */
|
|
7
|
+
outputDir: string;
|
|
8
|
+
/** Which framework(s) to export. */
|
|
9
|
+
format: "soc2" | "iso27001" | "both";
|
|
10
|
+
/** Optional date range filter. */
|
|
11
|
+
dateRange?: {
|
|
12
|
+
from: string;
|
|
13
|
+
to: string;
|
|
14
|
+
};
|
|
15
|
+
/** Organization name for the report header. */
|
|
16
|
+
organization?: string;
|
|
17
|
+
}
|
|
18
|
+
/** Result of an export operation. */
|
|
19
|
+
export interface ExportResult {
|
|
20
|
+
/** Output directory. */
|
|
21
|
+
outputDir: string;
|
|
22
|
+
/** Files created. */
|
|
23
|
+
files: string[];
|
|
24
|
+
/** Summary statistics. */
|
|
25
|
+
summary: AuditSummary;
|
|
26
|
+
}
|
|
27
|
+
/** Export compliance evidence from the audit log. */
|
|
28
|
+
export declare function exportEvidence(options: ExportOptions): Promise<ExportResult>;
|
package/dist/export.js
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
// Compliance evidence export — SOC 2 Type II and ISO 27001 Annex A.
|
|
2
|
+
//
|
|
3
|
+
// Reads the audit JSONL log and produces structured evidence bundles
|
|
4
|
+
// suitable for compliance auditors. The mapping is deterministic: audit
|
|
5
|
+
// events map to specific control IDs based on their type and outcome.
|
|
6
|
+
//
|
|
7
|
+
// Output: a directory of JSON evidence files + Markdown summary reports.
|
|
8
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { AuditReader } from "./audit-reader.js";
|
|
11
|
+
/** Export compliance evidence from the audit log. */
|
|
12
|
+
export async function exportEvidence(options) {
|
|
13
|
+
const reader = new AuditReader(options.auditPath);
|
|
14
|
+
const filter = options.dateRange
|
|
15
|
+
? { from: options.dateRange.from, to: options.dateRange.to }
|
|
16
|
+
: undefined;
|
|
17
|
+
const events = await reader.readAll(filter);
|
|
18
|
+
const summary = await reader.summarize(filter);
|
|
19
|
+
const sessionIndex = await reader.sessionIndex();
|
|
20
|
+
const files = [];
|
|
21
|
+
// Create output directories
|
|
22
|
+
mkdirSync(options.outputDir, { recursive: true });
|
|
23
|
+
// Write manifest
|
|
24
|
+
const manifest = {
|
|
25
|
+
generator: "kcp-harness",
|
|
26
|
+
generatedAt: new Date().toISOString(),
|
|
27
|
+
organization: options.organization,
|
|
28
|
+
format: options.format,
|
|
29
|
+
dateRange: options.dateRange ?? summary.dateRange,
|
|
30
|
+
statistics: summary,
|
|
31
|
+
};
|
|
32
|
+
writeJSON(options.outputDir, "manifest.json", manifest);
|
|
33
|
+
files.push("manifest.json");
|
|
34
|
+
// Write raw data
|
|
35
|
+
mkdirSync(join(options.outputDir, "raw"), { recursive: true });
|
|
36
|
+
writeJSON(options.outputDir, "raw/sessions.json", sessionIndex);
|
|
37
|
+
writeJSON(options.outputDir, "raw/statistics.json", summary);
|
|
38
|
+
files.push("raw/sessions.json", "raw/statistics.json");
|
|
39
|
+
// Generate framework-specific evidence
|
|
40
|
+
if (options.format === "soc2" || options.format === "both") {
|
|
41
|
+
const soc2Files = exportSOC2(options.outputDir, events, summary, options.organization);
|
|
42
|
+
files.push(...soc2Files);
|
|
43
|
+
}
|
|
44
|
+
if (options.format === "iso27001" || options.format === "both") {
|
|
45
|
+
const isoFiles = exportISO27001(options.outputDir, events, summary, options.organization);
|
|
46
|
+
files.push(...isoFiles);
|
|
47
|
+
}
|
|
48
|
+
return { outputDir: options.outputDir, files, summary };
|
|
49
|
+
}
|
|
50
|
+
// -- SOC 2 Type II (Trust Services Criteria) ----------------------------------
|
|
51
|
+
function exportSOC2(outputDir, events, summary, org) {
|
|
52
|
+
const dir = join(outputDir, "soc2");
|
|
53
|
+
mkdirSync(dir, { recursive: true });
|
|
54
|
+
const files = [];
|
|
55
|
+
// CC6.1 — Logical and physical access controls
|
|
56
|
+
const cc61 = buildControlEvidence("CC6.1", "Logical and Physical Access Controls", "The entity implements logical access security software, infrastructure, and architectures over protected information assets to protect them from security events.", events, (e) => e.type === "tool_call" && e.classification?.governed === true, (e) => `${e.outcome}: ${e.governance?.reason ?? e.toolCall?.name ?? "unknown"}`);
|
|
57
|
+
writeJSON(dir, "CC6.1-logical-access.json", cc61);
|
|
58
|
+
files.push("soc2/CC6.1-logical-access.json");
|
|
59
|
+
// CC6.3 — Authorized access
|
|
60
|
+
const cc63 = buildControlEvidence("CC6.3", "Authorized Access", "The entity authorizes, modifies, or removes access to data, software, functions, and other protected information assets based on roles, responsibilities, or the system design and changes.", events, (e) => e.type === "tool_call" && e.governance?.mode === "plan-first", (e) => `pre-authorized via plan "${e.governance?.approvedPlan?.task ?? "unknown"}"`);
|
|
61
|
+
writeJSON(dir, "CC6.3-authorized-access.json", cc63);
|
|
62
|
+
files.push("soc2/CC6.3-authorized-access.json");
|
|
63
|
+
// CC6.6 — System boundaries
|
|
64
|
+
const cc66 = buildControlEvidence("CC6.6", "System Boundaries", "The entity implements logical access security measures to protect against threats from sources outside its system boundaries.", events, (e) => e.type === "tool_call", (e) => e.classification?.governed
|
|
65
|
+
? `governed: ${e.classification.reason}`
|
|
66
|
+
: `pass-through: ${e.classification?.reason ?? "unclassified"}`);
|
|
67
|
+
writeJSON(dir, "CC6.6-system-boundaries.json", cc66);
|
|
68
|
+
files.push("soc2/CC6.6-system-boundaries.json");
|
|
69
|
+
// CC7.2 — Monitoring
|
|
70
|
+
const cc72 = buildControlEvidence("CC7.2", "Monitoring Activities", "The entity monitors system components and the operation of those components for anomalies that are indicative of malicious acts, natural disasters, and errors affecting the entity's ability to meet its objectives.", events, (_e) => true, (e) => `[${e.type}] ${e.outcome}: ${shortDetail(e)}`);
|
|
71
|
+
writeJSON(dir, "CC7.2-monitoring.json", cc72);
|
|
72
|
+
files.push("soc2/CC7.2-monitoring.json");
|
|
73
|
+
// CC8.1 — Change management
|
|
74
|
+
const cc81 = buildControlEvidence("CC8.1", "Change Management", "The entity authorizes, designs, develops or acquires, configures, documents, tests, approves, and implements changes to infrastructure, data, software, and procedures to meet its objectives.", events, (e) => e.type === "temporal_drift" || e.type === "plan_invalidated", (e) => e.drift
|
|
75
|
+
? `drift detected in ${e.drift.manifest}: ${e.drift.summary}`
|
|
76
|
+
: `${e.type}: ${e.outcome}`);
|
|
77
|
+
writeJSON(dir, "CC8.1-change-management.json", cc81);
|
|
78
|
+
files.push("soc2/CC8.1-change-management.json");
|
|
79
|
+
// Summary report
|
|
80
|
+
const report = generateSOC2Report(summary, cc61, cc63, cc66, cc72, cc81, org);
|
|
81
|
+
writeFile(dir, "summary.md", report);
|
|
82
|
+
files.push("soc2/summary.md");
|
|
83
|
+
return files;
|
|
84
|
+
}
|
|
85
|
+
// -- ISO 27001 Annex A --------------------------------------------------------
|
|
86
|
+
function exportISO27001(outputDir, events, summary, org) {
|
|
87
|
+
const dir = join(outputDir, "iso27001");
|
|
88
|
+
mkdirSync(dir, { recursive: true });
|
|
89
|
+
const files = [];
|
|
90
|
+
// A.8.3 — Information access restriction
|
|
91
|
+
const a83 = buildControlEvidence("A.8.3", "Information Access Restriction", "Access to information and other associated assets shall be restricted in accordance with the established topic-specific policy on access control.", events, (e) => e.type === "tool_call" && e.classification?.governed === true, (e) => `${e.outcome}: ${e.governance?.reason ?? e.toolCall?.name ?? "unknown"}`);
|
|
92
|
+
writeJSON(dir, "A.8.3-access-restriction.json", a83);
|
|
93
|
+
files.push("iso27001/A.8.3-access-restriction.json");
|
|
94
|
+
// A.8.4 — Access to source code
|
|
95
|
+
const a84 = buildControlEvidence("A.8.4", "Access to Source Code", "Read and write access to source code, development tools and software libraries shall be appropriately managed.", events, (e) => e.type === "tool_call" && e.classification?.governed === true
|
|
96
|
+
&& (e.toolCall?.name === "Read" || e.toolCall?.name === "Glob" || e.toolCall?.name === "Grep"), (e) => `${e.toolCall?.name} ${e.classification?.target ?? ""}: ${e.outcome}`);
|
|
97
|
+
writeJSON(dir, "A.8.4-source-code-access.json", a84);
|
|
98
|
+
files.push("iso27001/A.8.4-source-code-access.json");
|
|
99
|
+
// A.8.15 — Logging
|
|
100
|
+
const a815 = buildControlEvidence("A.8.15", "Logging", "Logs that record activities, exceptions, faults and other relevant events shall be produced, stored, protected and analysed.", events, (e) => e.type === "session_start" || e.type === "session_end", (e) => `session ${e.sessionId}: ${e.type}`);
|
|
101
|
+
writeJSON(dir, "A.8.15-logging.json", a815);
|
|
102
|
+
files.push("iso27001/A.8.15-logging.json");
|
|
103
|
+
// A.8.16 — Monitoring activities
|
|
104
|
+
const a816 = buildControlEvidence("A.8.16", "Monitoring Activities", "Networks, systems and applications shall be monitored for anomalous behaviour and appropriate actions taken to evaluate potential information security incidents.", events, (e) => e.type === "temporal_drift" || e.type === "budget_exceeded" || e.outcome === "blocked", (e) => `[${e.type}] ${e.outcome}: ${shortDetail(e)}`);
|
|
105
|
+
writeJSON(dir, "A.8.16-monitoring.json", a816);
|
|
106
|
+
files.push("iso27001/A.8.16-monitoring.json");
|
|
107
|
+
// A.5.23 — Information security for use of cloud services
|
|
108
|
+
const a523 = buildControlEvidence("A.5.23", "Information Security for Use of Cloud Services", "Processes for acquisition, use, management and exit from cloud services shall be established in accordance with the organization's information security requirements.", events, (e) => e.type === "tool_call" && e.signature !== undefined, (e) => `signature ${e.signature?.status ?? "unknown"}: ${e.signature?.detail ?? e.toolCall?.name ?? ""}`);
|
|
109
|
+
writeJSON(dir, "A.5.23-cloud-services.json", a523);
|
|
110
|
+
files.push("iso27001/A.5.23-cloud-services.json");
|
|
111
|
+
// Summary report
|
|
112
|
+
const report = generateISO27001Report(summary, a83, a84, a815, a816, a523, org);
|
|
113
|
+
writeFile(dir, "summary.md", report);
|
|
114
|
+
files.push("iso27001/summary.md");
|
|
115
|
+
return files;
|
|
116
|
+
}
|
|
117
|
+
// -- Helpers ------------------------------------------------------------------
|
|
118
|
+
function buildControlEvidence(controlId, controlName, description, events, predicate, detailFn) {
|
|
119
|
+
const matching = events.filter(predicate);
|
|
120
|
+
return {
|
|
121
|
+
controlId,
|
|
122
|
+
controlName,
|
|
123
|
+
description,
|
|
124
|
+
evidenceCount: matching.length,
|
|
125
|
+
events: matching.map((e) => ({
|
|
126
|
+
timestamp: e.timestamp,
|
|
127
|
+
sessionId: e.sessionId,
|
|
128
|
+
type: e.type,
|
|
129
|
+
outcome: e.outcome,
|
|
130
|
+
detail: detailFn(e),
|
|
131
|
+
})),
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
function shortDetail(e) {
|
|
135
|
+
if (e.drift)
|
|
136
|
+
return `drift in ${e.drift.manifest}`;
|
|
137
|
+
if (e.budget)
|
|
138
|
+
return `budget ${e.budget.totals?.USDC ?? 0}/${e.budget.ceiling?.amount ?? "∞"}`;
|
|
139
|
+
if (e.governance)
|
|
140
|
+
return e.governance.reason;
|
|
141
|
+
if (e.toolCall)
|
|
142
|
+
return `${e.toolCall.name}`;
|
|
143
|
+
return e.type;
|
|
144
|
+
}
|
|
145
|
+
function generateSOC2Report(summary, ...controls) {
|
|
146
|
+
const org = controls.pop();
|
|
147
|
+
const ctrls = controls;
|
|
148
|
+
const lines = [
|
|
149
|
+
`# SOC 2 Type II — Evidence Summary`,
|
|
150
|
+
org ? `\n**Organization**: ${org}` : "",
|
|
151
|
+
`\n**Generated**: ${new Date().toISOString()}`,
|
|
152
|
+
`**Period**: ${summary.dateRange.first || "N/A"} — ${summary.dateRange.last || "N/A"}`,
|
|
153
|
+
``,
|
|
154
|
+
`## Overview`,
|
|
155
|
+
``,
|
|
156
|
+
`| Metric | Value |`,
|
|
157
|
+
`|--------|-------|`,
|
|
158
|
+
`| Sessions | ${summary.sessions} |`,
|
|
159
|
+
`| Total events | ${summary.events} |`,
|
|
160
|
+
`| Governed access attempts | ${summary.governed} |`,
|
|
161
|
+
`| Blocked | ${summary.blocked} |`,
|
|
162
|
+
`| Budget exceeded | ${summary.budgetExceeded} |`,
|
|
163
|
+
`| Temporal drifts | ${summary.drifts} |`,
|
|
164
|
+
`| Signature-blocked | ${summary.signatureBlocked} |`,
|
|
165
|
+
``,
|
|
166
|
+
`## Controls`,
|
|
167
|
+
``,
|
|
168
|
+
`| Control | Name | Evidence Count |`,
|
|
169
|
+
`|---------|------|---------------|`,
|
|
170
|
+
...ctrls.map((c) => `| ${c.controlId} | ${c.controlName} | ${c.evidenceCount} |`),
|
|
171
|
+
``,
|
|
172
|
+
...ctrls.map((c) => [
|
|
173
|
+
`### ${c.controlId} — ${c.controlName}`,
|
|
174
|
+
``,
|
|
175
|
+
`> ${c.description}`,
|
|
176
|
+
``,
|
|
177
|
+
`Evidence items: **${c.evidenceCount}**`,
|
|
178
|
+
``,
|
|
179
|
+
].join("\n")),
|
|
180
|
+
];
|
|
181
|
+
return lines.filter(Boolean).join("\n") + "\n";
|
|
182
|
+
}
|
|
183
|
+
function generateISO27001Report(summary, ...controls) {
|
|
184
|
+
const org = controls.pop();
|
|
185
|
+
const ctrls = controls;
|
|
186
|
+
const lines = [
|
|
187
|
+
`# ISO 27001 Annex A — Evidence Summary`,
|
|
188
|
+
org ? `\n**Organization**: ${org}` : "",
|
|
189
|
+
`\n**Generated**: ${new Date().toISOString()}`,
|
|
190
|
+
`**Period**: ${summary.dateRange.first || "N/A"} — ${summary.dateRange.last || "N/A"}`,
|
|
191
|
+
``,
|
|
192
|
+
`## Overview`,
|
|
193
|
+
``,
|
|
194
|
+
`| Metric | Value |`,
|
|
195
|
+
`|--------|-------|`,
|
|
196
|
+
`| Sessions | ${summary.sessions} |`,
|
|
197
|
+
`| Total events | ${summary.events} |`,
|
|
198
|
+
`| Governed access attempts | ${summary.governed} |`,
|
|
199
|
+
`| Blocked | ${summary.blocked} |`,
|
|
200
|
+
`| Budget exceeded | ${summary.budgetExceeded} |`,
|
|
201
|
+
`| Temporal drifts | ${summary.drifts} |`,
|
|
202
|
+
`| Signature-blocked | ${summary.signatureBlocked} |`,
|
|
203
|
+
``,
|
|
204
|
+
`## Controls`,
|
|
205
|
+
``,
|
|
206
|
+
`| Control | Name | Evidence Count |`,
|
|
207
|
+
`|---------|------|---------------|`,
|
|
208
|
+
...ctrls.map((c) => `| ${c.controlId} | ${c.controlName} | ${c.evidenceCount} |`),
|
|
209
|
+
``,
|
|
210
|
+
...ctrls.map((c) => [
|
|
211
|
+
`### ${c.controlId} — ${c.controlName}`,
|
|
212
|
+
``,
|
|
213
|
+
`> ${c.description}`,
|
|
214
|
+
``,
|
|
215
|
+
`Evidence items: **${c.evidenceCount}**`,
|
|
216
|
+
``,
|
|
217
|
+
].join("\n")),
|
|
218
|
+
];
|
|
219
|
+
return lines.filter(Boolean).join("\n") + "\n";
|
|
220
|
+
}
|
|
221
|
+
function writeJSON(dir, filename, data) {
|
|
222
|
+
writeFileSync(join(dir, filename), JSON.stringify(data, null, 2) + "\n", "utf-8");
|
|
223
|
+
}
|
|
224
|
+
function writeFile(dir, filename, content) {
|
|
225
|
+
writeFileSync(join(dir, filename), content, "utf-8");
|
|
226
|
+
}
|
package/dist/governor.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type AgentPlan, type DecisionTrace } from "kcp-agent";
|
|
1
|
+
import { type AgentPlan, type DecisionTrace, type SignatureResult } from "kcp-agent";
|
|
2
2
|
import type { GovernancePolicy } from "./config.js";
|
|
3
3
|
import type { Classification } from "./classifier.js";
|
|
4
4
|
import type { SessionState, ApprovedPlan } from "./session.js";
|
|
@@ -19,6 +19,8 @@ export interface GovernanceDecision {
|
|
|
19
19
|
approvedPlan?: ApprovedPlan;
|
|
20
20
|
/** Budget spend result (for auto-plan mode with costs). */
|
|
21
21
|
budgetSpend?: SpendResult;
|
|
22
|
+
/** Manifest signature verification result (when signature checking is active). */
|
|
23
|
+
signature?: SignatureResult;
|
|
22
24
|
}
|
|
23
25
|
/**
|
|
24
26
|
* Govern a classified tool call — decide whether to approve or block.
|
package/dist/governor.js
CHANGED
|
@@ -100,6 +100,18 @@ async function autoGovern(target, domain, session, policy) {
|
|
|
100
100
|
approved: false,
|
|
101
101
|
mode: "blocked",
|
|
102
102
|
reason: `manifest error: ${tree.error}`,
|
|
103
|
+
signature: tree.signature,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
// Signature enforcement: if required, block on non-verified signatures
|
|
107
|
+
if (policy.signature_required && tree.signature?.status !== "verified") {
|
|
108
|
+
const status = tree.signature?.status ?? "unsigned";
|
|
109
|
+
const detail = tree.signature?.detail ?? "no signing block in manifest";
|
|
110
|
+
return {
|
|
111
|
+
approved: false,
|
|
112
|
+
mode: "blocked",
|
|
113
|
+
reason: `manifest signature ${status}: ${detail}`,
|
|
114
|
+
signature: tree.signature,
|
|
103
115
|
};
|
|
104
116
|
}
|
|
105
117
|
// Extract the flat plan list
|
|
@@ -131,6 +143,7 @@ async function autoGovern(target, domain, session, policy) {
|
|
|
131
143
|
mode: "auto-plan",
|
|
132
144
|
plan: rootPlan,
|
|
133
145
|
budgetSpend,
|
|
146
|
+
signature: tree.signature,
|
|
134
147
|
reason: `auto-plan blocked: budget ceiling exceeded — ${budgetSpend.reason}`,
|
|
135
148
|
};
|
|
136
149
|
}
|
|
@@ -142,6 +155,7 @@ async function autoGovern(target, domain, session, policy) {
|
|
|
142
155
|
mode: "auto-plan",
|
|
143
156
|
plan: rootPlan,
|
|
144
157
|
budgetSpend,
|
|
158
|
+
signature: tree.signature,
|
|
145
159
|
reason: `auto-plan approved: unit "${matchingUnit.id}" (score ${matchingUnit.score}) covers ${target}`,
|
|
146
160
|
};
|
|
147
161
|
}
|
|
@@ -152,6 +166,7 @@ async function autoGovern(target, domain, session, policy) {
|
|
|
152
166
|
approved: false,
|
|
153
167
|
mode: "auto-plan",
|
|
154
168
|
plan: rootPlan,
|
|
169
|
+
signature: tree.signature,
|
|
155
170
|
reason: `auto-plan blocked: unit "${ineligibleUnit.id}" covers ${target} but is not load-eligible (${ineligibleUnit.reasons.filter(r => r.startsWith("unaffordable") || r.startsWith("needs")).join("; ") || "gate restriction"})`,
|
|
156
171
|
};
|
|
157
172
|
}
|
|
@@ -164,6 +179,7 @@ async function autoGovern(target, domain, session, policy) {
|
|
|
164
179
|
approved: false,
|
|
165
180
|
mode: "auto-plan",
|
|
166
181
|
plan: rootPlan,
|
|
182
|
+
signature: tree.signature,
|
|
167
183
|
reason: `auto-plan blocked: ${skipReason}`,
|
|
168
184
|
};
|
|
169
185
|
}
|
|
@@ -188,6 +204,8 @@ function buildFollowOptions(policy, session) {
|
|
|
188
204
|
planOptions,
|
|
189
205
|
maxDepth: 0, // auto-plan doesn't follow federation by default
|
|
190
206
|
fetchGuard: {}, // default guards (no private hosts, https only)
|
|
207
|
+
requireSignature: policy.signature_required ?? false,
|
|
208
|
+
trustedKey: policy.trusted_keys?.[0], // FollowOptions takes a single key
|
|
191
209
|
};
|
|
192
210
|
}
|
|
193
211
|
function normalizePath(p) {
|
package/dist/index.d.ts
CHANGED
|
@@ -10,3 +10,6 @@ export { BudgetLedger, type BudgetCeiling, type LedgerEntry, type LedgerSource,
|
|
|
10
10
|
export { TemporalWatch, type WatchedPlan, type DriftResult, type WatchResult, } from "./temporal-watch.js";
|
|
11
11
|
export { generate, generateAll, listAgents, harnessServerEntry, governedPathsBlock, manifestRef, } from "./integrations/generate.js";
|
|
12
12
|
export { AGENTS, type AgentTarget, type IntegrationOutput, type IntegrationFile, type IntegrationOptions, type AgentInfo, } from "./integrations/types.js";
|
|
13
|
+
export { AuditReader, type AuditFilter, type AuditSummary, type SessionEntry, type SessionIndex, } from "./audit-reader.js";
|
|
14
|
+
export { exportEvidence, type ExportOptions, type ExportResult, } from "./export.js";
|
|
15
|
+
export { DashboardServer, type DashboardOptions, } from "./dashboard/server.js";
|
package/dist/index.js
CHANGED
|
@@ -19,3 +19,6 @@ export { BudgetLedger, } from "./budget-ledger.js";
|
|
|
19
19
|
export { TemporalWatch, } from "./temporal-watch.js";
|
|
20
20
|
export { generate, generateAll, listAgents, harnessServerEntry, governedPathsBlock, manifestRef, } from "./integrations/generate.js";
|
|
21
21
|
export { AGENTS, } from "./integrations/types.js";
|
|
22
|
+
export { AuditReader, } from "./audit-reader.js";
|
|
23
|
+
export { exportEvidence, } from "./export.js";
|
|
24
|
+
export { DashboardServer, } from "./dashboard/server.js";
|