@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
package/src/cli/mcp.js
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// `rcf-verify mcp` subcommand (spec §10 MCP; mirrors build's cli/mcp.js).
|
|
2
|
+
// Wires process stdin/stdout/stderr into the core MCP protocol shell and
|
|
3
|
+
// serves the verify tool registry. stdout carries MCP messages and NOTHING
|
|
4
|
+
// else; all logging goes to stderr.
|
|
5
|
+
|
|
6
|
+
import { readFile } from 'node:fs/promises';
|
|
7
|
+
import { dirname, resolve as resolvePath } from 'node:path';
|
|
8
|
+
import { parseArgs } from 'node:util';
|
|
9
|
+
import { fileURLToPath } from 'node:url';
|
|
10
|
+
|
|
11
|
+
import { createMcpServer, serveStreams } from '@stravica-ai/rcf-lite-core/mcp-shell';
|
|
12
|
+
|
|
13
|
+
import { createToolRegistry } from '../mcp/tools.js';
|
|
14
|
+
|
|
15
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
16
|
+
|
|
17
|
+
export const HELP = `Usage: rcf-verify mcp [options]
|
|
18
|
+
|
|
19
|
+
Serve verify over the Model Context Protocol (local stdio). Exposes one tool,
|
|
20
|
+
rcf_verify_run, over the same in-process engine the CLI uses. stdout carries
|
|
21
|
+
protocol messages only; logging goes to stderr.
|
|
22
|
+
|
|
23
|
+
Options:
|
|
24
|
+
--verbose Per-request logging on stderr
|
|
25
|
+
--help Print this help
|
|
26
|
+
`;
|
|
27
|
+
|
|
28
|
+
const INSTRUCTIONS = 'Fresh-context adversarial verifier for RCF chains. rcf_verify_run takes an RCF '
|
|
29
|
+
+ 'chain (the acceptance contract) and a running app under a declared runtime profile, launches an '
|
|
30
|
+
+ 'isolated verifier agent that tries to DISPROVE the app against its acceptance criteria, and '
|
|
31
|
+
+ 'returns a structured verdict stamped with the runtime it ran against. Only a "deployed" profile '
|
|
32
|
+
+ '(or a declared --parity-env runtime) yields a SHIP verdict. This is an independent ship-readiness '
|
|
33
|
+
+ 'signal, not a correctness guarantee.';
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @param {string[]} argv - argv slice after `mcp`
|
|
37
|
+
* @param {object} [deps]
|
|
38
|
+
* @returns {Promise<number>}
|
|
39
|
+
*/
|
|
40
|
+
export async function main(argv, deps = {}) {
|
|
41
|
+
const stdout = deps.stdout ?? process.stdout;
|
|
42
|
+
const stderr = deps.stderr ?? process.stderr;
|
|
43
|
+
const stdin = deps.stdin ?? process.stdin;
|
|
44
|
+
const onSignal = deps.onSignal ?? ((sig, fn) => process.on(sig, fn));
|
|
45
|
+
|
|
46
|
+
let parsed;
|
|
47
|
+
try {
|
|
48
|
+
parsed = parseArgs({ args: argv, options: { verbose: { type: 'boolean' }, help: { type: 'boolean' } }, allowPositionals: false, strict: true });
|
|
49
|
+
} catch (err) {
|
|
50
|
+
stderr.write(`[error] usage ${err.message}\n`);
|
|
51
|
+
stderr.write(HELP);
|
|
52
|
+
return 2;
|
|
53
|
+
}
|
|
54
|
+
if (parsed.values.help) {
|
|
55
|
+
stdout.write(HELP);
|
|
56
|
+
return 0;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const verbose = Boolean(parsed.values.verbose);
|
|
60
|
+
const log = {
|
|
61
|
+
info: verbose ? (line) => stderr.write(`[rcf-verify mcp] ${line}\n`) : () => {},
|
|
62
|
+
error: (line) => stderr.write(`${line}\n`),
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const tools = createToolRegistry(deps);
|
|
66
|
+
|
|
67
|
+
const server = createMcpServer({
|
|
68
|
+
serverInfo: { name: 'rcf-verify-lite', version: await readPackageVersion() },
|
|
69
|
+
instructions: INSTRUCTIONS,
|
|
70
|
+
capabilities: { tools: {} },
|
|
71
|
+
handlers: {
|
|
72
|
+
'tools/list': async () => ({ tools: tools.definitions }),
|
|
73
|
+
'tools/call': async (params) => {
|
|
74
|
+
log.info(`tools/call ${params.name}`);
|
|
75
|
+
return await tools.call(params.name, params.arguments);
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
log,
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
stderr.write('rcf-verify mcp: serving\n');
|
|
82
|
+
|
|
83
|
+
const { done, stop } = serveStreams(server, { input: stdin, output: stdout, log });
|
|
84
|
+
onSignal('SIGINT', () => { log.info('received SIGINT, shutting down'); stop(); });
|
|
85
|
+
onSignal('SIGTERM', () => { log.info('received SIGTERM, shutting down'); stop(); });
|
|
86
|
+
|
|
87
|
+
return await done;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function readPackageVersion() {
|
|
91
|
+
try {
|
|
92
|
+
const pkgPath = resolvePath(here, '..', '..', 'package.json');
|
|
93
|
+
const pkg = JSON.parse(await readFile(pkgPath, 'utf8'));
|
|
94
|
+
return pkg.version ?? '0.0.0';
|
|
95
|
+
} catch {
|
|
96
|
+
return '0.0.0';
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// `rcf-verify provision` (spec §3, §6). Stand up prerequisites standalone and
|
|
2
|
+
// write credentials to the --provision file. Derives what to provision from
|
|
3
|
+
// the chain's ACs (needs --repo). The concrete signup route is app-specific
|
|
4
|
+
// and agent-driven; where it can't be derived, this BLOCKS honestly rather
|
|
5
|
+
// than silently skipping (§6).
|
|
6
|
+
|
|
7
|
+
import { parseArgs } from 'node:util';
|
|
8
|
+
|
|
9
|
+
import { formatError, isRcfError } from '@stravica-ai/rcf-lite-core/errors';
|
|
10
|
+
|
|
11
|
+
import { readChain } from '../chain/index.js';
|
|
12
|
+
import { runProvisioning } from '../provision/index.js';
|
|
13
|
+
|
|
14
|
+
export const HELP = `Usage: rcf-verify provision --repo <path> --url <app-url> --provision <file>
|
|
15
|
+
|
|
16
|
+
Stand up prerequisite accounts / sandboxes / seed data for a later run.
|
|
17
|
+
Credentials are written to the --provision FILE only, never echoed.
|
|
18
|
+
|
|
19
|
+
Required:
|
|
20
|
+
--repo <path> RCF chain source (to derive what to provision)
|
|
21
|
+
--url <app-url> The running app
|
|
22
|
+
--provision <file> Where to write provisioned credentials
|
|
23
|
+
|
|
24
|
+
Exit codes:
|
|
25
|
+
0 provisioning attempted; see the report of what was provisioned/blocked
|
|
26
|
+
2 usage error
|
|
27
|
+
3 chain could not be loaded
|
|
28
|
+
`;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @param {string[]} argv
|
|
32
|
+
* @param {object} [deps]
|
|
33
|
+
* @returns {Promise<number>}
|
|
34
|
+
*/
|
|
35
|
+
export async function main(argv, deps = {}) {
|
|
36
|
+
const stdout = deps.stdout ?? process.stdout;
|
|
37
|
+
const stderr = deps.stderr ?? process.stderr;
|
|
38
|
+
|
|
39
|
+
let parsed;
|
|
40
|
+
try {
|
|
41
|
+
parsed = parseArgs({ args: argv, options: { repo: { type: 'string' }, url: { type: 'string' }, provision: { type: 'string' }, help: { type: 'boolean' } }, allowPositionals: false, strict: true });
|
|
42
|
+
} catch (err) {
|
|
43
|
+
stderr.write(`[error] usage ${err.message}\n`);
|
|
44
|
+
stderr.write(HELP);
|
|
45
|
+
return 2;
|
|
46
|
+
}
|
|
47
|
+
const flags = parsed.values;
|
|
48
|
+
if (flags.help) { stdout.write(HELP); return 0; }
|
|
49
|
+
if (!flags.repo || !flags.url || !flags.provision) {
|
|
50
|
+
stderr.write('[error] usage provision requires --repo, --url and --provision\n');
|
|
51
|
+
stderr.write(HELP);
|
|
52
|
+
return 2;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const chain = await (deps.readChain ?? readChain)({ repo: flags.repo });
|
|
56
|
+
if (isRcfError(chain)) {
|
|
57
|
+
stderr.write(`${formatError(chain, { verbose: true })}\n`);
|
|
58
|
+
return 3;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const { provisioning } = await runProvisioning({
|
|
62
|
+
acs: chain.acs,
|
|
63
|
+
url: flags.url,
|
|
64
|
+
provisionPath: flags.provision,
|
|
65
|
+
mode: 'run',
|
|
66
|
+
signup: deps.signup,
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
stdout.write(`${JSON.stringify(provisioning, null, 2)}\n`);
|
|
70
|
+
return 0;
|
|
71
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// `rcf-verify report <report-path>` (spec §3, parallels `rcf view`).
|
|
2
|
+
// Re-render / summarise a prior report artifact using verify's own
|
|
3
|
+
// self-contained finding-renderer (§7.2 — no core view dependency).
|
|
4
|
+
|
|
5
|
+
import { readFile } from 'node:fs/promises';
|
|
6
|
+
import { resolve as resolvePath } from 'node:path';
|
|
7
|
+
import { parseArgs } from 'node:util';
|
|
8
|
+
|
|
9
|
+
import { formatError, isRcfError } from '@stravica-ai/rcf-lite-core/errors';
|
|
10
|
+
|
|
11
|
+
import { parseReport } from '../report/index.js';
|
|
12
|
+
import { renderReport } from '../report/renderer.js';
|
|
13
|
+
|
|
14
|
+
export const HELP = `Usage: rcf-verify report <report-path> [--json]
|
|
15
|
+
|
|
16
|
+
Re-render a prior report artifact. Default output is the human-readable
|
|
17
|
+
summary; --json re-emits the parsed artifact as JSON.
|
|
18
|
+
|
|
19
|
+
Exit codes:
|
|
20
|
+
0 rendered
|
|
21
|
+
2 usage error (no path)
|
|
22
|
+
3 the report could not be read / parsed / is an unsupported schema
|
|
23
|
+
`;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @param {string[]} argv - argv slice after `report`
|
|
27
|
+
* @param {object} [deps]
|
|
28
|
+
* @returns {Promise<number>}
|
|
29
|
+
*/
|
|
30
|
+
export async function main(argv, deps = {}) {
|
|
31
|
+
const stdout = deps.stdout ?? process.stdout;
|
|
32
|
+
const stderr = deps.stderr ?? process.stderr;
|
|
33
|
+
const cwd = deps.cwd ?? process.cwd();
|
|
34
|
+
|
|
35
|
+
let parsed;
|
|
36
|
+
try {
|
|
37
|
+
parsed = parseArgs({ args: argv, options: { json: { type: 'boolean' }, help: { type: 'boolean' } }, allowPositionals: true, strict: true });
|
|
38
|
+
} catch (err) {
|
|
39
|
+
stderr.write(`[error] usage ${err.message}\n`);
|
|
40
|
+
stderr.write(HELP);
|
|
41
|
+
return 2;
|
|
42
|
+
}
|
|
43
|
+
if (parsed.values.help) {
|
|
44
|
+
stdout.write(HELP);
|
|
45
|
+
return 0;
|
|
46
|
+
}
|
|
47
|
+
const target = parsed.positionals[0];
|
|
48
|
+
if (!target) {
|
|
49
|
+
stderr.write('[error] usage report: a <report-path> is required\n');
|
|
50
|
+
stderr.write(HELP);
|
|
51
|
+
return 2;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const reader = deps.readFile ?? readFile;
|
|
55
|
+
let raw;
|
|
56
|
+
try {
|
|
57
|
+
raw = await reader(resolvePath(cwd, target), 'utf8');
|
|
58
|
+
} catch (err) {
|
|
59
|
+
stderr.write(`[error] missingFile ${target}: ${err.message}\n`);
|
|
60
|
+
return 3;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const report = parseReport(raw);
|
|
64
|
+
if (isRcfError(report)) {
|
|
65
|
+
stderr.write(`${formatError(report, { verbose: true })}\n`);
|
|
66
|
+
return 3;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
stdout.write(parsed.values.json ? `${JSON.stringify(report, null, 2)}\n` : renderReport(report));
|
|
70
|
+
return 0;
|
|
71
|
+
}
|
package/src/cli/run.js
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// `rcf-verify run` (spec §3 primary verb). Parses flags, runs the
|
|
2
|
+
// verification orchestrator, writes the report artifact (always, regardless of
|
|
3
|
+
// gate — §3 rule 5), and sets the process exit code from the severity gate
|
|
4
|
+
// (§8.2: exit 0 below the gate, non-zero at/above it, and always non-zero for
|
|
5
|
+
// NOT-DEPLOYED / BLOCKED). This is the exact call-path build-lite's finalise
|
|
6
|
+
// step spawns (§8.2).
|
|
7
|
+
|
|
8
|
+
import { writeFile } from 'node:fs/promises';
|
|
9
|
+
import { resolve as resolvePath } from 'node:path';
|
|
10
|
+
import { parseArgs } from 'node:util';
|
|
11
|
+
|
|
12
|
+
import { formatError, isRcfError } from '@stravica-ai/rcf-lite-core/errors';
|
|
13
|
+
|
|
14
|
+
import { runVerification } from '../engine/index.js';
|
|
15
|
+
import { serialiseReport } from '../report/index.js';
|
|
16
|
+
import { gateTripped, FINDING_SEVERITIES } from '../verdict/index.js';
|
|
17
|
+
|
|
18
|
+
const OPTION_SPEC = {
|
|
19
|
+
repo: { type: 'string' },
|
|
20
|
+
chain: { type: 'string' },
|
|
21
|
+
profile: { type: 'string' },
|
|
22
|
+
url: { type: 'string' },
|
|
23
|
+
'parity-env': { type: 'boolean' },
|
|
24
|
+
provision: { type: 'string' },
|
|
25
|
+
out: { type: 'string' },
|
|
26
|
+
'severity-gate': { type: 'string' },
|
|
27
|
+
'provision-mode': { type: 'string' },
|
|
28
|
+
persona: { type: 'string' },
|
|
29
|
+
help: { type: 'boolean' },
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export const HELP = `Usage: rcf-verify run [options]
|
|
33
|
+
|
|
34
|
+
Launch a fresh-context adversarial verifier against a running app and emit a
|
|
35
|
+
structured verdict stamped with the runtime it ran against.
|
|
36
|
+
|
|
37
|
+
Required:
|
|
38
|
+
--repo <path-or-ref> RCF chain source (the acceptance contract)
|
|
39
|
+
--profile <p> Runtime profile: deployed | ci | local-dev
|
|
40
|
+
--url <app-url> The running app for this profile
|
|
41
|
+
--out <report-path> Where to write the structured report artifact
|
|
42
|
+
|
|
43
|
+
Optional:
|
|
44
|
+
--chain <PRD/ref> Which PRD/chain to verify against (default: the repo's)
|
|
45
|
+
--parity-env Assert this ci/local-dev runtime is edge-identical
|
|
46
|
+
to prod — the only path to a SHIP verdict from a
|
|
47
|
+
non-deployed profile. Logged in the report.
|
|
48
|
+
--provision <path> Provisioning spec / prerequisite credentials FILE
|
|
49
|
+
(never inline; secrets never echoed)
|
|
50
|
+
--severity-gate <sev> Exit non-zero at/above this severity:
|
|
51
|
+
PASS | COSMETIC | DEGRADED | BROKEN
|
|
52
|
+
--provision-mode <m> run | skip (default: run)
|
|
53
|
+
--persona <name> Adversarial persona flavour (default: generic-sceptic)
|
|
54
|
+
--help Print this help
|
|
55
|
+
|
|
56
|
+
Exit codes:
|
|
57
|
+
0 report written, verdict below the severity gate
|
|
58
|
+
1 IO / unexpected runtime failure (e.g. the report could not be written)
|
|
59
|
+
2 usage error (missing/invalid flags)
|
|
60
|
+
3 chain could not be loaded (the acceptance contract is unreadable)
|
|
61
|
+
5 severity gate tripped, or NOT-DEPLOYED / BLOCKED / LAUNCH-FAILURE
|
|
62
|
+
(ship cannot be confirmed — a report is still written)
|
|
63
|
+
`;
|
|
64
|
+
|
|
65
|
+
function exitForError(err) {
|
|
66
|
+
switch (err.kind) {
|
|
67
|
+
case 'usage': return 2;
|
|
68
|
+
case 'missingFile':
|
|
69
|
+
case 'parseFailure':
|
|
70
|
+
case 'validation': return 3;
|
|
71
|
+
default: return 1; // ioFailure + anything unexpected
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* @param {string[]} argv - argv slice after `run`
|
|
77
|
+
* @param {object} [deps]
|
|
78
|
+
* @returns {Promise<number>}
|
|
79
|
+
*/
|
|
80
|
+
export async function main(argv, deps = {}) {
|
|
81
|
+
const stdout = deps.stdout ?? process.stdout;
|
|
82
|
+
const stderr = deps.stderr ?? process.stderr;
|
|
83
|
+
const cwd = deps.cwd ?? process.cwd();
|
|
84
|
+
|
|
85
|
+
let parsed;
|
|
86
|
+
try {
|
|
87
|
+
parsed = parseArgs({ args: argv, options: OPTION_SPEC, allowPositionals: false, strict: true });
|
|
88
|
+
} catch (err) {
|
|
89
|
+
stderr.write(`[error] usage ${err.message}\n`);
|
|
90
|
+
stderr.write(HELP);
|
|
91
|
+
return 2;
|
|
92
|
+
}
|
|
93
|
+
const flags = parsed.values;
|
|
94
|
+
if (flags.help) {
|
|
95
|
+
stdout.write(HELP);
|
|
96
|
+
return 0;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const gate = flags['severity-gate'];
|
|
100
|
+
if (gate && !FINDING_SEVERITIES.includes(gate)) {
|
|
101
|
+
stderr.write(`[error] usage --severity-gate must be one of ${FINDING_SEVERITIES.join(' | ')}\n`);
|
|
102
|
+
return 2;
|
|
103
|
+
}
|
|
104
|
+
const provisionMode = flags['provision-mode'] ?? 'run';
|
|
105
|
+
if (provisionMode !== 'run' && provisionMode !== 'skip') {
|
|
106
|
+
stderr.write('[error] usage --provision-mode must be run | skip\n');
|
|
107
|
+
return 2;
|
|
108
|
+
}
|
|
109
|
+
if (!flags.out) {
|
|
110
|
+
stderr.write('[error] usage --out <report-path> is required\n');
|
|
111
|
+
return 2;
|
|
112
|
+
}
|
|
113
|
+
// Dash-footgun (§3 rule 3, mirrors build's --body-file discipline): a
|
|
114
|
+
// --provision value that looks like a flag is almost always a swallowed next
|
|
115
|
+
// option or an attempt to pass a credential inline. Refuse it — credentials
|
|
116
|
+
// are FILE-only, never inline, never echoed.
|
|
117
|
+
if (typeof flags.provision === 'string' && flags.provision.startsWith('-')) {
|
|
118
|
+
stderr.write('[error] usage --provision takes a file path, not a flag or inline value (credentials are never accepted inline)\n');
|
|
119
|
+
return 2;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const result = await runVerification({
|
|
123
|
+
repo: flags.repo,
|
|
124
|
+
chainRef: flags.chain,
|
|
125
|
+
profile: flags.profile,
|
|
126
|
+
url: flags.url,
|
|
127
|
+
parityEnv: Boolean(flags['parity-env']),
|
|
128
|
+
provision: flags.provision,
|
|
129
|
+
provisionMode,
|
|
130
|
+
persona: flags.persona,
|
|
131
|
+
severityGate: gate,
|
|
132
|
+
}, deps);
|
|
133
|
+
|
|
134
|
+
if (isRcfError(result)) {
|
|
135
|
+
stderr.write(`${formatError(result, { verbose: true })}\n`);
|
|
136
|
+
return exitForError(result);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const { report } = result;
|
|
140
|
+
|
|
141
|
+
// The report is written ALWAYS, regardless of the gate (§3 rule 5).
|
|
142
|
+
const outPath = resolvePath(cwd, flags.out);
|
|
143
|
+
const writer = deps.writeFile ?? writeFile;
|
|
144
|
+
try {
|
|
145
|
+
await writer(outPath, serialiseReport(report), 'utf8');
|
|
146
|
+
} catch (err) {
|
|
147
|
+
stderr.write(`[rcf-verify] unexpected failure: could not write report: ${err.message}\n`);
|
|
148
|
+
return 1;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
stderr.write(`[rcf-verify] verdict ${report.verdict} [${report.verdictAuthority}] -> ${flags.out}\n`);
|
|
152
|
+
|
|
153
|
+
// Exit code is the gate (§8.2). NOT-DEPLOYED / BLOCKED always trip.
|
|
154
|
+
return gateTripped({ verdict: report.verdict, findings: report.findings, gate }) ? 5 : 0;
|
|
155
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Adversarial brief composition (spec §5, §9 guarantee 4). Verify is thin: it
|
|
2
|
+
// reads the chain and composes an adversarial brief from the ACs, then hands
|
|
3
|
+
// that brief to a fresh isolated agent (engine/launcher.js). The stance is
|
|
4
|
+
// DISPROOF — an adversarial walk derived independently from the ACs does not
|
|
5
|
+
// inherit the build's framing. The brief NEVER references the source tree, the
|
|
6
|
+
// build transcript, or a "this was verified" claim (§9 guarantee 1-2).
|
|
7
|
+
|
|
8
|
+
/** Default adversarial persona flavour (spec §3 --persona default). */
|
|
9
|
+
export const DEFAULT_PERSONA = 'generic-sceptic';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Compose the adversarial brief the verifier agent is launched with. The brief
|
|
13
|
+
* is built purely from the acceptance contract (ACs) + the live URL — the only
|
|
14
|
+
* two inputs (§9 guarantee 2). Returned as structured data so tests can assert
|
|
15
|
+
* the ACs drove it and no build context leaked in.
|
|
16
|
+
*
|
|
17
|
+
* @param {object} opts
|
|
18
|
+
* @param {Array<object>} opts.acs - flattened ACs from the chain
|
|
19
|
+
* @param {string} opts.url - the running app under test
|
|
20
|
+
* @param {string} [opts.persona]
|
|
21
|
+
* @param {string} [opts.chainRef]
|
|
22
|
+
* @returns {{ persona: string, url: string, chainRef: string, stance: string, acCount: number, journeys: object[], instructions: string }}
|
|
23
|
+
*/
|
|
24
|
+
export function composeBrief({ acs = [], url, persona = DEFAULT_PERSONA, chainRef } = {}) {
|
|
25
|
+
const testable = acs.filter((ac) => ac.testable !== false);
|
|
26
|
+
const journeys = testable.map((ac) => ({
|
|
27
|
+
acId: ac.acId,
|
|
28
|
+
usId: ac.usId,
|
|
29
|
+
journey: ac.title || ac.usId || ac.acId,
|
|
30
|
+
given: ac.given,
|
|
31
|
+
when: ac.when,
|
|
32
|
+
then: ac.then,
|
|
33
|
+
// The disproof prompt for THIS criterion: try to make `then` false.
|
|
34
|
+
disprove: `Attempt to make the app FAIL "${ac.then}" starting from "${ac.given}" by doing "${ac.when}", and adversarial variations of it.`,
|
|
35
|
+
}));
|
|
36
|
+
|
|
37
|
+
const instructions = [
|
|
38
|
+
'You are an adversarial verifier. Your job is to DISPROVE the application against its acceptance criteria, not to confirm it works.',
|
|
39
|
+
'You have NOT seen how this app was built, its source, or any claim that it was verified. Judge only the running app against the contract below.',
|
|
40
|
+
`Drive the running app at ${url} through each journey using your browser tooling. For each acceptance criterion, actively try to break it: edge inputs, boundary conditions, isolation between users, error paths, and the exact security/quality floors the criterion promises.`,
|
|
41
|
+
'For every defect, record: the acId it maps to, the journey, exact reproduction steps against the live URL, and evidence (screenshot path, response body, or runtime error).',
|
|
42
|
+
'Classify each finding: BROKEN (a journey is dead or wrong), DEGRADED (works but a criterion is materially weakened / a false promise / a missed floor), or COSMETIC (hygiene, no AC touched). Report PASS only for criteria you actively tried and could not break.',
|
|
43
|
+
'Do NOT claim the app is "fully verified" or "safe" — you are producing an independent ship-readiness signal, not a correctness guarantee.',
|
|
44
|
+
'',
|
|
45
|
+
'OUTPUT CONTRACT (mandatory). After any analysis, the FINAL thing you emit must be a single JSON object of exactly this shape, and nothing after it:',
|
|
46
|
+
'{"findings":[{"severity":"BROKEN|DEGRADED|COSMETIC|PASS","acId":"<AC id>","journey":"<journey name>","reproSteps":["..."],"evidence":{"kind":"<http_response|runtimeError|screenshot|note>","detail":"..."}}]}',
|
|
47
|
+
'Emit exactly one finding per acceptance criterion (a PASS finding for any you tried and could not break). The object must be valid JSON on its own — the harness extracts the last {"findings":[...]} object from your reply, so make sure your final object is complete and well-formed.',
|
|
48
|
+
].join('\n');
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
persona,
|
|
52
|
+
url,
|
|
53
|
+
chainRef: chainRef ?? 'PRD-UNKNOWN',
|
|
54
|
+
stance: 'disprove',
|
|
55
|
+
acCount: testable.length,
|
|
56
|
+
journeys,
|
|
57
|
+
instructions,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// Verification orchestrator (spec §5, §8.2). Ties the pieces together in the
|
|
2
|
+
// exact order build-lite's finalise step would drive them: resolve the runtime
|
|
3
|
+
// profile → read the acceptance contract off the chain → (deployed) gate on
|
|
4
|
+
// reachability → provision prerequisites → compose the adversarial brief →
|
|
5
|
+
// launch the isolated verifier agent → validate/aggregate/stamp the verdict →
|
|
6
|
+
// build the ingestible report. Every failure is returned as data (RcfError),
|
|
7
|
+
// never a fabricated PASS (§9 false-confidence prohibition).
|
|
8
|
+
|
|
9
|
+
import { rcfError, isRcfError } from '@stravica-ai/rcf-lite-core/errors';
|
|
10
|
+
import { isolationProvenance } from '@stravica-ai/rcf-lite-core/isolation';
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
resolveProfile,
|
|
14
|
+
checkDeployedReachability,
|
|
15
|
+
isNotDeployed,
|
|
16
|
+
verdictAuthorityFor,
|
|
17
|
+
} from '../profile/index.js';
|
|
18
|
+
import { readChain as defaultReadChain } from '../chain/index.js';
|
|
19
|
+
import { runProvisioning, cleanup as defaultCleanup } from '../provision/index.js';
|
|
20
|
+
import { composeBrief } from './brief.js';
|
|
21
|
+
import { resolveLauncher } from './launcher.js';
|
|
22
|
+
import { aggregateVerdict, validateFinding } from '../verdict/index.js';
|
|
23
|
+
import { buildReport } from '../report/index.js';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Validate + normalise the raw findings an agent returned. A malformed finding
|
|
27
|
+
* is an error-as-data, not a silent drop — the report contract (§5.2) requires
|
|
28
|
+
* every finding to be chain-node-addressed.
|
|
29
|
+
*
|
|
30
|
+
* @param {unknown} rawFindings
|
|
31
|
+
* @returns {{ findings: object[] } | import('@stravica-ai/rcf-lite-core/errors').RcfError}
|
|
32
|
+
*/
|
|
33
|
+
export function normaliseFindings(rawFindings) {
|
|
34
|
+
if (!Array.isArray(rawFindings)) {
|
|
35
|
+
return rcfError({ kind: 'validation', message: 'verifier agent must return a findings array' });
|
|
36
|
+
}
|
|
37
|
+
const findings = [];
|
|
38
|
+
for (const raw of rawFindings) {
|
|
39
|
+
const err = validateFinding(raw);
|
|
40
|
+
if (err) return err;
|
|
41
|
+
findings.push({
|
|
42
|
+
severity: raw.severity,
|
|
43
|
+
acId: raw.acId,
|
|
44
|
+
journey: raw.journey,
|
|
45
|
+
reproSteps: raw.reproSteps,
|
|
46
|
+
evidence: raw.evidence,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
return { findings };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Run a full verification. Returns `{ report }` on completion (including the
|
|
54
|
+
* NOT-DEPLOYED / BLOCKED refusals, which are legitimate reports), or an
|
|
55
|
+
* RcfError for usage / chain-load / agent failures the CLI maps to a non-zero
|
|
56
|
+
* exit.
|
|
57
|
+
*
|
|
58
|
+
* @param {object} opts - the CLI-parsed run options
|
|
59
|
+
* @param {object} [deps] - injectable seams (launchAgent, fetchImpl, signup, teardown, now, readChain)
|
|
60
|
+
* @returns {Promise<{ report: object } | import('@stravica-ai/rcf-lite-core/errors').RcfError>}
|
|
61
|
+
*/
|
|
62
|
+
export async function runVerification(opts = {}, deps = {}) {
|
|
63
|
+
const now = deps.now ?? (() => new Date().toISOString());
|
|
64
|
+
const readChain = deps.readChain ?? defaultReadChain;
|
|
65
|
+
const startedAt = now();
|
|
66
|
+
|
|
67
|
+
// 1. Resolve + validate the runtime declaration (§4).
|
|
68
|
+
const resolved = resolveProfile({ profile: opts.profile, url: opts.url, parityEnv: opts.parityEnv });
|
|
69
|
+
if (isRcfError(resolved)) return resolved;
|
|
70
|
+
const { profile, url, parityEnv } = resolved;
|
|
71
|
+
|
|
72
|
+
// 2. Read the acceptance contract off the chain (the ONLY structural input, §9).
|
|
73
|
+
const chain = await readChain({ repo: opts.repo, chainRef: opts.chainRef });
|
|
74
|
+
if (isRcfError(chain)) return chain;
|
|
75
|
+
|
|
76
|
+
const verdictAuthority = verdictAuthorityFor(profile, parityEnv);
|
|
77
|
+
|
|
78
|
+
// 3. Deployed reachability gate (§4). Only consulted for profile==='deployed'.
|
|
79
|
+
let reachability = null;
|
|
80
|
+
if (profile === 'deployed') {
|
|
81
|
+
reachability = await checkDeployedReachability(url, { fetchImpl: deps.fetchImpl });
|
|
82
|
+
if (isNotDeployed(profile, reachability)) {
|
|
83
|
+
// Refusal to issue a verdict — never a soft pass (§4, §5.1).
|
|
84
|
+
const report = buildReport({
|
|
85
|
+
profile, url, parityEnv, reachability, chainRef: chain.chainRef, repo: opts.repo,
|
|
86
|
+
persona: opts.persona, startedAt, finishedAt: now(),
|
|
87
|
+
verifierIsolation: isolationProvenance(),
|
|
88
|
+
verdict: 'NOT-DEPLOYED', verdictAuthority,
|
|
89
|
+
findings: [], blockedAcs: [], provisioning: null,
|
|
90
|
+
});
|
|
91
|
+
return { report };
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// 4. Provision prerequisites (§6). Unprovisionable → BLOCKED (named), dependent ACs BLOCKED.
|
|
96
|
+
const { provisioning, blockedAcs } = await runProvisioning({
|
|
97
|
+
acs: chain.acs,
|
|
98
|
+
url,
|
|
99
|
+
provisionPath: opts.provision,
|
|
100
|
+
mode: opts.provisionMode ?? 'run',
|
|
101
|
+
signup: deps.signup,
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// 5. Compose the adversarial brief from the chain ACs (§5, §9 guarantee 4).
|
|
105
|
+
const brief = composeBrief({ acs: chain.acs, url, persona: opts.persona, chainRef: chain.chainRef });
|
|
106
|
+
|
|
107
|
+
// Cleanup runs whether or not the launch succeeds — provisioned artefacts
|
|
108
|
+
// must not be orphaned by a launch failure (§6 cleanup contract).
|
|
109
|
+
const runCleanup = async () => {
|
|
110
|
+
const cleanupFn = deps.cleanup ?? defaultCleanup;
|
|
111
|
+
const cleanupResult = await cleanupFn({ provisioned: provisioning.provisioned, teardown: deps.teardown });
|
|
112
|
+
provisioning.cleanupRan = cleanupResult.cleanupRan;
|
|
113
|
+
provisioning.cleanupRemoved = cleanupResult.cleanupRemoved;
|
|
114
|
+
if (cleanupResult.cleanupBlocked?.length) provisioning.cleanupBlocked = cleanupResult.cleanupBlocked;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
// 6. Launch the isolated verifier agent (§7.3 isolation env, §9 fresh session).
|
|
118
|
+
let launchResult;
|
|
119
|
+
try {
|
|
120
|
+
const launchAgent = await resolveLauncher(deps);
|
|
121
|
+
launchResult = await launchAgent({ brief, url, profile });
|
|
122
|
+
} catch (err) {
|
|
123
|
+
// A verifier agent that could not run — or whose output could not be
|
|
124
|
+
// ingested — is NEVER a fabricated PASS (§9). But the report is still
|
|
125
|
+
// build-lite's next input (§5.4, §10 --out-always-written), so we build a
|
|
126
|
+
// LAUNCH-FAILURE report carrying the error + preserved-transcript path so
|
|
127
|
+
// the fix loop has something to ingest. Exit stays non-zero (gate trips).
|
|
128
|
+
await runCleanup();
|
|
129
|
+
const report = buildReport({
|
|
130
|
+
profile, url, parityEnv, reachability, chainRef: chain.chainRef, repo: opts.repo,
|
|
131
|
+
persona: opts.persona, startedAt, finishedAt: now(),
|
|
132
|
+
verifierIsolation: isolationProvenance(),
|
|
133
|
+
verdict: 'LAUNCH-FAILURE', verdictAuthority,
|
|
134
|
+
findings: [], blockedAcs, provisioning,
|
|
135
|
+
launchFailure: { message: err.message, rawOutputPath: err.rawOutputPath ?? null },
|
|
136
|
+
});
|
|
137
|
+
return { report };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// 7. Validate + stamp the findings (§5.2 chain-node addressing).
|
|
141
|
+
const normalised = normaliseFindings(launchResult?.findings);
|
|
142
|
+
if (isRcfError(normalised)) return normalised;
|
|
143
|
+
const { findings } = normalised;
|
|
144
|
+
|
|
145
|
+
// 8. Cleanup provisioned artefacts (§6 cleanup contract).
|
|
146
|
+
await runCleanup();
|
|
147
|
+
|
|
148
|
+
// 9. Aggregate the verdict — split, never averaged (§5.1).
|
|
149
|
+
const verdict = aggregateVerdict({ findings, blockedAcs, notDeployed: false });
|
|
150
|
+
|
|
151
|
+
// 10. Build the ingestible report (§5.3).
|
|
152
|
+
const report = buildReport({
|
|
153
|
+
profile, url, parityEnv, reachability, chainRef: chain.chainRef, repo: opts.repo,
|
|
154
|
+
persona: opts.persona, startedAt, finishedAt: now(),
|
|
155
|
+
verifierIsolation: isolationProvenance(),
|
|
156
|
+
verdict, verdictAuthority,
|
|
157
|
+
findings, blockedAcs, provisioning,
|
|
158
|
+
runStats: launchResult?.runStats ?? null,
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
return { report };
|
|
162
|
+
}
|