kcp-harness 0.1.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/README.md +267 -0
- package/dist/audit-reader.d.ts +68 -0
- package/dist/audit-reader.js +150 -0
- package/dist/audit.d.ts +5 -0
- package/dist/audit.js +2 -1
- package/dist/budget-ledger.js +8 -0
- package/dist/classifier.d.ts +1 -1
- package/dist/classifier.js +33 -7
- 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 +2 -1
package/README.md
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
# kcp-harness
|
|
2
|
+
|
|
3
|
+
**Deterministic knowledge governance for any AI agent.**
|
|
4
|
+
|
|
5
|
+
> *Your agent can read every file in your project. Can it prove why it read what it read?*
|
|
6
|
+
|
|
7
|
+
KCP Harness is an MCP compliance proxy that sits between an AI coding agent and its tools. It
|
|
8
|
+
intercepts knowledge-related calls, routes them through the
|
|
9
|
+
[kcp-agent](https://github.com/Cantara/kcp-agent) deterministic planner (13-gate cascade, no LLM),
|
|
10
|
+
and produces compliance artifacts — decision traces, audit logs, budget ledgers — as a side effect
|
|
11
|
+
of normal agent operation.
|
|
12
|
+
|
|
13
|
+
The agent can't bypass governance because it only talks to the proxy's MCP interface. The proxy
|
|
14
|
+
decides what knowledge is accessible, tracks spend, and logs every decision. **Fail-closed: if the
|
|
15
|
+
harness can't verify a request, the agent gets nothing.**
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
Agent (Claude Code / Cursor / Copilot / Windsurf / Cline / Crush / OpenClaw / ...)
|
|
19
|
+
│
|
|
20
|
+
│ MCP tool call
|
|
21
|
+
v
|
|
22
|
+
┌─────────────────────────────────────────────────────────┐
|
|
23
|
+
│ kcp-harness │
|
|
24
|
+
│ │
|
|
25
|
+
│ classify → govern (13 gates) → execute → audit │
|
|
26
|
+
│ │
|
|
27
|
+
│ Side outputs: │
|
|
28
|
+
│ · Decision traces (per-request, deterministic) │
|
|
29
|
+
│ · Audit log (append-only JSONL) │
|
|
30
|
+
│ · Budget ledger (itemized, ceiling-enforced) │
|
|
31
|
+
│ · Temporal drift (plan validity over time) │
|
|
32
|
+
└─────────────────────────────────────────────────────────┘
|
|
33
|
+
│
|
|
34
|
+
v
|
|
35
|
+
Knowledge manifests (knowledge.yaml)
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
**[Documentation →](https://cantara.github.io/kcp-harness/)**
|
|
39
|
+
|
|
40
|
+
## Why
|
|
41
|
+
|
|
42
|
+
Enterprises need agents that are *defensible* — auditable, reproducible, budget-controlled,
|
|
43
|
+
temporally pinned. Today's agents can't prove why they read what they read. The harness adds a
|
|
44
|
+
compliance layer without replacing the agent.
|
|
45
|
+
|
|
46
|
+
| What you keep | What the harness adds |
|
|
47
|
+
|---|---|
|
|
48
|
+
| Your agent (Claude Code, Cursor, Copilot, ...) | Deterministic knowledge selection |
|
|
49
|
+
| Your workflow (coding, reviewing, shipping) | Decision traces (13 gates per unit) |
|
|
50
|
+
| Your tools (MCP servers, shell, browser) | Budget enforcement (ceiling, per-currency) |
|
|
51
|
+
| | Temporal governance (drift detection) |
|
|
52
|
+
| | Append-only audit log |
|
|
53
|
+
| | Replay / cross-examination |
|
|
54
|
+
|
|
55
|
+
**You sell the compliance layer. The agents are pluggable.**
|
|
56
|
+
|
|
57
|
+
## Install
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
npm install -g kcp-harness
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Or use without installing:
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
npx kcp-harness --help
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
### Native executables
|
|
70
|
+
|
|
71
|
+
Pre-built binaries (no Node/Deno required) for Linux x64/arm64, macOS x64/arm64, and Windows x64
|
|
72
|
+
— grab them from a [release](https://github.com/Cantara/kcp-harness/releases). To build one yourself:
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
npm ci && npm run build
|
|
76
|
+
deno compile --allow-read --allow-env --allow-net --allow-run \
|
|
77
|
+
--node-modules-dir=auto --output kcp-harness dist/cli.js
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Quick start
|
|
81
|
+
|
|
82
|
+
### 1. Initialize
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
kcp-harness init # creates harness.yaml
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### 2. Generate agent integration
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
kcp-harness integrate claude-code # or: cursor, copilot, windsurf, cline, continue, crush, openclaw
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
### 3. Start coding
|
|
95
|
+
|
|
96
|
+
Your agent now routes knowledge access through the harness. Every decision is logged.
|
|
97
|
+
|
|
98
|
+
## Supported agents
|
|
99
|
+
|
|
100
|
+
| Agent | Config | Integration |
|
|
101
|
+
|---|---|---|
|
|
102
|
+
| **Claude Code** | `.mcp.json` + PreToolUse hooks | `kcp-harness integrate claude-code` |
|
|
103
|
+
| **Cursor** | `.cursor/mcp.json` + `.mdc` rules | `kcp-harness integrate cursor` |
|
|
104
|
+
| **GitHub Copilot** | `.vscode/mcp.json` (uses `"servers"` key) | `kcp-harness integrate copilot` |
|
|
105
|
+
| **Windsurf** | global config + `.windsurfrules` | `kcp-harness integrate windsurf` |
|
|
106
|
+
| **Cline** | MCP settings + `.clinerules` | `kcp-harness integrate cline` |
|
|
107
|
+
| **Continue** | `.continue/mcpServers/*.yaml` | `kcp-harness integrate continue` |
|
|
108
|
+
| **Crush** | `crush.json` + PrepareStep | `kcp-harness integrate crush` |
|
|
109
|
+
| **OpenClaw** | `openclaw.json` + plugin hooks | `kcp-harness integrate openclaw` |
|
|
110
|
+
|
|
111
|
+
Each agent has its own MCP config format, rules file, and quirks. The `integrate` command handles
|
|
112
|
+
them all — one governance layer, any agent.
|
|
113
|
+
|
|
114
|
+
## How it works
|
|
115
|
+
|
|
116
|
+
Every tool call flows through a five-stage pipeline:
|
|
117
|
+
|
|
118
|
+
```
|
|
119
|
+
1. RECEIVE MCP JSON-RPC request from agent
|
|
120
|
+
2. CLASSIFY Knowledge-navigation or pass-through?
|
|
121
|
+
3. GOVERN 13-gate cascade (audience → temporal → budget → ...)
|
|
122
|
+
4. EXECUTE Call downstream tool / return content
|
|
123
|
+
5. AUDIT Log decision to append-only audit log
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
### Classifier
|
|
127
|
+
|
|
128
|
+
The classifier examines each tool call and determines whether it targets governed knowledge.
|
|
129
|
+
`Read("docs/api.md")` where `docs/` is governed? Route through the planner. `Read("package.json")`
|
|
130
|
+
where `package.json` isn't governed? Pass through. KCP tools (`kcp_plan`, `kcp_load`) are always
|
|
131
|
+
governed.
|
|
132
|
+
|
|
133
|
+
### Governor
|
|
134
|
+
|
|
135
|
+
Two modes:
|
|
136
|
+
|
|
137
|
+
- **Plan-first (fast path)** — the agent calls `kcp_plan` first. The harness caches the approved
|
|
138
|
+
plan. Subsequent reads are checked against the cached plan — no re-planning.
|
|
139
|
+
- **Auto-plan (fallback)** — the agent reads a governed path without planning. The harness runs
|
|
140
|
+
the planner automatically. Slower, but governance is enforced even for agents that don't know
|
|
141
|
+
about `kcp_plan`.
|
|
142
|
+
|
|
143
|
+
### The 13-gate cascade
|
|
144
|
+
|
|
145
|
+
Every knowledge unit is evaluated through 13 deterministic gates, in order:
|
|
146
|
+
|
|
147
|
+
```
|
|
148
|
+
audience → not_for → temporal → deprecated → supersession → relevance →
|
|
149
|
+
attestation → payment → access → strict → max_units → money_budget → context_budget
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
A unit must pass **all** gates. The gate that blocks it is recorded in the decision trace. Same
|
|
153
|
+
inputs → same plan. No model involved.
|
|
154
|
+
|
|
155
|
+
## MCP tools
|
|
156
|
+
|
|
157
|
+
Once connected, agents can use these governance tools:
|
|
158
|
+
|
|
159
|
+
| Tool | Description |
|
|
160
|
+
|---|---|
|
|
161
|
+
| `kcp_plan` | Deterministic load plan — which units, in what order, which skipped and why |
|
|
162
|
+
| `kcp_load` | Plan + load eligible unit content |
|
|
163
|
+
| `kcp_trace` | Full 13-gate decision trace |
|
|
164
|
+
| `kcp_validate` | Lint a `knowledge.yaml` |
|
|
165
|
+
| `harness_status` | Current governance state |
|
|
166
|
+
| `harness_budget` | Itemized spend tracking |
|
|
167
|
+
| `harness_temporal_check` | Plan drift detection |
|
|
168
|
+
|
|
169
|
+
## Compliance artifacts
|
|
170
|
+
|
|
171
|
+
### Audit log
|
|
172
|
+
|
|
173
|
+
Append-only JSONL. Every decision — governed or pass-through — is logged with sequence number,
|
|
174
|
+
timestamp, tool, targets, and governance decision:
|
|
175
|
+
|
|
176
|
+
```bash
|
|
177
|
+
cat .kcp-harness/audit.jsonl | jq 'select(.governed == true)'
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
### Budget ledger
|
|
181
|
+
|
|
182
|
+
Append-only itemized spend tracking. Per-currency running totals. Ceiling enforcement — a load
|
|
183
|
+
that would exceed the budget is rejected atomically (no partial loads).
|
|
184
|
+
|
|
185
|
+
### Temporal governance
|
|
186
|
+
|
|
187
|
+
Plans are registered with a temporal watcher. On subsequent calls, the watcher re-evaluates
|
|
188
|
+
against the current time. If units have drifted (expired, newly valid), the harness emits a drift
|
|
189
|
+
event. Long-running sessions stay honest.
|
|
190
|
+
|
|
191
|
+
## Configuration
|
|
192
|
+
|
|
193
|
+
```yaml
|
|
194
|
+
# harness.yaml
|
|
195
|
+
version: "1.0"
|
|
196
|
+
|
|
197
|
+
governance:
|
|
198
|
+
domains:
|
|
199
|
+
- manifest: "./knowledge.yaml"
|
|
200
|
+
paths: ["docs/", "src/"]
|
|
201
|
+
|
|
202
|
+
policy:
|
|
203
|
+
fail_closed: true
|
|
204
|
+
audit_all: true
|
|
205
|
+
max_units: 5
|
|
206
|
+
budget:
|
|
207
|
+
amount: 1.00
|
|
208
|
+
currency: USDC
|
|
209
|
+
|
|
210
|
+
audit:
|
|
211
|
+
path: ".kcp-harness/audit.jsonl"
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
## CLI
|
|
215
|
+
|
|
216
|
+
```
|
|
217
|
+
kcp-harness serve [--config harness.yaml] Start the MCP proxy
|
|
218
|
+
kcp-harness init Create a harness.yaml template
|
|
219
|
+
kcp-harness check [--config harness.yaml] Validate configuration
|
|
220
|
+
kcp-harness integrate <agent> [options] Generate agent integration files
|
|
221
|
+
kcp-harness integrate --list List supported agents
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
## Library
|
|
225
|
+
|
|
226
|
+
```ts
|
|
227
|
+
import { classify, govern, BudgetLedger, TemporalWatch } from "kcp-harness";
|
|
228
|
+
import { generate, listAgents } from "kcp-harness";
|
|
229
|
+
|
|
230
|
+
// Classify a tool call
|
|
231
|
+
const result = classify("Read", { file_path: "docs/api.md" }, governedDomains);
|
|
232
|
+
|
|
233
|
+
// Generate integration files
|
|
234
|
+
const output = generate("claude-code", { manifest: "./knowledge.yaml", paths: ["docs/"] });
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
## Architecture
|
|
238
|
+
|
|
239
|
+
```
|
|
240
|
+
┌──────────────────────────────────────────────┐
|
|
241
|
+
│ Layer 3: Integration Packages │
|
|
242
|
+
│ Agent-specific configs + rules files │
|
|
243
|
+
│ (claude-code, cursor, copilot, ...) │
|
|
244
|
+
├──────────────────────────────────────────────┤
|
|
245
|
+
│ Layer 2: KCP Compliance Harness │ ← THIS
|
|
246
|
+
│ MCP proxy — deterministic governance │
|
|
247
|
+
├──────────────────────────────────────────────┤
|
|
248
|
+
│ Layer 1: kcp-agent (planner core) │
|
|
249
|
+
│ 13-gate cascade, decision traces │
|
|
250
|
+
└──────────────────────────────────────────────┘
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
**Forking agents puts you in competition. A harness puts you in composition.**
|
|
254
|
+
|
|
255
|
+
## Tests
|
|
256
|
+
|
|
257
|
+
```bash
|
|
258
|
+
npm test # 123 tests across 9 test files
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
Covers: classifier (28), audit (14), session (9), governor (5), proxy (10), integration (9),
|
|
262
|
+
budget-ledger (14), temporal-watch (6), integrations (28).
|
|
263
|
+
|
|
264
|
+
## License
|
|
265
|
+
|
|
266
|
+
Apache-2.0 · By [eXOReaction AS](https://www.exoreaction.com), hosted under
|
|
267
|
+
[Cantara](https://github.com/Cantara).
|
|
@@ -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 {
|
|
@@ -71,3 +74,5 @@ export declare function buildBudgetEvent(sessionId: string, sequence: number, ac
|
|
|
71
74
|
}): AuditEvent;
|
|
72
75
|
/** Build a temporal drift event. */
|
|
73
76
|
export declare function buildDriftEvent(sessionId: string, sequence: number, drift: DriftResult): AuditEvent;
|
|
77
|
+
/** Redact sensitive values from tool arguments for audit logging. */
|
|
78
|
+
export declare function sanitizeArgs(toolName: string, args: Record<string, unknown>): Record<string, unknown>;
|
package/dist/audit.js
CHANGED
|
@@ -61,6 +61,7 @@ export function buildEvent(sessionId, sequence, toolName, args, classification,
|
|
|
61
61
|
outcome,
|
|
62
62
|
durationMs,
|
|
63
63
|
error,
|
|
64
|
+
signature: governance?.signature,
|
|
64
65
|
};
|
|
65
66
|
}
|
|
66
67
|
/** Build a session lifecycle event. */
|
|
@@ -106,7 +107,7 @@ export function buildDriftEvent(sessionId, sequence, drift) {
|
|
|
106
107
|
};
|
|
107
108
|
}
|
|
108
109
|
/** Redact sensitive values from tool arguments for audit logging. */
|
|
109
|
-
function sanitizeArgs(toolName, args) {
|
|
110
|
+
export function sanitizeArgs(toolName, args) {
|
|
110
111
|
const sanitized = { ...args };
|
|
111
112
|
// Redact content from Write calls (could be large/sensitive)
|
|
112
113
|
if (toolName === "Write" && sanitized["content"]) {
|
package/dist/budget-ledger.js
CHANGED
|
@@ -25,6 +25,14 @@ export class BudgetLedger {
|
|
|
25
25
|
/** Record a spend event. Returns whether it was accepted. */
|
|
26
26
|
record(source, cost) {
|
|
27
27
|
const currency = cost.currency || "USDC";
|
|
28
|
+
// Reject negative amounts — no refunds through the ledger
|
|
29
|
+
if (cost.amount < 0) {
|
|
30
|
+
return {
|
|
31
|
+
accepted: false,
|
|
32
|
+
total: this.totals.get(currency) ?? 0,
|
|
33
|
+
reason: `negative spend rejected: ${cost.amount} ${currency}`,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
28
36
|
const currentTotal = this.totals.get(currency) ?? 0;
|
|
29
37
|
const newTotal = round6(currentTotal + cost.amount);
|
|
30
38
|
// Check ceiling
|
package/dist/classifier.d.ts
CHANGED
|
@@ -29,7 +29,7 @@ export declare function extractTargets(toolName: string, args: Record<string, un
|
|
|
29
29
|
paths: string[];
|
|
30
30
|
urls: string[];
|
|
31
31
|
};
|
|
32
|
-
/** Normalize a file path: strip leading
|
|
32
|
+
/** Normalize a file path: resolve ../, strip leading ./, collapse //. */
|
|
33
33
|
export declare function normalizePath(p: string): string;
|
|
34
34
|
/** Check if a path starts with a prefix (directory-boundary-aware). */
|
|
35
35
|
export declare function matchesPrefix(path: string, prefix: string): boolean;
|
package/dist/classifier.js
CHANGED
|
@@ -60,7 +60,7 @@ export function classify(toolName, args, domains) {
|
|
|
60
60
|
}
|
|
61
61
|
}
|
|
62
62
|
// Rule 3: extract path and match against governed path prefixes
|
|
63
|
-
const pathExtractor = PATH_EXTRACTORS[toolName];
|
|
63
|
+
const pathExtractor = Object.hasOwn(PATH_EXTRACTORS, toolName) ? PATH_EXTRACTORS[toolName] : undefined;
|
|
64
64
|
if (pathExtractor) {
|
|
65
65
|
const target = pathExtractor(args);
|
|
66
66
|
if (target) {
|
|
@@ -82,7 +82,7 @@ export function classify(toolName, args, domains) {
|
|
|
82
82
|
}
|
|
83
83
|
}
|
|
84
84
|
// Rule 4: extract URL and match against governed URL prefixes
|
|
85
|
-
const urlExtractor = URL_EXTRACTORS[toolName];
|
|
85
|
+
const urlExtractor = Object.hasOwn(URL_EXTRACTORS, toolName) ? URL_EXTRACTORS[toolName] : undefined;
|
|
86
86
|
if (urlExtractor) {
|
|
87
87
|
const target = urlExtractor(args);
|
|
88
88
|
if (target) {
|
|
@@ -130,12 +130,26 @@ export function extractTargets(toolName, args) {
|
|
|
130
130
|
function str(v) {
|
|
131
131
|
return typeof v === "string" ? v : undefined;
|
|
132
132
|
}
|
|
133
|
-
/** Normalize a file path: strip leading
|
|
133
|
+
/** Normalize a file path: resolve ../, strip leading ./, collapse //. */
|
|
134
134
|
export function normalizePath(p) {
|
|
135
135
|
let n = p.replace(/\/+/g, "/");
|
|
136
136
|
if (n.startsWith("./"))
|
|
137
137
|
n = n.slice(2);
|
|
138
|
-
//
|
|
138
|
+
// Resolve ../ segments to prevent traversal bypass
|
|
139
|
+
const parts = n.split("/");
|
|
140
|
+
const resolved = [];
|
|
141
|
+
for (const part of parts) {
|
|
142
|
+
if (part === "..") {
|
|
143
|
+
if (resolved.length > 0 && resolved[resolved.length - 1] !== "..") {
|
|
144
|
+
resolved.pop();
|
|
145
|
+
}
|
|
146
|
+
// else: leading ../ — keep it (can't resolve beyond root)
|
|
147
|
+
}
|
|
148
|
+
else if (part !== ".") {
|
|
149
|
+
resolved.push(part);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
n = resolved.join("/");
|
|
139
153
|
return n;
|
|
140
154
|
}
|
|
141
155
|
/** Check if a path starts with a prefix (directory-boundary-aware). */
|
|
@@ -170,8 +184,20 @@ function extractPathPrefix(pattern) {
|
|
|
170
184
|
function extractBashTarget(command) {
|
|
171
185
|
if (!command)
|
|
172
186
|
return undefined;
|
|
173
|
-
// Match common file-access commands:
|
|
174
|
-
const fileCommands = /\b(?:cat|head|tail|less|more|vi|vim|nano|code)\s+["']?([^\s"'|;&]+)/;
|
|
187
|
+
// Match common file-access commands: read, copy, move, link, archive
|
|
188
|
+
const fileCommands = /\b(?:cat|head|tail|less|more|vi|vim|nano|code|cp|mv|ln|tar|zip|scp|rsync)\s+(?:-[^\s]*\s+)*["']?([^\s"'|;&]+)/;
|
|
175
189
|
const m = command.match(fileCommands);
|
|
176
|
-
|
|
190
|
+
if (m?.[1])
|
|
191
|
+
return m[1];
|
|
192
|
+
// Match redirect targets: > or >> followed by a path
|
|
193
|
+
const redirect = /[>]\s*["']?([^\s"'|;&]+)/;
|
|
194
|
+
const r = command.match(redirect);
|
|
195
|
+
if (r?.[1])
|
|
196
|
+
return r[1];
|
|
197
|
+
// Match programming language file access: python/node/ruby -c '...open("path")...'
|
|
198
|
+
const langRead = /\bopen\s*\(\s*["']([^"']+)["']\s*\)/;
|
|
199
|
+
const l = command.match(langRead);
|
|
200
|
+
if (l?.[1])
|
|
201
|
+
return l[1];
|
|
202
|
+
return undefined;
|
|
177
203
|
}
|