@dzhechkov/harness-core 0.3.23 → 0.3.25
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/benchmark.d.ts +6 -0
- package/dist/benchmark.d.ts.map +1 -1
- package/dist/benchmark.js +50 -3
- package/dist/benchmark.js.map +1 -1
- package/dist/capability-vocab.d.ts +90 -0
- package/dist/capability-vocab.d.ts.map +1 -0
- package/dist/capability-vocab.js +286 -0
- package/dist/capability-vocab.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/mcp-scan.d.ts +75 -0
- package/dist/mcp-scan.d.ts.map +1 -0
- package/dist/mcp-scan.js +383 -0
- package/dist/mcp-scan.js.map +1 -0
- package/dist/operations.d.ts.map +1 -1
- package/dist/operations.js +8 -4
- package/dist/operations.js.map +1 -1
- package/dist/reconcile.d.ts +79 -0
- package/dist/reconcile.d.ts.map +1 -0
- package/dist/reconcile.js +134 -0
- package/dist/reconcile.js.map +1 -0
- package/dist/targets.d.ts +2 -8
- package/dist/targets.d.ts.map +1 -1
- package/dist/targets.js +5 -4
- package/dist/targets.js.map +1 -1
- package/package.json +4 -3
- package/src/benchmark.ts +55 -3
- package/src/capability-vocab.ts +307 -0
- package/src/index.ts +25 -0
- package/src/mcp-scan.ts +489 -0
- package/src/operations.ts +8 -4
- package/src/reconcile.ts +207 -0
- package/src/targets.ts +10 -5
package/src/reconcile.ts
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `dz mcp-scan --reconcile` — static capability reconciliation (Phase 3).
|
|
3
|
+
*
|
|
4
|
+
* Joins two things `dz` can read deterministically:
|
|
5
|
+
* - the project's GRANT surface (`scanMcp` of `.claude/settings*.json` + `.mcp.json`), and
|
|
6
|
+
* - the aggregate DECLARED capabilities of the installed skills (`.claude/skills/<id>/SKILL.md`),
|
|
7
|
+
* then REPORTS the gaps (under-grant / over-grant) and, optionally, EMITS a
|
|
8
|
+
* least-privilege advisory policy artifact for a host to consume.
|
|
9
|
+
*
|
|
10
|
+
* HONESTY: `dz` is build-time / static. It does NOT run agents and CANNOT block,
|
|
11
|
+
* time out, or rate-limit a tool call. The HOST (Claude Code enforcing
|
|
12
|
+
* settings.json allow/deny; or an MCP server consuming a policy.json) is the only
|
|
13
|
+
* thing that enforces at call time. Verbs here are REPORT / RECONCILE / EMIT /
|
|
14
|
+
* CANDIDATE — never BLOCK / DENIED / ENFORCED.
|
|
15
|
+
*
|
|
16
|
+
* @packageDocumentation
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
20
|
+
import { join } from 'node:path';
|
|
21
|
+
|
|
22
|
+
import { parseDeclaredCapabilities, parseDeclaredLimits, type DeclaredLimits } from './capability-vocab.js';
|
|
23
|
+
import type { McpScanReport } from './mcp-scan.js';
|
|
24
|
+
|
|
25
|
+
/** The honesty banner — repeated in --help, the report header, and the artifact $comment. */
|
|
26
|
+
export const RECONCILE_BANNER =
|
|
27
|
+
'dz is build-time/static: it REPORTS the grant-vs-declaration gap and may EMIT an advisory policy, ' +
|
|
28
|
+
'but does NOT block, time out, or rate-limit anything. The HOST (Claude Code settings.json; or an MCP ' +
|
|
29
|
+
'server consuming policy.json) is the only thing that enforces at call time.';
|
|
30
|
+
|
|
31
|
+
/** A reconcilable capability axis (the axes BOTH the grant surface and the manifest expose). */
|
|
32
|
+
export type ReconcileAxis = 'shell' | 'network' | 'file-write';
|
|
33
|
+
const AXES: readonly ReconcileAxis[] = ['shell', 'network', 'file-write'];
|
|
34
|
+
|
|
35
|
+
/** Per-axis reconciliation state. */
|
|
36
|
+
export interface AxisState {
|
|
37
|
+
readonly axis: ReconcileAxis;
|
|
38
|
+
/** Project permits this capability (from scanMcp). */
|
|
39
|
+
readonly grant: boolean;
|
|
40
|
+
/** At least one installed skill declares it needs this (declared === true). */
|
|
41
|
+
readonly need: boolean;
|
|
42
|
+
/** Skills that declared need (true) — for attribution. */
|
|
43
|
+
readonly needSkills: readonly string[];
|
|
44
|
+
/** Installed skills that are SILENT on this axis (declared === undefined). */
|
|
45
|
+
readonly silentCount: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** A single reconciliation finding. */
|
|
49
|
+
export interface ReconcileFinding {
|
|
50
|
+
readonly id: string;
|
|
51
|
+
readonly kind: 'under-grant' | 'over-grant';
|
|
52
|
+
readonly severity: 'medium' | 'low' | 'info';
|
|
53
|
+
readonly axis: ReconcileAxis;
|
|
54
|
+
readonly detail: string;
|
|
55
|
+
readonly skills: readonly string[];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Aggregated declared limits across installed skills (tightest values; inert). */
|
|
59
|
+
export interface LimitsRollup {
|
|
60
|
+
readonly declaredBy: number;
|
|
61
|
+
readonly toolTimeoutMs?: number;
|
|
62
|
+
readonly maxToolCallsPerTurn?: number;
|
|
63
|
+
readonly requireApprovalForDangerous?: boolean;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The least-privilege advisory policy artifact (only written with --emit-policy). */
|
|
67
|
+
export interface PolicyArtifact {
|
|
68
|
+
readonly $comment: string;
|
|
69
|
+
readonly version: 1;
|
|
70
|
+
readonly defaultDeny: true;
|
|
71
|
+
/** axis → true ONLY where an installed skill declared need; absent otherwise. */
|
|
72
|
+
readonly allow: Partial<Record<ReconcileAxis, true>>;
|
|
73
|
+
readonly limits?: LimitsRollup;
|
|
74
|
+
readonly derivedFrom: {
|
|
75
|
+
readonly grants: Record<ReconcileAxis, boolean>;
|
|
76
|
+
readonly declaredNeed: Record<ReconcileAxis, boolean>;
|
|
77
|
+
readonly skillsByAxis: Partial<Record<ReconcileAxis, readonly string[]>>;
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Result of {@link reconcileCapabilities}. */
|
|
82
|
+
export interface ReconcileReport {
|
|
83
|
+
readonly skillsDir: string;
|
|
84
|
+
readonly installedCount: number;
|
|
85
|
+
readonly axes: readonly AxisState[];
|
|
86
|
+
readonly findings: readonly ReconcileFinding[];
|
|
87
|
+
readonly limits: LimitsRollup | null;
|
|
88
|
+
readonly policy: PolicyArtifact;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
interface InstalledDecl {
|
|
92
|
+
id: string;
|
|
93
|
+
caps: ReturnType<typeof parseDeclaredCapabilities>;
|
|
94
|
+
limits: DeclaredLimits;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function readInstalled(skillsDir: string): InstalledDecl[] {
|
|
98
|
+
if (!existsSync(skillsDir)) return [];
|
|
99
|
+
let entries: import('node:fs').Dirent[];
|
|
100
|
+
try {
|
|
101
|
+
entries = readdirSync(skillsDir, { withFileTypes: true });
|
|
102
|
+
} catch {
|
|
103
|
+
return [];
|
|
104
|
+
}
|
|
105
|
+
const out: InstalledDecl[] = [];
|
|
106
|
+
for (const e of entries) {
|
|
107
|
+
if (!e.isDirectory()) continue;
|
|
108
|
+
const md = join(skillsDir, e.name, 'SKILL.md');
|
|
109
|
+
if (!existsSync(md)) continue;
|
|
110
|
+
let content: string;
|
|
111
|
+
try {
|
|
112
|
+
content = readFileSync(md, 'utf-8');
|
|
113
|
+
} catch {
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
out.push({ id: e.name, caps: parseDeclaredCapabilities(content), limits: parseDeclaredLimits(content) });
|
|
117
|
+
}
|
|
118
|
+
return out.sort((a, b) => a.id.localeCompare(b.id));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function grantFor(report: McpScanReport, axis: ReconcileAxis): boolean {
|
|
122
|
+
const c = report.capabilities;
|
|
123
|
+
return axis === 'shell' ? c.shell : axis === 'network' ? c.network : c.fileWrite;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Statically reconcile a project's GRANT surface against the DECLARED needs of
|
|
128
|
+
* its installed skills. Pure: same inputs → same report. No execution, no writes.
|
|
129
|
+
*/
|
|
130
|
+
export function reconcileCapabilities(report: McpScanReport, skillsDir: string): ReconcileReport {
|
|
131
|
+
const installed = readInstalled(skillsDir);
|
|
132
|
+
|
|
133
|
+
const axes: AxisState[] = [];
|
|
134
|
+
const findings: ReconcileFinding[] = [];
|
|
135
|
+
const allow: Partial<Record<ReconcileAxis, true>> = {};
|
|
136
|
+
const grants = {} as Record<ReconcileAxis, boolean>;
|
|
137
|
+
const declaredNeed = {} as Record<ReconcileAxis, boolean>;
|
|
138
|
+
const skillsByAxis: Partial<Record<ReconcileAxis, readonly string[]>> = {};
|
|
139
|
+
|
|
140
|
+
for (const axis of AXES) {
|
|
141
|
+
const grant = grantFor(report, axis);
|
|
142
|
+
const needSkills = installed.filter((s) => s.caps[axis] === true).map((s) => s.id);
|
|
143
|
+
const need = needSkills.length > 0;
|
|
144
|
+
const silentCount = installed.filter((s) => s.caps[axis] === undefined).length;
|
|
145
|
+
axes.push({ axis, grant, need, needSkills, silentCount });
|
|
146
|
+
grants[axis] = grant;
|
|
147
|
+
declaredNeed[axis] = need;
|
|
148
|
+
if (need) {
|
|
149
|
+
skillsByAxis[axis] = needSkills;
|
|
150
|
+
allow[axis] = true;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (need && !grant) {
|
|
154
|
+
findings.push({
|
|
155
|
+
id: `MS-UNDERGRANT-${axis.toUpperCase()}`,
|
|
156
|
+
kind: 'under-grant',
|
|
157
|
+
severity: 'medium',
|
|
158
|
+
axis,
|
|
159
|
+
detail: `${needSkills.length} installed skill(s) declare needing ${axis}, but the grant surface does not permit it — the host will starve them at runtime. Add the grant or remove the skill(s).`,
|
|
160
|
+
skills: needSkills,
|
|
161
|
+
});
|
|
162
|
+
} else if (grant && !need) {
|
|
163
|
+
// over-grant is ALWAYS advisory; downgrade to info when any skill is silent
|
|
164
|
+
// (it may genuinely need the grant but didn't declare), or when there are
|
|
165
|
+
// no installed skills at all.
|
|
166
|
+
const downgrade = silentCount > 0 || installed.length === 0;
|
|
167
|
+
findings.push({
|
|
168
|
+
id: `MS-OVERGRANT-${axis.toUpperCase()}`,
|
|
169
|
+
kind: 'over-grant',
|
|
170
|
+
severity: downgrade ? 'info' : 'low',
|
|
171
|
+
axis,
|
|
172
|
+
detail: `The project permits ${axis} but no installed skill declares needing it${silentCount > 0 ? ` (${silentCount} skill(s) silent on ${axis})` : ''} — least-privilege CANDIDATE to revoke; may be for the operator or a non-skill MCP server.`,
|
|
173
|
+
skills: [],
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// limits roll-up (tightest values) — INERT, never a gate
|
|
179
|
+
const withLimits = installed.filter((s) => Object.keys(s.limits).length > 0);
|
|
180
|
+
let limits: LimitsRollup | null = null;
|
|
181
|
+
if (withLimits.length > 0) {
|
|
182
|
+
const tts = withLimits.map((s) => s.limits.toolTimeoutMs).filter((n): n is number => typeof n === 'number');
|
|
183
|
+
const mcs = withLimits.map((s) => s.limits.maxToolCallsPerTurn).filter((n): n is number => typeof n === 'number');
|
|
184
|
+
const ras = withLimits.some((s) => s.limits.requireApprovalForDangerous === true);
|
|
185
|
+
limits = {
|
|
186
|
+
declaredBy: withLimits.length,
|
|
187
|
+
...(tts.length > 0 ? { toolTimeoutMs: Math.min(...tts) } : {}),
|
|
188
|
+
...(mcs.length > 0 ? { maxToolCallsPerTurn: Math.min(...mcs) } : {}),
|
|
189
|
+
...(ras ? { requireApprovalForDangerous: true } : {}),
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const policy: PolicyArtifact = {
|
|
194
|
+
$comment: `ADVISORY. ${RECONCILE_BANNER} A host MUST consume this; dz does not.`,
|
|
195
|
+
version: 1,
|
|
196
|
+
defaultDeny: true,
|
|
197
|
+
allow,
|
|
198
|
+
...(limits ? { limits } : {}),
|
|
199
|
+
derivedFrom: { grants, declaredNeed, skillsByAxis },
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
// stable ordering: under-grant (medium) first, then over-grant
|
|
203
|
+
const order = { medium: 0, low: 1, info: 2 } as const;
|
|
204
|
+
findings.sort((a, b) => order[a.severity] - order[b.severity] || a.axis.localeCompare(b.axis));
|
|
205
|
+
|
|
206
|
+
return { skillsDir, installedCount: installed.length, axes, findings, limits, policy };
|
|
207
|
+
}
|
package/src/targets.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import { claudeAdapter } from '@dzhechkov/adapter-claude';
|
|
8
8
|
import { codexAdapter } from '@dzhechkov/adapter-codex';
|
|
9
|
+
import { copilotAdapter } from '@dzhechkov/adapter-copilot';
|
|
9
10
|
import { hermesAdapter } from '@dzhechkov/adapter-hermes';
|
|
10
11
|
import { opencodeAdapter } from '@dzhechkov/adapter-opencode';
|
|
11
12
|
import { openclaudeAdapter } from '@dzhechkov/adapter-openclaude';
|
|
@@ -15,16 +16,20 @@ import type { Adapter } from '@dzhechkov/core';
|
|
|
15
16
|
* The targets the harness can initialise. The key is the CLI `--target` name
|
|
16
17
|
* (`claude-code`, not `claude`); the value is the adapter that emits for it.
|
|
17
18
|
*/
|
|
18
|
-
|
|
19
|
+
/** A valid `--target` name. */
|
|
20
|
+
export type TargetName = 'claude-code' | 'codex' | 'opencode' | 'hermes' | 'openclaude' | 'copilot';
|
|
21
|
+
|
|
22
|
+
// Explicitly annotated (not `as const satisfies`) so the type is portable across
|
|
23
|
+
// the workspace — adapters compiled against slightly different `@dzhechkov/core`
|
|
24
|
+
// versions would otherwise make the inferred type un-nameable (TS2742).
|
|
25
|
+
export const TARGETS: Record<TargetName, Adapter> = {
|
|
19
26
|
'claude-code': claudeAdapter,
|
|
20
27
|
codex: codexAdapter,
|
|
21
28
|
opencode: opencodeAdapter,
|
|
22
29
|
hermes: hermesAdapter,
|
|
23
30
|
openclaude: openclaudeAdapter,
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
/** A valid `--target` name. */
|
|
27
|
-
export type TargetName = keyof typeof TARGETS;
|
|
31
|
+
copilot: copilotAdapter,
|
|
32
|
+
};
|
|
28
33
|
|
|
29
34
|
/** Every supported `--target` name. */
|
|
30
35
|
export const TARGET_NAMES = Object.keys(TARGETS) as TargetName[];
|