@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,249 @@
|
|
|
1
|
+
// Prerequisite provisioning (spec §6, amendment 2). Adversarial verification
|
|
2
|
+
// usually can't start from nothing. One mechanism, three kinds of
|
|
3
|
+
// prerequisite the chain's ACs may imply: auth accounts (common), third-party
|
|
4
|
+
// service sandboxes/keys, and seeded test data.
|
|
5
|
+
//
|
|
6
|
+
// Hard rules this module enforces (each a §10 acceptance criterion):
|
|
7
|
+
// - the `zzverify-` prefix on ALL provisioned artefacts, so they are
|
|
8
|
+
// greppable and sweepable;
|
|
9
|
+
// - credentials/keys go ONLY to the --provision file, NEVER inline, NEVER
|
|
10
|
+
// echoed to logs or the report body (redactSecrets is the guard);
|
|
11
|
+
// - what cannot be provisioned is BLOCKED (naming the missing prerequisite)
|
|
12
|
+
// and its dependent ACs are marked BLOCKED — never silently skipped;
|
|
13
|
+
// - cleanup removes provisioned artefacts and the report states what it did.
|
|
14
|
+
|
|
15
|
+
import { writeFile, readFile } from 'node:fs/promises';
|
|
16
|
+
|
|
17
|
+
import { rcfError } from '@stravica-ai/rcf-lite-core/errors';
|
|
18
|
+
|
|
19
|
+
/** Greppable prefix on every provisioned artefact (accounts + data alike). */
|
|
20
|
+
export const ZZVERIFY_PREFIX = 'zzverify-';
|
|
21
|
+
|
|
22
|
+
/** v1 auth provisioning stands up at least this many accounts (multi-user isolation journeys). */
|
|
23
|
+
export const MIN_AUTH_ACCOUNTS = 2;
|
|
24
|
+
|
|
25
|
+
/** Field names whose VALUES must never reach a log or the report body. */
|
|
26
|
+
export const SECRET_FIELDS = Object.freeze(['password', 'token', 'secret', 'key', 'apiKey', 'credential', 'cookie']);
|
|
27
|
+
|
|
28
|
+
const AUTH_PATTERNS = /\b(sign[\s-]?in|sign[\s-]?up|log[\s-]?in|logout|register|registration|authenticat|account|password|credential|session|onboard)/i;
|
|
29
|
+
const SERVICE_PATTERNS = /\b(payment|stripe|checkout|billing|sandbox|webhook|third[\s-]?party|external api|e-?mail send|sms|twilio|sendgrid|oauth provider)/i;
|
|
30
|
+
const SEEDDATA_PATTERNS = /\b(admin[\s-]?(created|seeded)|seeded|pre[\s-]?populated|existing (record|row|dataset)|another user|user b\b|other user)/i;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Classify the prerequisite kind an AC implies (or null if none). Matches on
|
|
34
|
+
* the AC's given/when/then/description text. Heuristic — the honest hard part
|
|
35
|
+
* (§6): where the route can't be derived, the caller BLOCKS rather than skips.
|
|
36
|
+
*
|
|
37
|
+
* @param {object} ac - a flattened AC ({acId, description, given, when, then})
|
|
38
|
+
* @returns {'authAccount'|'serviceSandbox'|'seedData'|null}
|
|
39
|
+
*/
|
|
40
|
+
export function classifyPrerequisite(ac) {
|
|
41
|
+
const text = [ac.description, ac.given, ac.when, ac.then, ac.title].filter(Boolean).join(' ');
|
|
42
|
+
if (SERVICE_PATTERNS.test(text)) return 'serviceSandbox';
|
|
43
|
+
if (SEEDDATA_PATTERNS.test(text)) return 'seedData';
|
|
44
|
+
if (AUTH_PATTERNS.test(text)) return 'authAccount';
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Derive the provisioning plan from the chain's ACs: which prerequisite kinds
|
|
50
|
+
* are required, and which ACs depend on each. v1 automates authAccount against
|
|
51
|
+
* signup-exposing apps; serviceSandbox / seedData routes are honestly BLOCKED
|
|
52
|
+
* unless an out-of-band route is supplied (§6 v1 scope note).
|
|
53
|
+
*
|
|
54
|
+
* @param {Array<object>} acs
|
|
55
|
+
* @returns {{ required: string[], acsByKind: Record<string, string[]> }}
|
|
56
|
+
*/
|
|
57
|
+
export function deriveProvisioningPlan(acs = []) {
|
|
58
|
+
const acsByKind = {};
|
|
59
|
+
for (const ac of acs) {
|
|
60
|
+
const kind = classifyPrerequisite(ac);
|
|
61
|
+
if (!kind) continue;
|
|
62
|
+
(acsByKind[kind] ??= []).push(ac.acId);
|
|
63
|
+
}
|
|
64
|
+
return { required: Object.keys(acsByKind), acsByKind };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Deep-redact secret values for anything bound for a log or the report body.
|
|
69
|
+
* Returns a new object; input untouched. Any key in SECRET_FIELDS (case-
|
|
70
|
+
* insensitive) has its value replaced with '[redacted]'.
|
|
71
|
+
*
|
|
72
|
+
* @param {unknown} value
|
|
73
|
+
* @returns {unknown}
|
|
74
|
+
*/
|
|
75
|
+
export function redactSecrets(value) {
|
|
76
|
+
if (Array.isArray(value)) return value.map(redactSecrets);
|
|
77
|
+
if (value && typeof value === 'object') {
|
|
78
|
+
const out = {};
|
|
79
|
+
for (const [k, v] of Object.entries(value)) {
|
|
80
|
+
out[k] = SECRET_FIELDS.some((f) => f.toLowerCase() === k.toLowerCase())
|
|
81
|
+
? '[redacted]'
|
|
82
|
+
: redactSecrets(v);
|
|
83
|
+
}
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
return value;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Write provisioned credentials to the --provision file (spec §3 rule 3, §6).
|
|
91
|
+
* This is the ONLY sink for secrets. Never returns the secrets to the caller
|
|
92
|
+
* for logging.
|
|
93
|
+
*
|
|
94
|
+
* @param {string} provisionPath
|
|
95
|
+
* @param {object} data - full provisioning record including credentials
|
|
96
|
+
* @returns {Promise<import('@stravica-ai/rcf-lite-core/errors').RcfError | null>}
|
|
97
|
+
*/
|
|
98
|
+
export async function writeProvisionFile(provisionPath, data) {
|
|
99
|
+
if (typeof provisionPath !== 'string' || provisionPath.length === 0) {
|
|
100
|
+
return rcfError({ kind: 'usage', message: '--provision requires a file path', field: 'provision' });
|
|
101
|
+
}
|
|
102
|
+
try {
|
|
103
|
+
await writeFile(provisionPath, JSON.stringify(data, null, 2), 'utf8');
|
|
104
|
+
return null;
|
|
105
|
+
} catch (err) {
|
|
106
|
+
return rcfError({ kind: 'ioFailure', message: `failed to write provision file: ${err.message}`, filePath: provisionPath, stack: err.stack });
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Read a provisioning file (credentials/fixtures) back for a run/cleanup.
|
|
112
|
+
*
|
|
113
|
+
* @param {string} provisionPath
|
|
114
|
+
* @returns {Promise<object | import('@stravica-ai/rcf-lite-core/errors').RcfError>}
|
|
115
|
+
*/
|
|
116
|
+
export async function readProvisionFile(provisionPath) {
|
|
117
|
+
try {
|
|
118
|
+
return JSON.parse(await readFile(provisionPath, 'utf8'));
|
|
119
|
+
} catch (err) {
|
|
120
|
+
return rcfError({ kind: 'ioFailure', message: `failed to read provision file: ${err.message}`, filePath: provisionPath, stack: err.stack });
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Provision auth accounts against a signup-exposing app. The actual signup is
|
|
126
|
+
* app-specific and agent-driven — supplied via the injected `signup` function
|
|
127
|
+
* (the seam; §6 "the provisioning route is app-specific"). Without a signup
|
|
128
|
+
* route the whole kind is BLOCKED, honestly.
|
|
129
|
+
*
|
|
130
|
+
* @param {object} opts
|
|
131
|
+
* @param {string} opts.url
|
|
132
|
+
* @param {number} [opts.count]
|
|
133
|
+
* @param {((ctx: {url: string, username: string}) => Promise<object>)} [opts.signup]
|
|
134
|
+
* - returns account credentials; may throw to signal an unprovisionable app
|
|
135
|
+
* @returns {Promise<{ provisioned: object[], blocked: object[], credentials: object[] }>}
|
|
136
|
+
*/
|
|
137
|
+
export async function provisionAuth({ url, count = MIN_AUTH_ACCOUNTS, signup } = {}) {
|
|
138
|
+
const provisioned = [];
|
|
139
|
+
const blocked = [];
|
|
140
|
+
const credentials = [];
|
|
141
|
+
if (typeof signup !== 'function') {
|
|
142
|
+
blocked.push({ kind: 'authAccount', reason: 'cannot provision: app exposes no derivable signup route (invite-only / admin-seeded / out-of-band). See §6 deferred.' });
|
|
143
|
+
return { provisioned, blocked, credentials };
|
|
144
|
+
}
|
|
145
|
+
const suffixes = 'abcdefghijklmnopqrstuvwxyz';
|
|
146
|
+
for (let i = 0; i < Math.max(count, MIN_AUTH_ACCOUNTS); i += 1) {
|
|
147
|
+
const ref = `${ZZVERIFY_PREFIX}${suffixes[i] ?? String(i)}`;
|
|
148
|
+
try {
|
|
149
|
+
const creds = await signup({ url, username: ref });
|
|
150
|
+
provisioned.push({ kind: 'authAccount', ref });
|
|
151
|
+
credentials.push({ ref, ...creds });
|
|
152
|
+
} catch (err) {
|
|
153
|
+
blocked.push({ kind: 'authAccount', reason: `cannot provision ${ref}: ${err.message}` });
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return { provisioned, blocked, credentials };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Run provisioning end-to-end for a `run` (spec §6, run-internalised). Derives
|
|
161
|
+
* the plan, provisions auth where a signup route exists, BLOCKS every other
|
|
162
|
+
* required kind (naming the missing prerequisite), marks dependent ACs as
|
|
163
|
+
* BLOCKED, and writes credentials to the --provision file only.
|
|
164
|
+
*
|
|
165
|
+
* @param {object} opts
|
|
166
|
+
* @param {Array<object>} opts.acs
|
|
167
|
+
* @param {string} opts.url
|
|
168
|
+
* @param {string} [opts.provisionPath]
|
|
169
|
+
* @param {'run'|'skip'} [opts.mode]
|
|
170
|
+
* @param {((ctx: object) => Promise<object>)} [opts.signup] - injected signup route
|
|
171
|
+
* @returns {Promise<{ provisioning: object, blockedAcs: object[] }>}
|
|
172
|
+
*/
|
|
173
|
+
export async function runProvisioning({ acs = [], url, provisionPath, mode = 'run', signup } = {}) {
|
|
174
|
+
const provisioning = { provisioned: [], blocked: [], cleanupRan: false, cleanupRemoved: [] };
|
|
175
|
+
const blockedAcs = [];
|
|
176
|
+
|
|
177
|
+
if (mode === 'skip') return { provisioning, blockedAcs };
|
|
178
|
+
|
|
179
|
+
const plan = deriveProvisioningPlan(acs);
|
|
180
|
+
const allCredentials = [];
|
|
181
|
+
|
|
182
|
+
for (const kind of plan.required) {
|
|
183
|
+
const dependentAcIds = plan.acsByKind[kind] ?? [];
|
|
184
|
+
if (kind === 'authAccount') {
|
|
185
|
+
const { provisioned, blocked, credentials } = await provisionAuth({ url, signup });
|
|
186
|
+
provisioning.provisioned.push(...provisioned);
|
|
187
|
+
provisioning.blocked.push(...blocked);
|
|
188
|
+
allCredentials.push(...credentials);
|
|
189
|
+
// If auth could not be provisioned at all, its dependent ACs are BLOCKED.
|
|
190
|
+
if (provisioned.length === 0) {
|
|
191
|
+
const reason = blocked[0]?.reason ?? 'cannot provision: authAccount';
|
|
192
|
+
for (const acId of dependentAcIds) blockedAcs.push({ acId, reason });
|
|
193
|
+
}
|
|
194
|
+
} else {
|
|
195
|
+
// serviceSandbox / seedData — v1 honest BLOCK unless an out-of-band route exists.
|
|
196
|
+
const reason = `cannot provision: ${kind} (out-of-band route required — §6 v1 deferred)`;
|
|
197
|
+
provisioning.blocked.push({ kind, reason });
|
|
198
|
+
for (const acId of dependentAcIds) blockedAcs.push({ acId, reason });
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Credentials go ONLY to the provision file — never into `provisioning`
|
|
203
|
+
// (which lands in the report body) and never returned for logging.
|
|
204
|
+
if (allCredentials.length > 0 && provisionPath) {
|
|
205
|
+
const writeErr = await writeProvisionFile(provisionPath, {
|
|
206
|
+
schemaVersion: '1',
|
|
207
|
+
url,
|
|
208
|
+
credentials: allCredentials,
|
|
209
|
+
});
|
|
210
|
+
// Surface the write failure as data instead of discarding it: a failed
|
|
211
|
+
// credential write means the run's provisioned accounts are unusable
|
|
212
|
+
// downstream, and silently swallowing it hid a real fault (w-2026-07-21-006).
|
|
213
|
+
// Only the message + path are recorded — never the credentials themselves.
|
|
214
|
+
if (writeErr) {
|
|
215
|
+
provisioning.provisionWriteError = { message: writeErr.message, filePath: writeErr.filePath ?? provisionPath };
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return { provisioning, blockedAcs };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Tear down provisioned artefacts (spec §6 cleanup contract). The actual
|
|
224
|
+
* teardown is app-specific — supplied via the injected `teardown` function.
|
|
225
|
+
* Records what it removed for the report.
|
|
226
|
+
*
|
|
227
|
+
* @param {object} opts
|
|
228
|
+
* @param {object[]} [opts.provisioned] - [{kind, ref}]
|
|
229
|
+
* @param {((ref: string) => Promise<void>)} [opts.teardown]
|
|
230
|
+
* @returns {Promise<{ cleanupRan: boolean, cleanupRemoved: string[], cleanupBlocked: object[] }>}
|
|
231
|
+
*/
|
|
232
|
+
export async function cleanup({ provisioned = [], teardown } = {}) {
|
|
233
|
+
const cleanupRemoved = [];
|
|
234
|
+
const cleanupBlocked = [];
|
|
235
|
+
if (typeof teardown !== 'function') {
|
|
236
|
+
// No teardown route — report honestly rather than claim a clean sweep.
|
|
237
|
+
for (const p of provisioned) cleanupBlocked.push({ ref: p.ref, reason: 'no teardown route supplied' });
|
|
238
|
+
return { cleanupRan: false, cleanupRemoved, cleanupBlocked };
|
|
239
|
+
}
|
|
240
|
+
for (const p of provisioned) {
|
|
241
|
+
try {
|
|
242
|
+
await teardown(p.ref);
|
|
243
|
+
cleanupRemoved.push(p.ref);
|
|
244
|
+
} catch (err) {
|
|
245
|
+
cleanupBlocked.push({ ref: p.ref, reason: err.message });
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return { cleanupRan: true, cleanupRemoved, cleanupBlocked };
|
|
249
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// Report artifact (spec §5.3). The report is NOT a dead-end artifact — it is
|
|
2
|
+
// build-lite's next input (§5.4), so it is emitted chain-node-addressed and
|
|
3
|
+
// camelCase, with `schemaVersion` from day one so build-lite's ingest can
|
|
4
|
+
// version-gate. Credentials never appear in the report body (§10) — the
|
|
5
|
+
// builder passes an already-clean provisioning record, and redactSecrets is a
|
|
6
|
+
// defence-in-depth guard on top.
|
|
7
|
+
|
|
8
|
+
import { rcfError } from '@stravica-ai/rcf-lite-core/errors';
|
|
9
|
+
|
|
10
|
+
import { redactSecrets } from '../provision/index.js';
|
|
11
|
+
import { VERDICTS } from '../verdict/index.js';
|
|
12
|
+
|
|
13
|
+
/** The report schema version. Present from day one for build-lite's version-gated ingest. */
|
|
14
|
+
export const SCHEMA_VERSION = '1';
|
|
15
|
+
|
|
16
|
+
const DEFAULT_PERSONA = 'generic-sceptic';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Assemble the §5.3 report object. Pure — all runtime facts are passed in.
|
|
20
|
+
*
|
|
21
|
+
* @param {object} p
|
|
22
|
+
* @returns {object} the report artifact
|
|
23
|
+
*/
|
|
24
|
+
export function buildReport(p) {
|
|
25
|
+
const report = {
|
|
26
|
+
schemaVersion: SCHEMA_VERSION,
|
|
27
|
+
run: {
|
|
28
|
+
profile: p.profile,
|
|
29
|
+
url: p.url,
|
|
30
|
+
parityEnv: Boolean(p.parityEnv),
|
|
31
|
+
reachability: p.reachability ?? null,
|
|
32
|
+
chainRef: p.chainRef ?? null,
|
|
33
|
+
repo: p.repo ?? null,
|
|
34
|
+
persona: p.persona ?? DEFAULT_PERSONA,
|
|
35
|
+
startedAt: p.startedAt ?? null,
|
|
36
|
+
finishedAt: p.finishedAt ?? null,
|
|
37
|
+
verifierIsolation: p.verifierIsolation ?? { autoMemory: false, nonEssentialTraffic: false },
|
|
38
|
+
// Agent usage/timing from the --output-format json envelope (§5.3, additive).
|
|
39
|
+
// Omit-not-fake: null when the launcher could not report it.
|
|
40
|
+
runStats: p.runStats ?? null,
|
|
41
|
+
},
|
|
42
|
+
verdict: p.verdict,
|
|
43
|
+
verdictAuthority: p.verdictAuthority,
|
|
44
|
+
findings: Array.isArray(p.findings) ? p.findings : [],
|
|
45
|
+
blockedAcs: Array.isArray(p.blockedAcs) ? p.blockedAcs : [],
|
|
46
|
+
provisioning: p.provisioning ?? null,
|
|
47
|
+
// Present (non-null) only on a LAUNCH-FAILURE verdict: the agent could not
|
|
48
|
+
// run or its output could not be ingested. Carries the error + the path to
|
|
49
|
+
// the preserved raw transcript so the §5.4 fix loop has something to ingest.
|
|
50
|
+
launchFailure: p.launchFailure ?? null,
|
|
51
|
+
};
|
|
52
|
+
// Defence-in-depth: never let a secret reach the report body (§10).
|
|
53
|
+
return redactSecrets(report);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Serialise a report to the on-disk artifact string (camelCase JSON).
|
|
58
|
+
*
|
|
59
|
+
* @param {object} report
|
|
60
|
+
* @returns {string}
|
|
61
|
+
*/
|
|
62
|
+
export function serialiseReport(report) {
|
|
63
|
+
return `${JSON.stringify(report, null, 2)}\n`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Parse a report artifact back (for `rcf-verify report`). Errors as data.
|
|
68
|
+
*
|
|
69
|
+
* @param {string} raw
|
|
70
|
+
* @returns {object | import('@stravica-ai/rcf-lite-core/errors').RcfError}
|
|
71
|
+
*/
|
|
72
|
+
export function parseReport(raw) {
|
|
73
|
+
let doc;
|
|
74
|
+
try {
|
|
75
|
+
doc = JSON.parse(raw);
|
|
76
|
+
} catch (err) {
|
|
77
|
+
return rcfError({ kind: 'parseFailure', message: `report is not valid JSON: ${err.message}` });
|
|
78
|
+
}
|
|
79
|
+
const shapeErr = validateReportShape(doc);
|
|
80
|
+
if (shapeErr) return shapeErr;
|
|
81
|
+
return doc;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Minimal shape check on a report artifact.
|
|
86
|
+
*
|
|
87
|
+
* @param {object} doc
|
|
88
|
+
* @returns {import('@stravica-ai/rcf-lite-core/errors').RcfError | null}
|
|
89
|
+
*/
|
|
90
|
+
export function validateReportShape(doc) {
|
|
91
|
+
if (!doc || typeof doc !== 'object') {
|
|
92
|
+
return rcfError({ kind: 'validation', message: 'report must be an object' });
|
|
93
|
+
}
|
|
94
|
+
if (doc.schemaVersion !== SCHEMA_VERSION) {
|
|
95
|
+
return rcfError({ kind: 'validation', message: `unsupported report schemaVersion: ${doc.schemaVersion ?? '(missing)'}`, field: 'schemaVersion' });
|
|
96
|
+
}
|
|
97
|
+
if (!VERDICTS.includes(doc.verdict)) {
|
|
98
|
+
return rcfError({ kind: 'validation', message: `unknown verdict: ${doc.verdict}`, field: 'verdict' });
|
|
99
|
+
}
|
|
100
|
+
if (doc.verdictAuthority !== 'ship' && doc.verdictAuthority !== 'correctness') {
|
|
101
|
+
return rcfError({ kind: 'validation', message: `verdictAuthority must be ship|correctness (got ${doc.verdictAuthority})`, field: 'verdictAuthority' });
|
|
102
|
+
}
|
|
103
|
+
if (!doc.run || typeof doc.run !== 'object') {
|
|
104
|
+
return rcfError({ kind: 'validation', message: 'report.run is required', field: 'run' });
|
|
105
|
+
}
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// Self-contained finding-renderer (spec §5.3, §7.2). v1 core is store + errors
|
|
2
|
+
// + mcp-shell ONLY — the build `view/` layer is NOT a clean shell and is
|
|
3
|
+
// firmly OUT of v1 core. So verify ships its OWN minimal, dependency-free human
|
|
4
|
+
// render for `rcf-verify report`. Plain text, British English, ASCII hyphens —
|
|
5
|
+
// mirrors build's help-surface conventions without importing any core view code.
|
|
6
|
+
//
|
|
7
|
+
// Honest-limit language (§9): the render NEVER says "fully verified" / "safe".
|
|
8
|
+
|
|
9
|
+
/** One-line human summary of a verdict, including its ship implication. */
|
|
10
|
+
const VERDICT_LINE = {
|
|
11
|
+
PASS: 'PASS — every AC verified against the running app; no defect above COSMETIC.',
|
|
12
|
+
BROKEN: 'BROKEN — one or more ACs fail on the running app. Blocks ship.',
|
|
13
|
+
DEGRADED: 'DEGRADED — app works but a criterion is materially weakened. Reported; may block per gate.',
|
|
14
|
+
COSMETIC: 'COSMETIC — hygiene only; no AC touched. Does not block.',
|
|
15
|
+
'NOT-DEPLOYED': 'NOT-DEPLOYED — deployed profile declared but no real deploy reachable. A refusal to issue a verdict, not a pass.',
|
|
16
|
+
BLOCKED: 'BLOCKED — a prerequisite could not be provisioned; dependent ACs were not exercisable.',
|
|
17
|
+
'LAUNCH-FAILURE': 'LAUNCH-FAILURE — the verifier agent could not run or its output could not be ingested. A refusal to issue a verdict, not a pass.',
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Render a report artifact to a human-readable string.
|
|
22
|
+
*
|
|
23
|
+
* @param {object} report
|
|
24
|
+
* @returns {string}
|
|
25
|
+
*/
|
|
26
|
+
export function renderReport(report) {
|
|
27
|
+
const lines = [];
|
|
28
|
+
const run = report.run ?? {};
|
|
29
|
+
lines.push('RCF Verify — verification report');
|
|
30
|
+
lines.push('='.repeat(40));
|
|
31
|
+
lines.push(`Verdict: ${report.verdict} [authority: ${report.verdictAuthority}]`);
|
|
32
|
+
lines.push(` ${VERDICT_LINE[report.verdict] ?? ''}`.trimEnd());
|
|
33
|
+
lines.push('');
|
|
34
|
+
lines.push('Runtime provenance');
|
|
35
|
+
lines.push(` profile: ${run.profile}`);
|
|
36
|
+
lines.push(` url: ${run.url}`);
|
|
37
|
+
lines.push(` parityEnv: ${run.parityEnv === true}`);
|
|
38
|
+
if (run.reachability) {
|
|
39
|
+
lines.push(` reachable: ${run.reachability.reachable} looksLocal: ${run.reachability.looksLocal}`);
|
|
40
|
+
}
|
|
41
|
+
lines.push(` chainRef: ${run.chainRef ?? '(none)'}`);
|
|
42
|
+
lines.push(` persona: ${run.persona ?? '(default)'}`);
|
|
43
|
+
if (run.verifierIsolation) {
|
|
44
|
+
lines.push(` isolation: autoMemory=${run.verifierIsolation.autoMemory} nonEssentialTraffic=${run.verifierIsolation.nonEssentialTraffic}`);
|
|
45
|
+
}
|
|
46
|
+
lines.push('');
|
|
47
|
+
|
|
48
|
+
const findings = report.findings ?? [];
|
|
49
|
+
lines.push(`Findings (${findings.length})`);
|
|
50
|
+
if (findings.length === 0) {
|
|
51
|
+
lines.push(' (none)');
|
|
52
|
+
} else {
|
|
53
|
+
for (const f of findings) {
|
|
54
|
+
lines.push(` [${f.severity}] ${f.acId} — ${f.journey}`);
|
|
55
|
+
for (const step of f.reproSteps ?? []) lines.push(` - ${step}`);
|
|
56
|
+
if (f.evidence) {
|
|
57
|
+
const detail = f.evidence.detail ?? f.evidence.kind ?? JSON.stringify(f.evidence);
|
|
58
|
+
lines.push(` evidence: ${detail}`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
lines.push('');
|
|
63
|
+
|
|
64
|
+
const blocked = report.blockedAcs ?? [];
|
|
65
|
+
if (blocked.length > 0) {
|
|
66
|
+
lines.push(`Blocked ACs (${blocked.length}) — NOT exercisable, not silently skipped`);
|
|
67
|
+
for (const b of blocked) lines.push(` ${b.acId}: ${b.reason}`);
|
|
68
|
+
lines.push('');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (report.launchFailure) {
|
|
72
|
+
lines.push('Launch failure');
|
|
73
|
+
lines.push(` ${report.launchFailure.message}`);
|
|
74
|
+
if (report.launchFailure.rawOutputPath) lines.push(` raw transcript: ${report.launchFailure.rawOutputPath}`);
|
|
75
|
+
lines.push('');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (run.runStats) {
|
|
79
|
+
const s = run.runStats;
|
|
80
|
+
const bits = [];
|
|
81
|
+
if (typeof s.durationMs === 'number') bits.push(`duration=${s.durationMs}ms`);
|
|
82
|
+
if (typeof s.numTurns === 'number') bits.push(`turns=${s.numTurns}`);
|
|
83
|
+
if (s.tokens) bits.push(`tokens in/out=${s.tokens.inputTokens ?? '?'}/${s.tokens.outputTokens ?? '?'}`);
|
|
84
|
+
if (typeof s.totalCostUsd === 'number') bits.push(`cost=$${s.totalCostUsd.toFixed(4)}`);
|
|
85
|
+
if (bits.length) {
|
|
86
|
+
lines.push(`Run stats: ${bits.join(' ')}`);
|
|
87
|
+
lines.push('');
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const prov = report.provisioning;
|
|
92
|
+
if (prov) {
|
|
93
|
+
lines.push('Provisioning');
|
|
94
|
+
lines.push(` provisioned: ${(prov.provisioned ?? []).map((p) => p.ref).join(', ') || '(none)'}`);
|
|
95
|
+
if ((prov.blocked ?? []).length) {
|
|
96
|
+
lines.push(` blocked: ${prov.blocked.map((b) => `${b.kind} (${b.reason})`).join('; ')}`);
|
|
97
|
+
}
|
|
98
|
+
lines.push(` cleanupRan: ${prov.cleanupRan === true} removed: ${(prov.cleanupRemoved ?? []).join(', ') || '(none)'}`);
|
|
99
|
+
lines.push('');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
lines.push('Note: this is an independent ship-readiness signal, not a correctness guarantee.');
|
|
103
|
+
return `${lines.join('\n')}\n`;
|
|
104
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// Verdict taxonomy + aggregation (spec §5.1, §5.2). Mirrors the persona
|
|
2
|
+
// programme's PASS/BROKEN/DEGRADED/COSMETIC, plus the structural verdicts
|
|
3
|
+
// NOT-DEPLOYED (§4 refusal), BLOCKED (§6 unprovisionable), and LAUNCH-FAILURE
|
|
4
|
+
// (the verifier agent could not run or its output could not be ingested — a
|
|
5
|
+
// refusal to issue a verdict on the app, never a soft pass; see engine catch).
|
|
6
|
+
//
|
|
7
|
+
// Split verdicts are held split, NEVER averaged (§5.1): a run is BROKEN if
|
|
8
|
+
// ANY finding is BROKEN, regardless of how many ACs passed.
|
|
9
|
+
|
|
10
|
+
import { rcfError } from '@stravica-ai/rcf-lite-core/errors';
|
|
11
|
+
|
|
12
|
+
/** Finding severities, low → high. */
|
|
13
|
+
export const FINDING_SEVERITIES = Object.freeze(['PASS', 'COSMETIC', 'DEGRADED', 'BROKEN']);
|
|
14
|
+
|
|
15
|
+
/** Severity rank for the split-not-averaged max and the severity gate. */
|
|
16
|
+
export const SEVERITY_ORDER = Object.freeze({ PASS: 0, COSMETIC: 1, DEGRADED: 2, BROKEN: 3 });
|
|
17
|
+
|
|
18
|
+
/** All overall-verdict classes (findings severities + the three structural verdicts). */
|
|
19
|
+
export const VERDICTS = Object.freeze([...FINDING_SEVERITIES, 'NOT-DEPLOYED', 'BLOCKED', 'LAUNCH-FAILURE']);
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Required fields on every finding (spec §5.2): the RCF payoff is that every
|
|
23
|
+
* defect maps to a contract line (acId / chain node), never a free-floating
|
|
24
|
+
* bug.
|
|
25
|
+
*
|
|
26
|
+
* @param {object} finding
|
|
27
|
+
* @returns {import('@stravica-ai/rcf-lite-core/errors').RcfError | null} error as data, or null if valid
|
|
28
|
+
*/
|
|
29
|
+
export function validateFinding(finding) {
|
|
30
|
+
if (!finding || typeof finding !== 'object') {
|
|
31
|
+
return rcfError({ kind: 'validation', message: 'finding must be an object' });
|
|
32
|
+
}
|
|
33
|
+
if (!FINDING_SEVERITIES.includes(finding.severity)) {
|
|
34
|
+
return rcfError({ kind: 'validation', message: `finding.severity must be one of ${FINDING_SEVERITIES.join('/')}`, field: 'severity' });
|
|
35
|
+
}
|
|
36
|
+
if (typeof finding.acId !== 'string' || finding.acId.length === 0) {
|
|
37
|
+
return rcfError({ kind: 'validation', message: 'finding.acId (chain-node reference) is required', field: 'acId' });
|
|
38
|
+
}
|
|
39
|
+
if (typeof finding.journey !== 'string' || finding.journey.length === 0) {
|
|
40
|
+
return rcfError({ kind: 'validation', message: 'finding.journey is required', field: 'journey' });
|
|
41
|
+
}
|
|
42
|
+
if (!Array.isArray(finding.reproSteps)) {
|
|
43
|
+
return rcfError({ kind: 'validation', message: 'finding.reproSteps must be an array', field: 'reproSteps' });
|
|
44
|
+
}
|
|
45
|
+
if (!finding.evidence || typeof finding.evidence !== 'object') {
|
|
46
|
+
return rcfError({ kind: 'validation', message: 'finding.evidence must be an object', field: 'evidence' });
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The worst (max) severity across findings. Empty → PASS. This is the
|
|
53
|
+
* split-not-averaged rule: the single worst finding drives the class.
|
|
54
|
+
*
|
|
55
|
+
* @param {Array<{severity: string}>} findings
|
|
56
|
+
* @returns {'PASS'|'COSMETIC'|'DEGRADED'|'BROKEN'}
|
|
57
|
+
*/
|
|
58
|
+
export function aggregateSeverity(findings = []) {
|
|
59
|
+
let worst = 'PASS';
|
|
60
|
+
for (const f of findings) {
|
|
61
|
+
if ((SEVERITY_ORDER[f.severity] ?? -1) > SEVERITY_ORDER[worst]) worst = f.severity;
|
|
62
|
+
}
|
|
63
|
+
return worst;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The overall run verdict (spec §5.1). NOT-DEPLOYED and a fully-blocked run
|
|
68
|
+
* are structural verdicts; otherwise the worst finding severity wins
|
|
69
|
+
* (split-not-averaged). A run with SOME findings and SOME blocked ACs is a
|
|
70
|
+
* partial verification: the verdict reflects what WAS exercised, and the
|
|
71
|
+
* blocked ACs are named separately in the report.
|
|
72
|
+
*
|
|
73
|
+
* @param {object} opts
|
|
74
|
+
* @param {Array<{severity: string}>} [opts.findings]
|
|
75
|
+
* @param {Array<object>} [opts.blockedAcs]
|
|
76
|
+
* @param {boolean} [opts.notDeployed]
|
|
77
|
+
* @returns {string}
|
|
78
|
+
*/
|
|
79
|
+
export function aggregateVerdict({ findings = [], blockedAcs = [], notDeployed = false } = {}) {
|
|
80
|
+
if (notDeployed) return 'NOT-DEPLOYED';
|
|
81
|
+
if (findings.length === 0 && blockedAcs.length > 0) return 'BLOCKED';
|
|
82
|
+
return aggregateSeverity(findings);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Whether the severity gate is tripped → the process exits non-zero
|
|
87
|
+
* (spec §3 rule 5, §8.2). NOT-DEPLOYED, BLOCKED and LAUNCH-FAILURE always trip
|
|
88
|
+
* (ship cannot be confirmed); otherwise the worst finding severity is compared
|
|
89
|
+
* against the gate. With no gate configured, nothing trips — the report is
|
|
90
|
+
* still written.
|
|
91
|
+
*
|
|
92
|
+
* @param {object} opts
|
|
93
|
+
* @param {string} opts.verdict
|
|
94
|
+
* @param {Array<{severity: string}>} [opts.findings]
|
|
95
|
+
* @param {string|null} [opts.gate] - one of FINDING_SEVERITIES, or null/undefined
|
|
96
|
+
* @returns {boolean}
|
|
97
|
+
*/
|
|
98
|
+
export function gateTripped({ verdict, findings = [], gate }) {
|
|
99
|
+
if (verdict === 'NOT-DEPLOYED' || verdict === 'BLOCKED' || verdict === 'LAUNCH-FAILURE') return true;
|
|
100
|
+
if (!gate) return false;
|
|
101
|
+
const worst = aggregateSeverity(findings);
|
|
102
|
+
return SEVERITY_ORDER[worst] >= SEVERITY_ORDER[gate];
|
|
103
|
+
}
|