kcp-harness 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 +201 -0
- package/dist/audit.d.ts +73 -0
- package/dist/audit.js +142 -0
- package/dist/budget-ledger.d.ts +85 -0
- package/dist/budget-ledger.js +100 -0
- package/dist/classifier.d.ts +35 -0
- package/dist/classifier.js +177 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +177 -0
- package/dist/config.d.ts +65 -0
- package/dist/config.js +91 -0
- package/dist/downstream.d.ts +50 -0
- package/dist/downstream.js +167 -0
- package/dist/governor.d.ts +34 -0
- package/dist/governor.js +201 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +21 -0
- package/dist/integrations/claude-code.d.ts +2 -0
- package/dist/integrations/claude-code.js +95 -0
- package/dist/integrations/cline.d.ts +2 -0
- package/dist/integrations/cline.js +70 -0
- package/dist/integrations/continue.d.ts +2 -0
- package/dist/integrations/continue.js +39 -0
- package/dist/integrations/copilot.d.ts +2 -0
- package/dist/integrations/copilot.js +69 -0
- package/dist/integrations/crush.d.ts +2 -0
- package/dist/integrations/crush.js +48 -0
- package/dist/integrations/cursor.d.ts +2 -0
- package/dist/integrations/cursor.js +65 -0
- package/dist/integrations/generate.d.ts +13 -0
- package/dist/integrations/generate.js +60 -0
- package/dist/integrations/openclaw.d.ts +2 -0
- package/dist/integrations/openclaw.js +67 -0
- package/dist/integrations/types.d.ts +51 -0
- package/dist/integrations/types.js +78 -0
- package/dist/integrations/windsurf.d.ts +2 -0
- package/dist/integrations/windsurf.js +67 -0
- package/dist/kcp-bridge.d.ts +2 -0
- package/dist/kcp-bridge.js +85 -0
- package/dist/proxy.d.ts +37 -0
- package/dist/proxy.js +394 -0
- package/dist/session.d.ts +50 -0
- package/dist/session.js +82 -0
- package/dist/temporal-watch.d.ts +63 -0
- package/dist/temporal-watch.js +147 -0
- package/package.json +56 -0
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// Downstream MCP server management — spawn, initialize, and proxy to
|
|
2
|
+
// downstream MCP servers over stdio.
|
|
3
|
+
//
|
|
4
|
+
// The harness acts as an MCP client to each downstream server. It:
|
|
5
|
+
// 1. Spawns the server as a child process with stdio pipes
|
|
6
|
+
// 2. Performs the MCP initialization handshake
|
|
7
|
+
// 3. Collects the server's tool list
|
|
8
|
+
// 4. Routes tool calls to the appropriate downstream
|
|
9
|
+
// 5. Cleans up on shutdown
|
|
10
|
+
//
|
|
11
|
+
// Each downstream connection handles JSON-RPC 2.0 over newline-delimited
|
|
12
|
+
// stdio — the standard MCP framing. Request/response correlation uses
|
|
13
|
+
// monotonic integer IDs.
|
|
14
|
+
import { spawn } from "node:child_process";
|
|
15
|
+
import { createInterface } from "node:readline";
|
|
16
|
+
const HARNESS_VERSION = "0.1.0";
|
|
17
|
+
const INIT_TIMEOUT_MS = 15_000;
|
|
18
|
+
const CALL_TIMEOUT_MS = 60_000;
|
|
19
|
+
/** Manages the lifecycle of downstream MCP servers. */
|
|
20
|
+
export class DownstreamManager {
|
|
21
|
+
connections = [];
|
|
22
|
+
/** Map from tool name → downstream connection that owns it. */
|
|
23
|
+
toolOwners = new Map();
|
|
24
|
+
/** Spawn and initialize a downstream MCP server. */
|
|
25
|
+
async add(config) {
|
|
26
|
+
const conn = this.spawn(config);
|
|
27
|
+
await this.initialize(conn);
|
|
28
|
+
this.connections.push(conn);
|
|
29
|
+
// Register tool ownership
|
|
30
|
+
for (const tool of conn.tools) {
|
|
31
|
+
if (this.toolOwners.has(tool.name)) {
|
|
32
|
+
// Name collision — prefix with downstream name
|
|
33
|
+
const prefixed = `${config.name}__${tool.name}`;
|
|
34
|
+
this.toolOwners.set(prefixed, conn);
|
|
35
|
+
}
|
|
36
|
+
else {
|
|
37
|
+
this.toolOwners.set(tool.name, conn);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return conn;
|
|
41
|
+
}
|
|
42
|
+
/** Get all tools from all downstream servers. */
|
|
43
|
+
allTools() {
|
|
44
|
+
const tools = [];
|
|
45
|
+
for (const conn of this.connections) {
|
|
46
|
+
tools.push(...conn.tools);
|
|
47
|
+
}
|
|
48
|
+
return tools;
|
|
49
|
+
}
|
|
50
|
+
/** Find which downstream owns a tool. */
|
|
51
|
+
ownerOf(toolName) {
|
|
52
|
+
return this.toolOwners.get(toolName);
|
|
53
|
+
}
|
|
54
|
+
/** Call a tool on the downstream that owns it. */
|
|
55
|
+
async callTool(toolName, args) {
|
|
56
|
+
const conn = this.toolOwners.get(toolName);
|
|
57
|
+
if (!conn)
|
|
58
|
+
throw new Error(`no downstream owns tool: ${toolName}`);
|
|
59
|
+
if (!conn.alive)
|
|
60
|
+
throw new Error(`downstream ${conn.name} is not alive`);
|
|
61
|
+
const result = await this.rpc(conn, "tools/call", {
|
|
62
|
+
name: toolName,
|
|
63
|
+
arguments: args,
|
|
64
|
+
});
|
|
65
|
+
return result;
|
|
66
|
+
}
|
|
67
|
+
/** Shut down all downstream connections. */
|
|
68
|
+
async shutdown() {
|
|
69
|
+
for (const conn of this.connections) {
|
|
70
|
+
conn.alive = false;
|
|
71
|
+
conn.readline.close();
|
|
72
|
+
conn.process.kill("SIGTERM");
|
|
73
|
+
// Clear pending requests
|
|
74
|
+
for (const [, p] of conn.pending) {
|
|
75
|
+
clearTimeout(p.timer);
|
|
76
|
+
p.reject(new Error("downstream shutting down"));
|
|
77
|
+
}
|
|
78
|
+
conn.pending.clear();
|
|
79
|
+
}
|
|
80
|
+
this.connections = [];
|
|
81
|
+
this.toolOwners.clear();
|
|
82
|
+
}
|
|
83
|
+
// -- Internal lifecycle ---------------------------------------------------
|
|
84
|
+
spawn(config) {
|
|
85
|
+
const proc = spawn(config.command, config.args ?? [], {
|
|
86
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
87
|
+
env: { ...process.env, ...config.env },
|
|
88
|
+
});
|
|
89
|
+
if (!proc.stdout || !proc.stdin) {
|
|
90
|
+
throw new Error(`failed to spawn downstream ${config.name}: no stdio`);
|
|
91
|
+
}
|
|
92
|
+
const rl = createInterface({ input: proc.stdout, terminal: false });
|
|
93
|
+
const conn = {
|
|
94
|
+
name: config.name,
|
|
95
|
+
config,
|
|
96
|
+
process: proc,
|
|
97
|
+
readline: rl,
|
|
98
|
+
tools: [],
|
|
99
|
+
nextId: 1,
|
|
100
|
+
pending: new Map(),
|
|
101
|
+
alive: true,
|
|
102
|
+
};
|
|
103
|
+
// Wire up response handling
|
|
104
|
+
rl.on("line", (line) => {
|
|
105
|
+
const trimmed = line.trim();
|
|
106
|
+
if (!trimmed)
|
|
107
|
+
return;
|
|
108
|
+
try {
|
|
109
|
+
const msg = JSON.parse(trimmed);
|
|
110
|
+
if (msg.id !== undefined && msg.id !== null) {
|
|
111
|
+
const p = conn.pending.get(Number(msg.id));
|
|
112
|
+
if (p) {
|
|
113
|
+
conn.pending.delete(Number(msg.id));
|
|
114
|
+
clearTimeout(p.timer);
|
|
115
|
+
if (msg.error)
|
|
116
|
+
p.reject(new Error(msg.error.message));
|
|
117
|
+
else
|
|
118
|
+
p.resolve(msg.result);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
// Ignore unparseable lines (stderr leaking into stdout, etc.)
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
proc.on("exit", () => {
|
|
127
|
+
conn.alive = false;
|
|
128
|
+
// Reject all pending requests
|
|
129
|
+
for (const [, p] of conn.pending) {
|
|
130
|
+
clearTimeout(p.timer);
|
|
131
|
+
p.reject(new Error(`downstream ${config.name} exited`));
|
|
132
|
+
}
|
|
133
|
+
conn.pending.clear();
|
|
134
|
+
});
|
|
135
|
+
return conn;
|
|
136
|
+
}
|
|
137
|
+
async initialize(conn) {
|
|
138
|
+
// MCP initialization handshake
|
|
139
|
+
const initResult = (await this.rpc(conn, "initialize", {
|
|
140
|
+
protocolVersion: "2025-06-18",
|
|
141
|
+
capabilities: {},
|
|
142
|
+
clientInfo: { name: "kcp-harness", version: HARNESS_VERSION },
|
|
143
|
+
}, INIT_TIMEOUT_MS));
|
|
144
|
+
// Send initialized notification
|
|
145
|
+
this.notify(conn, "notifications/initialized");
|
|
146
|
+
// List tools
|
|
147
|
+
const toolsResult = (await this.rpc(conn, "tools/list", {}, INIT_TIMEOUT_MS));
|
|
148
|
+
conn.tools = toolsResult.tools ?? [];
|
|
149
|
+
}
|
|
150
|
+
// -- JSON-RPC transport ---------------------------------------------------
|
|
151
|
+
rpc(conn, method, params, timeoutMs = CALL_TIMEOUT_MS) {
|
|
152
|
+
const id = conn.nextId++;
|
|
153
|
+
const msg = JSON.stringify({ jsonrpc: "2.0", id, method, params });
|
|
154
|
+
conn.process.stdin.write(msg + "\n");
|
|
155
|
+
return new Promise((resolve, reject) => {
|
|
156
|
+
const timer = setTimeout(() => {
|
|
157
|
+
conn.pending.delete(id);
|
|
158
|
+
reject(new Error(`timeout waiting for ${method} from ${conn.name}`));
|
|
159
|
+
}, timeoutMs);
|
|
160
|
+
conn.pending.set(id, { resolve, reject, timer });
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
notify(conn, method, params) {
|
|
164
|
+
const msg = JSON.stringify({ jsonrpc: "2.0", method, params });
|
|
165
|
+
conn.process.stdin.write(msg + "\n");
|
|
166
|
+
}
|
|
167
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { type AgentPlan, type DecisionTrace } from "kcp-agent";
|
|
2
|
+
import type { GovernancePolicy } from "./config.js";
|
|
3
|
+
import type { Classification } from "./classifier.js";
|
|
4
|
+
import type { SessionState, ApprovedPlan } from "./session.js";
|
|
5
|
+
import type { SpendResult } from "./budget-ledger.js";
|
|
6
|
+
/** The governor's decision for a tool call. */
|
|
7
|
+
export interface GovernanceDecision {
|
|
8
|
+
/** Whether the tool call is approved. */
|
|
9
|
+
approved: boolean;
|
|
10
|
+
/** How the decision was made. */
|
|
11
|
+
mode: "plan-first" | "auto-plan" | "kcp-passthrough" | "blocked";
|
|
12
|
+
/** The plan that governs this decision (if any). */
|
|
13
|
+
plan?: AgentPlan;
|
|
14
|
+
/** The decision trace (if tracing is enabled). */
|
|
15
|
+
trace?: DecisionTrace;
|
|
16
|
+
/** Human-readable reason for the decision. */
|
|
17
|
+
reason: string;
|
|
18
|
+
/** The approved plan that matched (for plan-first mode). */
|
|
19
|
+
approvedPlan?: ApprovedPlan;
|
|
20
|
+
/** Budget spend result (for auto-plan mode with costs). */
|
|
21
|
+
budgetSpend?: SpendResult;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Govern a classified tool call — decide whether to approve or block.
|
|
25
|
+
*
|
|
26
|
+
* For KCP tools (kcp_plan, kcp_load, etc.), the call is always passed through
|
|
27
|
+
* to the kcp-agent planner directly — the harness doesn't gate KCP's own tools.
|
|
28
|
+
*
|
|
29
|
+
* For file/URL tools targeting governed domains, the governor checks:
|
|
30
|
+
* 1. Is there an existing approved plan that covers this path? → approve
|
|
31
|
+
* 2. If not, auto-plan against the domain's manifest → approve if selected
|
|
32
|
+
* 3. Otherwise → block (fail-closed)
|
|
33
|
+
*/
|
|
34
|
+
export declare function govern(classification: Classification, toolName: string, args: Record<string, unknown>, session: SessionState, policy: GovernancePolicy): Promise<GovernanceDecision>;
|
package/dist/governor.js
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
// Governor — enforce governance via kcp-agent's deterministic planner.
|
|
2
|
+
//
|
|
3
|
+
// The governor is the enforcement layer. When the classifier identifies a
|
|
4
|
+
// tool call as knowledge-navigation, the governor decides whether to allow
|
|
5
|
+
// it by running the kcp-agent planner and checking the result.
|
|
6
|
+
//
|
|
7
|
+
// Two modes:
|
|
8
|
+
//
|
|
9
|
+
// 1. **Plan-first** (preferred): the agent has called kcp_plan/kcp_load and
|
|
10
|
+
// established an approved plan. Subsequent tool calls are checked against
|
|
11
|
+
// the plan's selected units. Fast — no planner call needed.
|
|
12
|
+
//
|
|
13
|
+
// 2. **Auto-plan** (fallback): the agent tries to access a governed path
|
|
14
|
+
// without a prior plan. The governor auto-creates a plan with the file
|
|
15
|
+
// path as the task, then checks eligibility. If the path's unit is
|
|
16
|
+
// selected → approve; if not → block.
|
|
17
|
+
//
|
|
18
|
+
// In both modes, the governor is fail-closed: if the planner can't reach
|
|
19
|
+
// the manifest, if the unit isn't selected, or if the budget is exhausted,
|
|
20
|
+
// the call is blocked — never silently passed through.
|
|
21
|
+
import { planTree, plans, } from "kcp-agent";
|
|
22
|
+
import { isPathApproved, addPlan, recordSpend } from "./session.js";
|
|
23
|
+
/**
|
|
24
|
+
* Govern a classified tool call — decide whether to approve or block.
|
|
25
|
+
*
|
|
26
|
+
* For KCP tools (kcp_plan, kcp_load, etc.), the call is always passed through
|
|
27
|
+
* to the kcp-agent planner directly — the harness doesn't gate KCP's own tools.
|
|
28
|
+
*
|
|
29
|
+
* For file/URL tools targeting governed domains, the governor checks:
|
|
30
|
+
* 1. Is there an existing approved plan that covers this path? → approve
|
|
31
|
+
* 2. If not, auto-plan against the domain's manifest → approve if selected
|
|
32
|
+
* 3. Otherwise → block (fail-closed)
|
|
33
|
+
*/
|
|
34
|
+
export async function govern(classification, toolName, args, session, policy) {
|
|
35
|
+
// KCP tools pass through — they ARE the governance layer
|
|
36
|
+
if (toolName.startsWith("kcp_")) {
|
|
37
|
+
return { approved: true, mode: "kcp-passthrough", reason: "KCP tool — governance layer itself" };
|
|
38
|
+
}
|
|
39
|
+
if (!classification.governed || !classification.domain) {
|
|
40
|
+
return { approved: true, mode: "kcp-passthrough", reason: "ungoverned tool call" };
|
|
41
|
+
}
|
|
42
|
+
const domain = classification.domain;
|
|
43
|
+
const target = classification.target;
|
|
44
|
+
// Mode 1: check existing approved plans
|
|
45
|
+
if (target) {
|
|
46
|
+
const approved = isPathApproved(session, target);
|
|
47
|
+
if (approved) {
|
|
48
|
+
return {
|
|
49
|
+
approved: true,
|
|
50
|
+
mode: "plan-first",
|
|
51
|
+
plan: approved.plan,
|
|
52
|
+
approvedPlan: approved,
|
|
53
|
+
reason: `path ${target} is in approved plan for "${approved.task}"`,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
// Mode 2: auto-plan — create a governance plan on the fly
|
|
58
|
+
if (target && domain.manifest) {
|
|
59
|
+
try {
|
|
60
|
+
const autoPlan = await autoGovern(target, domain, session, policy);
|
|
61
|
+
return autoPlan;
|
|
62
|
+
}
|
|
63
|
+
catch (e) {
|
|
64
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
65
|
+
// Fail-closed: planner error → block
|
|
66
|
+
if (policy.fail_closed) {
|
|
67
|
+
return {
|
|
68
|
+
approved: false,
|
|
69
|
+
mode: "blocked",
|
|
70
|
+
reason: `auto-plan failed (fail-closed): ${msg}`,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
return {
|
|
74
|
+
approved: false,
|
|
75
|
+
mode: "blocked",
|
|
76
|
+
reason: `auto-plan failed: ${msg}`,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
// No target extractable + governed domain → block
|
|
81
|
+
return {
|
|
82
|
+
approved: false,
|
|
83
|
+
mode: "blocked",
|
|
84
|
+
reason: `governed tool call with no extractable target — blocked by policy`,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Auto-govern: run the kcp-agent planner to decide if accessing a target
|
|
89
|
+
* path is approved. Creates a plan and checks if the target is in the
|
|
90
|
+
* selected set.
|
|
91
|
+
*/
|
|
92
|
+
async function autoGovern(target, domain, session, policy) {
|
|
93
|
+
const followOptions = buildFollowOptions(policy, session);
|
|
94
|
+
// Use the target path as the task — the planner will score units
|
|
95
|
+
// against this and select the most relevant ones.
|
|
96
|
+
const task = `access ${target}`;
|
|
97
|
+
const tree = await planTree(domain.manifest, task, followOptions);
|
|
98
|
+
if (tree.error) {
|
|
99
|
+
return {
|
|
100
|
+
approved: false,
|
|
101
|
+
mode: "blocked",
|
|
102
|
+
reason: `manifest error: ${tree.error}`,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
// Extract the flat plan list
|
|
106
|
+
const allPlans = Array.from(plans(tree));
|
|
107
|
+
const rootPlan = allPlans[0];
|
|
108
|
+
if (!rootPlan) {
|
|
109
|
+
return {
|
|
110
|
+
approved: false,
|
|
111
|
+
mode: "blocked",
|
|
112
|
+
reason: "planner returned no plan",
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
// Check: is the target path in the selected set?
|
|
116
|
+
const targetNorm = normalizePath(target);
|
|
117
|
+
const matchingUnit = rootPlan.selected.find((u) => u.loadEligible && pathOverlaps(targetNorm, u.path));
|
|
118
|
+
if (matchingUnit) {
|
|
119
|
+
// Register the plan in the session for future fast-path lookups
|
|
120
|
+
addPlan(session, domain.manifest, task, rootPlan);
|
|
121
|
+
// Track budget spend via ledger
|
|
122
|
+
let budgetSpend;
|
|
123
|
+
if (rootPlan.budget?.projectedSpend) {
|
|
124
|
+
const currency = rootPlan.budget.currency ?? "USDC";
|
|
125
|
+
budgetSpend = session.ledger.recordPlanSpend(domain.manifest, task, rootPlan.budget.projectedSpend, currency);
|
|
126
|
+
// Also update the legacy counter
|
|
127
|
+
recordSpend(session, rootPlan.budget.projectedSpend);
|
|
128
|
+
if (!budgetSpend.accepted) {
|
|
129
|
+
return {
|
|
130
|
+
approved: false,
|
|
131
|
+
mode: "auto-plan",
|
|
132
|
+
plan: rootPlan,
|
|
133
|
+
budgetSpend,
|
|
134
|
+
reason: `auto-plan blocked: budget ceiling exceeded — ${budgetSpend.reason}`,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
// Register with temporal watcher
|
|
139
|
+
session.temporalWatch.register(domain.manifest, task, rootPlan, followOptions);
|
|
140
|
+
return {
|
|
141
|
+
approved: true,
|
|
142
|
+
mode: "auto-plan",
|
|
143
|
+
plan: rootPlan,
|
|
144
|
+
budgetSpend,
|
|
145
|
+
reason: `auto-plan approved: unit "${matchingUnit.id}" (score ${matchingUnit.score}) covers ${target}`,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
// Check: was the unit selected but not load-eligible? (paywall, attestation, etc.)
|
|
149
|
+
const ineligibleUnit = rootPlan.selected.find((u) => !u.loadEligible && pathOverlaps(targetNorm, u.path));
|
|
150
|
+
if (ineligibleUnit) {
|
|
151
|
+
return {
|
|
152
|
+
approved: false,
|
|
153
|
+
mode: "auto-plan",
|
|
154
|
+
plan: rootPlan,
|
|
155
|
+
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
|
+
};
|
|
157
|
+
}
|
|
158
|
+
// Target not in selected set → check if it was skipped and why
|
|
159
|
+
const skippedUnit = rootPlan.skipped.find((u) => pathOverlaps(targetNorm, u.id));
|
|
160
|
+
const skipReason = skippedUnit
|
|
161
|
+
? `unit "${skippedUnit.id}" was skipped: ${skippedUnit.reason}`
|
|
162
|
+
: `no unit covers path ${target}`;
|
|
163
|
+
return {
|
|
164
|
+
approved: false,
|
|
165
|
+
mode: "auto-plan",
|
|
166
|
+
plan: rootPlan,
|
|
167
|
+
reason: `auto-plan blocked: ${skipReason}`,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
/** Build FollowOptions from governance policy and session state. */
|
|
171
|
+
function buildFollowOptions(policy, session) {
|
|
172
|
+
const planOptions = {
|
|
173
|
+
maxUnits: policy.max_units,
|
|
174
|
+
strict: policy.strict,
|
|
175
|
+
env: policy.env,
|
|
176
|
+
};
|
|
177
|
+
if (policy.budget) {
|
|
178
|
+
planOptions.budget = {
|
|
179
|
+
amount: policy.budget.amount,
|
|
180
|
+
currency: policy.budget.currency,
|
|
181
|
+
spent: session.budgetSpent,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
if (policy.context_budget !== undefined) {
|
|
185
|
+
planOptions.contextBudget = policy.context_budget;
|
|
186
|
+
}
|
|
187
|
+
return {
|
|
188
|
+
planOptions,
|
|
189
|
+
maxDepth: 0, // auto-plan doesn't follow federation by default
|
|
190
|
+
fetchGuard: {}, // default guards (no private hosts, https only)
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
function normalizePath(p) {
|
|
194
|
+
return p.replace(/\/+/g, "/").replace(/^\.\//, "");
|
|
195
|
+
}
|
|
196
|
+
/** Check if a target path overlaps with a unit path (loose match). */
|
|
197
|
+
function pathOverlaps(target, unitPath) {
|
|
198
|
+
const a = normalizePath(target);
|
|
199
|
+
const b = normalizePath(unitPath);
|
|
200
|
+
return a === b || a.endsWith("/" + b) || a.endsWith(b) || b.endsWith("/" + a);
|
|
201
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export { classify, extractTargets, normalizePath, matchesPrefix, type Classification, } from "./classifier.js";
|
|
2
|
+
export { govern, type GovernanceDecision, } from "./governor.js";
|
|
3
|
+
export { AuditLog, InMemoryAuditLog, buildEvent, buildLifecycleEvent, buildBudgetEvent, buildDriftEvent, type AuditWriter, type AuditEvent, type AuditEventType, } from "./audit.js";
|
|
4
|
+
export { createSession, addPlan, isPathApproved, recordLoaded, getKnown, recordSpend, nextSequence, type SessionState, type ApprovedPlan, } from "./session.js";
|
|
5
|
+
export { DownstreamManager, type McpTool, type DownstreamConnection, } from "./downstream.js";
|
|
6
|
+
export { HarnessProxy, serveProxy, type ProxyOptions, } from "./proxy.js";
|
|
7
|
+
export { loadConfig, parseConfig, DEFAULT_POLICY, DEFAULT_AUDIT, type HarnessConfig, type GovernedDomain, type GovernancePolicy, type DownstreamConfig, type AuditConfig, } from "./config.js";
|
|
8
|
+
export { callKcpTool } from "./kcp-bridge.js";
|
|
9
|
+
export { BudgetLedger, type BudgetCeiling, type LedgerEntry, type LedgerSource, type LedgerCost, type LedgerSnapshot, type SpendResult, } from "./budget-ledger.js";
|
|
10
|
+
export { TemporalWatch, type WatchedPlan, type DriftResult, type WatchResult, } from "./temporal-watch.js";
|
|
11
|
+
export { generate, generateAll, listAgents, harnessServerEntry, governedPathsBlock, manifestRef, } from "./integrations/generate.js";
|
|
12
|
+
export { AGENTS, type AgentTarget, type IntegrationOutput, type IntegrationFile, type IntegrationOptions, type AgentInfo, } from "./integrations/types.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// kcp-harness — public API.
|
|
2
|
+
//
|
|
3
|
+
// The KCP Compliance Harness: an MCP proxy that enforces deterministic
|
|
4
|
+
// knowledge governance for any agent. The harness intercepts tool calls,
|
|
5
|
+
// classifies them as knowledge-navigation or pass-through, and routes
|
|
6
|
+
// governed calls through the kcp-agent planner before execution.
|
|
7
|
+
//
|
|
8
|
+
// Every decision is logged to an append-only audit log. The agent can't
|
|
9
|
+
// bypass governance because it only has access to the proxy's stdio.
|
|
10
|
+
export { classify, extractTargets, normalizePath, matchesPrefix, } from "./classifier.js";
|
|
11
|
+
export { govern, } from "./governor.js";
|
|
12
|
+
export { AuditLog, InMemoryAuditLog, buildEvent, buildLifecycleEvent, buildBudgetEvent, buildDriftEvent, } from "./audit.js";
|
|
13
|
+
export { createSession, addPlan, isPathApproved, recordLoaded, getKnown, recordSpend, nextSequence, } from "./session.js";
|
|
14
|
+
export { DownstreamManager, } from "./downstream.js";
|
|
15
|
+
export { HarnessProxy, serveProxy, } from "./proxy.js";
|
|
16
|
+
export { loadConfig, parseConfig, DEFAULT_POLICY, DEFAULT_AUDIT, } from "./config.js";
|
|
17
|
+
export { callKcpTool } from "./kcp-bridge.js";
|
|
18
|
+
export { BudgetLedger, } from "./budget-ledger.js";
|
|
19
|
+
export { TemporalWatch, } from "./temporal-watch.js";
|
|
20
|
+
export { generate, generateAll, listAgents, harnessServerEntry, governedPathsBlock, manifestRef, } from "./integrations/generate.js";
|
|
21
|
+
export { AGENTS, } from "./integrations/types.js";
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// Claude Code integration — MCP config + PreToolUse hooks + CLAUDE.md instructions.
|
|
2
|
+
import { harnessServerEntry, governedPathsBlock, manifestRef } from "./generate.js";
|
|
3
|
+
export function generateClaudeCode(options) {
|
|
4
|
+
const server = harnessServerEntry(options);
|
|
5
|
+
const manifest = manifestRef(options);
|
|
6
|
+
const paths = options.paths ?? ["docs/", "src/"];
|
|
7
|
+
const mcpJson = {
|
|
8
|
+
path: ".mcp.json",
|
|
9
|
+
content: JSON.stringify({
|
|
10
|
+
mcpServers: {
|
|
11
|
+
"kcp-harness": server,
|
|
12
|
+
},
|
|
13
|
+
}, null, 2) + "\n",
|
|
14
|
+
commitToGit: true,
|
|
15
|
+
description: "MCP server config — adds kcp-harness as a governance proxy",
|
|
16
|
+
};
|
|
17
|
+
const hooksJson = {
|
|
18
|
+
path: ".claude/settings.json",
|
|
19
|
+
content: JSON.stringify({
|
|
20
|
+
hooks: {
|
|
21
|
+
PreToolUse: [
|
|
22
|
+
{
|
|
23
|
+
matcher: "Read|Edit|Write|Glob|Grep",
|
|
24
|
+
hooks: [
|
|
25
|
+
{
|
|
26
|
+
type: "command",
|
|
27
|
+
command: "node",
|
|
28
|
+
args: ["-e", `
|
|
29
|
+
const args = JSON.parse(process.env.TOOL_INPUT || '{}');
|
|
30
|
+
const path = args.file_path || args.path || '';
|
|
31
|
+
const governed = ${JSON.stringify(paths)}.some(p => path.startsWith(p) || path.includes('/' + p));
|
|
32
|
+
if (governed) {
|
|
33
|
+
console.log(JSON.stringify({ decision: "block", reason: "Use kcp_load to access governed knowledge at " + path }));
|
|
34
|
+
process.exit(2);
|
|
35
|
+
}
|
|
36
|
+
`.trim()],
|
|
37
|
+
},
|
|
38
|
+
],
|
|
39
|
+
},
|
|
40
|
+
],
|
|
41
|
+
},
|
|
42
|
+
}, null, 2) + "\n",
|
|
43
|
+
commitToGit: true,
|
|
44
|
+
description: "PreToolUse hooks — block direct reads of governed paths, require kcp_load",
|
|
45
|
+
};
|
|
46
|
+
const claudeMd = {
|
|
47
|
+
path: "CLAUDE.md",
|
|
48
|
+
content: `# Knowledge Governance
|
|
49
|
+
|
|
50
|
+
This project uses the KCP Compliance Harness for deterministic knowledge governance.
|
|
51
|
+
|
|
52
|
+
## Governed Paths
|
|
53
|
+
|
|
54
|
+
The following paths are governed by \`${manifest}\`:
|
|
55
|
+
${governedPathsBlock(options)}
|
|
56
|
+
|
|
57
|
+
## Rules
|
|
58
|
+
|
|
59
|
+
1. **Always use \`kcp_load\`** before reading governed files — never read them directly
|
|
60
|
+
2. Call \`kcp_plan\` first to see what units are available for your task
|
|
61
|
+
3. The harness enforces budget ceilings and temporal validity automatically
|
|
62
|
+
4. Use \`harness_status\` to check the current governance state
|
|
63
|
+
5. Use \`harness_budget\` to check remaining budget
|
|
64
|
+
6. Use \`harness_temporal_check\` to verify plans are still valid
|
|
65
|
+
|
|
66
|
+
## Example Workflow
|
|
67
|
+
|
|
68
|
+
\`\`\`
|
|
69
|
+
1. kcp_plan task="understand the API" manifest="${manifest}"
|
|
70
|
+
2. kcp_load task="understand the API" manifest="${manifest}"
|
|
71
|
+
3. Read governed files through the harness (auto-governed)
|
|
72
|
+
\`\`\`
|
|
73
|
+
`,
|
|
74
|
+
commitToGit: true,
|
|
75
|
+
description: "CLAUDE.md — instructions for using governed knowledge",
|
|
76
|
+
};
|
|
77
|
+
return {
|
|
78
|
+
agent: "claude-code",
|
|
79
|
+
name: "Claude Code",
|
|
80
|
+
files: [mcpJson, hooksJson, claudeMd],
|
|
81
|
+
instructions: `## Claude Code Integration
|
|
82
|
+
|
|
83
|
+
1. The \`.mcp.json\` registers kcp-harness as an MCP server
|
|
84
|
+
2. The \`.claude/settings.json\` adds PreToolUse hooks that block direct reads of governed paths
|
|
85
|
+
3. The \`CLAUDE.md\` instructs the agent to use kcp_load for knowledge access
|
|
86
|
+
|
|
87
|
+
**Setup:**
|
|
88
|
+
\`\`\`bash
|
|
89
|
+
# Or add manually:
|
|
90
|
+
claude mcp add --scope project kcp-harness -- npx kcp-harness serve
|
|
91
|
+
\`\`\`
|
|
92
|
+
|
|
93
|
+
The hooks enforce governance at the tool level — even if the model tries to \`Read\` a governed path directly, the hook blocks it and redirects to kcp_load.`,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// Cline integration — MCP settings snippet + .clinerules
|
|
2
|
+
import { harnessServerEntry, governedPathsBlock, manifestRef } from "./generate.js";
|
|
3
|
+
export function generateCline(options) {
|
|
4
|
+
const server = harnessServerEntry(options);
|
|
5
|
+
const manifest = manifestRef(options);
|
|
6
|
+
const mcpSnippet = {
|
|
7
|
+
path: "cline-mcp-snippet.json",
|
|
8
|
+
content: JSON.stringify({
|
|
9
|
+
"kcp-harness": {
|
|
10
|
+
...server,
|
|
11
|
+
disabled: false,
|
|
12
|
+
autoApprove: [
|
|
13
|
+
"kcp_plan",
|
|
14
|
+
"kcp_trace",
|
|
15
|
+
"harness_status",
|
|
16
|
+
"harness_session",
|
|
17
|
+
"harness_budget",
|
|
18
|
+
"harness_temporal_check",
|
|
19
|
+
],
|
|
20
|
+
},
|
|
21
|
+
}, null, 2) + "\n",
|
|
22
|
+
commitToGit: false,
|
|
23
|
+
description: "MCP snippet to add via Cline's MCP Servers settings UI",
|
|
24
|
+
};
|
|
25
|
+
const rulesFile = {
|
|
26
|
+
path: ".clinerules",
|
|
27
|
+
content: `# KCP Knowledge Governance
|
|
28
|
+
|
|
29
|
+
This project uses the KCP Compliance Harness for deterministic knowledge governance.
|
|
30
|
+
|
|
31
|
+
## Governed Paths
|
|
32
|
+
|
|
33
|
+
${governedPathsBlock(options)}
|
|
34
|
+
|
|
35
|
+
Governed by: \`${manifest}\`
|
|
36
|
+
|
|
37
|
+
## Mandatory Rules
|
|
38
|
+
|
|
39
|
+
1. **Always call \`kcp_load\`** before reading files in governed paths
|
|
40
|
+
2. Call \`kcp_plan\` first to inspect the load plan
|
|
41
|
+
3. The harness blocks ungoverned access to governed paths (fail-closed)
|
|
42
|
+
4. Budget and temporal governance are enforced automatically
|
|
43
|
+
|
|
44
|
+
## Available MCP Tools (kcp-harness server)
|
|
45
|
+
|
|
46
|
+
- \`kcp_plan\` — deterministic load plan
|
|
47
|
+
- \`kcp_load\` — plan + load content
|
|
48
|
+
- \`kcp_trace\` — 13-gate decision trace
|
|
49
|
+
- \`kcp_validate\` — lint knowledge.yaml
|
|
50
|
+
- \`harness_status\` — governance state
|
|
51
|
+
- \`harness_budget\` — spend tracking
|
|
52
|
+
- \`harness_temporal_check\` — drift detection
|
|
53
|
+
`,
|
|
54
|
+
commitToGit: true,
|
|
55
|
+
description: "Cline rules — governance instructions",
|
|
56
|
+
};
|
|
57
|
+
return {
|
|
58
|
+
agent: "cline",
|
|
59
|
+
name: "Cline",
|
|
60
|
+
files: [mcpSnippet, rulesFile],
|
|
61
|
+
instructions: `## Cline Integration
|
|
62
|
+
|
|
63
|
+
**Setup:**
|
|
64
|
+
1. Open Cline sidebar > MCP Servers tab > gear icon
|
|
65
|
+
2. Add the kcp-harness entry from \`cline-mcp-snippet.json\`
|
|
66
|
+
3. Place \`.clinerules\` in your project root
|
|
67
|
+
|
|
68
|
+
The \`autoApprove\` list lets Cline call read-only governance tools without confirmation prompts. Write operations (kcp_load) still require approval.`,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Continue integration — .continue/mcpServers/kcp-harness.yaml
|
|
2
|
+
import { manifestRef } from "./generate.js";
|
|
3
|
+
export function generateContinue(options) {
|
|
4
|
+
const manifest = manifestRef(options);
|
|
5
|
+
const harnessCmd = options.harnessCommand ?? "npx";
|
|
6
|
+
const harnessArgs = options.harnessArgs ?? [
|
|
7
|
+
"kcp-harness", "serve",
|
|
8
|
+
...(options.harnessConfig ? ["--config", options.harnessConfig] : []),
|
|
9
|
+
];
|
|
10
|
+
const mcpYaml = {
|
|
11
|
+
path: ".continue/mcpServers/kcp-harness.yaml",
|
|
12
|
+
content: `name: KCP Compliance Harness
|
|
13
|
+
version: 0.1.0
|
|
14
|
+
schema: v1
|
|
15
|
+
mcpServers:
|
|
16
|
+
- name: kcp-harness
|
|
17
|
+
command: ${harnessCmd}
|
|
18
|
+
args:
|
|
19
|
+
${harnessArgs.map((a) => ` - "${a}"`).join("\n")}
|
|
20
|
+
`,
|
|
21
|
+
commitToGit: true,
|
|
22
|
+
description: "Continue MCP server config for kcp-harness",
|
|
23
|
+
};
|
|
24
|
+
return {
|
|
25
|
+
agent: "continue",
|
|
26
|
+
name: "Continue",
|
|
27
|
+
files: [mcpYaml],
|
|
28
|
+
instructions: `## Continue Integration
|
|
29
|
+
|
|
30
|
+
**Setup:**
|
|
31
|
+
1. Place \`.continue/mcpServers/kcp-harness.yaml\` in your project root
|
|
32
|
+
2. Restart Continue
|
|
33
|
+
|
|
34
|
+
Continue loads MCP servers from the \`.continue/mcpServers/\` directory automatically.
|
|
35
|
+
Use agent mode to access kcp-harness tools (\`kcp_plan\`, \`kcp_load\`, etc.).
|
|
36
|
+
|
|
37
|
+
Note: Continue uses YAML format (not JSON) for MCP server configs in the directory-based approach.`,
|
|
38
|
+
};
|
|
39
|
+
}
|