kcp-harness 0.5.1 → 0.6.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/approval.d.ts +104 -0
- package/dist/approval.js +179 -0
- package/dist/approvals-cli.d.ts +4 -0
- package/dist/approvals-cli.js +68 -0
- package/dist/audit.d.ts +37 -1
- package/dist/audit.js +44 -0
- package/dist/cli.js +45 -1
- package/dist/config.d.ts +48 -0
- package/dist/config.js +53 -1
- package/dist/governor.d.ts +15 -3
- package/dist/governor.js +91 -1
- package/dist/index.d.ts +5 -3
- package/dist/index.js +4 -2
- package/dist/proxy.d.ts +11 -0
- package/dist/proxy.js +184 -2
- package/package.json +2 -2
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { type ApprovalsConfig } from "./config.js";
|
|
2
|
+
import type { ConfidenceVerdict } from "kcp-agent";
|
|
3
|
+
/** Lifecycle states for an approval ticket. */
|
|
4
|
+
export type ApprovalState = "pending_review" | "approved" | "dismissed" | "expired";
|
|
5
|
+
/** A request for human approval of a governed call. */
|
|
6
|
+
export interface ApprovalRequest {
|
|
7
|
+
/** Ticket id, assigned by the harness. */
|
|
8
|
+
id: string;
|
|
9
|
+
/** Session that triggered the request. */
|
|
10
|
+
sessionId: string;
|
|
11
|
+
/** The intercepted tool call. */
|
|
12
|
+
toolName: string;
|
|
13
|
+
/** The classified target (path / action). */
|
|
14
|
+
target: string;
|
|
15
|
+
/** The task context the call was made under. */
|
|
16
|
+
task: string;
|
|
17
|
+
/** Role that must approve, from harness policy (e.g. "account-owner"). */
|
|
18
|
+
requiredRole: string;
|
|
19
|
+
/** ISO timestamp the ticket was opened. */
|
|
20
|
+
requestedAt: string;
|
|
21
|
+
/** ISO timestamp after which an unresolved ticket reads as expired. */
|
|
22
|
+
expiresAt?: string;
|
|
23
|
+
/** Evidence generated at request time — why a human is being asked. */
|
|
24
|
+
evidence: {
|
|
25
|
+
manifest?: string;
|
|
26
|
+
/** The policy rule that demanded human sign-off. */
|
|
27
|
+
policyRef?: string;
|
|
28
|
+
detail?: string;
|
|
29
|
+
/** The failed confidence verdict, when the gate routed here. */
|
|
30
|
+
confidence?: ConfidenceVerdict;
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
/** A named human's resolution of a ticket. */
|
|
34
|
+
export interface ApprovalResolution {
|
|
35
|
+
id: string;
|
|
36
|
+
state: "approved" | "dismissed";
|
|
37
|
+
/** Named reviewer — required, never anonymous. */
|
|
38
|
+
reviewer: string;
|
|
39
|
+
reviewedAt: string;
|
|
40
|
+
/** Policy/regulatory citation satisfied — required at approval time. */
|
|
41
|
+
policyRef: string;
|
|
42
|
+
note?: string;
|
|
43
|
+
}
|
|
44
|
+
/** Current status of a ticket: its request, computed state, and resolution. */
|
|
45
|
+
export interface ApprovalStatus {
|
|
46
|
+
state: ApprovalState;
|
|
47
|
+
request: ApprovalRequest;
|
|
48
|
+
resolution?: ApprovalResolution;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* The channel-agnostic provider surface. The harness submits and checks;
|
|
52
|
+
* the approval channel (CLI, Slack bot, ticketing system) resolves.
|
|
53
|
+
*/
|
|
54
|
+
export interface ApprovalProvider {
|
|
55
|
+
submit(req: ApprovalRequest): Promise<void>;
|
|
56
|
+
check(id: string): Promise<ApprovalStatus | undefined>;
|
|
57
|
+
resolve(res: ApprovalResolution): Promise<ApprovalStatus>;
|
|
58
|
+
list(filter?: {
|
|
59
|
+
state?: ApprovalState;
|
|
60
|
+
}): Promise<ApprovalStatus[]>;
|
|
61
|
+
}
|
|
62
|
+
/** Construct the configured ticket store. */
|
|
63
|
+
export declare function providerFromConfig(config: ApprovalsConfig): ApprovalProvider;
|
|
64
|
+
/** Build a new ticket with id + requestedAt assigned. */
|
|
65
|
+
export declare function newRequest(fields: Omit<ApprovalRequest, "id" | "requestedAt"> & {
|
|
66
|
+
expiresAt?: string;
|
|
67
|
+
}): ApprovalRequest;
|
|
68
|
+
/** Parse a policy duration ("72h", "30m", "7d") to milliseconds. */
|
|
69
|
+
export declare function parseDuration(text: string): number;
|
|
70
|
+
/**
|
|
71
|
+
* Find the most recent ticket for a (target, tool) pair, whatever its state.
|
|
72
|
+
* The governor uses this to decide whether to honor, wait on, or re-submit.
|
|
73
|
+
*/
|
|
74
|
+
export declare function latestForCall(provider: ApprovalProvider, target: string, toolName: string): Promise<ApprovalStatus | undefined>;
|
|
75
|
+
export declare class InMemoryApprovalProvider implements ApprovalProvider {
|
|
76
|
+
private readonly requests;
|
|
77
|
+
private readonly resolutions;
|
|
78
|
+
submit(req: ApprovalRequest): Promise<void>;
|
|
79
|
+
check(id: string): Promise<ApprovalStatus | undefined>;
|
|
80
|
+
resolve(res: ApprovalResolution): Promise<ApprovalStatus>;
|
|
81
|
+
list(filter?: {
|
|
82
|
+
state?: ApprovalState;
|
|
83
|
+
}): Promise<ApprovalStatus[]>;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Append-only JSONL store under a directory (default `.kcp-harness/approvals`).
|
|
87
|
+
* Every read replays the log, so a CLI in one process and the proxy in
|
|
88
|
+
* another always see each other's writes — no daemon, no lock protocol
|
|
89
|
+
* beyond O_APPEND line writes (approvals are low-volume by nature).
|
|
90
|
+
*/
|
|
91
|
+
export declare class FileApprovalProvider implements ApprovalProvider {
|
|
92
|
+
private readonly file;
|
|
93
|
+
constructor(dir: string);
|
|
94
|
+
/** The backing file path (for status displays). */
|
|
95
|
+
getPath(): string;
|
|
96
|
+
private read;
|
|
97
|
+
private append;
|
|
98
|
+
submit(req: ApprovalRequest): Promise<void>;
|
|
99
|
+
check(id: string): Promise<ApprovalStatus | undefined>;
|
|
100
|
+
resolve(res: ApprovalResolution): Promise<ApprovalStatus>;
|
|
101
|
+
list(filter?: {
|
|
102
|
+
state?: ApprovalState;
|
|
103
|
+
}): Promise<ApprovalStatus[]>;
|
|
104
|
+
}
|
package/dist/approval.js
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// Approval — pending governance decisions resolved by a named human.
|
|
2
|
+
//
|
|
3
|
+
// Some governed actions must not be decided by the automated gate cascade
|
|
4
|
+
// alone: org policy demands a named human sign off, and that can take
|
|
5
|
+
// minutes or days. This module is the state machine for those decisions:
|
|
6
|
+
//
|
|
7
|
+
// pending_review ─▶ approved (terminal, by a named reviewer)
|
|
8
|
+
// │────────▶ dismissed (terminal, by a named reviewer)
|
|
9
|
+
// └────────▶ expired (terminal, via TTL — fail-closed)
|
|
10
|
+
//
|
|
11
|
+
// Two invariants, from the governance pilot this design serves:
|
|
12
|
+
// 1. A resolution REQUIRES a named reviewer and a policy reference —
|
|
13
|
+
// `approved: true` alone is not a valid resolution. Evidence is
|
|
14
|
+
// generated at approval time, never reconstructed from logs later.
|
|
15
|
+
// 2. Approvals must survive process restart: sessions are ephemeral,
|
|
16
|
+
// human review is not. The FileApprovalProvider persists every ticket.
|
|
17
|
+
//
|
|
18
|
+
// The provider interface is deliberately channel-agnostic — Slack, email,
|
|
19
|
+
// or ticketing integrations are org-side implementations of the same
|
|
20
|
+
// submit/check/resolve/list surface the built-in file provider ships.
|
|
21
|
+
import { randomUUID } from "node:crypto";
|
|
22
|
+
import { appendFileSync, mkdirSync, readFileSync, existsSync } from "node:fs";
|
|
23
|
+
import { join } from "node:path";
|
|
24
|
+
import { DEFAULT_APPROVALS_DIR } from "./config.js";
|
|
25
|
+
/** Construct the configured ticket store. */
|
|
26
|
+
export function providerFromConfig(config) {
|
|
27
|
+
if (config.provider === "memory")
|
|
28
|
+
return new InMemoryApprovalProvider();
|
|
29
|
+
return new FileApprovalProvider(config.dir ?? DEFAULT_APPROVALS_DIR);
|
|
30
|
+
}
|
|
31
|
+
/** Build a new ticket with id + requestedAt assigned. */
|
|
32
|
+
export function newRequest(fields) {
|
|
33
|
+
return {
|
|
34
|
+
id: randomUUID(),
|
|
35
|
+
requestedAt: new Date().toISOString(),
|
|
36
|
+
...fields,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
/** Parse a policy duration ("72h", "30m", "7d") to milliseconds. */
|
|
40
|
+
export function parseDuration(text) {
|
|
41
|
+
const m = /^(\d+)([mhd])$/.exec(text.trim());
|
|
42
|
+
if (!m)
|
|
43
|
+
throw new Error(`invalid duration "${text}" — expected <number><m|h|d>, e.g. "72h"`);
|
|
44
|
+
const n = Number(m[1]);
|
|
45
|
+
const unit = m[2] === "m" ? 60_000 : m[2] === "h" ? 3600_000 : 24 * 3600_000;
|
|
46
|
+
return n * unit;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Find the most recent ticket for a (target, tool) pair, whatever its state.
|
|
50
|
+
* The governor uses this to decide whether to honor, wait on, or re-submit.
|
|
51
|
+
*/
|
|
52
|
+
export async function latestForCall(provider, target, toolName) {
|
|
53
|
+
const all = await provider.list();
|
|
54
|
+
const matching = all.filter((s) => s.request.target === target && s.request.toolName === toolName);
|
|
55
|
+
return matching[matching.length - 1];
|
|
56
|
+
}
|
|
57
|
+
// -- Shared state-machine core ----------------------------------------------
|
|
58
|
+
/** Compute the effective state, applying TTL expiry to unresolved tickets. */
|
|
59
|
+
function effectiveState(request, resolution) {
|
|
60
|
+
if (resolution)
|
|
61
|
+
return resolution.state;
|
|
62
|
+
if (request.expiresAt && Date.parse(request.expiresAt) < Date.now())
|
|
63
|
+
return "expired";
|
|
64
|
+
return "pending_review";
|
|
65
|
+
}
|
|
66
|
+
function validateResolution(res) {
|
|
67
|
+
if (!res.reviewer?.trim())
|
|
68
|
+
throw new Error("approval resolution requires a named reviewer");
|
|
69
|
+
if (!res.policyRef?.trim()) {
|
|
70
|
+
throw new Error("approval resolution requires a policyRef — approved alone is not evidence");
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/** Check a ticket is resolvable; throws with the reason if not. */
|
|
74
|
+
function assertResolvable(status, id) {
|
|
75
|
+
if (!status)
|
|
76
|
+
throw new Error(`unknown approval ticket: ${id}`);
|
|
77
|
+
if (status.state === "expired")
|
|
78
|
+
throw new Error(`approval ticket ${id} has expired`);
|
|
79
|
+
if (status.state !== "pending_review") {
|
|
80
|
+
throw new Error(`approval ticket ${id} is already resolved (${status.state}) — terminal states are terminal`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
// -- In-memory provider (tests, ephemeral setups) ---------------------------
|
|
84
|
+
export class InMemoryApprovalProvider {
|
|
85
|
+
requests = [];
|
|
86
|
+
resolutions = new Map();
|
|
87
|
+
async submit(req) {
|
|
88
|
+
this.requests.push(req);
|
|
89
|
+
}
|
|
90
|
+
async check(id) {
|
|
91
|
+
const request = this.requests.find((r) => r.id === id);
|
|
92
|
+
if (!request)
|
|
93
|
+
return undefined;
|
|
94
|
+
const resolution = this.resolutions.get(id);
|
|
95
|
+
return { state: effectiveState(request, resolution), request, resolution };
|
|
96
|
+
}
|
|
97
|
+
async resolve(res) {
|
|
98
|
+
validateResolution(res);
|
|
99
|
+
const status = await this.check(res.id);
|
|
100
|
+
assertResolvable(status, res.id);
|
|
101
|
+
this.resolutions.set(res.id, res);
|
|
102
|
+
return { state: res.state, request: status.request, resolution: res };
|
|
103
|
+
}
|
|
104
|
+
async list(filter) {
|
|
105
|
+
const all = await Promise.all(this.requests.map((r) => this.check(r.id)));
|
|
106
|
+
const statuses = all.filter((s) => s !== undefined);
|
|
107
|
+
return filter?.state ? statuses.filter((s) => s.state === filter.state) : statuses;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Append-only JSONL store under a directory (default `.kcp-harness/approvals`).
|
|
112
|
+
* Every read replays the log, so a CLI in one process and the proxy in
|
|
113
|
+
* another always see each other's writes — no daemon, no lock protocol
|
|
114
|
+
* beyond O_APPEND line writes (approvals are low-volume by nature).
|
|
115
|
+
*/
|
|
116
|
+
export class FileApprovalProvider {
|
|
117
|
+
file;
|
|
118
|
+
constructor(dir) {
|
|
119
|
+
this.file = join(dir, "approvals.jsonl");
|
|
120
|
+
mkdirSync(dir, { recursive: true });
|
|
121
|
+
}
|
|
122
|
+
/** The backing file path (for status displays). */
|
|
123
|
+
getPath() {
|
|
124
|
+
return this.file;
|
|
125
|
+
}
|
|
126
|
+
read() {
|
|
127
|
+
const requests = [];
|
|
128
|
+
const resolutions = new Map();
|
|
129
|
+
if (!existsSync(this.file))
|
|
130
|
+
return { requests, resolutions };
|
|
131
|
+
for (const line of readFileSync(this.file, "utf-8").split("\n")) {
|
|
132
|
+
const trimmed = line.trim();
|
|
133
|
+
if (!trimmed)
|
|
134
|
+
continue;
|
|
135
|
+
try {
|
|
136
|
+
const record = JSON.parse(trimmed);
|
|
137
|
+
if (record.kind === "request")
|
|
138
|
+
requests.push(record.request);
|
|
139
|
+
else if (record.kind === "resolution")
|
|
140
|
+
resolutions.set(record.resolution.id, record.resolution);
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
// A torn write must not take the whole store down — skip the line.
|
|
144
|
+
// Fail-closed still holds: a missing resolution reads as pending/expired.
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return { requests, resolutions };
|
|
148
|
+
}
|
|
149
|
+
append(record) {
|
|
150
|
+
appendFileSync(this.file, JSON.stringify(record) + "\n", "utf-8");
|
|
151
|
+
}
|
|
152
|
+
async submit(req) {
|
|
153
|
+
this.append({ kind: "request", request: req });
|
|
154
|
+
}
|
|
155
|
+
async check(id) {
|
|
156
|
+
const { requests, resolutions } = this.read();
|
|
157
|
+
const request = requests.find((r) => r.id === id);
|
|
158
|
+
if (!request)
|
|
159
|
+
return undefined;
|
|
160
|
+
const resolution = resolutions.get(id);
|
|
161
|
+
return { state: effectiveState(request, resolution), request, resolution };
|
|
162
|
+
}
|
|
163
|
+
async resolve(res) {
|
|
164
|
+
validateResolution(res);
|
|
165
|
+
const status = await this.check(res.id);
|
|
166
|
+
assertResolvable(status, res.id);
|
|
167
|
+
this.append({ kind: "resolution", resolution: res });
|
|
168
|
+
return { state: res.state, request: status.request, resolution: res };
|
|
169
|
+
}
|
|
170
|
+
async list(filter) {
|
|
171
|
+
const { requests, resolutions } = this.read();
|
|
172
|
+
const statuses = requests.map((request) => ({
|
|
173
|
+
state: effectiveState(request, resolutions.get(request.id)),
|
|
174
|
+
request,
|
|
175
|
+
resolution: resolutions.get(request.id),
|
|
176
|
+
}));
|
|
177
|
+
return filter?.state ? statuses.filter((s) => s.state === filter.state) : statuses;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { HarnessConfig } from "./config.js";
|
|
2
|
+
import { type AuditWriter } from "./audit.js";
|
|
3
|
+
/** Run an approvals subcommand; returns the text to print. Throws on misuse. */
|
|
4
|
+
export declare function runApprovals(argv: string[], config: HarnessConfig, audit?: AuditWriter): Promise<string>;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// Approvals CLI — the built-in review channel for the FileApprovalProvider.
|
|
2
|
+
//
|
|
3
|
+
// The proxy opens tickets; a human resolves them here (or via any other
|
|
4
|
+
// ApprovalProvider channel an org wires up). Resolutions require a named
|
|
5
|
+
// reviewer and a policy reference — the CLI refuses anything less, the
|
|
6
|
+
// same invariant the provider enforces.
|
|
7
|
+
//
|
|
8
|
+
// kcp-harness approvals list [--state pending_review]
|
|
9
|
+
// kcp-harness approvals approve <id> --reviewer "Kari N." --policy-ref POL-7.2 [--note ...]
|
|
10
|
+
// kcp-harness approvals dismiss <id> --reviewer "Kari N." --policy-ref POL-7.2 [--note ...]
|
|
11
|
+
import { providerFromConfig } from "./approval.js";
|
|
12
|
+
import { buildApprovalEvent } from "./audit.js";
|
|
13
|
+
/** Run an approvals subcommand; returns the text to print. Throws on misuse. */
|
|
14
|
+
export async function runApprovals(argv, config, audit) {
|
|
15
|
+
const approvalsConfig = config.governance.approvals;
|
|
16
|
+
if (!approvalsConfig) {
|
|
17
|
+
throw new Error("no approvals configured — add governance.approvals to harness.yaml");
|
|
18
|
+
}
|
|
19
|
+
const provider = providerFromConfig(approvalsConfig);
|
|
20
|
+
const sub = argv[0];
|
|
21
|
+
switch (sub) {
|
|
22
|
+
case "list": {
|
|
23
|
+
const state = flag(argv, "--state");
|
|
24
|
+
const statuses = await provider.list(state ? { state } : undefined);
|
|
25
|
+
if (statuses.length === 0)
|
|
26
|
+
return "no approval tickets\n";
|
|
27
|
+
return statuses.map(formatStatus).join("\n") + "\n";
|
|
28
|
+
}
|
|
29
|
+
case "approve":
|
|
30
|
+
case "dismiss": {
|
|
31
|
+
const id = argv[1];
|
|
32
|
+
if (!id || id.startsWith("--"))
|
|
33
|
+
throw new Error(`usage: approvals ${sub} <id> --reviewer <name> --policy-ref <ref>`);
|
|
34
|
+
const reviewer = flag(argv, "--reviewer");
|
|
35
|
+
if (!reviewer)
|
|
36
|
+
throw new Error(`--reviewer is required — resolutions are never anonymous`);
|
|
37
|
+
const policyRef = flag(argv, "--policy-ref");
|
|
38
|
+
if (!policyRef)
|
|
39
|
+
throw new Error(`--policy-ref is required — cite the policy this resolution satisfies`);
|
|
40
|
+
const note = flag(argv, "--note");
|
|
41
|
+
const status = await provider.resolve({
|
|
42
|
+
id,
|
|
43
|
+
state: sub === "approve" ? "approved" : "dismissed",
|
|
44
|
+
reviewer,
|
|
45
|
+
reviewedAt: new Date().toISOString(),
|
|
46
|
+
policyRef,
|
|
47
|
+
note,
|
|
48
|
+
});
|
|
49
|
+
// The resolution is an audit event on the same log the proxy writes
|
|
50
|
+
audit?.emit(buildApprovalEvent(status.request.sessionId, 0, "approval_resolved", status));
|
|
51
|
+
return formatStatus(status) + "\n";
|
|
52
|
+
}
|
|
53
|
+
default:
|
|
54
|
+
throw new Error(`unknown approvals subcommand: ${sub ?? "(none)"} — expected list, approve, or dismiss`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
function formatStatus(s) {
|
|
58
|
+
const head = `${s.request.id} ${s.state} ${s.request.toolName} ${s.request.target} role=${s.request.requiredRole}`;
|
|
59
|
+
const when = ` requested=${s.request.requestedAt}${s.request.expiresAt ? ` expires=${s.request.expiresAt}` : ""}`;
|
|
60
|
+
const who = s.resolution
|
|
61
|
+
? `\n ${s.resolution.state} by ${s.resolution.reviewer} at ${s.resolution.reviewedAt} (${s.resolution.policyRef})${s.resolution.note ? ` — ${s.resolution.note}` : ""}`
|
|
62
|
+
: "";
|
|
63
|
+
return head + when + who;
|
|
64
|
+
}
|
|
65
|
+
function flag(argv, name) {
|
|
66
|
+
const idx = argv.indexOf(name);
|
|
67
|
+
return idx >= 0 && idx + 1 < argv.length ? argv[idx + 1] : undefined;
|
|
68
|
+
}
|
package/dist/audit.d.ts
CHANGED
|
@@ -3,8 +3,9 @@ import type { Classification } from "./classifier.js";
|
|
|
3
3
|
import type { GovernanceDecision } from "./governor.js";
|
|
4
4
|
import type { LedgerSnapshot } from "./budget-ledger.js";
|
|
5
5
|
import type { DriftResult } from "./temporal-watch.js";
|
|
6
|
+
import type { ApprovalStatus } from "./approval.js";
|
|
6
7
|
/** Event types for structured audit logging. */
|
|
7
|
-
export type AuditEventType = "tool_call" | "session_start" | "session_end" | "budget_spend" | "budget_exceeded" | "temporal_drift" | "plan_invalidated";
|
|
8
|
+
export type AuditEventType = "tool_call" | "session_start" | "session_end" | "budget_spend" | "budget_exceeded" | "temporal_drift" | "plan_invalidated" | "approval_requested" | "approval_resolved" | "confidence_verdict";
|
|
8
9
|
/** A single audit event. */
|
|
9
10
|
export interface AuditEvent {
|
|
10
11
|
/** ISO 8601 timestamp. */
|
|
@@ -42,6 +43,31 @@ export interface AuditEvent {
|
|
|
42
43
|
};
|
|
43
44
|
/** Manifest signature verification result. */
|
|
44
45
|
signature?: SignatureResult;
|
|
46
|
+
/** Approval ticket details (for approval_requested / approval_resolved events). */
|
|
47
|
+
approval?: {
|
|
48
|
+
id: string;
|
|
49
|
+
state: string;
|
|
50
|
+
toolName?: string;
|
|
51
|
+
target?: string;
|
|
52
|
+
requiredRole?: string;
|
|
53
|
+
/** Policy citation — from the rule at request time, from the reviewer at resolution time. */
|
|
54
|
+
policyRef?: string;
|
|
55
|
+
reviewer?: string;
|
|
56
|
+
reviewedAt?: string;
|
|
57
|
+
note?: string;
|
|
58
|
+
expiresAt?: string;
|
|
59
|
+
};
|
|
60
|
+
/** Confidence verdict summary (for confidence_verdict events; no answer text). */
|
|
61
|
+
confidence?: {
|
|
62
|
+
task: string;
|
|
63
|
+
passed: boolean;
|
|
64
|
+
score: number;
|
|
65
|
+
threshold: number;
|
|
66
|
+
detail: string;
|
|
67
|
+
severity?: string;
|
|
68
|
+
/** Ticket opened for the failure, when routing applied. */
|
|
69
|
+
ticketId?: string;
|
|
70
|
+
};
|
|
45
71
|
}
|
|
46
72
|
/** Append-only audit log writer. */
|
|
47
73
|
export declare class AuditLog {
|
|
@@ -72,6 +98,16 @@ export declare function buildBudgetEvent(sessionId: string, sequence: number, ac
|
|
|
72
98
|
amount?: number;
|
|
73
99
|
currency?: string;
|
|
74
100
|
}): AuditEvent;
|
|
101
|
+
/** Build a human-approval lifecycle event from a ticket's current status. */
|
|
102
|
+
export declare function buildApprovalEvent(sessionId: string, sequence: number, type: "approval_requested" | "approval_resolved", status: ApprovalStatus): AuditEvent;
|
|
103
|
+
/** Build a confidence-gate event: the verdict, never the answer text. */
|
|
104
|
+
export declare function buildConfidenceEvent(sessionId: string, sequence: number, task: string, verdict: {
|
|
105
|
+
passed: boolean;
|
|
106
|
+
score: number;
|
|
107
|
+
threshold: number;
|
|
108
|
+
detail: string;
|
|
109
|
+
severity?: string;
|
|
110
|
+
}, ticketId?: string): AuditEvent;
|
|
75
111
|
/** Build a temporal drift event. */
|
|
76
112
|
export declare function buildDriftEvent(sessionId: string, sequence: number, drift: DriftResult): AuditEvent;
|
|
77
113
|
/** Redact sensitive values from tool arguments for audit logging. */
|
package/dist/audit.js
CHANGED
|
@@ -89,6 +89,50 @@ export function buildBudgetEvent(sessionId, sequence, accepted, snapshot, detail
|
|
|
89
89
|
...(details ? { toolCall: { name: accepted ? "budget_spend" : "budget_exceeded", args: details } } : {}),
|
|
90
90
|
};
|
|
91
91
|
}
|
|
92
|
+
/** Build a human-approval lifecycle event from a ticket's current status. */
|
|
93
|
+
export function buildApprovalEvent(sessionId, sequence, type, status) {
|
|
94
|
+
return {
|
|
95
|
+
timestamp: new Date().toISOString(),
|
|
96
|
+
sessionId,
|
|
97
|
+
sequence,
|
|
98
|
+
type,
|
|
99
|
+
// A request is not yet an outcome; a resolution's outcome follows the reviewer.
|
|
100
|
+
outcome: status.state === "approved" ? "approved" : "blocked",
|
|
101
|
+
durationMs: 0,
|
|
102
|
+
approval: {
|
|
103
|
+
id: status.request.id,
|
|
104
|
+
state: status.state,
|
|
105
|
+
toolName: status.request.toolName,
|
|
106
|
+
target: status.request.target,
|
|
107
|
+
requiredRole: status.request.requiredRole,
|
|
108
|
+
policyRef: status.resolution?.policyRef ?? status.request.evidence.policyRef,
|
|
109
|
+
reviewer: status.resolution?.reviewer,
|
|
110
|
+
reviewedAt: status.resolution?.reviewedAt,
|
|
111
|
+
note: status.resolution?.note,
|
|
112
|
+
expiresAt: status.request.expiresAt,
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
/** Build a confidence-gate event: the verdict, never the answer text. */
|
|
117
|
+
export function buildConfidenceEvent(sessionId, sequence, task, verdict, ticketId) {
|
|
118
|
+
return {
|
|
119
|
+
timestamp: new Date().toISOString(),
|
|
120
|
+
sessionId,
|
|
121
|
+
sequence,
|
|
122
|
+
type: "confidence_verdict",
|
|
123
|
+
outcome: verdict.passed ? "approved" : "blocked",
|
|
124
|
+
durationMs: 0,
|
|
125
|
+
confidence: {
|
|
126
|
+
task,
|
|
127
|
+
passed: verdict.passed,
|
|
128
|
+
score: verdict.score,
|
|
129
|
+
threshold: verdict.threshold,
|
|
130
|
+
detail: verdict.detail,
|
|
131
|
+
severity: verdict.severity,
|
|
132
|
+
ticketId,
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
}
|
|
92
136
|
/** Build a temporal drift event. */
|
|
93
137
|
export function buildDriftEvent(sessionId, sequence, drift) {
|
|
94
138
|
return {
|
package/dist/cli.js
CHANGED
|
@@ -16,6 +16,8 @@ import { serveProxy } from "./proxy.js";
|
|
|
16
16
|
import { generate, listAgents } from "./integrations/generate.js";
|
|
17
17
|
import { exportEvidence } from "./export.js";
|
|
18
18
|
import { DashboardServer } from "./dashboard/server.js";
|
|
19
|
+
import { runApprovals } from "./approvals-cli.js";
|
|
20
|
+
import { AuditLog } from "./audit.js";
|
|
19
21
|
const USAGE = `kcp-harness — KCP Compliance Harness
|
|
20
22
|
|
|
21
23
|
Usage:
|
|
@@ -26,6 +28,9 @@ Usage:
|
|
|
26
28
|
kcp-harness integrate --list List supported agents
|
|
27
29
|
kcp-harness export [options] Export compliance evidence
|
|
28
30
|
kcp-harness dashboard [options] Launch compliance dashboard
|
|
31
|
+
kcp-harness approvals list [--state s] List human-approval tickets
|
|
32
|
+
kcp-harness approvals approve <id> --reviewer <name> --policy-ref <ref> [--note n]
|
|
33
|
+
kcp-harness approvals dismiss <id> --reviewer <name> --policy-ref <ref> [--note n]
|
|
29
34
|
kcp-harness --version Show version
|
|
30
35
|
kcp-harness --help Show this help
|
|
31
36
|
|
|
@@ -36,7 +41,7 @@ governance for any agent. It intercepts tool calls, classifies them
|
|
|
36
41
|
as knowledge-navigation or pass-through, and routes governed calls
|
|
37
42
|
through the kcp-agent planner before execution.
|
|
38
43
|
`;
|
|
39
|
-
const VERSION = "0.
|
|
44
|
+
const VERSION = "0.6.0";
|
|
40
45
|
const TEMPLATE = `# kcp-harness configuration
|
|
41
46
|
version: "1.0"
|
|
42
47
|
|
|
@@ -65,6 +70,27 @@ governance:
|
|
|
65
70
|
# trusted_keys:
|
|
66
71
|
# - "./keys/manifest-key.pem"
|
|
67
72
|
|
|
73
|
+
# Confidence gate — harness_assess adjudicates an answer's confidence
|
|
74
|
+
# against this threshold before it may be acted on; failures route to
|
|
75
|
+
# the named approval role when set
|
|
76
|
+
# confidence:
|
|
77
|
+
# threshold: 0.7
|
|
78
|
+
# severity: critical
|
|
79
|
+
# route_to_role: account-owner
|
|
80
|
+
# expires_after: 72h
|
|
81
|
+
# policy_ref: POL-9.1
|
|
82
|
+
|
|
83
|
+
# Human-approval gates — calls matching a rule are held for a named
|
|
84
|
+
# reviewer (resolve with: kcp-harness approvals approve <id> ...)
|
|
85
|
+
# approvals:
|
|
86
|
+
# provider: file
|
|
87
|
+
# dir: .kcp-harness/approvals
|
|
88
|
+
# rules:
|
|
89
|
+
# - match: { tools: [Write, Edit], paths: [records/] }
|
|
90
|
+
# required_role: account-owner
|
|
91
|
+
# expires_after: 72h
|
|
92
|
+
# policy_ref: POL-7.2
|
|
93
|
+
|
|
68
94
|
downstream:
|
|
69
95
|
# - name: "filesystem"
|
|
70
96
|
# command: "npx"
|
|
@@ -225,6 +251,24 @@ async function main() {
|
|
|
225
251
|
process.on("SIGTERM", shutdown);
|
|
226
252
|
break;
|
|
227
253
|
}
|
|
254
|
+
case "approvals": {
|
|
255
|
+
if (!existsSync(configPath)) {
|
|
256
|
+
process.stderr.write(`[kcp-harness] config not found: ${configPath}\n`);
|
|
257
|
+
process.exit(1);
|
|
258
|
+
}
|
|
259
|
+
try {
|
|
260
|
+
const config = loadConfig(configPath);
|
|
261
|
+
const audit = new AuditLog(config.audit.path);
|
|
262
|
+
const out = await runApprovals(args.slice(1), config, audit);
|
|
263
|
+
process.stdout.write(out);
|
|
264
|
+
}
|
|
265
|
+
catch (e) {
|
|
266
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
267
|
+
process.stderr.write(`[kcp-harness] approvals error: ${msg}\n`);
|
|
268
|
+
process.exit(1);
|
|
269
|
+
}
|
|
270
|
+
break;
|
|
271
|
+
}
|
|
228
272
|
default:
|
|
229
273
|
process.stderr.write(`[kcp-harness] unknown command: ${command}\n\n`);
|
|
230
274
|
process.stdout.write(USAGE);
|
package/dist/config.d.ts
CHANGED
|
@@ -33,6 +33,50 @@ export interface GovernancePolicy {
|
|
|
33
33
|
/** Trusted public keys for signature verification (paths, URLs, or inline). */
|
|
34
34
|
trusted_keys?: string[];
|
|
35
35
|
}
|
|
36
|
+
/** A rule demanding human approval for matching governed calls. */
|
|
37
|
+
export interface ApprovalRule {
|
|
38
|
+
/** What the rule applies to. Absent criteria match everything; present criteria AND together. */
|
|
39
|
+
match: {
|
|
40
|
+
/** Tool names (exact), e.g. [Write, Edit]. */
|
|
41
|
+
tools?: string[];
|
|
42
|
+
/** Path prefixes, matched like governed-domain paths. */
|
|
43
|
+
paths?: string[];
|
|
44
|
+
};
|
|
45
|
+
/** Role that must approve (recorded on the ticket; enforcement is channel-side). */
|
|
46
|
+
required_role: string;
|
|
47
|
+
/** TTL after which an unresolved ticket expires ("30m", "72h", "7d"). */
|
|
48
|
+
expires_after?: string;
|
|
49
|
+
/** Policy/regulatory citation this rule enforces — carried as ticket evidence. */
|
|
50
|
+
policy_ref?: string;
|
|
51
|
+
}
|
|
52
|
+
/** Human-approval configuration — org policy, deliberately not manifest data. */
|
|
53
|
+
export interface ApprovalsConfig {
|
|
54
|
+
/** Ticket store: "file" (persisted, default) or "memory" (ephemeral). */
|
|
55
|
+
provider: "file" | "memory";
|
|
56
|
+
/** Directory for the file provider's store (default: .kcp-harness/approvals). */
|
|
57
|
+
dir?: string;
|
|
58
|
+
/** Rules evaluated before any automated governance path. */
|
|
59
|
+
rules: ApprovalRule[];
|
|
60
|
+
}
|
|
61
|
+
/** Default approvals store directory. */
|
|
62
|
+
export declare const DEFAULT_APPROVALS_DIR = ".kcp-harness/approvals";
|
|
63
|
+
/**
|
|
64
|
+
* Confidence-gate configuration for harness_assess — org policy for when a
|
|
65
|
+
* synthesized conclusion may be acted on (kcp-agent's assess() decides,
|
|
66
|
+
* the harness enforces).
|
|
67
|
+
*/
|
|
68
|
+
export interface ConfidenceConfig {
|
|
69
|
+
/** Pass/fail line, 0..1. A caller may tighten it, never loosen it. */
|
|
70
|
+
threshold: number;
|
|
71
|
+
/** Severity label recorded on verdicts (e.g. "critical"). */
|
|
72
|
+
severity?: string;
|
|
73
|
+
/** Route failed verdicts to this approval role (requires governance.approvals). */
|
|
74
|
+
route_to_role?: string;
|
|
75
|
+
/** TTL for routed tickets ("30m", "72h", "7d"). */
|
|
76
|
+
expires_after?: string;
|
|
77
|
+
/** Policy citation carried as ticket evidence. */
|
|
78
|
+
policy_ref?: string;
|
|
79
|
+
}
|
|
36
80
|
/** A downstream MCP server to proxy tool calls to. */
|
|
37
81
|
export interface DownstreamConfig {
|
|
38
82
|
/** Human-readable name for this downstream server. */
|
|
@@ -60,6 +104,10 @@ export interface HarnessConfig {
|
|
|
60
104
|
governance: {
|
|
61
105
|
domains: GovernedDomain[];
|
|
62
106
|
policy: GovernancePolicy;
|
|
107
|
+
/** Human-approval gates (absent = no approval rules). */
|
|
108
|
+
approvals?: ApprovalsConfig;
|
|
109
|
+
/** Confidence gate for harness_assess (absent = caller must supply a threshold). */
|
|
110
|
+
confidence?: ConfidenceConfig;
|
|
63
111
|
};
|
|
64
112
|
downstream: DownstreamConfig[];
|
|
65
113
|
audit: AuditConfig;
|
package/dist/config.js
CHANGED
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
// which downstream MCP servers to proxy. Everything else passes through.
|
|
6
6
|
import { readFileSync } from "node:fs";
|
|
7
7
|
import yaml from "js-yaml";
|
|
8
|
+
/** Default approvals store directory. */
|
|
9
|
+
export const DEFAULT_APPROVALS_DIR = ".kcp-harness/approvals";
|
|
8
10
|
/** Default governance policy. */
|
|
9
11
|
export const DEFAULT_POLICY = {
|
|
10
12
|
fail_closed: true,
|
|
@@ -29,12 +31,19 @@ export function parseConfig(text) {
|
|
|
29
31
|
const governance = raw["governance"];
|
|
30
32
|
const domains = parseDomains(governance?.["domains"]);
|
|
31
33
|
const policy = parsePolicy(governance?.["policy"]);
|
|
34
|
+
const approvals = parseApprovals(governance?.["approvals"]);
|
|
35
|
+
const confidence = parseConfidence(governance?.["confidence"]);
|
|
32
36
|
const downstream = parseDownstream(raw["downstream"]);
|
|
33
37
|
const audit = parseAudit(raw["audit"]);
|
|
34
38
|
const dashboard = parseDashboard(raw["dashboard"]);
|
|
35
39
|
return {
|
|
36
40
|
version: String(raw["version"] ?? "1.0"),
|
|
37
|
-
governance: {
|
|
41
|
+
governance: {
|
|
42
|
+
domains,
|
|
43
|
+
policy,
|
|
44
|
+
...(approvals ? { approvals } : {}),
|
|
45
|
+
...(confidence ? { confidence } : {}),
|
|
46
|
+
},
|
|
38
47
|
downstream,
|
|
39
48
|
audit,
|
|
40
49
|
...(dashboard ? { dashboard } : {}),
|
|
@@ -46,6 +55,49 @@ function parseDashboard(raw) {
|
|
|
46
55
|
const d = raw;
|
|
47
56
|
return { url: d["url"] === undefined ? undefined : String(d["url"]) };
|
|
48
57
|
}
|
|
58
|
+
function parseApprovals(raw) {
|
|
59
|
+
if (!raw || typeof raw !== "object")
|
|
60
|
+
return undefined;
|
|
61
|
+
const a = raw;
|
|
62
|
+
const rules = Array.isArray(a["rules"]) ? a["rules"].map(parseApprovalRule) : [];
|
|
63
|
+
return {
|
|
64
|
+
provider: a["provider"] === "memory" ? "memory" : "file",
|
|
65
|
+
dir: a["dir"] === undefined ? undefined : String(a["dir"]),
|
|
66
|
+
rules,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
function parseApprovalRule(raw) {
|
|
70
|
+
const requiredRole = raw["required_role"];
|
|
71
|
+
if (!requiredRole || typeof requiredRole !== "string") {
|
|
72
|
+
throw new Error("approval rule requires required_role — an approval nobody is named to give cannot resolve");
|
|
73
|
+
}
|
|
74
|
+
const match = (raw["match"] ?? {});
|
|
75
|
+
return {
|
|
76
|
+
match: {
|
|
77
|
+
tools: Array.isArray(match["tools"]) ? match["tools"].map(String) : undefined,
|
|
78
|
+
paths: Array.isArray(match["paths"]) ? match["paths"].map(String) : undefined,
|
|
79
|
+
},
|
|
80
|
+
required_role: requiredRole,
|
|
81
|
+
expires_after: raw["expires_after"] === undefined ? undefined : String(raw["expires_after"]),
|
|
82
|
+
policy_ref: raw["policy_ref"] === undefined ? undefined : String(raw["policy_ref"]),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
function parseConfidence(raw) {
|
|
86
|
+
if (!raw || typeof raw !== "object")
|
|
87
|
+
return undefined;
|
|
88
|
+
const c = raw;
|
|
89
|
+
const threshold = Number(c["threshold"]);
|
|
90
|
+
if (Number.isNaN(threshold) || threshold < 0 || threshold > 1) {
|
|
91
|
+
throw new Error(`confidence.threshold must be a number in 0..1, got ${String(c["threshold"])}`);
|
|
92
|
+
}
|
|
93
|
+
return {
|
|
94
|
+
threshold,
|
|
95
|
+
severity: c["severity"] === undefined ? undefined : String(c["severity"]),
|
|
96
|
+
route_to_role: c["route_to_role"] === undefined ? undefined : String(c["route_to_role"]),
|
|
97
|
+
expires_after: c["expires_after"] === undefined ? undefined : String(c["expires_after"]),
|
|
98
|
+
policy_ref: c["policy_ref"] === undefined ? undefined : String(c["policy_ref"]),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
49
101
|
function parseDomains(raw) {
|
|
50
102
|
if (!Array.isArray(raw))
|
|
51
103
|
return [];
|
package/dist/governor.d.ts
CHANGED
|
@@ -1,14 +1,20 @@
|
|
|
1
1
|
import { type AgentPlan, type DecisionTrace, type SignatureResult } from "kcp-agent";
|
|
2
|
-
import type { GovernancePolicy } from "./config.js";
|
|
2
|
+
import type { GovernancePolicy, ApprovalRule } from "./config.js";
|
|
3
3
|
import type { Classification } from "./classifier.js";
|
|
4
4
|
import type { SessionState, ApprovedPlan } from "./session.js";
|
|
5
5
|
import type { SpendResult } from "./budget-ledger.js";
|
|
6
|
+
import { type ApprovalProvider, type ApprovalResolution } from "./approval.js";
|
|
7
|
+
/** Approval wiring the proxy hands to the governor: the store + the rules. */
|
|
8
|
+
export interface ApprovalContext {
|
|
9
|
+
provider: ApprovalProvider;
|
|
10
|
+
rules: ApprovalRule[];
|
|
11
|
+
}
|
|
6
12
|
/** The governor's decision for a tool call. */
|
|
7
13
|
export interface GovernanceDecision {
|
|
8
14
|
/** Whether the tool call is approved. */
|
|
9
15
|
approved: boolean;
|
|
10
16
|
/** How the decision was made. */
|
|
11
|
-
mode: "plan-first" | "auto-plan" | "kcp-passthrough" | "blocked";
|
|
17
|
+
mode: "plan-first" | "auto-plan" | "kcp-passthrough" | "blocked" | "pending" | "human-approved";
|
|
12
18
|
/** The plan that governs this decision (if any). */
|
|
13
19
|
plan?: AgentPlan;
|
|
14
20
|
/** The decision trace (if tracing is enabled). */
|
|
@@ -21,6 +27,12 @@ export interface GovernanceDecision {
|
|
|
21
27
|
budgetSpend?: SpendResult;
|
|
22
28
|
/** Manifest signature verification result (when signature checking is active). */
|
|
23
29
|
signature?: SignatureResult;
|
|
30
|
+
/** Ticket id, when mode is "pending". */
|
|
31
|
+
pendingId?: string;
|
|
32
|
+
/** True when this call opened a new ticket (the proxy audits approval_requested). */
|
|
33
|
+
submitted?: boolean;
|
|
34
|
+
/** The named human's resolution, when mode is "human-approved". */
|
|
35
|
+
resolution?: ApprovalResolution;
|
|
24
36
|
}
|
|
25
37
|
/**
|
|
26
38
|
* Govern a classified tool call — decide whether to approve or block.
|
|
@@ -33,4 +45,4 @@ export interface GovernanceDecision {
|
|
|
33
45
|
* 2. If not, auto-plan against the domain's manifest → approve if selected
|
|
34
46
|
* 3. Otherwise → block (fail-closed)
|
|
35
47
|
*/
|
|
36
|
-
export declare function govern(classification: Classification, toolName: string, args: Record<string, unknown>, session: SessionState, policy: GovernancePolicy): Promise<GovernanceDecision>;
|
|
48
|
+
export declare function govern(classification: Classification, toolName: string, args: Record<string, unknown>, session: SessionState, policy: GovernancePolicy, approvals?: ApprovalContext): Promise<GovernanceDecision>;
|
package/dist/governor.js
CHANGED
|
@@ -19,7 +19,9 @@
|
|
|
19
19
|
// the manifest, if the unit isn't selected, or if the budget is exhausted,
|
|
20
20
|
// the call is blocked — never silently passed through.
|
|
21
21
|
import { planTree, plans, } from "kcp-agent";
|
|
22
|
+
import { matchesPrefix } from "./classifier.js";
|
|
22
23
|
import { isPathApproved, addPlan, recordSpend } from "./session.js";
|
|
24
|
+
import { latestForCall, newRequest, parseDuration, } from "./approval.js";
|
|
23
25
|
/**
|
|
24
26
|
* Govern a classified tool call — decide whether to approve or block.
|
|
25
27
|
*
|
|
@@ -31,7 +33,7 @@ import { isPathApproved, addPlan, recordSpend } from "./session.js";
|
|
|
31
33
|
* 2. If not, auto-plan against the domain's manifest → approve if selected
|
|
32
34
|
* 3. Otherwise → block (fail-closed)
|
|
33
35
|
*/
|
|
34
|
-
export async function govern(classification, toolName, args, session, policy) {
|
|
36
|
+
export async function govern(classification, toolName, args, session, policy, approvals) {
|
|
35
37
|
// KCP tools pass through — they ARE the governance layer
|
|
36
38
|
if (toolName.startsWith("kcp_")) {
|
|
37
39
|
return { approved: true, mode: "kcp-passthrough", reason: "KCP tool — governance layer itself" };
|
|
@@ -41,6 +43,22 @@ export async function govern(classification, toolName, args, session, policy) {
|
|
|
41
43
|
}
|
|
42
44
|
const domain = classification.domain;
|
|
43
45
|
const target = classification.target;
|
|
46
|
+
// Mode 0: human-approval rules outrank every automated path. A matched
|
|
47
|
+
// rule means a named human decides — an approved plan must not bypass it.
|
|
48
|
+
if (approvals) {
|
|
49
|
+
const rule = approvals.rules.find((r) => ruleMatches(r, toolName, target));
|
|
50
|
+
if (rule) {
|
|
51
|
+
try {
|
|
52
|
+
return await governByApproval(rule, toolName, target ?? "", session, domain, approvals.provider);
|
|
53
|
+
}
|
|
54
|
+
catch (e) {
|
|
55
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
56
|
+
// Fail-closed: if the approval store is unreachable we cannot prove
|
|
57
|
+
// a human signed off, so the call is blocked.
|
|
58
|
+
return { approved: false, mode: "blocked", reason: `approval check failed (fail-closed): ${msg}` };
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
44
62
|
// Mode 1: check existing approved plans
|
|
45
63
|
if (target) {
|
|
46
64
|
const approved = isPathApproved(session, target);
|
|
@@ -84,6 +102,78 @@ export async function govern(classification, toolName, args, session, policy) {
|
|
|
84
102
|
reason: `governed tool call with no extractable target — blocked by policy`,
|
|
85
103
|
};
|
|
86
104
|
}
|
|
105
|
+
/** Does an approval rule apply to this call? Present criteria AND together. */
|
|
106
|
+
function ruleMatches(rule, toolName, target) {
|
|
107
|
+
if (rule.match.tools && !rule.match.tools.includes(toolName))
|
|
108
|
+
return false;
|
|
109
|
+
if (rule.match.paths) {
|
|
110
|
+
if (!target)
|
|
111
|
+
return false;
|
|
112
|
+
if (!rule.match.paths.some((p) => matchesPrefix(target, p)))
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
return true;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Decide a rule-matched call from the approval store:
|
|
119
|
+
* approved → allow with the resolution attached; pending → wait;
|
|
120
|
+
* dismissed → terminal block; expired or absent → open a fresh ticket.
|
|
121
|
+
*/
|
|
122
|
+
async function governByApproval(rule, toolName, target, session, domain, provider) {
|
|
123
|
+
const existing = await latestForCall(provider, target, toolName);
|
|
124
|
+
if (existing?.state === "approved" && existing.resolution) {
|
|
125
|
+
return {
|
|
126
|
+
approved: true,
|
|
127
|
+
mode: "human-approved",
|
|
128
|
+
resolution: existing.resolution,
|
|
129
|
+
reason: `approved by ${existing.resolution.reviewer} at ${existing.resolution.reviewedAt} ` +
|
|
130
|
+
`(${existing.resolution.policyRef}) — ticket ${existing.request.id}`,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
if (existing?.state === "pending_review") {
|
|
134
|
+
return {
|
|
135
|
+
approved: false,
|
|
136
|
+
mode: "pending",
|
|
137
|
+
pendingId: existing.request.id,
|
|
138
|
+
reason: `pending approval ${existing.request.id} from role ${existing.request.requiredRole} — ` +
|
|
139
|
+
`re-try after approval or check harness_approvals`,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
if (existing?.state === "dismissed" && existing.resolution) {
|
|
143
|
+
return {
|
|
144
|
+
approved: false,
|
|
145
|
+
mode: "blocked",
|
|
146
|
+
reason: `dismissed by ${existing.resolution.reviewer}` +
|
|
147
|
+
`${existing.resolution.note ? `: ${existing.resolution.note}` : ""} — ticket ${existing.request.id}`,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
// No usable ticket (none yet, or the last one expired) → open a fresh one.
|
|
151
|
+
const request = newRequest({
|
|
152
|
+
sessionId: session.id,
|
|
153
|
+
toolName,
|
|
154
|
+
target,
|
|
155
|
+
task: `${toolName} ${target}`.trim(),
|
|
156
|
+
requiredRole: rule.required_role,
|
|
157
|
+
expiresAt: rule.expires_after
|
|
158
|
+
? new Date(Date.now() + parseDuration(rule.expires_after)).toISOString()
|
|
159
|
+
: undefined,
|
|
160
|
+
evidence: {
|
|
161
|
+
manifest: domain.manifest,
|
|
162
|
+
policyRef: rule.policy_ref,
|
|
163
|
+
detail: existing?.state === "expired" ? `previous ticket ${existing.request.id} expired` : undefined,
|
|
164
|
+
},
|
|
165
|
+
});
|
|
166
|
+
await provider.submit(request);
|
|
167
|
+
return {
|
|
168
|
+
approved: false,
|
|
169
|
+
mode: "pending",
|
|
170
|
+
pendingId: request.id,
|
|
171
|
+
submitted: true,
|
|
172
|
+
reason: `pending approval ${request.id} from role ${rule.required_role}` +
|
|
173
|
+
`${rule.policy_ref ? ` (${rule.policy_ref})` : ""} — ` +
|
|
174
|
+
`re-try after approval or check harness_approvals`,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
87
177
|
/**
|
|
88
178
|
* Auto-govern: run the kcp-agent planner to decide if accessing a target
|
|
89
179
|
* path is approved. Creates a plan and checks if the target is in the
|
package/dist/index.d.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
export { classify, extractTargets, normalizePath, matchesPrefix, type Classification, } from "./classifier.js";
|
|
2
|
-
export { govern, type GovernanceDecision, } from "./governor.js";
|
|
3
|
-
export {
|
|
2
|
+
export { govern, type GovernanceDecision, type ApprovalContext, } from "./governor.js";
|
|
3
|
+
export { InMemoryApprovalProvider, FileApprovalProvider, providerFromConfig, newRequest, parseDuration, latestForCall, type ApprovalProvider, type ApprovalRequest, type ApprovalResolution, type ApprovalStatus, type ApprovalState, } from "./approval.js";
|
|
4
|
+
export { runApprovals } from "./approvals-cli.js";
|
|
5
|
+
export { AuditLog, InMemoryAuditLog, buildEvent, buildLifecycleEvent, buildBudgetEvent, buildDriftEvent, buildApprovalEvent, buildConfidenceEvent, type AuditWriter, type AuditEvent, type AuditEventType, } from "./audit.js";
|
|
4
6
|
export { createSession, addPlan, isPathApproved, recordLoaded, getKnown, recordSpend, nextSequence, type SessionState, type ApprovedPlan, } from "./session.js";
|
|
5
7
|
export { DownstreamManager, type McpTool, type DownstreamConnection, } from "./downstream.js";
|
|
6
8
|
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";
|
|
9
|
+
export { loadConfig, parseConfig, DEFAULT_POLICY, DEFAULT_AUDIT, DEFAULT_APPROVALS_DIR, type HarnessConfig, type GovernedDomain, type GovernancePolicy, type DownstreamConfig, type AuditConfig, type ApprovalRule, type ApprovalsConfig, type ConfidenceConfig, } from "./config.js";
|
|
8
10
|
export { callKcpTool } from "./kcp-bridge.js";
|
|
9
11
|
export { BudgetLedger, type BudgetCeiling, type LedgerEntry, type LedgerSource, type LedgerCost, type LedgerSnapshot, type SpendResult, } from "./budget-ledger.js";
|
|
10
12
|
export { TemporalWatch, type WatchedPlan, type DriftResult, type WatchResult, } from "./temporal-watch.js";
|
package/dist/index.js
CHANGED
|
@@ -9,11 +9,13 @@
|
|
|
9
9
|
// bypass governance because it only has access to the proxy's stdio.
|
|
10
10
|
export { classify, extractTargets, normalizePath, matchesPrefix, } from "./classifier.js";
|
|
11
11
|
export { govern, } from "./governor.js";
|
|
12
|
-
export {
|
|
12
|
+
export { InMemoryApprovalProvider, FileApprovalProvider, providerFromConfig, newRequest, parseDuration, latestForCall, } from "./approval.js";
|
|
13
|
+
export { runApprovals } from "./approvals-cli.js";
|
|
14
|
+
export { AuditLog, InMemoryAuditLog, buildEvent, buildLifecycleEvent, buildBudgetEvent, buildDriftEvent, buildApprovalEvent, buildConfidenceEvent, } from "./audit.js";
|
|
13
15
|
export { createSession, addPlan, isPathApproved, recordLoaded, getKnown, recordSpend, nextSequence, } from "./session.js";
|
|
14
16
|
export { DownstreamManager, } from "./downstream.js";
|
|
15
17
|
export { HarnessProxy, serveProxy, } from "./proxy.js";
|
|
16
|
-
export { loadConfig, parseConfig, DEFAULT_POLICY, DEFAULT_AUDIT, } from "./config.js";
|
|
18
|
+
export { loadConfig, parseConfig, DEFAULT_POLICY, DEFAULT_AUDIT, DEFAULT_APPROVALS_DIR, } from "./config.js";
|
|
17
19
|
export { callKcpTool } from "./kcp-bridge.js";
|
|
18
20
|
export { BudgetLedger, } from "./budget-ledger.js";
|
|
19
21
|
export { TemporalWatch, } from "./temporal-watch.js";
|
package/dist/proxy.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { HarnessConfig } from "./config.js";
|
|
2
|
+
import { type ApprovalProvider } from "./approval.js";
|
|
2
3
|
import { type AuditWriter } from "./audit.js";
|
|
3
4
|
import { type SessionState } from "./session.js";
|
|
4
5
|
interface JsonRpcRequest {
|
|
@@ -16,6 +17,7 @@ export declare class HarnessProxy {
|
|
|
16
17
|
private readonly downstream;
|
|
17
18
|
private readonly audit;
|
|
18
19
|
private readonly session;
|
|
20
|
+
private readonly approvals?;
|
|
19
21
|
constructor(options: ProxyOptions);
|
|
20
22
|
/** Start the proxy: spawn downstream servers and begin serving. */
|
|
21
23
|
start(): Promise<void>;
|
|
@@ -35,8 +37,17 @@ export declare class HarnessProxy {
|
|
|
35
37
|
private maybeEmitTrace;
|
|
36
38
|
/** Handle harness-internal tools. */
|
|
37
39
|
private handleHarnessTool;
|
|
40
|
+
/**
|
|
41
|
+
* Confidence-gate an answer: kcp-agent's assess() decides, the harness
|
|
42
|
+
* enforces. A failed verdict on a routed config opens an approval ticket
|
|
43
|
+
* carrying the verdict as evidence; a named human's approval overrides
|
|
44
|
+
* the gate on retry. Dismissal is terminal.
|
|
45
|
+
*/
|
|
46
|
+
private handleAssess;
|
|
38
47
|
/** Expose session for testing. */
|
|
39
48
|
getSession(): SessionState;
|
|
49
|
+
/** Expose the approval ticket store (for testing and embedding). */
|
|
50
|
+
getApprovalProvider(): ApprovalProvider | undefined;
|
|
40
51
|
}
|
|
41
52
|
/** Serve the harness proxy over stdio until stdin closes. */
|
|
42
53
|
export declare function serveProxy(config: HarnessConfig): Promise<void>;
|
package/dist/proxy.js
CHANGED
|
@@ -14,7 +14,9 @@
|
|
|
14
14
|
import { createInterface } from "node:readline";
|
|
15
15
|
import { classify } from "./classifier.js";
|
|
16
16
|
import { govern } from "./governor.js";
|
|
17
|
-
import {
|
|
17
|
+
import { providerFromConfig, latestForCall, newRequest, parseDuration } from "./approval.js";
|
|
18
|
+
import { assess } from "kcp-agent";
|
|
19
|
+
import { AuditLog, buildEvent, buildLifecycleEvent, buildDriftEvent, buildApprovalEvent, buildConfidenceEvent, } from "./audit.js";
|
|
18
20
|
import { createSession, nextSequence } from "./session.js";
|
|
19
21
|
import { DownstreamManager } from "./downstream.js";
|
|
20
22
|
import { callKcpTool } from "./kcp-bridge.js";
|
|
@@ -57,6 +59,40 @@ const HARNESS_TOOLS = [
|
|
|
57
59
|
"time and report any plans that would produce different results now.",
|
|
58
60
|
inputSchema: { type: "object", properties: {} },
|
|
59
61
|
},
|
|
62
|
+
{
|
|
63
|
+
name: "harness_approvals",
|
|
64
|
+
description: "List human-approval tickets: calls held for a named reviewer. " +
|
|
65
|
+
"A pending call is denied with its ticket id — re-try it after approval.",
|
|
66
|
+
inputSchema: {
|
|
67
|
+
type: "object",
|
|
68
|
+
properties: {
|
|
69
|
+
state: {
|
|
70
|
+
type: "string",
|
|
71
|
+
description: "Filter by state: pending_review, approved, dismissed, expired",
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
name: "harness_assess",
|
|
78
|
+
description: "Confidence-gate a synthesized answer before acting on it. Runs kcp-agent's " +
|
|
79
|
+
"post-synthesis assess(): the answer's self-reported confidence is adjudicated " +
|
|
80
|
+
"against the org threshold. A failed verdict is routed to a named human when " +
|
|
81
|
+
"governance.confidence.route_to_role is set — re-try after approval.",
|
|
82
|
+
inputSchema: {
|
|
83
|
+
type: "object",
|
|
84
|
+
properties: {
|
|
85
|
+
task: { type: "string", description: "The task the answer concludes" },
|
|
86
|
+
answer: { type: "string", description: "The synthesized answer to gate" },
|
|
87
|
+
threshold: {
|
|
88
|
+
type: "number",
|
|
89
|
+
description: "Optional tightening of the configured threshold (the strictest wins)",
|
|
90
|
+
},
|
|
91
|
+
severity: { type: "string", description: "Severity label override (e.g. critical)" },
|
|
92
|
+
},
|
|
93
|
+
required: ["task", "answer"],
|
|
94
|
+
},
|
|
95
|
+
},
|
|
60
96
|
];
|
|
61
97
|
/** KCP tool names that the harness routes to kcp-agent directly. */
|
|
62
98
|
const KCP_TOOLS = new Set(["kcp_plan", "kcp_load", "kcp_trace", "kcp_validate", "kcp_replay"]);
|
|
@@ -65,10 +101,18 @@ export class HarnessProxy {
|
|
|
65
101
|
downstream;
|
|
66
102
|
audit;
|
|
67
103
|
session;
|
|
104
|
+
approvals;
|
|
68
105
|
constructor(options) {
|
|
69
106
|
this.config = options.config;
|
|
70
107
|
this.downstream = new DownstreamManager();
|
|
71
108
|
this.audit = options.audit ?? new AuditLog(options.config.audit.path);
|
|
109
|
+
const approvalsConfig = options.config.governance.approvals;
|
|
110
|
+
if (approvalsConfig) {
|
|
111
|
+
this.approvals = {
|
|
112
|
+
provider: providerFromConfig(approvalsConfig),
|
|
113
|
+
rules: approvalsConfig.rules,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
72
116
|
// Derive budget ceiling from policy
|
|
73
117
|
const policy = options.config.governance.policy;
|
|
74
118
|
const ceiling = policy.budget
|
|
@@ -213,7 +257,14 @@ export class HarnessProxy {
|
|
|
213
257
|
// Step 2: Govern (for governed calls)
|
|
214
258
|
let governance;
|
|
215
259
|
if (classification.governed) {
|
|
216
|
-
governance = await govern(classification, toolName, args, this.session, this.config.governance.policy);
|
|
260
|
+
governance = await govern(classification, toolName, args, this.session, this.config.governance.policy, this.approvals);
|
|
261
|
+
// A freshly opened ticket is its own audit event
|
|
262
|
+
if (governance.submitted && governance.pendingId && this.approvals) {
|
|
263
|
+
const status = await this.approvals.provider.check(governance.pendingId);
|
|
264
|
+
if (status) {
|
|
265
|
+
this.audit.emit(buildApprovalEvent(this.session.id, nextSequence(this.session), "approval_requested", status));
|
|
266
|
+
}
|
|
267
|
+
}
|
|
217
268
|
if (!governance.approved) {
|
|
218
269
|
// Blocked — emit audit and return error
|
|
219
270
|
const event = buildEvent(this.session.id, seq, toolName, args, classification, governance, "blocked", Date.now() - startTime);
|
|
@@ -381,6 +432,39 @@ export class HarnessProxy {
|
|
|
381
432
|
isError: false,
|
|
382
433
|
};
|
|
383
434
|
}
|
|
435
|
+
case "harness_approvals": {
|
|
436
|
+
if (!this.approvals) {
|
|
437
|
+
return {
|
|
438
|
+
content: [{ type: "text", text: "no approval rules configured — see governance.approvals in harness.yaml" }],
|
|
439
|
+
isError: false,
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
const stateFilter = typeof _args["state"] === "string" ? _args["state"] : undefined;
|
|
443
|
+
const statuses = await this.approvals.provider.list(stateFilter ? { state: stateFilter } : undefined);
|
|
444
|
+
return {
|
|
445
|
+
content: [{
|
|
446
|
+
type: "text",
|
|
447
|
+
text: JSON.stringify({
|
|
448
|
+
approvals: statuses.map((s) => ({
|
|
449
|
+
id: s.request.id,
|
|
450
|
+
state: s.state,
|
|
451
|
+
toolName: s.request.toolName,
|
|
452
|
+
target: s.request.target,
|
|
453
|
+
requiredRole: s.request.requiredRole,
|
|
454
|
+
requestedAt: s.request.requestedAt,
|
|
455
|
+
expiresAt: s.request.expiresAt,
|
|
456
|
+
policyRef: s.resolution?.policyRef ?? s.request.evidence.policyRef,
|
|
457
|
+
reviewer: s.resolution?.reviewer,
|
|
458
|
+
reviewedAt: s.resolution?.reviewedAt,
|
|
459
|
+
note: s.resolution?.note,
|
|
460
|
+
})),
|
|
461
|
+
}, null, 2),
|
|
462
|
+
}],
|
|
463
|
+
isError: false,
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
case "harness_assess":
|
|
467
|
+
return this.handleAssess(_args);
|
|
384
468
|
default:
|
|
385
469
|
return {
|
|
386
470
|
content: [{ type: "text", text: `unknown harness tool: ${name}` }],
|
|
@@ -388,10 +472,108 @@ export class HarnessProxy {
|
|
|
388
472
|
};
|
|
389
473
|
}
|
|
390
474
|
}
|
|
475
|
+
/**
|
|
476
|
+
* Confidence-gate an answer: kcp-agent's assess() decides, the harness
|
|
477
|
+
* enforces. A failed verdict on a routed config opens an approval ticket
|
|
478
|
+
* carrying the verdict as evidence; a named human's approval overrides
|
|
479
|
+
* the gate on retry. Dismissal is terminal.
|
|
480
|
+
*/
|
|
481
|
+
async handleAssess(args) {
|
|
482
|
+
const err = (text) => ({ content: [{ type: "text", text }], isError: true });
|
|
483
|
+
const ok = (body) => ({
|
|
484
|
+
content: [{ type: "text", text: JSON.stringify(body, null, 2) }],
|
|
485
|
+
isError: false,
|
|
486
|
+
});
|
|
487
|
+
const task = typeof args["task"] === "string" ? args["task"] : "";
|
|
488
|
+
const answer = typeof args["answer"] === "string" ? args["answer"] : "";
|
|
489
|
+
if (!task)
|
|
490
|
+
return err("harness_assess requires a task");
|
|
491
|
+
if (!answer)
|
|
492
|
+
return err("harness_assess requires an answer to gate");
|
|
493
|
+
const confidence = this.config.governance.confidence;
|
|
494
|
+
const callerThreshold = typeof args["threshold"] === "number" ? args["threshold"] : undefined;
|
|
495
|
+
// Strictest wins: a caller may tighten org policy, never loosen it.
|
|
496
|
+
const candidates = [confidence?.threshold, callerThreshold].filter((t) => t !== undefined);
|
|
497
|
+
if (candidates.length === 0) {
|
|
498
|
+
return err("harness_assess: no threshold — set governance.confidence.threshold or pass one");
|
|
499
|
+
}
|
|
500
|
+
const threshold = Math.max(...candidates);
|
|
501
|
+
const severity = typeof args["severity"] === "string" ? args["severity"] : confidence?.severity;
|
|
502
|
+
const verdict = await assess(task, answer, [], { threshold, severity });
|
|
503
|
+
const routing = confidence?.route_to_role && this.approvals ? confidence : undefined;
|
|
504
|
+
let ticketInfo;
|
|
505
|
+
let override;
|
|
506
|
+
let dismissed;
|
|
507
|
+
let allowed = verdict.passed;
|
|
508
|
+
if (!verdict.passed && routing && this.approvals) {
|
|
509
|
+
const provider = this.approvals.provider;
|
|
510
|
+
const existing = await latestForCall(provider, task, "harness_assess");
|
|
511
|
+
if (existing?.state === "approved" && existing.resolution) {
|
|
512
|
+
// The gate failed, but a named human has overridden it for this task.
|
|
513
|
+
allowed = true;
|
|
514
|
+
override = {
|
|
515
|
+
reviewer: existing.resolution.reviewer,
|
|
516
|
+
reviewedAt: existing.resolution.reviewedAt,
|
|
517
|
+
policyRef: existing.resolution.policyRef,
|
|
518
|
+
ticketId: existing.request.id,
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
else if (existing?.state === "pending_review") {
|
|
522
|
+
ticketInfo = ticketSummary(existing.request.id, existing.state, existing.request.requiredRole);
|
|
523
|
+
}
|
|
524
|
+
else if (existing?.state === "dismissed" && existing.resolution) {
|
|
525
|
+
dismissed = {
|
|
526
|
+
reviewer: existing.resolution.reviewer,
|
|
527
|
+
note: existing.resolution.note,
|
|
528
|
+
ticketId: existing.request.id,
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
else {
|
|
532
|
+
// None yet, or the last ticket expired → open a fresh one with the
|
|
533
|
+
// verdict as evidence, generated at gate time.
|
|
534
|
+
const request = newRequest({
|
|
535
|
+
sessionId: this.session.id,
|
|
536
|
+
toolName: "harness_assess",
|
|
537
|
+
target: task,
|
|
538
|
+
task,
|
|
539
|
+
requiredRole: routing.route_to_role,
|
|
540
|
+
expiresAt: routing.expires_after
|
|
541
|
+
? new Date(Date.now() + parseDuration(routing.expires_after)).toISOString()
|
|
542
|
+
: undefined,
|
|
543
|
+
evidence: {
|
|
544
|
+
policyRef: routing.policy_ref,
|
|
545
|
+
detail: verdict.detail,
|
|
546
|
+
confidence: verdict,
|
|
547
|
+
},
|
|
548
|
+
});
|
|
549
|
+
await provider.submit(request);
|
|
550
|
+
const status = await provider.check(request.id);
|
|
551
|
+
if (status) {
|
|
552
|
+
this.audit.emit(buildApprovalEvent(this.session.id, nextSequence(this.session), "approval_requested", status));
|
|
553
|
+
}
|
|
554
|
+
ticketInfo = ticketSummary(request.id, "pending_review", routing.route_to_role);
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
this.audit.emit(buildConfidenceEvent(this.session.id, nextSequence(this.session), task, verdict, ticketInfo ? ticketInfo["id"] : undefined));
|
|
558
|
+
return ok({
|
|
559
|
+
allowed,
|
|
560
|
+
verdict,
|
|
561
|
+
...(ticketInfo ? { ticket: ticketInfo } : {}),
|
|
562
|
+
...(override ? { override } : {}),
|
|
563
|
+
...(dismissed ? { dismissed } : {}),
|
|
564
|
+
});
|
|
565
|
+
}
|
|
391
566
|
/** Expose session for testing. */
|
|
392
567
|
getSession() {
|
|
393
568
|
return this.session;
|
|
394
569
|
}
|
|
570
|
+
/** Expose the approval ticket store (for testing and embedding). */
|
|
571
|
+
getApprovalProvider() {
|
|
572
|
+
return this.approvals?.provider;
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
function ticketSummary(id, state, requiredRole) {
|
|
576
|
+
return { id, state, requiredRole };
|
|
395
577
|
}
|
|
396
578
|
/** Serve the harness proxy over stdio until stdin closes. */
|
|
397
579
|
export async function serveProxy(config) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kcp-harness",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"mcpName": "no.cantara/kcp-harness",
|
|
5
5
|
"description": "KCP Compliance Harness — MCP proxy that enforces deterministic knowledge governance for any agent",
|
|
6
6
|
"type": "module",
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
},
|
|
26
26
|
"dependencies": {
|
|
27
27
|
"js-yaml": "^4.1.0",
|
|
28
|
-
"kcp-agent": "^0.
|
|
28
|
+
"kcp-agent": "^0.15.0"
|
|
29
29
|
},
|
|
30
30
|
"devDependencies": {
|
|
31
31
|
"@types/js-yaml": "^4.0.9",
|