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,177 @@
|
|
|
1
|
+
// Tool call classifier — detect knowledge-navigation tool calls.
|
|
2
|
+
//
|
|
3
|
+
// The classifier is a pure function: given a tool name, its arguments, and
|
|
4
|
+
// the configured governed domains, it returns whether the call targets a
|
|
5
|
+
// governed knowledge domain, which domain matched, and why.
|
|
6
|
+
//
|
|
7
|
+
// Classification happens BEFORE governance. Every tool call is classified
|
|
8
|
+
// exactly once; the governance layer only runs for governed calls.
|
|
9
|
+
//
|
|
10
|
+
// The classifier extracts the target path or URL from known tool argument
|
|
11
|
+
// shapes (Read, Glob, Grep, WebFetch, etc.) and matches against the
|
|
12
|
+
// governed domain's path/URL prefixes. Tool names in the domain's `tools`
|
|
13
|
+
// list are matched directly regardless of arguments.
|
|
14
|
+
// -- Path extraction from tool arguments -----------------------------------
|
|
15
|
+
/** Known tool argument shapes for extracting file paths. */
|
|
16
|
+
const PATH_EXTRACTORS = {
|
|
17
|
+
// Claude Code built-in tools
|
|
18
|
+
Read: (a) => str(a["file_path"]),
|
|
19
|
+
Edit: (a) => str(a["file_path"]),
|
|
20
|
+
Write: (a) => str(a["file_path"]),
|
|
21
|
+
Glob: (a) => str(a["path"]) ?? extractPathPrefix(str(a["pattern"])),
|
|
22
|
+
Grep: (a) => str(a["path"]),
|
|
23
|
+
// MCP filesystem tools (common patterns)
|
|
24
|
+
read_file: (a) => str(a["path"]),
|
|
25
|
+
write_file: (a) => str(a["path"]),
|
|
26
|
+
list_directory: (a) => str(a["path"]),
|
|
27
|
+
search_files: (a) => str(a["path"]),
|
|
28
|
+
get_file_info: (a) => str(a["path"]),
|
|
29
|
+
// Bash — best-effort: check for file access patterns
|
|
30
|
+
Bash: (a) => extractBashTarget(str(a["command"])),
|
|
31
|
+
};
|
|
32
|
+
/** Known tool argument shapes for extracting URLs. */
|
|
33
|
+
const URL_EXTRACTORS = {
|
|
34
|
+
WebFetch: (a) => str(a["url"]),
|
|
35
|
+
WebSearch: (a) => undefined, // search queries aren't URL targets
|
|
36
|
+
fetch: (a) => str(a["url"]),
|
|
37
|
+
};
|
|
38
|
+
/** KCP tools are always classified as knowledge-nav. */
|
|
39
|
+
const KCP_TOOLS = new Set(["kcp_plan", "kcp_load", "kcp_trace", "kcp_validate", "kcp_replay"]);
|
|
40
|
+
// -- Classification ---------------------------------------------------------
|
|
41
|
+
/**
|
|
42
|
+
* Classify a tool call: is it knowledge-navigation targeting a governed domain?
|
|
43
|
+
*
|
|
44
|
+
* Classification rules (in order):
|
|
45
|
+
* 1. KCP tools → always governed (route to kcp-agent directly)
|
|
46
|
+
* 2. Tool name in domain's `tools` list → governed by that domain
|
|
47
|
+
* 3. File path extracted from arguments → match against domain paths
|
|
48
|
+
* 4. URL extracted from arguments → match against domain URLs
|
|
49
|
+
* 5. Otherwise → pass-through (ungoverned)
|
|
50
|
+
*/
|
|
51
|
+
export function classify(toolName, args, domains) {
|
|
52
|
+
// Rule 1: KCP tools are always knowledge-nav
|
|
53
|
+
if (KCP_TOOLS.has(toolName)) {
|
|
54
|
+
return { governed: true, reason: `KCP tool: ${toolName}` };
|
|
55
|
+
}
|
|
56
|
+
// Rule 2: tool name explicitly listed in a domain
|
|
57
|
+
for (const domain of domains) {
|
|
58
|
+
if (domain.tools?.includes(toolName)) {
|
|
59
|
+
return { governed: true, domain, reason: `tool ${toolName} is governed by ${domain.manifest}` };
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
// Rule 3: extract path and match against governed path prefixes
|
|
63
|
+
const pathExtractor = PATH_EXTRACTORS[toolName];
|
|
64
|
+
if (pathExtractor) {
|
|
65
|
+
const target = pathExtractor(args);
|
|
66
|
+
if (target) {
|
|
67
|
+
const normalized = normalizePath(target);
|
|
68
|
+
for (const domain of domains) {
|
|
69
|
+
if (domain.paths) {
|
|
70
|
+
for (const prefix of domain.paths) {
|
|
71
|
+
if (matchesPrefix(normalized, normalizePath(prefix))) {
|
|
72
|
+
return {
|
|
73
|
+
governed: true,
|
|
74
|
+
domain,
|
|
75
|
+
target: normalized,
|
|
76
|
+
reason: `path ${normalized} is governed by ${domain.manifest} (prefix: ${prefix})`,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
// Rule 4: extract URL and match against governed URL prefixes
|
|
85
|
+
const urlExtractor = URL_EXTRACTORS[toolName];
|
|
86
|
+
if (urlExtractor) {
|
|
87
|
+
const target = urlExtractor(args);
|
|
88
|
+
if (target) {
|
|
89
|
+
for (const domain of domains) {
|
|
90
|
+
if (domain.urls) {
|
|
91
|
+
for (const prefix of domain.urls) {
|
|
92
|
+
if (target.startsWith(prefix)) {
|
|
93
|
+
return {
|
|
94
|
+
governed: true,
|
|
95
|
+
domain,
|
|
96
|
+
target,
|
|
97
|
+
reason: `URL ${target} is governed by ${domain.manifest} (prefix: ${prefix})`,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
// Rule 5: pass-through
|
|
106
|
+
return { governed: false, reason: `tool ${toolName} does not target a governed domain` };
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Extract known targets from any tool call arguments.
|
|
110
|
+
* Useful for audit logging even when not governed.
|
|
111
|
+
*/
|
|
112
|
+
export function extractTargets(toolName, args) {
|
|
113
|
+
const paths = [];
|
|
114
|
+
const urls = [];
|
|
115
|
+
const pathFn = PATH_EXTRACTORS[toolName];
|
|
116
|
+
if (pathFn) {
|
|
117
|
+
const p = pathFn(args);
|
|
118
|
+
if (p)
|
|
119
|
+
paths.push(p);
|
|
120
|
+
}
|
|
121
|
+
const urlFn = URL_EXTRACTORS[toolName];
|
|
122
|
+
if (urlFn) {
|
|
123
|
+
const u = urlFn(args);
|
|
124
|
+
if (u)
|
|
125
|
+
urls.push(u);
|
|
126
|
+
}
|
|
127
|
+
return { paths, urls };
|
|
128
|
+
}
|
|
129
|
+
// -- Helpers ----------------------------------------------------------------
|
|
130
|
+
function str(v) {
|
|
131
|
+
return typeof v === "string" ? v : undefined;
|
|
132
|
+
}
|
|
133
|
+
/** Normalize a file path: strip leading ./ and trailing /, collapse //. */
|
|
134
|
+
export function normalizePath(p) {
|
|
135
|
+
let n = p.replace(/\/+/g, "/");
|
|
136
|
+
if (n.startsWith("./"))
|
|
137
|
+
n = n.slice(2);
|
|
138
|
+
// Don't strip leading / — absolute paths stay absolute
|
|
139
|
+
return n;
|
|
140
|
+
}
|
|
141
|
+
/** Check if a path starts with a prefix (directory-boundary-aware). */
|
|
142
|
+
export function matchesPrefix(path, prefix) {
|
|
143
|
+
// Normalize both: strip trailing slashes for comparison
|
|
144
|
+
const a = path.replace(/\/+$/, "");
|
|
145
|
+
const b = prefix.replace(/\/+$/, "");
|
|
146
|
+
if (a === b)
|
|
147
|
+
return true;
|
|
148
|
+
// Ensure prefix matches at a directory boundary
|
|
149
|
+
return a.startsWith(b + "/");
|
|
150
|
+
}
|
|
151
|
+
/** Extract the directory prefix from a glob pattern (best-effort). */
|
|
152
|
+
function extractPathPrefix(pattern) {
|
|
153
|
+
if (!pattern)
|
|
154
|
+
return undefined;
|
|
155
|
+
// Take everything before the first glob character
|
|
156
|
+
const idx = pattern.search(/[*?\[{]/);
|
|
157
|
+
if (idx <= 0)
|
|
158
|
+
return undefined;
|
|
159
|
+
const prefix = pattern.slice(0, idx);
|
|
160
|
+
// Find the last / before the glob
|
|
161
|
+
const lastSlash = prefix.lastIndexOf("/");
|
|
162
|
+
if (lastSlash > 0)
|
|
163
|
+
return prefix.slice(0, lastSlash);
|
|
164
|
+
// If the prefix itself is a directory name (e.g. "src" from "src/**"), return it
|
|
165
|
+
if (prefix.length > 0 && !prefix.includes("."))
|
|
166
|
+
return prefix.replace(/\/$/, "");
|
|
167
|
+
return undefined;
|
|
168
|
+
}
|
|
169
|
+
/** Best-effort extraction of file paths from Bash commands. */
|
|
170
|
+
function extractBashTarget(command) {
|
|
171
|
+
if (!command)
|
|
172
|
+
return undefined;
|
|
173
|
+
// Match common file-access commands: cat, head, tail, less, more, vi, nano
|
|
174
|
+
const fileCommands = /\b(?:cat|head|tail|less|more|vi|vim|nano|code)\s+["']?([^\s"'|;&]+)/;
|
|
175
|
+
const m = command.match(fileCommands);
|
|
176
|
+
return m?.[1] ?? undefined;
|
|
177
|
+
}
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// kcp-harness CLI — launch the compliance proxy.
|
|
3
|
+
//
|
|
4
|
+
// Usage:
|
|
5
|
+
// kcp-harness serve [--config harness.yaml] — start the MCP proxy
|
|
6
|
+
// kcp-harness init — create a harness.yaml template
|
|
7
|
+
// kcp-harness check [--config harness.yaml] — validate the config
|
|
8
|
+
//
|
|
9
|
+
// The harness reads its configuration from harness.yaml (or the path given
|
|
10
|
+
// by --config). The config declares governed domains, policies, downstream
|
|
11
|
+
// MCP servers, and audit log settings.
|
|
12
|
+
import { writeFileSync, existsSync, mkdirSync } from "node:fs";
|
|
13
|
+
import { dirname, join } from "node:path";
|
|
14
|
+
import { loadConfig } from "./config.js";
|
|
15
|
+
import { serveProxy } from "./proxy.js";
|
|
16
|
+
import { generate, listAgents } from "./integrations/generate.js";
|
|
17
|
+
const USAGE = `kcp-harness — KCP Compliance Harness
|
|
18
|
+
|
|
19
|
+
Usage:
|
|
20
|
+
kcp-harness serve [--config harness.yaml] Start the MCP proxy
|
|
21
|
+
kcp-harness init Create a harness.yaml template
|
|
22
|
+
kcp-harness check [--config harness.yaml] Validate configuration
|
|
23
|
+
kcp-harness integrate <agent> [options] Generate agent integration files
|
|
24
|
+
kcp-harness integrate --list List supported agents
|
|
25
|
+
kcp-harness --version Show version
|
|
26
|
+
kcp-harness --help Show this help
|
|
27
|
+
|
|
28
|
+
Agents: claude-code, cursor, windsurf, cline, continue, copilot, copilot-cli, crush, openclaw
|
|
29
|
+
|
|
30
|
+
The harness is an MCP proxy that enforces deterministic knowledge
|
|
31
|
+
governance for any agent. It intercepts tool calls, classifies them
|
|
32
|
+
as knowledge-navigation or pass-through, and routes governed calls
|
|
33
|
+
through the kcp-agent planner before execution.
|
|
34
|
+
`;
|
|
35
|
+
const VERSION = "0.1.0";
|
|
36
|
+
const TEMPLATE = `# kcp-harness configuration
|
|
37
|
+
version: "1.0"
|
|
38
|
+
|
|
39
|
+
governance:
|
|
40
|
+
domains:
|
|
41
|
+
- manifest: "./knowledge.yaml"
|
|
42
|
+
paths:
|
|
43
|
+
- "docs/"
|
|
44
|
+
- "src/"
|
|
45
|
+
# urls:
|
|
46
|
+
# - "https://docs.example.com/"
|
|
47
|
+
# tools:
|
|
48
|
+
# - "custom_knowledge_tool"
|
|
49
|
+
|
|
50
|
+
policy:
|
|
51
|
+
fail_closed: true
|
|
52
|
+
audit_all: true
|
|
53
|
+
max_units: 5
|
|
54
|
+
strict: false
|
|
55
|
+
# budget:
|
|
56
|
+
# amount: 1.00
|
|
57
|
+
# currency: USDC
|
|
58
|
+
# context_budget: 50000
|
|
59
|
+
# env: prod
|
|
60
|
+
|
|
61
|
+
downstream:
|
|
62
|
+
# - name: "filesystem"
|
|
63
|
+
# command: "npx"
|
|
64
|
+
# args: ["-y", "@modelcontextprotocol/server-filesystem", "."]
|
|
65
|
+
|
|
66
|
+
audit:
|
|
67
|
+
path: ".kcp-harness/audit.jsonl"
|
|
68
|
+
`;
|
|
69
|
+
async function main() {
|
|
70
|
+
const args = process.argv.slice(2);
|
|
71
|
+
const command = args[0];
|
|
72
|
+
if (!command || command === "--help" || command === "-h") {
|
|
73
|
+
process.stdout.write(USAGE);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
if (command === "--version" || command === "-v") {
|
|
77
|
+
process.stdout.write(`kcp-harness ${VERSION}\n`);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
const configPath = getFlag(args, "--config") ?? "harness.yaml";
|
|
81
|
+
switch (command) {
|
|
82
|
+
case "serve": {
|
|
83
|
+
if (!existsSync(configPath)) {
|
|
84
|
+
process.stderr.write(`[kcp-harness] config not found: ${configPath}\n`);
|
|
85
|
+
process.stderr.write(`[kcp-harness] run 'kcp-harness init' to create one\n`);
|
|
86
|
+
process.exit(1);
|
|
87
|
+
}
|
|
88
|
+
const config = loadConfig(configPath);
|
|
89
|
+
process.stderr.write(`[kcp-harness] starting proxy (${config.governance.domains.length} governed domains)\n`);
|
|
90
|
+
await serveProxy(config);
|
|
91
|
+
break;
|
|
92
|
+
}
|
|
93
|
+
case "init": {
|
|
94
|
+
const target = configPath === "harness.yaml" ? "harness.yaml" : configPath;
|
|
95
|
+
if (existsSync(target)) {
|
|
96
|
+
process.stderr.write(`[kcp-harness] ${target} already exists — not overwriting\n`);
|
|
97
|
+
process.exit(1);
|
|
98
|
+
}
|
|
99
|
+
writeFileSync(target, TEMPLATE, "utf-8");
|
|
100
|
+
process.stderr.write(`[kcp-harness] created ${target}\n`);
|
|
101
|
+
break;
|
|
102
|
+
}
|
|
103
|
+
case "check": {
|
|
104
|
+
if (!existsSync(configPath)) {
|
|
105
|
+
process.stderr.write(`[kcp-harness] config not found: ${configPath}\n`);
|
|
106
|
+
process.exit(1);
|
|
107
|
+
}
|
|
108
|
+
try {
|
|
109
|
+
const config = loadConfig(configPath);
|
|
110
|
+
process.stdout.write(JSON.stringify(config, null, 2) + "\n");
|
|
111
|
+
process.stderr.write(`[kcp-harness] config valid: ${config.governance.domains.length} domain(s), ${config.downstream.length} downstream(s)\n`);
|
|
112
|
+
}
|
|
113
|
+
catch (e) {
|
|
114
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
115
|
+
process.stderr.write(`[kcp-harness] config error: ${msg}\n`);
|
|
116
|
+
process.exit(1);
|
|
117
|
+
}
|
|
118
|
+
break;
|
|
119
|
+
}
|
|
120
|
+
case "integrate": {
|
|
121
|
+
const agent = args[1];
|
|
122
|
+
if (!agent || agent === "--list") {
|
|
123
|
+
const agents = listAgents();
|
|
124
|
+
process.stdout.write("Supported agents:\n");
|
|
125
|
+
for (const a of agents) {
|
|
126
|
+
process.stdout.write(` ${a}\n`);
|
|
127
|
+
}
|
|
128
|
+
break;
|
|
129
|
+
}
|
|
130
|
+
const opts = {
|
|
131
|
+
manifest: getFlag(args, "--manifest"),
|
|
132
|
+
harnessConfig: getFlag(args, "--config"),
|
|
133
|
+
harnessCommand: getFlag(args, "--command"),
|
|
134
|
+
paths: getFlag(args, "--paths")?.split(","),
|
|
135
|
+
};
|
|
136
|
+
try {
|
|
137
|
+
const output = generate(agent, opts);
|
|
138
|
+
const outDir = getFlag(args, "--out") ?? ".";
|
|
139
|
+
const dryRun = args.includes("--dry-run");
|
|
140
|
+
process.stderr.write(`[kcp-harness] generating ${output.name} integration (${output.files.length} files)\n`);
|
|
141
|
+
for (const file of output.files) {
|
|
142
|
+
const filePath = join(outDir, file.path);
|
|
143
|
+
if (dryRun) {
|
|
144
|
+
process.stdout.write(`--- ${filePath} ---\n`);
|
|
145
|
+
process.stdout.write(file.content);
|
|
146
|
+
process.stdout.write("\n");
|
|
147
|
+
}
|
|
148
|
+
else {
|
|
149
|
+
const dir = dirname(filePath);
|
|
150
|
+
mkdirSync(dir, { recursive: true });
|
|
151
|
+
writeFileSync(filePath, file.content, "utf-8");
|
|
152
|
+
process.stderr.write(`[kcp-harness] wrote ${filePath}\n`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
process.stdout.write("\n" + output.instructions + "\n");
|
|
156
|
+
}
|
|
157
|
+
catch (e) {
|
|
158
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
159
|
+
process.stderr.write(`[kcp-harness] error: ${msg}\n`);
|
|
160
|
+
process.exit(1);
|
|
161
|
+
}
|
|
162
|
+
break;
|
|
163
|
+
}
|
|
164
|
+
default:
|
|
165
|
+
process.stderr.write(`[kcp-harness] unknown command: ${command}\n\n`);
|
|
166
|
+
process.stdout.write(USAGE);
|
|
167
|
+
process.exit(1);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
function getFlag(args, flag) {
|
|
171
|
+
const idx = args.indexOf(flag);
|
|
172
|
+
return idx >= 0 && idx + 1 < args.length ? args[idx + 1] : undefined;
|
|
173
|
+
}
|
|
174
|
+
main().catch((e) => {
|
|
175
|
+
process.stderr.write(`[kcp-harness] fatal: ${e instanceof Error ? e.message : String(e)}\n`);
|
|
176
|
+
process.exit(1);
|
|
177
|
+
});
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/** A knowledge domain governed by a KCP manifest. */
|
|
2
|
+
export interface GovernedDomain {
|
|
3
|
+
/** Path or URL to the knowledge.yaml that governs this domain. */
|
|
4
|
+
manifest: string;
|
|
5
|
+
/** File path prefixes under governance (relative to the project root). */
|
|
6
|
+
paths?: string[];
|
|
7
|
+
/** URL prefixes under governance. */
|
|
8
|
+
urls?: string[];
|
|
9
|
+
/** Specific tool names that are always governed in this domain. */
|
|
10
|
+
tools?: string[];
|
|
11
|
+
}
|
|
12
|
+
/** Governance policy — what happens when a governed domain is accessed. */
|
|
13
|
+
export interface GovernancePolicy {
|
|
14
|
+
/** Block access to governed paths without plan approval (default: true). */
|
|
15
|
+
fail_closed: boolean;
|
|
16
|
+
/** Log all tool calls, not just governed ones (default: true). */
|
|
17
|
+
audit_all: boolean;
|
|
18
|
+
/** Money budget ceiling for the session. */
|
|
19
|
+
budget?: {
|
|
20
|
+
amount: number;
|
|
21
|
+
currency?: string;
|
|
22
|
+
};
|
|
23
|
+
/** Token ceiling for context window. */
|
|
24
|
+
context_budget?: number;
|
|
25
|
+
/** Cap on selected units per plan (default: 5). */
|
|
26
|
+
max_units?: number;
|
|
27
|
+
/** Fail-closed: drop non-eligible units from plan instead of listing them. */
|
|
28
|
+
strict?: boolean;
|
|
29
|
+
/** Environment for federation context selection (dev/test/staging/prod). */
|
|
30
|
+
env?: string;
|
|
31
|
+
}
|
|
32
|
+
/** A downstream MCP server to proxy tool calls to. */
|
|
33
|
+
export interface DownstreamConfig {
|
|
34
|
+
/** Human-readable name for this downstream server. */
|
|
35
|
+
name: string;
|
|
36
|
+
/** Command to spawn the server. */
|
|
37
|
+
command: string;
|
|
38
|
+
/** Arguments to the command. */
|
|
39
|
+
args?: string[];
|
|
40
|
+
/** Extra environment variables. */
|
|
41
|
+
env?: Record<string, string>;
|
|
42
|
+
}
|
|
43
|
+
/** Audit log configuration. */
|
|
44
|
+
export interface AuditConfig {
|
|
45
|
+
/** Path to the append-only JSONL audit log. */
|
|
46
|
+
path: string;
|
|
47
|
+
}
|
|
48
|
+
/** Top-level harness configuration. */
|
|
49
|
+
export interface HarnessConfig {
|
|
50
|
+
version: string;
|
|
51
|
+
governance: {
|
|
52
|
+
domains: GovernedDomain[];
|
|
53
|
+
policy: GovernancePolicy;
|
|
54
|
+
};
|
|
55
|
+
downstream: DownstreamConfig[];
|
|
56
|
+
audit: AuditConfig;
|
|
57
|
+
}
|
|
58
|
+
/** Default governance policy. */
|
|
59
|
+
export declare const DEFAULT_POLICY: GovernancePolicy;
|
|
60
|
+
/** Default audit configuration. */
|
|
61
|
+
export declare const DEFAULT_AUDIT: AuditConfig;
|
|
62
|
+
/** Load and validate a harness configuration from a YAML file. */
|
|
63
|
+
export declare function loadConfig(path: string): HarnessConfig;
|
|
64
|
+
/** Parse a harness configuration from YAML text. */
|
|
65
|
+
export declare function parseConfig(text: string): HarnessConfig;
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// Harness configuration — governed domains, policies, downstream servers.
|
|
2
|
+
//
|
|
3
|
+
// The harness reads a YAML config that declares which knowledge domains are
|
|
4
|
+
// governed (by manifest + path/URL prefixes), what policy to enforce, and
|
|
5
|
+
// which downstream MCP servers to proxy. Everything else passes through.
|
|
6
|
+
import { readFileSync } from "node:fs";
|
|
7
|
+
import yaml from "js-yaml";
|
|
8
|
+
/** Default governance policy. */
|
|
9
|
+
export const DEFAULT_POLICY = {
|
|
10
|
+
fail_closed: true,
|
|
11
|
+
audit_all: true,
|
|
12
|
+
max_units: 5,
|
|
13
|
+
strict: false,
|
|
14
|
+
};
|
|
15
|
+
/** Default audit configuration. */
|
|
16
|
+
export const DEFAULT_AUDIT = {
|
|
17
|
+
path: ".kcp-harness/audit.jsonl",
|
|
18
|
+
};
|
|
19
|
+
/** Load and validate a harness configuration from a YAML file. */
|
|
20
|
+
export function loadConfig(path) {
|
|
21
|
+
const text = readFileSync(path, "utf-8");
|
|
22
|
+
return parseConfig(text);
|
|
23
|
+
}
|
|
24
|
+
/** Parse a harness configuration from YAML text. */
|
|
25
|
+
export function parseConfig(text) {
|
|
26
|
+
const raw = yaml.load(text);
|
|
27
|
+
if (!raw || typeof raw !== "object")
|
|
28
|
+
throw new Error("harness config must be a YAML object");
|
|
29
|
+
const governance = raw["governance"];
|
|
30
|
+
const domains = parseDomains(governance?.["domains"]);
|
|
31
|
+
const policy = parsePolicy(governance?.["policy"]);
|
|
32
|
+
const downstream = parseDownstream(raw["downstream"]);
|
|
33
|
+
const audit = parseAudit(raw["audit"]);
|
|
34
|
+
return {
|
|
35
|
+
version: String(raw["version"] ?? "1.0"),
|
|
36
|
+
governance: { domains, policy },
|
|
37
|
+
downstream,
|
|
38
|
+
audit,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
function parseDomains(raw) {
|
|
42
|
+
if (!Array.isArray(raw))
|
|
43
|
+
return [];
|
|
44
|
+
return raw.map((d) => ({
|
|
45
|
+
manifest: String(d["manifest"] ?? ""),
|
|
46
|
+
paths: Array.isArray(d["paths"]) ? d["paths"].map(String) : undefined,
|
|
47
|
+
urls: Array.isArray(d["urls"]) ? d["urls"].map(String) : undefined,
|
|
48
|
+
tools: Array.isArray(d["tools"]) ? d["tools"].map(String) : undefined,
|
|
49
|
+
}));
|
|
50
|
+
}
|
|
51
|
+
function parsePolicy(raw) {
|
|
52
|
+
if (!raw || typeof raw !== "object")
|
|
53
|
+
return { ...DEFAULT_POLICY };
|
|
54
|
+
const p = raw;
|
|
55
|
+
return {
|
|
56
|
+
fail_closed: p["fail_closed"] !== false,
|
|
57
|
+
audit_all: p["audit_all"] !== false,
|
|
58
|
+
budget: parseBudget(p["budget"]),
|
|
59
|
+
context_budget: p["context_budget"] === undefined ? undefined : Number(p["context_budget"]),
|
|
60
|
+
max_units: p["max_units"] === undefined ? DEFAULT_POLICY.max_units : Number(p["max_units"]),
|
|
61
|
+
strict: p["strict"] === true,
|
|
62
|
+
env: p["env"] === undefined ? undefined : String(p["env"]),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
function parseBudget(raw) {
|
|
66
|
+
if (!raw || typeof raw !== "object")
|
|
67
|
+
return undefined;
|
|
68
|
+
const b = raw;
|
|
69
|
+
return {
|
|
70
|
+
amount: Number(b["amount"] ?? 0),
|
|
71
|
+
currency: b["currency"] === undefined ? undefined : String(b["currency"]),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
function parseDownstream(raw) {
|
|
75
|
+
if (!Array.isArray(raw))
|
|
76
|
+
return [];
|
|
77
|
+
return raw.map((d) => ({
|
|
78
|
+
name: String(d["name"] ?? ""),
|
|
79
|
+
command: String(d["command"] ?? ""),
|
|
80
|
+
args: Array.isArray(d["args"]) ? d["args"].map(String) : undefined,
|
|
81
|
+
env: d["env"] && typeof d["env"] === "object" ? Object.fromEntries(Object.entries(d["env"]).map(([k, v]) => [k, String(v)])) : undefined,
|
|
82
|
+
}));
|
|
83
|
+
}
|
|
84
|
+
function parseAudit(raw) {
|
|
85
|
+
if (!raw || typeof raw !== "object")
|
|
86
|
+
return { ...DEFAULT_AUDIT };
|
|
87
|
+
const a = raw;
|
|
88
|
+
return {
|
|
89
|
+
path: String(a["path"] ?? DEFAULT_AUDIT.path),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { type ChildProcess } from "node:child_process";
|
|
2
|
+
import { type Interface } from "node:readline";
|
|
3
|
+
import type { DownstreamConfig } from "./config.js";
|
|
4
|
+
/** An MCP tool descriptor (matches MCP spec). */
|
|
5
|
+
export interface McpTool {
|
|
6
|
+
name: string;
|
|
7
|
+
description?: string;
|
|
8
|
+
inputSchema?: Record<string, unknown>;
|
|
9
|
+
}
|
|
10
|
+
/** A live connection to a downstream MCP server. */
|
|
11
|
+
export interface DownstreamConnection {
|
|
12
|
+
name: string;
|
|
13
|
+
config: DownstreamConfig;
|
|
14
|
+
process: ChildProcess;
|
|
15
|
+
readline: Interface;
|
|
16
|
+
tools: McpTool[];
|
|
17
|
+
nextId: number;
|
|
18
|
+
pending: Map<number, {
|
|
19
|
+
resolve: (v: unknown) => void;
|
|
20
|
+
reject: (e: Error) => void;
|
|
21
|
+
timer: ReturnType<typeof setTimeout>;
|
|
22
|
+
}>;
|
|
23
|
+
alive: boolean;
|
|
24
|
+
}
|
|
25
|
+
/** Manages the lifecycle of downstream MCP servers. */
|
|
26
|
+
export declare class DownstreamManager {
|
|
27
|
+
private connections;
|
|
28
|
+
/** Map from tool name → downstream connection that owns it. */
|
|
29
|
+
private toolOwners;
|
|
30
|
+
/** Spawn and initialize a downstream MCP server. */
|
|
31
|
+
add(config: DownstreamConfig): Promise<DownstreamConnection>;
|
|
32
|
+
/** Get all tools from all downstream servers. */
|
|
33
|
+
allTools(): McpTool[];
|
|
34
|
+
/** Find which downstream owns a tool. */
|
|
35
|
+
ownerOf(toolName: string): DownstreamConnection | undefined;
|
|
36
|
+
/** Call a tool on the downstream that owns it. */
|
|
37
|
+
callTool(toolName: string, args: Record<string, unknown>): Promise<{
|
|
38
|
+
content: Array<{
|
|
39
|
+
type: string;
|
|
40
|
+
text: string;
|
|
41
|
+
}>;
|
|
42
|
+
isError: boolean;
|
|
43
|
+
}>;
|
|
44
|
+
/** Shut down all downstream connections. */
|
|
45
|
+
shutdown(): Promise<void>;
|
|
46
|
+
private spawn;
|
|
47
|
+
private initialize;
|
|
48
|
+
private rpc;
|
|
49
|
+
private notify;
|
|
50
|
+
}
|