fini-proof 0.3.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/LICENSE +5 -0
- package/README.md +220 -0
- package/action.yml +53 -0
- package/bin/fini-proof.mjs +6 -0
- package/ci/Jenkinsfile +13 -0
- package/ci/Jenkinsfile.daily +23 -0
- package/ci/bitbucket-pipelines-daily.yml +15 -0
- package/ci/bitbucket-pipelines.yml +12 -0
- package/ci/github-actions-daily.yml +31 -0
- package/ci/github-actions-example.yml +14 -0
- package/ci/gitlab-ci-daily.yml +17 -0
- package/ci/gitlab-ci.yml +13 -0
- package/package.json +31 -0
- package/src/cli.mjs +258 -0
- package/src/engines/fail-open.mjs +133 -0
- package/src/engines/gitleaks.mjs +143 -0
- package/src/engines/hollow-tests.mjs +121 -0
- package/src/engines/migrations.mjs +316 -0
- package/src/engines/secrets.mjs +88 -0
- package/src/engines/skip-ratchet.mjs +78 -0
- package/src/engines/tenant-filter.mjs +516 -0
- package/src/engines/vendor/entrypoint-guard-core.mjs +43 -0
- package/src/engines/vendor/migration-invariants-core.mjs +127 -0
- package/src/engines/vendor/secrets-core.mjs +123 -0
- package/src/engines/vendor/vacuous-spec-core.mjs +1066 -0
- package/src/evidence.mjs +130 -0
- package/src/files.mjs +89 -0
- package/src/hooks.mjs +118 -0
- package/src/licence.mjs +85 -0
- package/src/mcp.mjs +230 -0
- package/src/report.mjs +73 -0
- package/src/runner.mjs +306 -0
- package/src/state.mjs +189 -0
- package/src/upload.mjs +85 -0
- package/src/usage.mjs +64 -0
package/src/evidence.mjs
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// The evidence record: schema ids, digest, verification, attestation, and the @1/@2 read-compatibility layer.
|
|
2
|
+
//
|
|
3
|
+
// WHY THIS FILE EXISTS (reuse note): before evidence@2 the digest lived inline in runner.mjs finish() and nothing read
|
|
4
|
+
// an evidence file back. `state --against`, `evidence verify` and the @1/@2 readers all need the SAME digest rule and
|
|
5
|
+
// the SAME view of an old record, so it is one module here instead of a copy in each reader. runner.mjs finish() now
|
|
6
|
+
// calls computeDigest() from here — the digest semantics are unchanged (sha256 of JSON.stringify of the record without
|
|
7
|
+
// the post-digest fields), byte for byte, so the Developer workspace (backend fini-proof-evidence.ts) still verifies.
|
|
8
|
+
//
|
|
9
|
+
// evidence@2 (SSOT PART 10, action 1) adds to @1, and changes nothing that @1 had:
|
|
10
|
+
// snapshot { tree_hash, commit, dirty, status, reason? } git HEAD tree; NOT_MEASURED when not a git repo
|
|
11
|
+
// findings[] .property_id = "<engine>/<rule>"
|
|
12
|
+
// engines[] .nc_results[] = { id, expect, caught, ms } (the negative-control runs that gate the engine)
|
|
13
|
+
// .scope_files = sorted paths the engine read (scan scope + schema/context files) — Proof.scope
|
|
14
|
+
// attestation { maker: { kind: human|agent|unknown, id, model?, session?, source },
|
|
15
|
+
// verifier: { tool, version, engine_digests, runner: { host_digest, ci } } }
|
|
16
|
+
import { createHash } from 'node:crypto'
|
|
17
|
+
import { readFileSync } from 'node:fs'
|
|
18
|
+
import os from 'node:os'
|
|
19
|
+
|
|
20
|
+
export const EVIDENCE_SCHEMA = 'fini-proof/evidence@2'
|
|
21
|
+
export const EVIDENCE_SCHEMAS = Object.freeze(['fini-proof/evidence@1', EVIDENCE_SCHEMA])
|
|
22
|
+
/** Set AFTER the digest is computed (runner finish() → evidenceDigest, cli executeCheck() → entitlement). */
|
|
23
|
+
export const POST_DIGEST_FIELDS = Object.freeze(['evidenceDigest', 'entitlement'])
|
|
24
|
+
|
|
25
|
+
const sha256 = (s) => createHash('sha256').update(s).digest('hex')
|
|
26
|
+
|
|
27
|
+
/** The evidence digest: sha256 of the record without the post-digest fields, keys in their written order. */
|
|
28
|
+
export function computeDigest(doc) {
|
|
29
|
+
const rest = {}
|
|
30
|
+
for (const [k, v] of Object.entries(doc)) if (!POST_DIGEST_FIELDS.includes(k)) rest[k] = v
|
|
31
|
+
return sha256(JSON.stringify(rest))
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Verify a parsed record: known schema, digest reproduces, maker is not the verifier. Returns { ok, reason?, code? }. */
|
|
35
|
+
export function verifyEvidence(doc) {
|
|
36
|
+
if (!doc || typeof doc !== 'object' || Array.isArray(doc)) return { ok: false, code: 'NOT_EVIDENCE', reason: 'not a JSON object' }
|
|
37
|
+
if (!EVIDENCE_SCHEMAS.includes(doc.schema)) return { ok: false, code: 'SCHEMA_UNKNOWN', reason: `unknown schema ${JSON.stringify(doc.schema)} (accepted: ${EVIDENCE_SCHEMAS.join(', ')})` }
|
|
38
|
+
if (typeof doc.evidenceDigest !== 'string' || !/^[0-9a-f]{64}$/.test(doc.evidenceDigest)) return { ok: false, code: 'DIGEST_MISSING', reason: 'evidenceDigest is missing or not a sha256 hex string' }
|
|
39
|
+
if (computeDigest(doc) !== doc.evidenceDigest) return { ok: false, code: 'DIGEST_MISMATCH', reason: 'evidenceDigest does not match the record: it was edited after the check ran, or was not written by fini-proof' }
|
|
40
|
+
if (makerIsVerifier(doc.attestation)) return { ok: false, code: 'MAKER_IS_VERIFIER', reason: 'the attested maker is the verifier itself — a verifier cannot attest its own change' }
|
|
41
|
+
return { ok: true, schema: doc.schema }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Read + parse + verify an evidence file. Returns { ok, doc?, reason?, code? }; never throws. */
|
|
45
|
+
export function readEvidenceFile(file) {
|
|
46
|
+
let doc
|
|
47
|
+
try { doc = JSON.parse(readFileSync(file, 'utf8')) } catch (e) { return { ok: false, code: 'UNREADABLE', reason: `cannot read ${file}: ${e.code || e.message}` } }
|
|
48
|
+
const v = verifyEvidence(doc)
|
|
49
|
+
return v.ok ? { ok: true, doc } : { ...v, doc }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ── @1/@2 read compatibility: every reader goes through these, never through the raw @2 field names ──
|
|
53
|
+
|
|
54
|
+
export const propertyId = (f) => f.property_id || `${f.engine}/${f.rule}`
|
|
55
|
+
|
|
56
|
+
/** Negative-control results of one engine record; @1 records derive them from `controls` (no timing then). */
|
|
57
|
+
export function ncResults(er) {
|
|
58
|
+
if (Array.isArray(er.nc_results)) return er.nc_results
|
|
59
|
+
return (er.controls || []).filter((c) => c.kind === 'negative').map((c) => ({ id: c.id, expect: c.expect, caught: c.ok === true, ms: null }))
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The files a proof of this engine covers. @1 has no per-engine scope → every hashed input (conservative). */
|
|
63
|
+
export function scopeFiles(er, doc) {
|
|
64
|
+
if (Array.isArray(er.scope_files)) return { files: er.scope_files, source: 'engine' }
|
|
65
|
+
return { files: Object.keys(doc.inputs?.perFile || {}).sort(), source: 'all-inputs' }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function snapshotOf(doc) {
|
|
69
|
+
if (doc.snapshot) return doc.snapshot
|
|
70
|
+
return { tree_hash: null, commit: doc.repo?.commit ?? null, dirty: doc.repo?.dirty ?? null, status: 'NOT_MEASURED', reason: `${doc.schema} records no tree hash` }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ── attestation (SSOT PART 8: maker ≠ verifier) ──
|
|
74
|
+
|
|
75
|
+
const MAKER_KINDS = new Set(['human', 'agent'])
|
|
76
|
+
const VERIFIER_TOOL = 'fini-proof'
|
|
77
|
+
const clean = (v, max = 200) => (typeof v === 'string' && v.trim() ? v.replace(/[\x00-\x1f\x7f]/g, '').trim().slice(0, max) : undefined)
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Who made the change. Precedence: an explicit maker from the caller that KNOWS it (the Claude Code hook, the MCP
|
|
81
|
+
* client's own name) → FINI_PROOF_MAKER_KIND / _ID / _MODEL / _SESSION → the git author of HEAD. The kind is never
|
|
82
|
+
* guessed: a git author may be a person or an agent using that person's identity, so it is recorded as 'unknown'.
|
|
83
|
+
*/
|
|
84
|
+
export function makerFrom({ explicit = null, env = process.env, gitAuthor = null } = {}) {
|
|
85
|
+
const envKind = clean(env.FINI_PROOF_MAKER_KIND)
|
|
86
|
+
const fromEnv = {
|
|
87
|
+
kind: envKind && MAKER_KINDS.has(envKind) ? envKind : undefined,
|
|
88
|
+
id: clean(env.FINI_PROOF_MAKER_ID),
|
|
89
|
+
model: clean(env.FINI_PROOF_MAKER_MODEL),
|
|
90
|
+
session: clean(env.FINI_PROOF_MAKER_SESSION),
|
|
91
|
+
}
|
|
92
|
+
const pick = (o) => Object.fromEntries(Object.entries(o).filter(([, v]) => v !== undefined))
|
|
93
|
+
if (explicit && clean(explicit.id)) {
|
|
94
|
+
const kind = MAKER_KINDS.has(explicit.kind) ? explicit.kind : 'unknown'
|
|
95
|
+
return pick({ kind, id: clean(explicit.id), model: clean(explicit.model) ?? fromEnv.model, session: clean(explicit.session) ?? fromEnv.session, source: clean(explicit.source) || 'caller' })
|
|
96
|
+
}
|
|
97
|
+
if (fromEnv.id || envKind) return pick({ kind: fromEnv.kind || 'unknown', id: fromEnv.id || 'unknown', model: fromEnv.model, session: fromEnv.session, source: 'env' })
|
|
98
|
+
if (clean(gitAuthor, 320)) return { kind: 'unknown', id: clean(gitAuthor, 320), source: 'git-author' }
|
|
99
|
+
return { kind: 'unknown', id: 'unknown', source: 'none' }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Which CI system and job ran the verifier (null on a workstation). */
|
|
103
|
+
export function ciIdentity(env = process.env) {
|
|
104
|
+
if (env.GITHUB_ACTIONS === 'true') return { system: 'github-actions', job: [env.GITHUB_REPOSITORY, env.GITHUB_RUN_ID, env.GITHUB_RUN_ATTEMPT].filter(Boolean).join('/') || null }
|
|
105
|
+
if (env.GITLAB_CI) return { system: 'gitlab', job: env.CI_JOB_ID || null }
|
|
106
|
+
if (env.BITBUCKET_BUILD_NUMBER) return { system: 'bitbucket', job: [env.BITBUCKET_REPO_FULL_NAME, env.BITBUCKET_BUILD_NUMBER].filter(Boolean).join('/') }
|
|
107
|
+
if (env.JENKINS_URL) return { system: 'jenkins', job: env.BUILD_TAG || null }
|
|
108
|
+
if (env.CI) return { system: 'ci', job: null }
|
|
109
|
+
return null
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The verifier: this tool, its version, the digest of every engine that ran, and the runner. The host name is
|
|
114
|
+
* recorded as a digest only (enough to tell two runners apart; a workstation name is not sent anywhere).
|
|
115
|
+
*/
|
|
116
|
+
export function verifierFrom({ version, engines, env = process.env }) {
|
|
117
|
+
return {
|
|
118
|
+
tool: VERIFIER_TOOL,
|
|
119
|
+
version,
|
|
120
|
+
engine_digests: Object.fromEntries(engines.map((e) => [e.id, e.sha256])),
|
|
121
|
+
runner: { host_digest: sha256(os.hostname()).slice(0, 16), ci: ciIdentity(env) },
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** A run whose maker identity equals its verifier identity is refused (backend 422 MAKER_IS_VERIFIER). */
|
|
126
|
+
export function makerIsVerifier(att) {
|
|
127
|
+
if (!att || !att.maker || !att.verifier) return false
|
|
128
|
+
const id = String(att.maker.id || '').toLowerCase()
|
|
129
|
+
return id === String(att.verifier.tool || VERIFIER_TOOL).toLowerCase() || id.startsWith(`${VERIFIER_TOOL}/`) || id.startsWith(`${VERIFIER_TOOL}@`)
|
|
130
|
+
}
|
package/src/files.mjs
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// Repository file listing, scope (--base) and ignore config. Fails closed: an unresolvable base is reported,
|
|
2
|
+
// never silently widened or narrowed (same rule as finipe check-vacuous-spec.mjs resolveBase(), V2 cert note).
|
|
3
|
+
import { spawnSync } from 'node:child_process'
|
|
4
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs'
|
|
5
|
+
import path from 'node:path'
|
|
6
|
+
|
|
7
|
+
export function git(root, args) {
|
|
8
|
+
const r = spawnSync('git', ['-C', root, ...args], { encoding: 'utf8', maxBuffer: 256 * 1024 * 1024 })
|
|
9
|
+
return { ok: r.status === 0, out: (r.stdout || '').trim(), err: (r.stderr || '').trim() }
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function isGitRepo(root) { return git(root, ['rev-parse', '--is-inside-work-tree']).out === 'true' }
|
|
13
|
+
|
|
14
|
+
export function repoInfo(root) {
|
|
15
|
+
if (!isGitRepo(root)) return { git: false, commit: null, rootCommit: null, dirty: null }
|
|
16
|
+
const commit = git(root, ['rev-parse', 'HEAD'])
|
|
17
|
+
const rootCommit = git(root, ['rev-list', '--max-parents=0', 'HEAD'])
|
|
18
|
+
const status = git(root, ['status', '--porcelain'])
|
|
19
|
+
return {
|
|
20
|
+
git: true,
|
|
21
|
+
commit: commit.ok ? commit.out : null,
|
|
22
|
+
rootCommit: rootCommit.ok ? rootCommit.out.split('\n').sort()[0] : null,
|
|
23
|
+
dirty: status.ok ? status.out.length > 0 : null,
|
|
24
|
+
author: git(root, ['log', '-1', '--format=%ae']).out || null,
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function walk(root) {
|
|
29
|
+
const acc = []
|
|
30
|
+
const rec = (d) => {
|
|
31
|
+
for (const e of readdirSync(path.join(root, d), { withFileTypes: true })) {
|
|
32
|
+
if (e.name === 'node_modules' || e.name === '.git' || e.name === '.fini-proof') continue
|
|
33
|
+
const rel = d ? `${d}/${e.name}` : e.name
|
|
34
|
+
if (e.isDirectory()) rec(rel)
|
|
35
|
+
else if (e.isFile()) acc.push(rel)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
rec('')
|
|
39
|
+
return acc
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** All files in scope. Returns { files } or { unmeasured: reason }. */
|
|
43
|
+
export function listFiles(root, { base } = {}) {
|
|
44
|
+
if (!isGitRepo(root)) {
|
|
45
|
+
if (base) return { unmeasured: `--base ${base} given but ${root} is not a git repository` }
|
|
46
|
+
return { files: walk(root).sort(), mode: 'full-walk' }
|
|
47
|
+
}
|
|
48
|
+
const all = git(root, ['ls-files', '-co', '--exclude-standard', '-z'])
|
|
49
|
+
if (!all.ok) return { unmeasured: `git ls-files failed: ${all.err}` }
|
|
50
|
+
let files = all.out.split('\0').filter(Boolean).filter((f) => !f.split('/').includes('node_modules'))
|
|
51
|
+
if (!base) return { files: [...new Set(files)].sort(), mode: 'full' }
|
|
52
|
+
const b = git(root, ['rev-parse', '--verify', '--quiet', `${base}^{commit}`])
|
|
53
|
+
if (!b.ok) return { unmeasured: `base ref "${base}" does not resolve to a commit (fetch it: git fetch origin ${base.replace(/^origin\//, '')}) — a guessed base would silently change what is checked` }
|
|
54
|
+
const mb = git(root, ['merge-base', b.out, 'HEAD'])
|
|
55
|
+
if (!mb.ok) return { unmeasured: `no merge-base between ${base} and HEAD (shallow clone? use fetch-depth: 0)` }
|
|
56
|
+
const changed = new Set([
|
|
57
|
+
...git(root, ['diff', '--name-only', '--diff-filter=d', `${mb.out}..HEAD`]).out.split('\n'),
|
|
58
|
+
...git(root, ['diff', '--name-only', '--diff-filter=d']).out.split('\n'),
|
|
59
|
+
...git(root, ['diff', '--name-only', '--diff-filter=d', '--cached']).out.split('\n'),
|
|
60
|
+
...git(root, ['ls-files', '--others', '--exclude-standard']).out.split('\n'),
|
|
61
|
+
].filter(Boolean))
|
|
62
|
+
files = files.filter((f) => changed.has(f))
|
|
63
|
+
return { files: [...new Set(files)].sort(), mode: 'diff', baseCommit: b.out, mergeBase: mb.out }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function globToRegExp(glob) {
|
|
67
|
+
let re = ''
|
|
68
|
+
for (let i = 0; i < glob.length; i++) {
|
|
69
|
+
const c = glob[i]
|
|
70
|
+
if (c === '*' && glob[i + 1] === '*') { re += '.*'; i++; if (glob[i + 1] === '/') i++ }
|
|
71
|
+
else if (c === '*') re += '[^/]*'
|
|
72
|
+
else if (c === '?') re += '[^/]'
|
|
73
|
+
else re += c.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
74
|
+
}
|
|
75
|
+
return new RegExp(`^${re}$`)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function loadConfig(root) {
|
|
79
|
+
const p = path.join(root, '.fini-proof.json')
|
|
80
|
+
if (!existsSync(p)) return { ignore: [], source: null, raw: {} }
|
|
81
|
+
const cfg = JSON.parse(readFileSync(p, 'utf8'))
|
|
82
|
+
return { ignore: Array.isArray(cfg.ignore) ? cfg.ignore : [], failOn: cfg.failOn, source: '.fini-proof.json', raw: cfg }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function loadBaseline(root) {
|
|
86
|
+
const p = path.join(root, '.fini-proof', 'baseline.json')
|
|
87
|
+
if (!existsSync(p)) return {}
|
|
88
|
+
return JSON.parse(readFileSync(p, 'utf8'))
|
|
89
|
+
}
|
package/src/hooks.mjs
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// `fini-proof hook claude` — Claude Code hook handler. Claude Code pipes one JSON object on stdin
|
|
2
|
+
// ({ session_id, cwd, hook_event_name, … }) and reads the exit code: 2 = BLOCK, and stderr is fed back to Claude.
|
|
3
|
+
//
|
|
4
|
+
// SessionStart record HEAD, so Stop can check exactly what this session changed (commits included)
|
|
5
|
+
// Stop / SubagentStop run the full licensed check on the session's changes; FAIL or NOT_MEASURED → exit 2
|
|
6
|
+
// with the findings, so the agent cannot report "done" on unverified work
|
|
7
|
+
// PostToolUse (Edit / Write / MultiEdit) check only the file just written; blocking finding → exit 2,
|
|
8
|
+
// so the agent sees the problem immediately
|
|
9
|
+
//
|
|
10
|
+
// Loop guard: a Stop is blocked at most FINI_PROOF_HOOK_MAX_BLOCKS times (default 5) per session; after that the
|
|
11
|
+
// stop is allowed but a systemMessage tells the USER the work is still unverified — never a silent pass.
|
|
12
|
+
import { spawnSync } from 'node:child_process'
|
|
13
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs'
|
|
14
|
+
import path from 'node:path'
|
|
15
|
+
import { homeDir } from './licence.mjs'
|
|
16
|
+
import { sha256 } from './runner.mjs'
|
|
17
|
+
|
|
18
|
+
const git = (cwd, args) => {
|
|
19
|
+
const r = spawnSync('git', ['-C', cwd, ...args], { encoding: 'utf8' })
|
|
20
|
+
return r.status === 0 ? r.stdout.trim() : null
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function readStdin(input) {
|
|
24
|
+
const chunks = []
|
|
25
|
+
for await (const c of input) chunks.push(c)
|
|
26
|
+
return Buffer.concat(chunks).toString('utf8')
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function stateFile(sessionId) {
|
|
30
|
+
return path.join(homeDir(), 'hooks', `${sha256(String(sessionId || 'no-session')).slice(0, 24)}.json`)
|
|
31
|
+
}
|
|
32
|
+
function loadState(sessionId) {
|
|
33
|
+
const f = stateFile(sessionId)
|
|
34
|
+
if (!existsSync(f)) return {}
|
|
35
|
+
try { return JSON.parse(readFileSync(f, 'utf8')) } catch { return {} }
|
|
36
|
+
}
|
|
37
|
+
function saveState(sessionId, st) {
|
|
38
|
+
const f = stateFile(sessionId)
|
|
39
|
+
mkdirSync(path.dirname(f), { recursive: true })
|
|
40
|
+
writeFileSync(f, JSON.stringify(st))
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Which changes a Stop verifies: env override → HEAD at session start → the remote default branch → uncommitted. */
|
|
44
|
+
export function stopBase(root, state, env = process.env) {
|
|
45
|
+
if (env.FINI_PROOF_HOOK_BASE) return env.FINI_PROOF_HOOK_BASE
|
|
46
|
+
if (state.startHead && git(root, ['cat-file', '-e', `${state.startHead}^{commit}`]) !== null) return state.startHead
|
|
47
|
+
for (const ref of ['origin/HEAD', 'origin/main', 'origin/master']) if (git(root, ['rev-parse', '--verify', '--quiet', `${ref}^{commit}`])) return ref
|
|
48
|
+
return 'HEAD'
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function reasonText(result, heading, max = 20) {
|
|
52
|
+
const L = [heading]
|
|
53
|
+
const blocking = result.findings.filter((f) => f.blocking)
|
|
54
|
+
for (const f of blocking.slice(0, max)) L.push(`- ${f.file}:${f.line} ${f.rule} [${f.engine}] ${f.message}\n proof: ${f.proof}`)
|
|
55
|
+
if (blocking.length > max) L.push(`… and ${blocking.length - max} more (see the evidence file).`)
|
|
56
|
+
if (result.verdict === 'NOT_MEASURED') L.push(`- not measured: ${result.reason}`)
|
|
57
|
+
L.push('Fini Proof is an independent verifier: fix the code. Do not weaken tests, add ignore globs to .fini-proof.json, or raise the baseline to make it pass.')
|
|
58
|
+
return L.join('\n')
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Returns { code, stderr, stdout } — pure enough to test without spawning. */
|
|
62
|
+
export async function handleClaudeHook(payload, { env = process.env, executeCheck } = {}) {
|
|
63
|
+
const event = payload.hook_event_name
|
|
64
|
+
const cwd = payload.cwd || process.cwd()
|
|
65
|
+
const root = git(cwd, ['rev-parse', '--show-toplevel']) || cwd
|
|
66
|
+
const session = payload.session_id
|
|
67
|
+
const state = loadState(session)
|
|
68
|
+
const blockOn = (env.FINI_PROOF_HOOK_BLOCK_ON || 'FAIL,NOT_MEASURED').split(',').map((s) => s.trim())
|
|
69
|
+
// evidence@2 attestation: this hook KNOWS the maker is the Claude Code agent of this session (SSOT PART 8).
|
|
70
|
+
const maker = { kind: 'agent', id: 'claude-code', session: session ? String(session) : undefined, model: env.FINI_PROOF_MAKER_MODEL, source: 'claude-code-hook' }
|
|
71
|
+
|
|
72
|
+
if (event === 'SessionStart') {
|
|
73
|
+
const head = git(root, ['rev-parse', 'HEAD'])
|
|
74
|
+
if (head && !state.startHead) saveState(session, { ...state, startHead: head, blocks: 0 })
|
|
75
|
+
return { code: 0 }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (event === 'PostToolUse') {
|
|
79
|
+
const file = payload.tool_input?.file_path || payload.tool_input?.notebook_path
|
|
80
|
+
if (!file) return { code: 0 }
|
|
81
|
+
// realpath both sides: macOS /var → /private/var and symlinked checkouts must not turn a repo file into "outside"
|
|
82
|
+
const real = (p) => { try { return realpathSync(p) } catch { return p } }
|
|
83
|
+
const rel = path.relative(real(root), real(path.resolve(cwd, file))).split(path.sep).join('/')
|
|
84
|
+
if (rel.startsWith('..') || path.isAbsolute(rel)) return { code: 0 }
|
|
85
|
+
const x = executeCheck({ root, only: [rel], maker })
|
|
86
|
+
if (x.error || x.result.verdict !== 'FAIL') return { code: 0 } // per-edit feedback only; Stop is the gate
|
|
87
|
+
return { code: 2, stderr: reasonText(x.result, `Fini Proof: the edit to ${rel} introduced ${x.result.summary.blocking} blocking finding(s). Fix them now:`) }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (event === 'Stop' || event === 'SubagentStop') {
|
|
91
|
+
const base = git(root, ['rev-parse', '--is-inside-work-tree']) === 'true' ? stopBase(root, state, env) : undefined
|
|
92
|
+
const x = executeCheck({ root, base, maker })
|
|
93
|
+
const verdict = x.error ? 'NOT_MEASURED' : x.result.verdict
|
|
94
|
+
if (!blockOn.includes(verdict)) { saveState(session, { ...state, blocks: 0 }); return { code: 0 } }
|
|
95
|
+
const max = Number(env.FINI_PROOF_HOOK_MAX_BLOCKS || 5)
|
|
96
|
+
const blocks = (state.blocks || 0) + 1
|
|
97
|
+
saveState(session, { ...state, blocks })
|
|
98
|
+
const why = x.error
|
|
99
|
+
? `Fini Proof: VERDICT NOT_MEASURED — ${x.error.replace(/^VERDICT: NOT_MEASURED — /, '')}. The work is not verified.`
|
|
100
|
+
: reasonText(x.result, `Fini Proof: VERDICT ${verdict} on the changes since ${base || 'the start'} — ${x.result.summary.blocking} blocking finding(s). The task is NOT done.`)
|
|
101
|
+
if (blocks > max) {
|
|
102
|
+
return { code: 0, stdout: JSON.stringify({ systemMessage: `Fini Proof: verdict is still ${verdict} after ${max} attempts; the agent was allowed to stop, but this work is NOT verified. Evidence: ${x.evidencePath || 'none'}` }) }
|
|
103
|
+
}
|
|
104
|
+
return { code: 2, stderr: why }
|
|
105
|
+
}
|
|
106
|
+
return { code: 0 }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export async function runHook(kind, { input = process.stdin, env = process.env } = {}) {
|
|
110
|
+
if (kind !== 'claude') { process.stderr.write('usage: fini-proof hook claude (reads the Claude Code hook JSON on stdin)\n'); return 2 }
|
|
111
|
+
let payload
|
|
112
|
+
try { payload = JSON.parse(await readStdin(input)) } catch (e) { process.stderr.write(`fini-proof hook: stdin is not the Claude Code hook JSON (${e.message})\n`); return 1 }
|
|
113
|
+
const { executeCheck } = await import('./cli.mjs')
|
|
114
|
+
const r = await handleClaudeHook(payload, { env, executeCheck })
|
|
115
|
+
if (r.stdout) process.stdout.write(r.stdout + '\n')
|
|
116
|
+
if (r.stderr) process.stderr.write(r.stderr + '\n')
|
|
117
|
+
return r.code
|
|
118
|
+
}
|
package/src/licence.mjs
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// Offline licence: Ed25519-signed JSON { org, seats, expiry, … }. No network call, ever.
|
|
2
|
+
// Without a licence the tool runs in TRIAL mode: 1 repository (identified by its root-commit digest) for 14 days,
|
|
3
|
+
// recorded in $FINI_PROOF_HOME/trial.json. Every failure here is a refusal with a reason — never a silent pass.
|
|
4
|
+
import { createPublicKey, verify, sign, createPrivateKey } from 'node:crypto'
|
|
5
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
6
|
+
import os from 'node:os'
|
|
7
|
+
import path from 'node:path'
|
|
8
|
+
|
|
9
|
+
// Finipe Ventures vendor public key (Ed25519, SPKI PEM). The private half never enters this repository.
|
|
10
|
+
export const VENDOR_PUBLIC_KEY_PEM = `-----BEGIN PUBLIC KEY-----
|
|
11
|
+
MCowBQYDK2VwAyEAKeoiHBV27P3aOOXlia2/7AmbfffS96NWVHeTRCvJr8E=
|
|
12
|
+
-----END PUBLIC KEY-----
|
|
13
|
+
`
|
|
14
|
+
export const TRIAL_DAYS = 14
|
|
15
|
+
export const TRIAL_REPOS = 1
|
|
16
|
+
const DAY = 86_400_000
|
|
17
|
+
|
|
18
|
+
export const homeDir = () => process.env.FINI_PROOF_HOME || path.join(os.homedir(), '.fini-proof')
|
|
19
|
+
|
|
20
|
+
/** Stable byte form that is signed: keys sorted, no whitespace. */
|
|
21
|
+
export function canonical(obj) {
|
|
22
|
+
if (Array.isArray(obj)) return `[${obj.map(canonical).join(',')}]`
|
|
23
|
+
if (obj && typeof obj === 'object') return `{${Object.keys(obj).sort().map((k) => `${JSON.stringify(k)}:${canonical(obj[k])}`).join(',')}}`
|
|
24
|
+
return JSON.stringify(obj)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function issueLicence(payload, privateKeyPem) {
|
|
28
|
+
const p = { product: 'fini-proof', ...payload }
|
|
29
|
+
for (const k of ['org', 'seats', 'expiry']) if (p[k] === undefined) throw new Error(`licence payload needs ${k}`)
|
|
30
|
+
const signature = sign(null, Buffer.from(canonical(p)), createPrivateKey(privateKeyPem)).toString('base64')
|
|
31
|
+
return Buffer.from(JSON.stringify({ payload: p, signature })).toString('base64url')
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Returns { ok, payload } or { ok:false, reason }. `now` is injectable for tests only (no env override). */
|
|
35
|
+
export function verifyLicence(key, { publicKeyPem = VENDOR_PUBLIC_KEY_PEM, now = Date.now() } = {}) {
|
|
36
|
+
let doc
|
|
37
|
+
try { doc = JSON.parse(Buffer.from(String(key).trim(), 'base64url').toString('utf8')) } catch { return { ok: false, reason: 'licence key is not readable (corrupted or truncated)' } }
|
|
38
|
+
if (!doc?.payload || !doc?.signature) return { ok: false, reason: 'licence key has no payload/signature' }
|
|
39
|
+
let good = false
|
|
40
|
+
try { good = verify(null, Buffer.from(canonical(doc.payload)), createPublicKey(publicKeyPem), Buffer.from(doc.signature, 'base64')) } catch { good = false }
|
|
41
|
+
if (!good) return { ok: false, reason: 'licence signature is invalid (not issued by Finipe, or edited after issue)' }
|
|
42
|
+
const p = doc.payload
|
|
43
|
+
if (p.product !== 'fini-proof') return { ok: false, reason: `licence is for product "${p.product}"` }
|
|
44
|
+
const exp = Date.parse(p.expiry)
|
|
45
|
+
if (!Number.isFinite(exp)) return { ok: false, reason: 'licence expiry is not a date' }
|
|
46
|
+
if (now > exp) return { ok: false, reason: `licence for ${p.org} expired on ${p.expiry}` }
|
|
47
|
+
if (!Number.isInteger(p.seats) || p.seats < 1) return { ok: false, reason: 'licence seats must be a positive integer' }
|
|
48
|
+
return { ok: true, payload: p }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function findLicenceKey(root, explicit) {
|
|
52
|
+
if (explicit) return { key: existsSync(explicit) ? readFileSync(explicit, 'utf8') : explicit, source: '--licence' }
|
|
53
|
+
const env = process.env.FINI_PROOF_LICENCE || process.env.FINI_PROOF_LICENSE
|
|
54
|
+
if (env) return { key: existsSync(env) ? readFileSync(env, 'utf8') : env, source: 'FINI_PROOF_LICENCE' }
|
|
55
|
+
for (const p of [path.join(root, '.fini-proof', 'licence.key'), path.join(homeDir(), 'licence.key')]) {
|
|
56
|
+
if (existsSync(p)) return { key: readFileSync(p, 'utf8'), source: p }
|
|
57
|
+
}
|
|
58
|
+
return null
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Resolve the entitlement for this run: a verified licence, an active trial, or a refusal. */
|
|
62
|
+
export function entitlement(root, repoDigest, { explicit, now = Date.now(), publicKeyPem } = {}) {
|
|
63
|
+
const found = findLicenceKey(root, explicit)
|
|
64
|
+
if (found) {
|
|
65
|
+
const v = verifyLicence(found.key, { now, publicKeyPem })
|
|
66
|
+
if (!v.ok) return { ok: false, mode: 'licence', reason: v.reason }
|
|
67
|
+
return { ok: true, mode: 'licence', org: v.payload.org, seats: v.payload.seats, expiry: v.payload.expiry, licenceId: v.payload.licenceId || null }
|
|
68
|
+
}
|
|
69
|
+
const dir = homeDir()
|
|
70
|
+
const file = path.join(dir, 'trial.json')
|
|
71
|
+
let trial = existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : null
|
|
72
|
+
if (!trial) {
|
|
73
|
+
trial = { startedAt: new Date(now).toISOString(), repos: [repoDigest] }
|
|
74
|
+
mkdirSync(dir, { recursive: true })
|
|
75
|
+
writeFileSync(file, JSON.stringify(trial, null, 2))
|
|
76
|
+
}
|
|
77
|
+
const ends = Date.parse(trial.startedAt) + TRIAL_DAYS * DAY
|
|
78
|
+
if (now > ends) return { ok: false, mode: 'trial', reason: `the ${TRIAL_DAYS}-day trial ended on ${new Date(ends).toISOString().slice(0, 10)} — a licence key is needed (contact Finipe)` }
|
|
79
|
+
if (!trial.repos.includes(repoDigest)) {
|
|
80
|
+
if (trial.repos.length >= TRIAL_REPOS) return { ok: false, mode: 'trial', reason: `the trial covers ${TRIAL_REPOS} repository and is already bound to another one — a licence key is needed for more` }
|
|
81
|
+
trial.repos.push(repoDigest)
|
|
82
|
+
writeFileSync(file, JSON.stringify(trial, null, 2))
|
|
83
|
+
}
|
|
84
|
+
return { ok: true, mode: 'trial', org: null, daysLeft: Math.max(0, Math.ceil((ends - now) / DAY)), trialEnds: new Date(ends).toISOString() }
|
|
85
|
+
}
|
package/src/mcp.mjs
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
// `fini-proof mcp` — a Model Context Protocol server on stdio, so an AI coding agent (Claude Code, Codex, Cursor,
|
|
2
|
+
// any MCP client) can ask an INDEPENDENT verifier whether its work is done. The agent cannot change the verdict:
|
|
3
|
+
// the tools run the same licensed check path as the CLI (executeCheck), write the same evidence file, and a check
|
|
4
|
+
// that cannot run is NOT_MEASURED, never PASS.
|
|
5
|
+
//
|
|
6
|
+
// Protocol: JSON-RPC 2.0, one message per line on stdin/stdout (MCP stdio transport). Implemented directly — no SDK,
|
|
7
|
+
// no dependencies. Methods: initialize, notifications/initialized, ping, tools/list, tools/call.
|
|
8
|
+
// Nothing but protocol messages is ever written to stdout; diagnostics go to stderr.
|
|
9
|
+
import { realpathSync } from 'node:fs'
|
|
10
|
+
import path from 'node:path'
|
|
11
|
+
import { createInterface } from 'node:readline'
|
|
12
|
+
import { ENGINES, TOOL_VERSION, prove, ruleCatalogue } from './runner.mjs'
|
|
13
|
+
import { toText } from './report.mjs'
|
|
14
|
+
|
|
15
|
+
export const PROTOCOL_VERSIONS = ['2025-11-25', '2025-06-18', '2025-03-26', '2024-11-05']
|
|
16
|
+
const ENGINE_IDS = ENGINES.map((e) => e.id)
|
|
17
|
+
|
|
18
|
+
const TOOLS = [
|
|
19
|
+
{
|
|
20
|
+
name: 'check',
|
|
21
|
+
title: 'Verify the repository (independent of the agent)',
|
|
22
|
+
description: 'Run Fini Proof on the repository and return the verdict: PASS, FAIL or NOT_MEASURED. Every finding has file:line, rule, a proof command and a negative-control id. Call this BEFORE telling the user a task is done; if the verdict is FAIL, fix the findings (do not weaken tests, ignore globs or baselines) and call it again. Use `base` (e.g. "origin/main" or "HEAD") to check only what changed.',
|
|
23
|
+
inputSchema: {
|
|
24
|
+
type: 'object',
|
|
25
|
+
properties: {
|
|
26
|
+
path: { type: 'string', description: 'Repository directory (default: the server working directory). Must be inside it.' },
|
|
27
|
+
base: { type: 'string', description: 'Git ref: check only files changed against it. "HEAD" = uncommitted changes only.' },
|
|
28
|
+
failOn: { type: 'string', enum: ['high', 'medium', 'low', 'none'], description: 'Lowest severity that fails the verdict (default high).' },
|
|
29
|
+
engines: { type: 'array', items: { type: 'string', enum: ENGINE_IDS }, description: 'Only these engines (default: all).' },
|
|
30
|
+
},
|
|
31
|
+
additionalProperties: false,
|
|
32
|
+
},
|
|
33
|
+
outputSchema: {
|
|
34
|
+
type: 'object',
|
|
35
|
+
properties: {
|
|
36
|
+
verdict: { type: 'string', enum: ['PASS', 'FAIL', 'NOT_MEASURED'] },
|
|
37
|
+
exitCode: { type: 'integer' },
|
|
38
|
+
reason: { type: 'string' },
|
|
39
|
+
blocking: { type: 'integer' },
|
|
40
|
+
findings: { type: 'array', items: { type: 'object' } },
|
|
41
|
+
engines: { type: 'array', items: { type: 'object' } },
|
|
42
|
+
evidencePath: { type: 'string' },
|
|
43
|
+
evidenceDigest: { type: 'string' },
|
|
44
|
+
},
|
|
45
|
+
required: ['verdict', 'exitCode'],
|
|
46
|
+
},
|
|
47
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
name: 'explain_finding',
|
|
51
|
+
title: 'Explain a finding and re-run its proof',
|
|
52
|
+
description: 'Explain a rule (what it catches, why it matters, how to fix it), show the planted defect its negative control catches, and — when file (and line) are given — re-run the proof on that file now: REPRODUCED yes/no.',
|
|
53
|
+
inputSchema: {
|
|
54
|
+
type: 'object',
|
|
55
|
+
properties: {
|
|
56
|
+
engine: { type: 'string', enum: ENGINE_IDS },
|
|
57
|
+
rule: { type: 'string', description: 'Rule id, e.g. NO_ASSERTION or TENANT_FILTER_MISSING.' },
|
|
58
|
+
file: { type: 'string', description: 'Repository-relative file to re-check.' },
|
|
59
|
+
line: { type: 'integer', minimum: 1 },
|
|
60
|
+
path: { type: 'string', description: 'Repository directory (default: the server working directory).' },
|
|
61
|
+
},
|
|
62
|
+
required: ['engine'],
|
|
63
|
+
additionalProperties: false,
|
|
64
|
+
},
|
|
65
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
name: 'list_rules',
|
|
69
|
+
title: 'List engines and rules',
|
|
70
|
+
description: 'List every engine, its rules (severity, what it catches, fix), where it came from, and its negative controls.',
|
|
71
|
+
inputSchema: { type: 'object', properties: { engine: { type: 'string', enum: ENGINE_IDS } }, additionalProperties: false },
|
|
72
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
73
|
+
},
|
|
74
|
+
]
|
|
75
|
+
|
|
76
|
+
class ToolError extends Error {}
|
|
77
|
+
|
|
78
|
+
/** A tool may only look inside the server's working directory (or FINI_PROOF_MCP_ROOTS, ':'-separated). */
|
|
79
|
+
function resolveRoot(p, cwd) {
|
|
80
|
+
const allowed = [cwd, ...(process.env.FINI_PROOF_MCP_ROOTS || '').split(':').filter(Boolean)].map((r) => { try { return realpathSync(r) } catch { return path.resolve(r) } })
|
|
81
|
+
let target
|
|
82
|
+
try { target = realpathSync(path.resolve(cwd, p || '.')) } catch { throw new ToolError(`path does not exist: ${p}`) }
|
|
83
|
+
if (!allowed.some((r) => target === r || target.startsWith(r + path.sep))) throw new ToolError(`path ${p} is outside the allowed roots (${allowed.join(', ')}); set FINI_PROOF_MCP_ROOTS to widen`)
|
|
84
|
+
return target
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function toolCheck(args, { cwd, executeCheck, maker }) {
|
|
88
|
+
const root = resolveRoot(args.path, cwd)
|
|
89
|
+
if (args.failOn && !['high', 'medium', 'low', 'none'].includes(args.failOn)) throw new ToolError('failOn must be high|medium|low|none')
|
|
90
|
+
const x = executeCheck({ root, base: args.base, failOn: args.failOn, engines: args.engines || [], maker })
|
|
91
|
+
if (x.error) {
|
|
92
|
+
const verdict = 'NOT_MEASURED' // a licence refusal or a usage error: nothing was verified
|
|
93
|
+
return { isError: true, text: `VERDICT: ${verdict} — ${x.error.replace(/^VERDICT: NOT_MEASURED — /, '')}\nThe work is NOT verified.`, structured: { verdict, exitCode: x.rc, reason: x.error } }
|
|
94
|
+
}
|
|
95
|
+
const r = x.result
|
|
96
|
+
const blocking = r.findings.filter((f) => f.blocking)
|
|
97
|
+
const head = r.verdict === 'PASS'
|
|
98
|
+
? 'VERDICT: PASS — every measured check passed. Evidence recorded.'
|
|
99
|
+
: r.verdict === 'FAIL'
|
|
100
|
+
? `VERDICT: FAIL — ${blocking.length} blocking finding(s). The task is NOT done: fix each finding at its file:line, then call check again. Do not weaken tests, add ignore globs or raise baselines to make this pass.`
|
|
101
|
+
: `VERDICT: NOT_MEASURED — ${r.reason}. The work is NOT verified; do not report it as done.`
|
|
102
|
+
return {
|
|
103
|
+
isError: false,
|
|
104
|
+
text: `${head}\n\n${toText(r, { evidencePath: path.relative(root, x.evidencePath) })}`,
|
|
105
|
+
structured: {
|
|
106
|
+
verdict: r.verdict,
|
|
107
|
+
exitCode: x.rc,
|
|
108
|
+
...(r.reason ? { reason: r.reason } : {}),
|
|
109
|
+
blocking: blocking.length,
|
|
110
|
+
findings: r.findings.map(({ file, line, rule, engine, severity, blocking: b, message, proof, negativeControl }) => ({ file, line, rule, engine, severity, blocking: b, message, proof, negativeControl })),
|
|
111
|
+
engines: r.engines.map((e) => ({ id: e.id, status: e.status, ...(e.reason ? { reason: e.reason } : {}), findings: e.findings || 0 })),
|
|
112
|
+
evidencePath: x.evidencePath,
|
|
113
|
+
evidenceDigest: r.evidenceDigest,
|
|
114
|
+
},
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function toolExplain(args, { cwd }) {
|
|
119
|
+
const engine = ENGINES.find((e) => e.id === args.engine)
|
|
120
|
+
if (!engine) throw new ToolError(`unknown engine ${args.engine}`)
|
|
121
|
+
const cat = ruleCatalogue().find((c) => c.engine === engine.id)
|
|
122
|
+
const rule = args.rule ? cat.rules.find((r) => r.id === args.rule) : null
|
|
123
|
+
if (args.rule && !rule) throw new ToolError(`engine ${engine.id} has no rule ${args.rule}; known: ${cat.rules.map((r) => r.id).join(', ')}`)
|
|
124
|
+
const ctl = engine.negativeControls.find((c) => c.expect === args.rule) || engine.negativeControls[0]
|
|
125
|
+
const L = [`${engine.id} ${engine.version} — ${engine.title}`, `source: ${engine.provenance}`]
|
|
126
|
+
if (rule) L.push('', `${rule.id} (${rule.severity}): ${rule.summary}`, `fix: ${rule.fix || '-'}`)
|
|
127
|
+
L.push('', `negative control ${ctl.id} — this planted defect in ${ctl.file} must be caught (${ctl.expect}) before every run, or the engine is NOT_MEASURED:`, ctl.content.replace(/\n$/, ''))
|
|
128
|
+
const structured = { engine: engine.id, rule: rule || null, negativeControl: { id: ctl.id, file: ctl.file, expect: ctl.expect, content: ctl.content } }
|
|
129
|
+
if (args.file) {
|
|
130
|
+
const root = resolveRoot(args.path, cwd)
|
|
131
|
+
const p = prove(root, engine.id, args.file, args.line ?? null)
|
|
132
|
+
if (p.status !== 'OK') {
|
|
133
|
+
L.push('', `proof: NOT_MEASURED — ${p.reason}`)
|
|
134
|
+
structured.proof = { status: 'NOT_MEASURED', reason: p.reason }
|
|
135
|
+
} else {
|
|
136
|
+
const hits = args.rule ? p.hits.filter((h) => h.rule === args.rule) : p.hits
|
|
137
|
+
const src = p.content.split('\n')
|
|
138
|
+
L.push('', `proof · ${args.file}${args.line ? `:${args.line}` : ''} · sha256 ${p.sha.slice(0, 16)}…`)
|
|
139
|
+
for (const f of hits) {
|
|
140
|
+
L.push(` ${f.rule} (${f.severity}) at ${args.file}:${f.line}`)
|
|
141
|
+
for (let i = Math.max(1, f.line - 1); i <= Math.min(src.length, f.line + 2); i++) L.push(` ${String(i).padStart(4)} ${i === f.line ? '>' : ' '} ${src[i - 1]}`)
|
|
142
|
+
L.push(` ${f.message}`)
|
|
143
|
+
}
|
|
144
|
+
L.push(hits.length ? `REPRODUCED: yes — ${hits.length} finding(s)` : 'REPRODUCED: no — the defect is not present there (fixed).')
|
|
145
|
+
structured.proof = { status: 'OK', reproduced: hits.length > 0, sha256: p.sha, hits: hits.map(({ line, rule: r, severity, message }) => ({ line, rule: r, severity, message })) }
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return { isError: false, text: L.join('\n'), structured }
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function toolListRules(args) {
|
|
152
|
+
const cat = ruleCatalogue().filter((c) => !args.engine || c.engine === args.engine)
|
|
153
|
+
const L = []
|
|
154
|
+
for (const c of cat) {
|
|
155
|
+
L.push(`${c.engine} ${c.version} — ${c.title}`, ` source: ${c.provenance}`)
|
|
156
|
+
for (const r of c.rules) L.push(` ${r.id.padEnd(26)} ${r.severity.padEnd(6)} ${r.summary}`)
|
|
157
|
+
L.push(` negative controls: ${c.negativeControls.join(', ')}`, '')
|
|
158
|
+
}
|
|
159
|
+
return { isError: false, text: L.join('\n').trimEnd(), structured: { engines: cat } }
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const HANDLERS = { check: toolCheck, explain_finding: toolExplain, list_rules: toolListRules }
|
|
163
|
+
|
|
164
|
+
/** Pure request handler (tested directly and over stdio). Returns a response object, or null for notifications. */
|
|
165
|
+
export function createMcpHandler({ cwd = process.cwd(), executeCheck } = {}) {
|
|
166
|
+
let initialized = false
|
|
167
|
+
// evidence@2 attestation: the MCP client that calls `check` is the agent whose work is being verified.
|
|
168
|
+
let maker = null
|
|
169
|
+
const ok = (id, result) => ({ jsonrpc: '2.0', id, result })
|
|
170
|
+
const fail = (id, code, message) => ({ jsonrpc: '2.0', id, error: { code, message } })
|
|
171
|
+
return function handle(msg) {
|
|
172
|
+
if (!msg || typeof msg !== 'object' || msg.jsonrpc !== '2.0' || typeof msg.method !== 'string') {
|
|
173
|
+
return msg && msg.id !== undefined && msg.method === undefined ? null : fail(msg?.id ?? null, -32600, 'Invalid Request')
|
|
174
|
+
}
|
|
175
|
+
const isNotification = msg.id === undefined || msg.id === null
|
|
176
|
+
if (isNotification) { if (msg.method === 'notifications/initialized') initialized = true; return null }
|
|
177
|
+
const { id, method, params = {} } = msg
|
|
178
|
+
switch (method) {
|
|
179
|
+
case 'initialize': {
|
|
180
|
+
initialized = true
|
|
181
|
+
const ci = params.clientInfo
|
|
182
|
+
if (ci && typeof ci.name === 'string' && ci.name.trim()) maker = { kind: 'agent', id: ci.name, source: 'mcp-client', ...(typeof ci.version === 'string' ? { model: undefined } : {}) }
|
|
183
|
+
const want = params.protocolVersion
|
|
184
|
+
return ok(id, {
|
|
185
|
+
protocolVersion: PROTOCOL_VERSIONS.includes(want) ? want : PROTOCOL_VERSIONS[0],
|
|
186
|
+
capabilities: { tools: { listChanged: false } },
|
|
187
|
+
serverInfo: { name: 'fini-proof', title: 'Fini Proof', version: TOOL_VERSION },
|
|
188
|
+
instructions: 'Fini Proof is an independent verifier. Before you tell the user a coding task is done, call `check` (base "HEAD" for uncommitted work, or the target branch). A FAIL or NOT_MEASURED verdict means the work is not done. Never make it pass by weakening tests, ignore globs, baselines or the verifier config.',
|
|
189
|
+
})
|
|
190
|
+
}
|
|
191
|
+
case 'ping': return ok(id, {})
|
|
192
|
+
case 'tools/list': return ok(id, { tools: TOOLS })
|
|
193
|
+
case 'tools/call': {
|
|
194
|
+
if (!initialized) return fail(id, -32002, 'server not initialized')
|
|
195
|
+
const h = HANDLERS[params.name]
|
|
196
|
+
if (!h) return fail(id, -32602, `Unknown tool: ${params.name}`)
|
|
197
|
+
const args = params.arguments || {}
|
|
198
|
+
if (typeof args !== 'object' || Array.isArray(args)) return fail(id, -32602, 'arguments must be an object')
|
|
199
|
+
try {
|
|
200
|
+
const r = h(args, { cwd, executeCheck, maker })
|
|
201
|
+
return ok(id, { content: [{ type: 'text', text: r.text }], structuredContent: r.structured, isError: r.isError })
|
|
202
|
+
} catch (e) {
|
|
203
|
+
// A tool-level failure is reported IN the result (the agent sees it), never as a silent success.
|
|
204
|
+
return ok(id, { content: [{ type: 'text', text: `${params.name} failed: ${e.message}` }], isError: true })
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
default: return fail(id, -32601, `Method not found: ${method}`)
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Serve on stdio until stdin closes. Resolves to exit code 0. */
|
|
213
|
+
export async function serveMcp({ input = process.stdin, output = process.stdout, cwd = process.cwd() } = {}) {
|
|
214
|
+
const { executeCheck } = await import('./cli.mjs')
|
|
215
|
+
const handle = createMcpHandler({ cwd, executeCheck })
|
|
216
|
+
const send = (m) => output.write(JSON.stringify(m) + '\n')
|
|
217
|
+
const rl = createInterface({ input, crlfDelay: Infinity })
|
|
218
|
+
for await (const line of rl) {
|
|
219
|
+
if (!line.trim()) continue
|
|
220
|
+
let msg
|
|
221
|
+
try { msg = JSON.parse(line) } catch { send({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } }); continue }
|
|
222
|
+
const batch = Array.isArray(msg)
|
|
223
|
+
const replies = (batch ? msg : [msg]).map((m) => {
|
|
224
|
+
try { return handle(m) } catch (e) { return { jsonrpc: '2.0', id: m?.id ?? null, error: { code: -32603, message: `Internal error: ${e.message}` } } }
|
|
225
|
+
}).filter(Boolean)
|
|
226
|
+
if (batch && replies.length) send(replies)
|
|
227
|
+
else for (const r of replies) send(r)
|
|
228
|
+
}
|
|
229
|
+
return 0
|
|
230
|
+
}
|