@stravica-ai/rcf-verify-lite 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/CHANGELOG.md +19 -0
- package/LICENSE +202 -0
- package/README.md +76 -0
- package/bin/rcf-verify.js +102 -0
- package/package.json +47 -0
- package/src/chain/index.js +94 -0
- package/src/cli/cleanup.js +61 -0
- package/src/cli/help.js +56 -0
- package/src/cli/mcp.js +98 -0
- package/src/cli/provision.js +71 -0
- package/src/cli/report.js +71 -0
- package/src/cli/run.js +155 -0
- package/src/engine/brief.js +59 -0
- package/src/engine/index.js +162 -0
- package/src/engine/launcher.js +302 -0
- package/src/mcp/tools.js +107 -0
- package/src/profile/index.js +146 -0
- package/src/provision/index.js +249 -0
- package/src/report/index.js +107 -0
- package/src/report/renderer.js +104 -0
- package/src/verdict/index.js +103 -0
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
// Verifier-agent launcher (spec §5, §7.3, §9). Verify does NOT hand-code
|
|
2
|
+
// browser scripts; it launches a fresh, isolated agent (Claude Code by
|
|
3
|
+
// default) configured with browser tooling (Playwright) and the §7.3
|
|
4
|
+
// isolation env, and that agent drives the running app and returns structured
|
|
5
|
+
// findings. This keeps verify harness-agnostic.
|
|
6
|
+
//
|
|
7
|
+
// Shared-parent-state caution (§9): the launched agent MUST get its OWN
|
|
8
|
+
// isolated browser context. We provision the Playwright MCP server explicitly
|
|
9
|
+
// via --mcp-config + --strict-mcp-config so the launcher does NOT inherit the
|
|
10
|
+
// operator's ambient user-level MCP config (which may be absent on a fresh
|
|
11
|
+
// machine, or may leak the parent's shared browser/OAuth session). The spawned
|
|
12
|
+
// agent gets exactly the tool surface it needs — scoped via --allowedTools —
|
|
13
|
+
// and nothing more (no --dangerously-skip-permissions).
|
|
14
|
+
|
|
15
|
+
import { isolationEnv, isolationProvenance } from '@stravica-ai/rcf-lite-core/isolation';
|
|
16
|
+
|
|
17
|
+
/** Env var naming a module (exporting `launchAgent`) to use instead of the default spawn launcher. The integration + manual-e2e seam. */
|
|
18
|
+
export const LAUNCHER_ENV = 'RCF_VERIFY_LAUNCHER';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The minimal, SCOPED tool surface the verifier agent needs to drive a live
|
|
22
|
+
* app and collect runtime evidence (spec §9 method). Deliberately narrow —
|
|
23
|
+
* NOT --dangerously-skip-permissions. The Playwright browser family (provided
|
|
24
|
+
* by the explicitly-provisioned MCP server below), WebFetch, and a curl/grep
|
|
25
|
+
* Bash surface for raw HTTP probing cover the empirically-observed method.
|
|
26
|
+
* `mcp__playwright` grants every tool exported by the provisioned `playwright`
|
|
27
|
+
* server.
|
|
28
|
+
*/
|
|
29
|
+
export const DEFAULT_ALLOWED_TOOLS = Object.freeze([
|
|
30
|
+
'mcp__playwright',
|
|
31
|
+
'WebFetch',
|
|
32
|
+
'Bash(curl:*)',
|
|
33
|
+
'Bash(grep:*)',
|
|
34
|
+
]);
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Explicit Playwright MCP provisioning for the spawned agent. Provisioned by
|
|
38
|
+
* value (not by reference to ambient user config) so the launcher works on a
|
|
39
|
+
* machine with no user-level MCP setup, and so the agent gets its OWN browser
|
|
40
|
+
* context (§9). Server name `playwright` -> tool prefix `mcp__playwright__*`.
|
|
41
|
+
* `npx` is resolved off PATH for portability (no machine-specific absolute).
|
|
42
|
+
*/
|
|
43
|
+
export const DEFAULT_MCP_CONFIG = Object.freeze({
|
|
44
|
+
mcpServers: {
|
|
45
|
+
playwright: {
|
|
46
|
+
type: 'stdio',
|
|
47
|
+
command: 'npx',
|
|
48
|
+
args: ['-y', '@playwright/mcp@latest'],
|
|
49
|
+
env: {},
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Build the child-process launch configuration for the verifier agent. Pure
|
|
56
|
+
* and testable: asserts the §7.3 isolation env is applied, the agent is pointed
|
|
57
|
+
* at the brief + live URL, the network-capable tool surface is scoped, and the
|
|
58
|
+
* Playwright MCP server is provisioned explicitly. The command defaults to
|
|
59
|
+
* Claude Code headless; callers may override via deps for other harnesses.
|
|
60
|
+
*
|
|
61
|
+
* @param {object} opts
|
|
62
|
+
* @param {object} opts.brief - the composed adversarial brief
|
|
63
|
+
* @param {string} opts.url
|
|
64
|
+
* @param {object} [deps]
|
|
65
|
+
* @param {string} [deps.command] - the agent CLI (default: 'claude')
|
|
66
|
+
* @param {Record<string,string|undefined>} [deps.baseEnv]
|
|
67
|
+
* @param {string[]} [deps.allowedTools] - override the scoped tool surface
|
|
68
|
+
* @param {object} [deps.mcpConfig] - override the provisioned MCP servers
|
|
69
|
+
* @returns {{ command: string, args: string[], env: Record<string,string|undefined>, isolation: {autoMemory: boolean, nonEssentialTraffic: boolean}, briefUrl: string, brief: object }}
|
|
70
|
+
*/
|
|
71
|
+
export function buildLaunchConfig({ brief, url }, deps = {}) {
|
|
72
|
+
const command = deps.command ?? 'claude';
|
|
73
|
+
const env = isolationEnv(deps.baseEnv ?? process.env);
|
|
74
|
+
const allowedTools = deps.allowedTools ?? DEFAULT_ALLOWED_TOOLS;
|
|
75
|
+
const mcpConfig = deps.mcpConfig ?? DEFAULT_MCP_CONFIG;
|
|
76
|
+
// The brief is passed to the agent over stdin by the concrete launcher; here
|
|
77
|
+
// we assemble the invariant config. --output-format json wraps the agent's
|
|
78
|
+
// reply in a result envelope (robust ingestion + runStats, see parseAgentOutput);
|
|
79
|
+
// --allowedTools scopes the network-capable surface; --mcp-config +
|
|
80
|
+
// --strict-mcp-config provision Playwright without touching ambient user config.
|
|
81
|
+
const args = [
|
|
82
|
+
'--print',
|
|
83
|
+
'--output-format', 'json',
|
|
84
|
+
'--allowedTools', allowedTools.join(','),
|
|
85
|
+
'--mcp-config', JSON.stringify(mcpConfig),
|
|
86
|
+
'--strict-mcp-config',
|
|
87
|
+
];
|
|
88
|
+
return { command, args, env, isolation: isolationProvenance(), briefUrl: url, brief };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Brace-balanced extraction of the LAST `{"findings" ...}` object from an
|
|
93
|
+
* arbitrary text blob. The verifier agent empirically ALWAYS prepends prose
|
|
94
|
+
* even when told to emit only JSON, so whole-string JSON.parse cannot be
|
|
95
|
+
* trusted (both shakedown transcripts prove it). We scan for the last
|
|
96
|
+
* `{ "findings"` opener (tolerant of whitespace) and walk braces — respecting
|
|
97
|
+
* string literals and escapes — to its matching close.
|
|
98
|
+
*
|
|
99
|
+
* @param {string} text
|
|
100
|
+
* @returns {string | null} the JSON substring, or null if none found
|
|
101
|
+
*/
|
|
102
|
+
export function extractFindingsObject(text) {
|
|
103
|
+
if (typeof text !== 'string' || text.length === 0) return null;
|
|
104
|
+
// Find the last opener `{ "findings"` (allow whitespace between { and key).
|
|
105
|
+
const opener = /\{\s*"findings"/g;
|
|
106
|
+
let start = -1;
|
|
107
|
+
let m;
|
|
108
|
+
while ((m = opener.exec(text)) !== null) start = m.index;
|
|
109
|
+
if (start < 0) return null;
|
|
110
|
+
let depth = 0;
|
|
111
|
+
let inStr = false;
|
|
112
|
+
let esc = false;
|
|
113
|
+
for (let j = start; j < text.length; j += 1) {
|
|
114
|
+
const c = text[j];
|
|
115
|
+
if (inStr) {
|
|
116
|
+
if (esc) esc = false;
|
|
117
|
+
else if (c === '\\') esc = true;
|
|
118
|
+
else if (c === '"') inStr = false;
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
if (c === '"') inStr = true;
|
|
122
|
+
else if (c === '{') depth += 1;
|
|
123
|
+
else if (c === '}') {
|
|
124
|
+
depth -= 1;
|
|
125
|
+
if (depth === 0) return text.slice(start, j + 1);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Map the `--output-format json` result envelope's usage/timing to a camelCase
|
|
133
|
+
* runStats block for the report (spec §5.3, additive). Omit-not-fake: any
|
|
134
|
+
* field absent from the envelope is simply left off.
|
|
135
|
+
*
|
|
136
|
+
* @param {object} envelope
|
|
137
|
+
* @returns {object | null}
|
|
138
|
+
*/
|
|
139
|
+
export function extractRunStats(envelope) {
|
|
140
|
+
if (!envelope || typeof envelope !== 'object') return null;
|
|
141
|
+
const stats = {};
|
|
142
|
+
if (typeof envelope.duration_ms === 'number') stats.durationMs = envelope.duration_ms;
|
|
143
|
+
if (typeof envelope.num_turns === 'number') stats.numTurns = envelope.num_turns;
|
|
144
|
+
if (typeof envelope.total_cost_usd === 'number') stats.totalCostUsd = envelope.total_cost_usd;
|
|
145
|
+
const u = envelope.usage;
|
|
146
|
+
if (u && typeof u === 'object') {
|
|
147
|
+
const tokens = {};
|
|
148
|
+
if (typeof u.input_tokens === 'number') tokens.inputTokens = u.input_tokens;
|
|
149
|
+
if (typeof u.output_tokens === 'number') tokens.outputTokens = u.output_tokens;
|
|
150
|
+
if (typeof u.cache_read_input_tokens === 'number') tokens.cacheReadInputTokens = u.cache_read_input_tokens;
|
|
151
|
+
if (typeof u.cache_creation_input_tokens === 'number') tokens.cacheCreationInputTokens = u.cache_creation_input_tokens;
|
|
152
|
+
if (Object.keys(tokens).length > 0) stats.tokens = tokens;
|
|
153
|
+
}
|
|
154
|
+
return Object.keys(stats).length > 0 ? stats : null;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Two-layer robust ingestion of the raw agent stdout (spec §5.4 — the report
|
|
159
|
+
* is build-lite's next input, so parse must not lose the verdict to a stray
|
|
160
|
+
* prose prefix):
|
|
161
|
+
* Layer A — parse the whole stdout as the `--output-format json` result
|
|
162
|
+
* envelope; the agent's text reply is in `.result`, and usage/timing
|
|
163
|
+
* give runStats.
|
|
164
|
+
* Layer B — brace-balanced extraction of the `{"findings":[...]}` object,
|
|
165
|
+
* first from the envelope's `.result` text, then (belt) from the
|
|
166
|
+
* whole raw stdout in case the envelope shape ever changes.
|
|
167
|
+
* Both layers, never either. Success requires a `{ findings: [...] }` object.
|
|
168
|
+
*
|
|
169
|
+
* @param {string} rawStdout
|
|
170
|
+
* @returns {{ ok: true, findings: object[], runStats: (object|null) } | { ok: false, reason: string }}
|
|
171
|
+
*/
|
|
172
|
+
export function parseAgentOutput(rawStdout) {
|
|
173
|
+
const raw = typeof rawStdout === 'string' ? rawStdout : '';
|
|
174
|
+
let envelope = null;
|
|
175
|
+
let resultText = raw;
|
|
176
|
+
let runStats = null;
|
|
177
|
+
// Layer A: the result envelope.
|
|
178
|
+
try {
|
|
179
|
+
const parsed = JSON.parse(raw);
|
|
180
|
+
if (parsed && typeof parsed === 'object' && parsed.type === 'result') {
|
|
181
|
+
envelope = parsed;
|
|
182
|
+
runStats = extractRunStats(envelope);
|
|
183
|
+
if (typeof envelope.result === 'string') resultText = envelope.result;
|
|
184
|
+
}
|
|
185
|
+
} catch {
|
|
186
|
+
// Not an envelope (text-mode stdout, or truncated). Fall through to Layer B on raw.
|
|
187
|
+
}
|
|
188
|
+
// Layer B: brace-balanced extraction — from the envelope text first, then raw.
|
|
189
|
+
const candidate = extractFindingsObject(resultText) ?? extractFindingsObject(raw);
|
|
190
|
+
if (!candidate) {
|
|
191
|
+
const detail = envelope
|
|
192
|
+
? `agent produced no {"findings"} object (envelope subtype=${envelope.subtype ?? 'unknown'}, is_error=${envelope.is_error ?? 'unknown'})`
|
|
193
|
+
: 'agent output was neither a result envelope nor did it contain a {"findings"} object';
|
|
194
|
+
return { ok: false, reason: detail };
|
|
195
|
+
}
|
|
196
|
+
let obj;
|
|
197
|
+
try {
|
|
198
|
+
obj = JSON.parse(candidate);
|
|
199
|
+
} catch (err) {
|
|
200
|
+
return { ok: false, reason: `extracted {"findings"} object failed to parse: ${err.message}` };
|
|
201
|
+
}
|
|
202
|
+
if (!obj || !Array.isArray(obj.findings)) {
|
|
203
|
+
return { ok: false, reason: 'extracted object had no findings array' };
|
|
204
|
+
}
|
|
205
|
+
return { ok: true, findings: obj.findings, runStats };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Resolve the launchAgent function for this run. Precedence:
|
|
210
|
+
* 1. an explicitly injected `deps.launchAgent` (unit tests, in-process harness);
|
|
211
|
+
* 2. a module named by RCF_VERIFY_LAUNCHER (integration + manual e2e seam);
|
|
212
|
+
* 3. the default spawn launcher (live Claude Code + Playwright).
|
|
213
|
+
*
|
|
214
|
+
* @param {object} [deps]
|
|
215
|
+
* @returns {Promise<(ctx: object) => Promise<{findings: object[], runStats?: object}>>}
|
|
216
|
+
*/
|
|
217
|
+
export async function resolveLauncher(deps = {}) {
|
|
218
|
+
if (typeof deps.launchAgent === 'function') return deps.launchAgent;
|
|
219
|
+
const envModule = process.env[LAUNCHER_ENV];
|
|
220
|
+
if (envModule) {
|
|
221
|
+
const mod = await import(envModule);
|
|
222
|
+
const fn = mod.launchAgent ?? mod.default;
|
|
223
|
+
if (typeof fn !== 'function') {
|
|
224
|
+
throw new Error(`${LAUNCHER_ENV} module "${envModule}" does not export launchAgent`);
|
|
225
|
+
}
|
|
226
|
+
return fn;
|
|
227
|
+
}
|
|
228
|
+
return defaultSpawnLauncher;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Default launcher: spawn the live verifier agent (Claude Code) with the
|
|
233
|
+
* isolation env + scoped tools + provisioned Playwright, and let it drive the
|
|
234
|
+
* app. Ingests its output through the two-layer parser.
|
|
235
|
+
*
|
|
236
|
+
* v1 honesty (§9, §11): the live-agent-drives-live-app path is NOT unit-
|
|
237
|
+
* testable and is exercised only by the recorded manual e2e; the PARSE logic
|
|
238
|
+
* (parseAgentOutput / extractFindingsObject) IS unit-tested against real
|
|
239
|
+
* captured transcripts. When ingestion fails, the raw transcript is written to
|
|
240
|
+
* disk and its path carried on the thrown error — never lose the evidence, and
|
|
241
|
+
* never fabricate a PASS (§9). Callers (engine) turn the throw into a
|
|
242
|
+
* LAUNCH-FAILURE report so the §5.4 fix loop still has something to ingest.
|
|
243
|
+
*
|
|
244
|
+
* @param {object} ctx
|
|
245
|
+
* @returns {Promise<{ findings: object[], runStats: (object|null) }>}
|
|
246
|
+
*/
|
|
247
|
+
export async function defaultSpawnLauncher(ctx) {
|
|
248
|
+
const { spawn } = await import('node:child_process');
|
|
249
|
+
const config = buildLaunchConfig(ctx, {});
|
|
250
|
+
const out = await new Promise((resolve, reject) => {
|
|
251
|
+
let child;
|
|
252
|
+
try {
|
|
253
|
+
child = spawn(config.command, config.args, { env: config.env, stdio: ['pipe', 'pipe', 'inherit'] });
|
|
254
|
+
} catch (err) {
|
|
255
|
+
reject(new Error(`could not launch verifier agent "${config.command}": ${err.message}. `
|
|
256
|
+
+ `Set ${LAUNCHER_ENV} to a module exporting launchAgent, or install the agent CLI.`));
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
let buf = '';
|
|
260
|
+
child.stdout.on('data', (d) => { buf += d.toString(); });
|
|
261
|
+
child.on('error', (err) => reject(new Error(`verifier agent failed to start: ${err.message}. Set ${LAUNCHER_ENV} to inject a launcher.`)));
|
|
262
|
+
child.on('close', (code) => {
|
|
263
|
+
if (code !== 0) {
|
|
264
|
+
reject(new Error(`verifier agent exited ${code}`));
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
resolve(buf);
|
|
268
|
+
});
|
|
269
|
+
// Hand the brief to the agent on stdin.
|
|
270
|
+
child.stdin.write(JSON.stringify(config.brief));
|
|
271
|
+
child.stdin.end();
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
const parsed = parseAgentOutput(out);
|
|
275
|
+
if (!parsed.ok) {
|
|
276
|
+
const rawPath = await persistRawOutput(out);
|
|
277
|
+
const err = new Error(`verifier agent output could not be ingested: ${parsed.reason}. Raw transcript: ${rawPath}`);
|
|
278
|
+
err.rawOutputPath = rawPath;
|
|
279
|
+
throw err;
|
|
280
|
+
}
|
|
281
|
+
return { findings: parsed.findings, runStats: parsed.runStats };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Persist an un-ingestible raw agent transcript so it is never lost (spec §5.4
|
|
286
|
+
* — the transcript is the only evidence of what the agent actually emitted).
|
|
287
|
+
*
|
|
288
|
+
* @param {string} raw
|
|
289
|
+
* @returns {Promise<string>} the path written (or a marker if the write failed)
|
|
290
|
+
*/
|
|
291
|
+
async function persistRawOutput(raw) {
|
|
292
|
+
try {
|
|
293
|
+
const { writeFile } = await import('node:fs/promises');
|
|
294
|
+
const { tmpdir } = await import('node:os');
|
|
295
|
+
const { join } = await import('node:path');
|
|
296
|
+
const path = join(tmpdir(), `rcf-verify-agent-output-${Date.now()}.txt`);
|
|
297
|
+
await writeFile(path, raw ?? '', 'utf8');
|
|
298
|
+
return path;
|
|
299
|
+
} catch (err) {
|
|
300
|
+
return `(could not persist raw transcript: ${err.message})`;
|
|
301
|
+
}
|
|
302
|
+
}
|
package/src/mcp/tools.js
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// MCP tool registry for verify (spec §10 MCP, mirrors build's mcp/ shape). A
|
|
2
|
+
// thin adapter over the SAME in-process engine the CLI uses (runVerification),
|
|
3
|
+
// never spawning the CLI. Input schemas are JSON Schema 2020-12, camelCase,
|
|
4
|
+
// closed objects. Argument validation maps to tool execution errors
|
|
5
|
+
// (isError: true) — the self-correction channel — not protocol errors.
|
|
6
|
+
|
|
7
|
+
import { JsonRpcError, INVALID_PARAMS } from '@stravica-ai/rcf-lite-core/mcp-shell';
|
|
8
|
+
import { isRcfError } from '@stravica-ai/rcf-lite-core/errors';
|
|
9
|
+
|
|
10
|
+
import { runVerification } from '../engine/index.js';
|
|
11
|
+
import { gateTripped, FINDING_SEVERITIES } from '../verdict/index.js';
|
|
12
|
+
|
|
13
|
+
const RUN_INPUT_SCHEMA = {
|
|
14
|
+
type: 'object',
|
|
15
|
+
additionalProperties: false,
|
|
16
|
+
properties: {
|
|
17
|
+
repo: { type: 'string', description: 'RCF chain source (the acceptance contract)' },
|
|
18
|
+
chainRef: { type: 'string', description: 'Which PRD/chain (default: the repo\'s)' },
|
|
19
|
+
profile: { type: 'string', enum: ['deployed', 'ci', 'local-dev'], description: 'Runtime profile (scopes the verdict authority)' },
|
|
20
|
+
url: { type: 'string', description: 'The running app for this profile' },
|
|
21
|
+
parityEnv: { type: 'boolean', description: 'Assert a non-deployed runtime is edge-identical to prod (the only path to SHIP from a non-deployed profile)' },
|
|
22
|
+
provision: { type: 'string', description: 'Provisioning credentials FILE path (never inline)' },
|
|
23
|
+
provisionMode: { type: 'string', enum: ['run', 'skip'] },
|
|
24
|
+
persona: { type: 'string' },
|
|
25
|
+
severityGate: { type: 'string', enum: FINDING_SEVERITIES },
|
|
26
|
+
},
|
|
27
|
+
required: ['repo', 'profile', 'url'],
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const RUN_OUTPUT_SCHEMA = {
|
|
31
|
+
type: 'object',
|
|
32
|
+
properties: {
|
|
33
|
+
ok: { type: 'boolean' },
|
|
34
|
+
gateTripped: { type: 'boolean' },
|
|
35
|
+
report: { type: 'object' },
|
|
36
|
+
errors: { type: 'array' },
|
|
37
|
+
},
|
|
38
|
+
required: ['ok'],
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Build the tool registry. `definitions` is the tools/list payload;
|
|
43
|
+
* `call(name, args)` dispatches a tools/call.
|
|
44
|
+
*
|
|
45
|
+
* @param {object} [deps] - injectable engine seams (launchAgent, fetchImpl, signup, ...)
|
|
46
|
+
* @returns {{ definitions: object[], call: (name: string, args: object) => Promise<object> }}
|
|
47
|
+
*/
|
|
48
|
+
export function createToolRegistry(deps = {}) {
|
|
49
|
+
const definitions = [
|
|
50
|
+
{
|
|
51
|
+
name: 'rcf_verify_run',
|
|
52
|
+
title: 'RCF Verify — run adversarial verification',
|
|
53
|
+
description: 'Launch a fresh-context adversarial verifier against a running app under a declared runtime profile and return a structured verdict stamped with the runtime it ran against. Returns the report inline (no --out file needed over MCP).',
|
|
54
|
+
inputSchema: RUN_INPUT_SCHEMA,
|
|
55
|
+
outputSchema: RUN_OUTPUT_SCHEMA,
|
|
56
|
+
annotations: { readOnlyHint: false, openWorldHint: true },
|
|
57
|
+
},
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
async function call(name, args) {
|
|
61
|
+
if (name !== 'rcf_verify_run') {
|
|
62
|
+
throw new JsonRpcError(INVALID_PARAMS, `Unknown tool: ${name}`);
|
|
63
|
+
}
|
|
64
|
+
const a = args ?? {};
|
|
65
|
+
const errors = [];
|
|
66
|
+
if (typeof a.repo !== 'string') errors.push({ kind: 'usage', field: 'repo', message: 'repo is required' });
|
|
67
|
+
if (!['deployed', 'ci', 'local-dev'].includes(a.profile)) errors.push({ kind: 'usage', field: 'profile', message: 'profile must be deployed|ci|local-dev' });
|
|
68
|
+
if (typeof a.url !== 'string') errors.push({ kind: 'usage', field: 'url', message: 'url is required' });
|
|
69
|
+
if (errors.length > 0) {
|
|
70
|
+
return errorResult(errors);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const result = await runVerification({
|
|
74
|
+
repo: a.repo,
|
|
75
|
+
chainRef: a.chainRef,
|
|
76
|
+
profile: a.profile,
|
|
77
|
+
url: a.url,
|
|
78
|
+
parityEnv: Boolean(a.parityEnv),
|
|
79
|
+
provision: a.provision,
|
|
80
|
+
provisionMode: a.provisionMode ?? 'run',
|
|
81
|
+
persona: a.persona,
|
|
82
|
+
severityGate: a.severityGate,
|
|
83
|
+
}, deps);
|
|
84
|
+
|
|
85
|
+
if (isRcfError(result)) {
|
|
86
|
+
return errorResult([{ kind: result.kind, field: result.field ?? null, message: result.message }]);
|
|
87
|
+
}
|
|
88
|
+
const { report } = result;
|
|
89
|
+
const tripped = gateTripped({ verdict: report.verdict, findings: report.findings, gate: a.severityGate });
|
|
90
|
+
const payload = { ok: true, gateTripped: tripped, report };
|
|
91
|
+
return {
|
|
92
|
+
content: [{ type: 'text', text: `verdict ${report.verdict} [${report.verdictAuthority}]${tripped ? ' — gate tripped' : ''}` }],
|
|
93
|
+
structuredContent: payload,
|
|
94
|
+
isError: false,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return { definitions, call };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function errorResult(errors) {
|
|
102
|
+
return {
|
|
103
|
+
content: [{ type: 'text', text: errors.map((e) => `[${e.kind}] ${e.message}`).join('\n') }],
|
|
104
|
+
structuredContent: { ok: false, errors },
|
|
105
|
+
isError: true,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// Runtime-profile module (spec §4, amendments 1 + 3) — the load-bearing
|
|
2
|
+
// correctness property of the whole tool. The single most expensive miss in
|
|
3
|
+
// the persona programme was "verified against one runtime, reported as
|
|
4
|
+
// another" (run-05: verified against wrangler-dev on localhost, reported as a
|
|
5
|
+
// live Worker). This module makes that failure structurally impossible: every
|
|
6
|
+
// verdict carries the runtime it ran against, and only `deployed` (or a
|
|
7
|
+
// declared-parity runtime) may carry the authority of a ship gate.
|
|
8
|
+
//
|
|
9
|
+
// Provenance-honesty, NOT a URL blocklist. localhost is a first-class target
|
|
10
|
+
// for ci/local-dev (that is the CI use case, amendment 3). What is forbidden
|
|
11
|
+
// is a lower profile claiming the authority of `deployed`.
|
|
12
|
+
|
|
13
|
+
import { rcfError } from '@stravica-ai/rcf-lite-core/errors';
|
|
14
|
+
|
|
15
|
+
/** The three runtime profiles (spec §4). */
|
|
16
|
+
export const PROFILES = Object.freeze(['deployed', 'ci', 'local-dev']);
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Resolve + validate the runtime declaration from parsed CLI flags. Errors
|
|
20
|
+
* are returned as data (RcfError, kind 'usage'), never thrown — the CLI maps
|
|
21
|
+
* them to a non-zero usage exit (spec §3 hard rules 1-2, §10).
|
|
22
|
+
*
|
|
23
|
+
* @param {{ profile?: string, url?: string, parityEnv?: boolean }} flags
|
|
24
|
+
* @returns {{ profile: string, url: string, parityEnv: boolean } | import('@stravica-ai/rcf-lite-core/errors').RcfError}
|
|
25
|
+
*/
|
|
26
|
+
export function resolveProfile(flags = {}) {
|
|
27
|
+
const { profile, url } = flags;
|
|
28
|
+
if (!profile) {
|
|
29
|
+
return rcfError({ kind: 'usage', message: '--profile is required (one of: deployed, ci, local-dev)', field: 'profile' });
|
|
30
|
+
}
|
|
31
|
+
if (!PROFILES.includes(profile)) {
|
|
32
|
+
return rcfError({ kind: 'usage', message: `--profile must be one of: ${PROFILES.join(', ')} (got "${profile}")`, field: 'profile' });
|
|
33
|
+
}
|
|
34
|
+
if (!url) {
|
|
35
|
+
return rcfError({ kind: 'usage', message: '--url is required (the running app for this profile)', field: 'url' });
|
|
36
|
+
}
|
|
37
|
+
return { profile, url, parityEnv: Boolean(flags.parityEnv) };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Heuristic local-hostname detection. Pure/sync — no network. Errs toward
|
|
42
|
+
* flagging local so the deployed reachability gate is advisory-strict.
|
|
43
|
+
*
|
|
44
|
+
* @param {string} url
|
|
45
|
+
* @returns {boolean}
|
|
46
|
+
*/
|
|
47
|
+
export function looksLocal(url) {
|
|
48
|
+
let host;
|
|
49
|
+
try {
|
|
50
|
+
host = new URL(url).hostname.toLowerCase();
|
|
51
|
+
} catch {
|
|
52
|
+
// Unparseable URL — treat as suspicious (advisory-strict).
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
if (host === 'localhost' || host === '127.0.0.1' || host === '0.0.0.0' || host === '::1' || host === '[::1]') {
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
// *.local / *.localhost mDNS + dev conventions, and RFC1918 private ranges.
|
|
59
|
+
if (host.endsWith('.local') || host.endsWith('.localhost')) return true;
|
|
60
|
+
if (/^10\./.test(host) || /^192\.168\./.test(host)) return true;
|
|
61
|
+
if (/^172\.(1[6-9]|2\d|3[01])\./.test(host)) return true;
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Deployed reachability gate (spec §4). Only consulted for
|
|
67
|
+
* profile === 'deployed'. `looksLocal` is derived purely; `reachable` is
|
|
68
|
+
* probed over the wire via an injectable fetch (unit tests fake it — no real
|
|
69
|
+
* network). Advisory-strict: any probe failure yields reachable:false, which
|
|
70
|
+
* routes the run to NOT-DEPLOYED rather than a false SHIP.
|
|
71
|
+
*
|
|
72
|
+
* @param {string} url
|
|
73
|
+
* @param {{ fetchImpl?: typeof fetch, timeoutMs?: number }} [deps]
|
|
74
|
+
* @returns {Promise<{ reachable: boolean, looksLocal: boolean }>}
|
|
75
|
+
*/
|
|
76
|
+
export async function checkDeployedReachability(url, deps = {}) {
|
|
77
|
+
const local = looksLocal(url);
|
|
78
|
+
const fetchImpl = deps.fetchImpl ?? globalThis.fetch;
|
|
79
|
+
const timeoutMs = deps.timeoutMs ?? 5000;
|
|
80
|
+
let reachable = false;
|
|
81
|
+
if (typeof fetchImpl === 'function') {
|
|
82
|
+
const controller = new AbortController();
|
|
83
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
84
|
+
try {
|
|
85
|
+
const res = await fetchImpl(url, { method: 'HEAD', redirect: 'manual', signal: controller.signal });
|
|
86
|
+
// Any HTTP response (even 4xx/5xx/3xx) proves something is listening at
|
|
87
|
+
// the wire address. A thrown/aborted probe means unreachable.
|
|
88
|
+
reachable = Boolean(res && typeof res.status === 'number');
|
|
89
|
+
} catch {
|
|
90
|
+
reachable = false;
|
|
91
|
+
} finally {
|
|
92
|
+
clearTimeout(timer);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return { reachable, looksLocal: local };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Whether a profile+parity combination carries ship authority (spec §4).
|
|
100
|
+
* `deployed` is always a ship gate; a non-deployed profile is a ship gate
|
|
101
|
+
* ONLY with an explicit `--parity-env` operator assertion. Everything else is
|
|
102
|
+
* a correctness/regression verdict.
|
|
103
|
+
*
|
|
104
|
+
* @param {string} profile
|
|
105
|
+
* @param {boolean} parityEnv
|
|
106
|
+
* @returns {'ship' | 'correctness'}
|
|
107
|
+
*/
|
|
108
|
+
export function verdictAuthorityFor(profile, parityEnv) {
|
|
109
|
+
if (profile === 'deployed') return 'ship';
|
|
110
|
+
if (parityEnv && (profile === 'ci' || profile === 'local-dev')) return 'ship';
|
|
111
|
+
return 'correctness';
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Whether a `deployed` run must refuse to issue a verdict (NOT-DEPLOYED).
|
|
116
|
+
* Only meaningful for profile === 'deployed'. Errs toward refusal.
|
|
117
|
+
*
|
|
118
|
+
* @param {string} profile
|
|
119
|
+
* @param {{ reachable: boolean, looksLocal: boolean }} reachability
|
|
120
|
+
* @returns {boolean}
|
|
121
|
+
*/
|
|
122
|
+
export function isNotDeployed(profile, reachability) {
|
|
123
|
+
if (profile !== 'deployed') return false;
|
|
124
|
+
if (!reachability) return true;
|
|
125
|
+
return reachability.looksLocal === true || reachability.reachable === false;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Stamp runtime provenance onto a verdict object (spec §4, §5.3). Returns a
|
|
130
|
+
* new object; does not mutate the input.
|
|
131
|
+
*
|
|
132
|
+
* @param {object} verdict
|
|
133
|
+
* @param {{ profile: string, url: string, parityEnv: boolean, reachability?: object }} ctx
|
|
134
|
+
* @returns {object}
|
|
135
|
+
*/
|
|
136
|
+
export function stampProvenance(verdict, ctx) {
|
|
137
|
+
return {
|
|
138
|
+
...verdict,
|
|
139
|
+
provenance: {
|
|
140
|
+
profile: ctx.profile,
|
|
141
|
+
url: ctx.url,
|
|
142
|
+
parityEnv: Boolean(ctx.parityEnv),
|
|
143
|
+
reachability: ctx.reachability ?? null,
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
}
|