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.
@@ -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
+ }
@@ -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
+ }