@prism-d1/cli 1.10.0 → 1.11.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/dist/assets/github-workflows/prism-eval-gate-kiro.yml +2 -2
- package/dist/assets/gitlab-workflows/prism-eval-gate.yml +2 -2
- package/dist/assets/kirocrew-app/skills/prism-fix-findings/SKILL.md +19 -12
- package/dist/src/codewatch/config.d.ts +70 -0
- package/dist/src/codewatch/config.d.ts.map +1 -1
- package/dist/src/codewatch/config.js +143 -0
- package/dist/src/codewatch/config.js.map +1 -1
- package/dist/src/codewatch/gitleaks.d.ts +67 -0
- package/dist/src/codewatch/gitleaks.d.ts.map +1 -1
- package/dist/src/codewatch/gitleaks.js +158 -2
- package/dist/src/codewatch/gitleaks.js.map +1 -1
- package/dist/src/codewatch/harness/claude.d.ts +83 -0
- package/dist/src/codewatch/harness/claude.d.ts.map +1 -0
- package/dist/src/codewatch/harness/claude.js +429 -0
- package/dist/src/codewatch/harness/claude.js.map +1 -0
- package/dist/src/codewatch/harness/diff.d.ts +51 -0
- package/dist/src/codewatch/harness/diff.d.ts.map +1 -0
- package/dist/src/codewatch/harness/diff.js +227 -0
- package/dist/src/codewatch/harness/diff.js.map +1 -0
- package/dist/src/codewatch/harness/engine.d.ts +127 -0
- package/dist/src/codewatch/harness/engine.d.ts.map +1 -0
- package/dist/src/codewatch/harness/engine.js +683 -0
- package/dist/src/codewatch/harness/engine.js.map +1 -0
- package/dist/src/codewatch/harness/state.d.ts +48 -0
- package/dist/src/codewatch/harness/state.d.ts.map +1 -0
- package/dist/src/codewatch/harness/state.js +168 -0
- package/dist/src/codewatch/harness/state.js.map +1 -0
- package/dist/src/codewatch/prepush.d.ts +4 -1
- package/dist/src/codewatch/prepush.d.ts.map +1 -1
- package/dist/src/codewatch/prepush.js +27 -25
- package/dist/src/codewatch/prepush.js.map +1 -1
- package/dist/src/codewatch/pushbase.d.ts +19 -0
- package/dist/src/codewatch/pushbase.d.ts.map +1 -0
- package/dist/src/codewatch/pushbase.js +82 -0
- package/dist/src/codewatch/pushbase.js.map +1 -0
- package/dist/src/codewatch/results.d.ts +32 -3
- package/dist/src/codewatch/results.d.ts.map +1 -1
- package/dist/src/codewatch/results.js +6 -2
- package/dist/src/codewatch/results.js.map +1 -1
- package/dist/src/codewatch/reviewers/claude.d.ts.map +1 -1
- package/dist/src/codewatch/reviewers/claude.js +4 -2
- package/dist/src/codewatch/reviewers/claude.js.map +1 -1
- package/dist/src/codewatch/reviewers/index.d.ts +3 -1
- package/dist/src/codewatch/reviewers/index.d.ts.map +1 -1
- package/dist/src/codewatch/reviewers/index.js +4 -2
- package/dist/src/codewatch/reviewers/index.js.map +1 -1
- package/dist/src/codewatch/reviewers/kiro.d.ts.map +1 -1
- package/dist/src/codewatch/reviewers/kiro.js +2 -1
- package/dist/src/codewatch/reviewers/kiro.js.map +1 -1
- package/dist/src/codewatch/reviewers/types.d.ts +4 -0
- package/dist/src/codewatch/reviewers/types.d.ts.map +1 -1
- package/dist/src/codewatch/reviewers/types.js.map +1 -1
- package/dist/src/codewatch/rules.d.ts +13 -0
- package/dist/src/codewatch/rules.d.ts.map +1 -1
- package/dist/src/codewatch/rules.js +20 -1
- package/dist/src/codewatch/rules.js.map +1 -1
- package/dist/src/commands/_top/mcp.js +1 -1
- package/dist/src/commands/_top/mcp.js.map +1 -1
- package/dist/src/commands/codewatch/hook.d.ts +17 -0
- package/dist/src/commands/codewatch/hook.d.ts.map +1 -0
- package/dist/src/commands/codewatch/hook.js +46 -0
- package/dist/src/commands/codewatch/hook.js.map +1 -0
- package/dist/src/commands/codewatch/review.d.ts +25 -0
- package/dist/src/commands/codewatch/review.d.ts.map +1 -0
- package/dist/src/commands/codewatch/review.js +80 -0
- package/dist/src/commands/codewatch/review.js.map +1 -0
- package/dist/src/commands/enable/codewatch.d.ts +34 -0
- package/dist/src/commands/enable/codewatch.d.ts.map +1 -1
- package/dist/src/commands/enable/codewatch.js +136 -4
- package/dist/src/commands/enable/codewatch.js.map +1 -1
- package/dist/src/dashboard/page.js +1 -1
- package/dist/src/dashboard/payload.d.ts +2 -0
- package/dist/src/dashboard/payload.d.ts.map +1 -1
- package/dist/src/dashboard/payload.js +1 -1
- package/dist/src/dashboard/payload.js.map +1 -1
- package/dist/src/dashboard/setup-page.d.ts +1 -1
- package/dist/src/dashboard/setup-page.d.ts.map +1 -1
- package/dist/src/dashboard/setup-page.js +1 -1
- package/dist/src/dashboard/setup.d.ts +5 -0
- package/dist/src/dashboard/setup.d.ts.map +1 -1
- package/dist/src/dashboard/setup.js +15 -4
- package/dist/src/dashboard/setup.js.map +1 -1
- package/dist/src/index.js +1 -1
- package/dist/src/index.js.map +1 -1
- package/dist/src/mcp/server.js +2 -2
- package/dist/src/mcp/server.js.map +1 -1
- package/dist/src/mcp/tools.d.ts +9 -1
- package/dist/src/mcp/tools.d.ts.map +1 -1
- package/dist/src/mcp/tools.js +49 -1
- package/dist/src/mcp/tools.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,683 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The harness loop engine: `prism-cli codewatch review --json`.
|
|
3
|
+
*
|
|
4
|
+
* One call reviews the lines an agent changed and answers pass, fix or stop
|
|
5
|
+
* (docs/DESIGN-harness-loop.md sections 2.4, 3, 4, 5, 6 and 12):
|
|
6
|
+
*
|
|
7
|
+
* 1. diff for the scope (empty: pass, no cost)
|
|
8
|
+
* 2. tamper check on the session's changed paths
|
|
9
|
+
* 3. gitleaks on the changed files, redacted, filtered to the changed lines
|
|
10
|
+
* 4. AI review with the existing reviewer resolution, skipped when a secret
|
|
11
|
+
* was found, for docs-only diffs, and when the push gate is off; one retry
|
|
12
|
+
* only when the reply cannot be parsed; fail open after that (D1)
|
|
13
|
+
* 5. scope filter, block_on, round counter and loop guard
|
|
14
|
+
* 6. result file under .prism/results/ (kind agent-harness)
|
|
15
|
+
*
|
|
16
|
+
* It never commits, pushes, stages or edits tracked files. It writes only
|
|
17
|
+
* under .prism/results/ and .prism/harness/.
|
|
18
|
+
*/
|
|
19
|
+
import { createHash } from 'node:crypto';
|
|
20
|
+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
21
|
+
import { tmpdir } from 'node:os';
|
|
22
|
+
import { join, relative } from 'node:path';
|
|
23
|
+
import { HARNESS_MAX_ROUNDS_CEILING, modelLabel, readEvalConfig, readHarnessConfig, resolveReviewModel, reviewModelGitKey, REVIEWER_GIT_CONFIG_KEY } from '../config.js';
|
|
24
|
+
import { GITLEAKS_SCAN_TIMEOUT_MS, gitleaksInstallHint, safeWrite, scanCommits, scanFiles } from '../gitleaks.js';
|
|
25
|
+
import { ensureResultsExcluded, makeResult } from '../results.js';
|
|
26
|
+
import { ADAPTERS, detectReviewers, findOnPath, normalizeReview, resolveReviewer, runReview, toolVersion } from '../reviewers/index.js';
|
|
27
|
+
import { fallbackPushBase } from '../pushbase.js';
|
|
28
|
+
import { loadReviewRules } from '../rules.js';
|
|
29
|
+
import { diffTrees, gitIn, matchesAny, parseDiff, readBlobs, revParse, objectExists, snapshotTree, touchedPaths } from './diff.js';
|
|
30
|
+
import { cleanupState, ensureHarnessDir, loadState, readCache, saveState, STATE_VERSION, writeCache } from './state.js';
|
|
31
|
+
export const VERDICT_SCHEMA = 'prism.harness.verdict';
|
|
32
|
+
export const VERDICT_VERSION = 1;
|
|
33
|
+
/** The harness reviews a smaller diff than the pre-push gate (200,000), because a person waits on it. */
|
|
34
|
+
export const MAX_HARNESS_DIFF_CHARS = 60_000;
|
|
35
|
+
/** Set in the reviewer's environment, so a reviewer that loads the same plugin does not re-enter the hook. */
|
|
36
|
+
export const HARNESS_CHILD_ENV = 'PRISM_HARNESS_CHILD';
|
|
37
|
+
export const RETRY_NOTE = 'Your previous reply was not valid JSON. Return exactly one JSON object matching that schema and nothing else: no prose, no code fences.';
|
|
38
|
+
export const SCOPES = ['new-lines', 'files', 'branch'];
|
|
39
|
+
/** Paths whose change during a session ends the loop (section 6.2). `.prism/results/**` and
|
|
40
|
+
* `.prism/harness/**` are on the design's list but never appear here: every diff and snapshot
|
|
41
|
+
* excludes PRISM's own output, so an edit to them is never seen (the section 6.2 non-goal). */
|
|
42
|
+
export const TAMPER_GLOBS = [
|
|
43
|
+
'.gitleaksignore', '.gitleaks.toml', '.prism/gitleaks-baseline.json',
|
|
44
|
+
'.prism/config.json', '.prism/review/rules.md',
|
|
45
|
+
'.kiro/steering/code-review.md', '.github/workflows/prism-*.yml', '.prism/gitlab-workflows/**',
|
|
46
|
+
'.claude/settings*.json', 'claude-plugin/hooks/**', '.cursor/hooks.json', '.codex/hooks.json', '.codex/config.toml', '.kiro/hooks/**',
|
|
47
|
+
];
|
|
48
|
+
/** Tamper globs are anchored at the repo root, unlike skip_paths. */
|
|
49
|
+
const TAMPER_RES = TAMPER_GLOBS.map((g) => new RegExp(`^${g.split('**').map((p) => p.split('*').map((s) => s.replace(/[.+^${}()|[\]\\?]/g, '\\$&')).join('[^/]*')).join('.*')}$`));
|
|
50
|
+
export const isTamperPath = (p) => TAMPER_RES.some((re) => re.test(p));
|
|
51
|
+
/** Files whose content is the scanner's own configuration: never copied into the scan. */
|
|
52
|
+
const SCANNER_CONFIG = new Set(['.gitleaks.toml', '.gitleaksignore', '.prism/gitleaks-baseline.json']);
|
|
53
|
+
const SEV_RANK = { high: 0, medium: 1, low: 2 };
|
|
54
|
+
const where = (f) => `${f.file}${f.line ? `:${f.line}` : ''}`;
|
|
55
|
+
const plural = (n, w) => `${n} ${w}${n === 1 ? '' : 's'}`;
|
|
56
|
+
function emptyVerdict(opts) {
|
|
57
|
+
return {
|
|
58
|
+
schema: VERDICT_SCHEMA, version: VERDICT_VERSION, verdict: 'pass', round: 1, max_rounds: opts.maxRounds,
|
|
59
|
+
session: opts.session, scope: opts.scope, diff: { files: 0, added_lines: 0, hash: null, cached: false },
|
|
60
|
+
secrets_scanned: false, secrets: [], commit_secrets: [], block_on: opts.blockOn, findings: [], dropped: { out_of_scope: 0, unknown_file: 0 },
|
|
61
|
+
reasons: [], next_action: '', human_needed: false, human_why: null, warnings: opts.warnings, reviewer: null, result_path: null, fail_open: false,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* The base a branch-scope review compares the tip with: `since` when given, the
|
|
66
|
+
* upstream, then what the push writes to and the shared fallback chain
|
|
67
|
+
* (pushbase.ts), down to the commits on no remote and, on a first push, the
|
|
68
|
+
* empty tree. Never "HEAD": that would review only uncommitted work while
|
|
69
|
+
* commits leave the machine. Null only when git itself fails.
|
|
70
|
+
*/
|
|
71
|
+
function branchBase(repoRoot, tip, since, push, warnings) {
|
|
72
|
+
const merge = (ref, label) => {
|
|
73
|
+
const sha = revParse(repoRoot, `${ref}^{commit}`);
|
|
74
|
+
if (!sha)
|
|
75
|
+
return null;
|
|
76
|
+
const mb = gitIn(repoRoot, ['merge-base', sha, tip]);
|
|
77
|
+
return mb.ok && mb.stdout ? { base: mb.stdout, label, emptyTree: false, note: null } : null;
|
|
78
|
+
};
|
|
79
|
+
if (since && !since.startsWith('-')) {
|
|
80
|
+
const b = merge(since, since);
|
|
81
|
+
if (b)
|
|
82
|
+
return b;
|
|
83
|
+
warnings.push(`--since ${since.slice(0, 100)} is not a commit git knows; using the upstream or remote branch instead`);
|
|
84
|
+
}
|
|
85
|
+
const up = merge('@{upstream}', '@{upstream}');
|
|
86
|
+
if (up)
|
|
87
|
+
return up;
|
|
88
|
+
const branch = gitIn(repoRoot, ['symbolic-ref', '--quiet', '--short', 'HEAD']).stdout || null;
|
|
89
|
+
const b = fallbackPushBase(repoRoot, { remote: push?.remote, dst: push?.dst, branch, tip });
|
|
90
|
+
if (b?.note)
|
|
91
|
+
warnings.push(b.note);
|
|
92
|
+
return b;
|
|
93
|
+
}
|
|
94
|
+
function hashOf(parts) {
|
|
95
|
+
const h = createHash('sha256');
|
|
96
|
+
for (const p of parts) {
|
|
97
|
+
h.update(p);
|
|
98
|
+
h.update('\0');
|
|
99
|
+
}
|
|
100
|
+
return `sha256:${h.digest('hex')}`;
|
|
101
|
+
}
|
|
102
|
+
/** Line `n` (1-based) of a buffer, without its newline. */
|
|
103
|
+
function lineOf(buf, n) {
|
|
104
|
+
if (!buf)
|
|
105
|
+
return null;
|
|
106
|
+
const lines = buf.toString('utf8').split('\n');
|
|
107
|
+
return n >= 1 && n <= lines.length ? lines[n - 1].replace(/\r$/, '') : null;
|
|
108
|
+
}
|
|
109
|
+
/** Up to five items, then "and N more", the way the secrets list is capped. */
|
|
110
|
+
const listFive = (items) => `${items.slice(0, 5).join(', ')}${items.length > 5 ? ` and ${items.length - 5} more` : ''}`;
|
|
111
|
+
/** A path from the diff, quoted so a file name cannot read as an instruction. */
|
|
112
|
+
const quotePath = (p) => JSON.stringify(p.length > 200 ? `${p.slice(0, 200)}...` : p);
|
|
113
|
+
/** The tamper stop text, naming what tripped it: the guarded files changed, and any gitleaks:allow markers added. */
|
|
114
|
+
function tamperAction(integ, session) {
|
|
115
|
+
const parts = [];
|
|
116
|
+
if (integ.tamper.length)
|
|
117
|
+
parts.push(`${session ? 'This session changed' : 'This change edits'} PRISM, secret-scan or hook configuration: ${listFive(integ.tamper.map(quotePath))}.`);
|
|
118
|
+
if (integ.allowMarkers.length)
|
|
119
|
+
parts.push(`${session ? 'This session added' : 'This change adds'} a gitleaks:allow marker at ${listFive(integ.allowMarkers.map((m) => quotePath(`${m.file}:${m.line}`)))}.`);
|
|
120
|
+
return `Stop. ${parts.join(' ')} Tell the person what you changed there and why; do not undo or repeat the change to get past the check.`;
|
|
121
|
+
}
|
|
122
|
+
/** The stop text for a secret in a commit of the range: which commit, why a new commit does not help, what the person does. */
|
|
123
|
+
function committedAction(hits, base) {
|
|
124
|
+
const commits = [...new Set(hits.map((h) => h.commit.slice(0, 12)))];
|
|
125
|
+
const where = listFive(hits.map((h) => quotePath(`${h.file}:${h.line ?? '?'}`)));
|
|
126
|
+
const onto = base && /^[0-9a-f]{40,64}$/.test(base) ? ` (for example git rebase -i ${base.slice(0, 12)}, or a reset onto ${base.slice(0, 12)} and a new commit without the secret)` : ' (for example an interactive rebase onto the base, or a reset onto it and a new commit without the secret)';
|
|
127
|
+
return `Stop. A secret is in commit ${listFive(commits)} at ${where}, which this push would send. Removing it in a new commit does not help: the earlier commit still carries the value. Do not push. Tell the person: they must rotate the key, then rewrite the unpushed history so no commit holds it${onto} before pushing. Offer to do the history rewrite only if the person asks.`;
|
|
128
|
+
}
|
|
129
|
+
/** Fixed-template text for the agent. Never contains reviewer prose. */
|
|
130
|
+
function nextAction(v, integ, base, event) {
|
|
131
|
+
if (v.verdict === 'pass' && event === 'pre-push' && v.findings.length) {
|
|
132
|
+
return `The push is allowed: the PRISM push check blocks only on secrets, secret-scan failures and changes to the checks. The review found ${plural(v.findings.length, 'issue')} in this push, listed below; they are candidates for a follow-up change.`;
|
|
133
|
+
}
|
|
134
|
+
if (v.verdict === 'pass')
|
|
135
|
+
return v.findings.length ? 'No blocking findings. The non-blocking findings are listed for information.' : 'No blocking findings.';
|
|
136
|
+
if (v.verdict === 'stop') {
|
|
137
|
+
switch (v.human_why) {
|
|
138
|
+
case 'tamper': return integ && (integ.tamper.length || integ.allowMarkers.length)
|
|
139
|
+
? tamperAction(integ, v.session !== null)
|
|
140
|
+
: 'Stop. This change touched PRISM, secret-scan or hook configuration. Tell the person what you changed there and why; do not undo or repeat the change to get past the check.';
|
|
141
|
+
case 'secrets-scan-failed': return 'Stop. The secret scan did not finish or gave no verdict, so the push is held. Tell the person why (see the warnings): a timeout means the scan needs more time than the hook allows; otherwise check gitleaks and its config.';
|
|
142
|
+
case 'push-unknown': return 'Stop. PRISM could not tell which commits this push would send, so it is held. Push one branch at a time with an explicit remote and branch (git push origin <branch>), or tell the person why it was held.';
|
|
143
|
+
case 'max-rounds': return `Stop fixing: the ${plural(v.max_rounds, 'fix round')} for this session are used. Report the remaining blocking findings to the person${v.secrets.length ? ', and do not push while a secret remains' : ''}.`;
|
|
144
|
+
case 'no-progress': return 'Stop: nothing changed since the last review. Report the remaining blocking findings to the person instead of trying again.';
|
|
145
|
+
case 'secret-committed': return v.commit_secrets.length
|
|
146
|
+
? committedAction(v.commit_secrets, base)
|
|
147
|
+
: 'Stop. A secret is already in a commit. Tell the person: it must be rotated and the history cleaned; removing the line does not un-expose it.';
|
|
148
|
+
default: return 'Stop and report the remaining findings to the person.';
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
const parts = [];
|
|
152
|
+
if (v.secrets.length) {
|
|
153
|
+
parts.push(`Remove the secret${v.secrets.length > 1 ? 's' : ''} at ${v.secrets.slice(0, 5).map(where).join(', ')} and read the value from an environment variable or the project's secret store; if the project has an example env file, add a placeholder there. Do not add an allowlist entry or a gitleaks:allow comment.`);
|
|
154
|
+
if (v.secrets.some((s) => s.committed))
|
|
155
|
+
parts.push('A secret is already in a commit: also tell the person it must be rotated, because removing the line does not un-expose it.');
|
|
156
|
+
}
|
|
157
|
+
const blocking = v.findings.filter((f) => f.blocking);
|
|
158
|
+
if (blocking.length)
|
|
159
|
+
parts.push(`${v.secrets.length ? 'Then fix' : 'Fix'} the blocking finding${blocking.length > 1 ? 's' : ''} at ${blocking.slice(0, 5).map(where).join(', ')}${blocking.length > 5 ? ` and ${blocking.length - 5} more` : ''}.`);
|
|
160
|
+
parts.push('Do not commit or push.');
|
|
161
|
+
return parts.join(' ');
|
|
162
|
+
}
|
|
163
|
+
function reasons(v) {
|
|
164
|
+
const scope = v.scope === 'new-lines' ? 'in lines changed this session' : v.scope === 'files' ? 'in files changed this session' : 'in this branch\'s changes';
|
|
165
|
+
const out = [];
|
|
166
|
+
if (v.secrets.length)
|
|
167
|
+
out.push(`${plural(v.secrets.length, 'secret')} ${scope}`);
|
|
168
|
+
if (v.commit_secrets.length)
|
|
169
|
+
out.push(`${plural(v.commit_secrets.length, 'secret')} in commits this push would send (commit ${[...new Set(v.commit_secrets.map((c) => c.commit.slice(0, 12)))].slice(0, 5).join(', ')})`);
|
|
170
|
+
const c = { high: 0, medium: 0, low: 0 };
|
|
171
|
+
for (const f of v.findings)
|
|
172
|
+
if (f.blocking)
|
|
173
|
+
c[f.severity]++;
|
|
174
|
+
for (const s of ['high', 'medium'])
|
|
175
|
+
if (c[s])
|
|
176
|
+
out.push(`${plural(c[s], `${s} finding`)} ${scope}`);
|
|
177
|
+
const info = v.findings.filter((f) => !f.blocking).length;
|
|
178
|
+
if (info)
|
|
179
|
+
out.push(`${plural(info, 'non-blocking finding')} (reported, not blocking)`);
|
|
180
|
+
if (v.human_why)
|
|
181
|
+
out.push(`human needed: ${v.human_why}`);
|
|
182
|
+
return out;
|
|
183
|
+
}
|
|
184
|
+
/** Loads the session state and picks the session's baseline; may narrow the scope to `files`. */
|
|
185
|
+
function loadSession(r) {
|
|
186
|
+
const { repoRoot, session, head, warnings } = r;
|
|
187
|
+
let state = session ? loadState(repoRoot, session, warnings) : null;
|
|
188
|
+
if (session && !state) {
|
|
189
|
+
state = { version: STATE_VERSION, session, client: r.opts.client ?? null, started_at: r.started, head, baseline_tree: null, rounds: 0, last: null };
|
|
190
|
+
}
|
|
191
|
+
if (!state)
|
|
192
|
+
return { state, sessionBase: head, rewritten: false };
|
|
193
|
+
let sessionBase = head;
|
|
194
|
+
if (state.baseline_tree && objectExists(repoRoot, state.baseline_tree))
|
|
195
|
+
sessionBase = state.baseline_tree;
|
|
196
|
+
else {
|
|
197
|
+
if (state.baseline_tree)
|
|
198
|
+
warnings.push('the session baseline snapshot is gone (git gc?); comparing against the commit the session started on');
|
|
199
|
+
else
|
|
200
|
+
warnings.push('no session baseline (the SessionStart hook did not run); uncommitted work from before the session counts as session work');
|
|
201
|
+
sessionBase = state.head && objectExists(repoRoot, state.head) ? state.head : head;
|
|
202
|
+
}
|
|
203
|
+
// A reset or rebase past the commit the session started on makes the session diff describe the
|
|
204
|
+
// rewrite, not the agent's edits: nothing judged on it (tamper) may decide this run.
|
|
205
|
+
let rewritten = !!state.head && !gitIn(repoRoot, ['merge-base', '--is-ancestor', state.head, 'HEAD']).ok;
|
|
206
|
+
if (rewritten && state.head && r.event === 'pre-push') {
|
|
207
|
+
// At a push, commits after the fork point with the session's start commit were all made
|
|
208
|
+
// during the session (earlier ones are its ancestors), so they still describe session work;
|
|
209
|
+
// tamper then counts only what the push carries (integrityChecks).
|
|
210
|
+
const fork = gitIn(repoRoot, ['merge-base', state.head, 'HEAD']);
|
|
211
|
+
if (fork.ok && fork.stdout) {
|
|
212
|
+
sessionBase = fork.stdout;
|
|
213
|
+
rewritten = false;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
if (rewritten && r.scope === 'new-lines') {
|
|
217
|
+
warnings.push('HEAD is no longer a descendant of the commit the session started on (branch switch or rebase); judging whole changed files this run');
|
|
218
|
+
r.scope = 'files';
|
|
219
|
+
}
|
|
220
|
+
return { state, sessionBase, rewritten };
|
|
221
|
+
}
|
|
222
|
+
/** The current tree, the base it is compared with, and the review and session diffs; or why the push target is unknown. */
|
|
223
|
+
function resolveDiffs(r) {
|
|
224
|
+
const { repoRoot, opts, warnings, head } = r;
|
|
225
|
+
const push = opts.push;
|
|
226
|
+
if (push?.unknown)
|
|
227
|
+
return { unknown: push.unknown };
|
|
228
|
+
const { state, sessionBase, rewritten } = loadSession(r);
|
|
229
|
+
// A push of something other than HEAD (`git push origin other:main`) reviews that commit's tree.
|
|
230
|
+
let tip = head;
|
|
231
|
+
let current = null;
|
|
232
|
+
if (push?.src && push.src !== 'HEAD') {
|
|
233
|
+
// A source git cannot resolve makes git refuse the push; HEAD is reviewed instead.
|
|
234
|
+
const src = revParse(repoRoot, `${push.src}^{commit}`);
|
|
235
|
+
if (src && src !== head) {
|
|
236
|
+
tip = src;
|
|
237
|
+
current = revParse(repoRoot, `${src}^{tree}`);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
if (!current)
|
|
241
|
+
current = opts.mode === 'staged' ? (gitIn(repoRoot, ['write-tree']).stdout || null) : snapshotTree(repoRoot, warnings);
|
|
242
|
+
if (!current)
|
|
243
|
+
throw new Error('could not snapshot the working tree');
|
|
244
|
+
let reviewBase;
|
|
245
|
+
let upstreamLabel = null;
|
|
246
|
+
if (opts.mode === 'staged')
|
|
247
|
+
reviewBase = head;
|
|
248
|
+
else if (r.scope === 'branch' || opts.mode === 'since' || !state || tip !== head) {
|
|
249
|
+
const b = branchBase(repoRoot, tip, opts.since, push, warnings);
|
|
250
|
+
if (!b) {
|
|
251
|
+
if (r.event === 'pre-push')
|
|
252
|
+
return { unknown: 'git could not tell which commits this push carries' };
|
|
253
|
+
warnings.push('git could not tell which commits are on a remote; reviewing uncommitted changes only');
|
|
254
|
+
reviewBase = head;
|
|
255
|
+
}
|
|
256
|
+
else {
|
|
257
|
+
reviewBase = b.base;
|
|
258
|
+
upstreamLabel = b.label;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
else
|
|
262
|
+
reviewBase = sessionBase;
|
|
263
|
+
const diffText = diffTrees(repoRoot, reviewBase, current);
|
|
264
|
+
if (diffText === null)
|
|
265
|
+
throw new Error('git diff failed');
|
|
266
|
+
const diff = parseDiff(diffText);
|
|
267
|
+
const sessionDiff = state && reviewBase !== sessionBase && opts.mode !== 'staged'
|
|
268
|
+
? parseDiff(diffTrees(repoRoot, sessionBase, current) ?? '')
|
|
269
|
+
: diff;
|
|
270
|
+
return { state, rewritten, tip, current, reviewBase, upstreamLabel, diffText, diff, sessionDiff };
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* Tamper rules (section 6.2). With a session: any listed path the session
|
|
274
|
+
* changed, and any `gitleaks:allow` it added. At a push, only what this push
|
|
275
|
+
* also carries counts: the baseline survives resume and a reused session id,
|
|
276
|
+
* so a path that differs from the baseline only because history was reset
|
|
277
|
+
* since then would otherwise deny a push that does not touch it. Without a
|
|
278
|
+
* session (or, outside a push, after a rewrite past its start) there are no fix rounds to
|
|
279
|
+
* guard and a branch may legitimately tune its own config, so only added
|
|
280
|
+
* `gitleaks:allow` lines count; the scanner config is handled by scanning
|
|
281
|
+
* with the base's copy instead (scannerOverride).
|
|
282
|
+
*/
|
|
283
|
+
function integrityChecks(r, d) {
|
|
284
|
+
if (!d.state || d.rewritten)
|
|
285
|
+
return { tamper: [], allowMarkers: d.diff.allowMarkers };
|
|
286
|
+
const tamper = touchedPaths(d.sessionDiff).filter(isTamperPath);
|
|
287
|
+
if (r.event !== 'pre-push')
|
|
288
|
+
return { tamper, allowMarkers: d.sessionDiff.allowMarkers };
|
|
289
|
+
const inPush = new Set(touchedPaths(d.diff));
|
|
290
|
+
const added = new Map(d.diff.files.map((f) => [f.path, f.added]));
|
|
291
|
+
return {
|
|
292
|
+
tamper: tamper.filter((p) => inPush.has(p)),
|
|
293
|
+
allowMarkers: d.sessionDiff.allowMarkers.filter((m) => added.get(m.file)?.has(m.line)),
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* Stateless runs: when the diff changes the scanner's own config, scan with
|
|
298
|
+
* the base's version of it, so a change cannot allowlist itself.
|
|
299
|
+
*/
|
|
300
|
+
function scannerOverride(r, d) {
|
|
301
|
+
if (d.state && !d.rewritten)
|
|
302
|
+
return undefined;
|
|
303
|
+
const changed = touchedPaths(d.diff).filter((p) => SCANNER_CONFIG.has(p));
|
|
304
|
+
if (!changed.length)
|
|
305
|
+
return undefined;
|
|
306
|
+
const blobs = readBlobs(r.repoRoot, d.reviewBase, [...SCANNER_CONFIG]);
|
|
307
|
+
r.warnings.push(`this change edits ${changed.join(', ')}; secrets were scanned with the base's version of the gitleaks config`);
|
|
308
|
+
return {
|
|
309
|
+
config: blobs.get('.gitleaks.toml') ?? null,
|
|
310
|
+
ignore: blobs.get('.gitleaksignore') ?? null,
|
|
311
|
+
baseline: blobs.get('.prism/gitleaks-baseline.json') ?? null,
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
/** gitleaks on the changed files, filtered to added lines (section 6.1), and in branch scope on every commit of the range. */
|
|
315
|
+
function secretScan(r, d) {
|
|
316
|
+
const { repoRoot, warnings, opts } = r;
|
|
317
|
+
const override = scannerOverride(r, d);
|
|
318
|
+
const scanPaths = d.diff.files.filter((f) => !f.deleted && f.added.size && !SCANNER_CONFIG.has(f.path)).map((f) => f.path);
|
|
319
|
+
const unreadable = [];
|
|
320
|
+
const blobs = readBlobs(repoRoot, d.current, scanPaths, unreadable);
|
|
321
|
+
const scan = timedScan(r, 'the changed files', (timeoutMs) => scanFiles(repoRoot, [...blobs].map(([path, content]) => ({ path, content })), opts.gitleaksBin, { override, timeoutMs }), { status: 'error', findings: [], version: null, reason: null, skipped: [] });
|
|
322
|
+
// A path git cannot hand over one per line is unscanned, like an unsafe one: never a clean result.
|
|
323
|
+
scan.skipped.push(...unreadable);
|
|
324
|
+
if (scan.status === 'missing')
|
|
325
|
+
warnings.push(`secrets not scanned: ${scan.reason}. Install it: ${gitleaksInstallHint()}`);
|
|
326
|
+
if (scan.status === 'error')
|
|
327
|
+
warnings.push(`secrets not scanned: ${scan.reason}`);
|
|
328
|
+
if (scan.skipped.length)
|
|
329
|
+
warnings.push(`secrets not scanned in ${scan.skipped.length} file(s) with an unsafe path: ${scan.skipped.slice(0, 5).map((p) => JSON.stringify(p.slice(0, 200))).join(', ')}`);
|
|
330
|
+
const addedIn = new Map(d.diff.files.map((f) => [f.path, f.added]));
|
|
331
|
+
const hits = scan.findings.filter((f) => f.line !== null && addedIn.get(f.file)?.has(f.line));
|
|
332
|
+
const headBlobs = hits.length ? readBlobs(repoRoot, d.tip, [...new Set(hits.map((f) => f.file))]) : new Map();
|
|
333
|
+
const secrets = hits.map((f) => {
|
|
334
|
+
const text = lineOf(blobs.get(f.file), f.line);
|
|
335
|
+
const headLines = headBlobs.get(f.file)?.toString('utf8').split('\n').map((l) => l.replace(/\r$/, ''));
|
|
336
|
+
return { file: f.file, line: f.line, rule: f.rule, committed: !!(text && text.trim() && headLines?.includes(text)) };
|
|
337
|
+
});
|
|
338
|
+
return { scan, secrets, history: commitScan(r, d, override) };
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* Branch scope: every commit the range carries, scanned one by one. The tree
|
|
342
|
+
* diff alone misses a secret one commit adds and a later one removes, yet that
|
|
343
|
+
* commit still leaves with the push (section 6.1, "push stays blocked").
|
|
344
|
+
*/
|
|
345
|
+
function commitScan(r, d, override) {
|
|
346
|
+
if (r.scope !== 'branch' || r.opts.mode === 'staged')
|
|
347
|
+
return null;
|
|
348
|
+
// A commit base gives `base..tip`; the empty tree (a first push) means the tip's whole history.
|
|
349
|
+
const range = revParse(r.repoRoot, `${d.reviewBase}^{commit}`) ? `${d.reviewBase}..${d.tip}` : d.tip;
|
|
350
|
+
const count = gitIn(r.repoRoot, ['rev-list', '--count', range]);
|
|
351
|
+
if (!count.ok || count.stdout === '0')
|
|
352
|
+
return null;
|
|
353
|
+
const h = timedScan(r, 'the pushed commits', (timeoutMs) => scanCommits(r.repoRoot, range, r.opts.gitleaksBin, { override, timeoutMs }), { status: 'error', findings: [], reason: null });
|
|
354
|
+
if (h.status === 'error')
|
|
355
|
+
r.warnings.push(`commits not scanned for secrets: ${h.reason}`);
|
|
356
|
+
return h;
|
|
357
|
+
}
|
|
358
|
+
/** Time kept back from the deadline for writing the state and the result and printing the answer. */
|
|
359
|
+
export const DEADLINE_RESERVE_MS = 5000;
|
|
360
|
+
/** Below this, a step is not started at all: it could not finish in time. */
|
|
361
|
+
const MIN_STEP_MS = 1000;
|
|
362
|
+
/** Milliseconds a step may still use under the run's deadline (Infinity without one). */
|
|
363
|
+
function remainingMs(r) {
|
|
364
|
+
return r.opts.deadlineMs === undefined ? Infinity : r.opts.deadlineMs - Date.now() - DEADLINE_RESERVE_MS;
|
|
365
|
+
}
|
|
366
|
+
/**
|
|
367
|
+
* Runs one gitleaks scan inside what is left of the deadline. When too little
|
|
368
|
+
* time is left to start, or the scan is cut off, the result is an error with a
|
|
369
|
+
* reason that says so; at push that holds the push (secrets-scan-failed)
|
|
370
|
+
* rather than letting the client kill the hook.
|
|
371
|
+
*/
|
|
372
|
+
function timedScan(r, what, run, failed) {
|
|
373
|
+
const left = remainingMs(r);
|
|
374
|
+
if (left < MIN_STEP_MS)
|
|
375
|
+
return { ...failed, reason: `no time left in the hook's budget to scan ${what}` };
|
|
376
|
+
const res = run(Number.isFinite(left) ? Math.min(left, GITLEAKS_SCAN_TIMEOUT_MS) : undefined);
|
|
377
|
+
return res.status === 'error' && res.reason?.includes('timed out') ? { ...res, reason: `scanning ${what} did not finish in time (${res.reason})` } : res;
|
|
378
|
+
}
|
|
379
|
+
/** Runs the reviewer once, plus one retry when the reply cannot be parsed, each attempt inside the remaining deadline. */
|
|
380
|
+
function callReviewer(r, adapter, req) {
|
|
381
|
+
const bin = findOnPath(adapter.binary, r.pathEnv) || adapter.binary;
|
|
382
|
+
const perAttempt = (r.opts.timeoutSeconds ?? r.cfg.timeout_seconds) * 1000;
|
|
383
|
+
const budget = () => Math.min(perAttempt, remainingMs(r));
|
|
384
|
+
const outOfTime = { ok: false, review: null, error: 'no time left in the hook\'s budget for the review', rawStdout: '' };
|
|
385
|
+
const childEnv = { [HARNESS_CHILD_ENV]: '1' };
|
|
386
|
+
if (budget() < MIN_STEP_MS)
|
|
387
|
+
return { res: outOfTime, attempts: 0 };
|
|
388
|
+
const res = runReview(adapter, req, budget(), bin, childEnv);
|
|
389
|
+
if (res.ok || !res.unparseable)
|
|
390
|
+
return { res, attempts: 1 };
|
|
391
|
+
if (budget() < MIN_STEP_MS)
|
|
392
|
+
return { res, attempts: 1 };
|
|
393
|
+
return { res: runReview(adapter, { ...req, retryNote: RETRY_NOTE }, budget(), bin, childEnv), attempts: 2 };
|
|
394
|
+
}
|
|
395
|
+
/** The AI review (section 2.4 step 5), from the cache when the diff hash was seen. `skipWhy` set: no reviewer call. */
|
|
396
|
+
function aiReview(r, d, skipWhy) {
|
|
397
|
+
const { repoRoot, warnings } = r;
|
|
398
|
+
const evalCfg = readEvalConfig(repoRoot);
|
|
399
|
+
const choice = resolveReviewer({
|
|
400
|
+
gitConfigValue: gitIn(repoRoot, ['config', '--get', REVIEWER_GIT_CONFIG_KEY]).stdout || null,
|
|
401
|
+
prismConfigValue: evalCfg.reviewer,
|
|
402
|
+
available: detectReviewers(r.pathEnv),
|
|
403
|
+
});
|
|
404
|
+
const adapter = choice.id ? ADAPTERS[choice.id] : null;
|
|
405
|
+
const modelChoice = resolveReviewModel({
|
|
406
|
+
reviewer: adapter?.id ?? null, prismConfigValue: evalCfg.model, prismConfigReviewer: evalCfg.reviewer,
|
|
407
|
+
gitConfigValue: adapter ? gitIn(repoRoot, ['config', '--get', reviewModelGitKey(adapter.id)]).stdout || null : null,
|
|
408
|
+
});
|
|
409
|
+
const rules = loadReviewRules(repoRoot);
|
|
410
|
+
const truncated = d.diffText.length > MAX_HARNESS_DIFF_CHARS;
|
|
411
|
+
const diffText = truncated ? d.diffText.slice(0, MAX_HARNESS_DIFF_CHARS) : d.diffText;
|
|
412
|
+
const hash = hashOf([diffText, hashOf([rules.rulesText]), adapter?.id ?? 'none', modelChoice.model ?? 'default', r.scope]);
|
|
413
|
+
const out = { findings: [], summary: null, score: null, hash, cached: false, failOpen: false, reviewer: null, toolVersion: null };
|
|
414
|
+
if (skipWhy) {
|
|
415
|
+
// Docs-only and tamper skips are routine or reported elsewhere; the rest are worth saying.
|
|
416
|
+
if (skipWhy !== SKIP_DOCS && skipWhy !== SKIP_TAMPER)
|
|
417
|
+
warnings.push(skipWhy);
|
|
418
|
+
return out;
|
|
419
|
+
}
|
|
420
|
+
if (!adapter) {
|
|
421
|
+
warnings.push(`AI review did not run: ${choice.note}. Install kiro-cli or Claude Code to get reviews; gitleaks still ran`);
|
|
422
|
+
return out;
|
|
423
|
+
}
|
|
424
|
+
for (const n of [choice.note, modelChoice.note, rules.note])
|
|
425
|
+
if (n)
|
|
426
|
+
warnings.push(n);
|
|
427
|
+
if (truncated)
|
|
428
|
+
warnings.push(`diff truncated to ${MAX_HARNESS_DIFF_CHARS} characters for the review; findings in the rest are not seen`);
|
|
429
|
+
const rt0 = Date.now();
|
|
430
|
+
let model = modelChoice.model;
|
|
431
|
+
let attempts = 0;
|
|
432
|
+
let cost = null;
|
|
433
|
+
const cached = readCache(repoRoot, hash);
|
|
434
|
+
if (cached) {
|
|
435
|
+
out.findings = normalizeReview({ findings: cached.findings }, null).findings;
|
|
436
|
+
out.summary = typeof cached.summary === 'string' ? cached.summary.slice(0, 500) : null;
|
|
437
|
+
out.score = typeof cached.score === 'number' ? cached.score : null;
|
|
438
|
+
model = typeof cached.model === 'string' ? cached.model : model;
|
|
439
|
+
out.cached = true;
|
|
440
|
+
}
|
|
441
|
+
else {
|
|
442
|
+
const tmp = mkdtempSync(join(tmpdir(), 'prism-harness-review-'));
|
|
443
|
+
try {
|
|
444
|
+
const diffPath = join(tmp, 'session.diff');
|
|
445
|
+
writeFileSync(diffPath, diffText, { mode: 0o600 });
|
|
446
|
+
const call = callReviewer(r, adapter, { repoRoot, rulesPath: rules.rulesPath, rulesText: rules.rulesText, diffPath, diff: diffText, model: model || undefined });
|
|
447
|
+
attempts = call.attempts;
|
|
448
|
+
if (call.res.ok && call.res.review) {
|
|
449
|
+
const rv = call.res.review;
|
|
450
|
+
Object.assign(out, { findings: rv.findings, summary: rv.summary, score: rv.score });
|
|
451
|
+
model = rv.model || model;
|
|
452
|
+
cost = rv.cost_usd ?? null;
|
|
453
|
+
writeCache(repoRoot, { hash, tool: adapter.id, model, findings: rv.findings, summary: rv.summary, score: rv.score });
|
|
454
|
+
}
|
|
455
|
+
else {
|
|
456
|
+
out.failOpen = true;
|
|
457
|
+
warnings.push(`AI review failed open after ${plural(attempts, 'attempt')} (${String(call.res.error).slice(0, 300)}); the git pre-push review and the CI gate still run`);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
finally {
|
|
461
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
462
|
+
}
|
|
463
|
+
out.toolVersion = toolVersion(findOnPath(adapter.binary, r.pathEnv) || adapter.binary);
|
|
464
|
+
}
|
|
465
|
+
out.reviewer = { tool: adapter.id, model: model ?? modelLabel(adapter.id, null), attempts, wall_s: Math.round((Date.now() - rt0) / 100) / 10, cost_usd: cost };
|
|
466
|
+
return out;
|
|
467
|
+
}
|
|
468
|
+
const SKIP_DOCS = 'review skipped: docs-only change';
|
|
469
|
+
const SKIP_TAMPER = 'review skipped (tamper)';
|
|
470
|
+
function skipReason(r, d, integ, sec) {
|
|
471
|
+
if (r.opts.noReview)
|
|
472
|
+
return 'review off (--no-review)';
|
|
473
|
+
if (r.opts.aiReview === false)
|
|
474
|
+
return 'review off (push gate disabled)';
|
|
475
|
+
if (integ.tamper.length || integ.allowMarkers.length)
|
|
476
|
+
return SKIP_TAMPER;
|
|
477
|
+
if (sec.secrets.length || sec.history?.findings.length)
|
|
478
|
+
return 'review skipped: a secret was found; the diff is not sent to a reviewer';
|
|
479
|
+
if ((sec.scan.status === 'error' || sec.history?.status === 'error') && r.event === 'pre-push')
|
|
480
|
+
return 'review skipped (secret scan failed)';
|
|
481
|
+
if (d.diff.files.every((f) => matchesAny(f.path, r.cfg.skip_paths)))
|
|
482
|
+
return SKIP_DOCS;
|
|
483
|
+
return null;
|
|
484
|
+
}
|
|
485
|
+
/** Keeps findings in scope (section 4.2), marks the blocking ones (D3) and sorts them. */
|
|
486
|
+
function scopeFindings(r, d, findings, v) {
|
|
487
|
+
const addedIn = new Map(d.diff.files.map((f) => [f.path, f.added]));
|
|
488
|
+
for (const f of findings) {
|
|
489
|
+
const file = f.file.replace(/^\.\//, '').replace(/^[ab]\//, '');
|
|
490
|
+
const added = addedIn.get(file);
|
|
491
|
+
if (!added) {
|
|
492
|
+
v.dropped.unknown_file++;
|
|
493
|
+
continue;
|
|
494
|
+
}
|
|
495
|
+
const introduced = f.line !== null && added.has(f.line);
|
|
496
|
+
if (r.scope === 'new-lines' && f.line !== null && !introduced) {
|
|
497
|
+
v.dropped.out_of_scope++;
|
|
498
|
+
continue;
|
|
499
|
+
}
|
|
500
|
+
// D4: at push only secrets and tamper block; block_on is the finish gate's.
|
|
501
|
+
const blocking = r.event !== 'pre-push' && ((f.severity === 'high' && r.cfg.block_on.includes('high')) || (f.severity === 'medium' && r.cfg.block_on.includes('medium')));
|
|
502
|
+
v.findings.push({ ...f, file, introduced_in_session: r.scope === 'new-lines' ? true : introduced, blocking });
|
|
503
|
+
}
|
|
504
|
+
v.findings.sort((a, b) => Number(b.blocking) - Number(a.blocking) || SEV_RANK[a.severity] - SEV_RANK[b.severity] || a.file.localeCompare(b.file) || (a.line ?? 0) - (b.line ?? 0));
|
|
505
|
+
}
|
|
506
|
+
/** Verdict and round rules (sections 3 and 5). Updates `state.last` and the round counter. */
|
|
507
|
+
function decide(r, v, state, integ, sec, rev, base) {
|
|
508
|
+
let verdict = 'pass';
|
|
509
|
+
let why = null;
|
|
510
|
+
if (integ.tamper.length || integ.allowMarkers.length) {
|
|
511
|
+
verdict = 'stop';
|
|
512
|
+
why = 'tamper';
|
|
513
|
+
const hits = [...integ.tamper, ...integ.allowMarkers.map((m) => `${m.file}:${m.line} (gitleaks:allow)`)];
|
|
514
|
+
r.warnings.push(`tamper: ${state ? 'this session changed' : 'this change adds'} ${hits.slice(0, 10).join(', ')}`);
|
|
515
|
+
}
|
|
516
|
+
else if (v.commit_secrets.length) {
|
|
517
|
+
// Recomputed from the range every run, so no later pass clears it while the commit is in the range.
|
|
518
|
+
verdict = 'stop';
|
|
519
|
+
why = 'secret-committed';
|
|
520
|
+
}
|
|
521
|
+
else if ((sec.scan.status === 'error' || sec.history?.status === 'error') && r.event === 'pre-push') {
|
|
522
|
+
verdict = 'stop';
|
|
523
|
+
why = 'secrets-scan-failed';
|
|
524
|
+
}
|
|
525
|
+
else if (v.secrets.length || v.findings.some((f) => f.blocking)) {
|
|
526
|
+
verdict = 'fix';
|
|
527
|
+
}
|
|
528
|
+
let round = 1;
|
|
529
|
+
// At a push every deny (secret, tamper, scan failure) is recomputed from the range each run, so
|
|
530
|
+
// pushes neither spend nor read the finish gate's rounds: a reused or rewritten session's
|
|
531
|
+
// counter can never decide a push (D4).
|
|
532
|
+
if (state && r.event !== 'pre-push') {
|
|
533
|
+
const last = state.last;
|
|
534
|
+
round = Math.min(state.rounds + 1, r.maxRounds + 1);
|
|
535
|
+
if (verdict === 'fix') {
|
|
536
|
+
if (last && last.hash === rev.hash && last.verdict === 'stop') {
|
|
537
|
+
verdict = 'stop';
|
|
538
|
+
why = last.human_why ?? 'no-progress';
|
|
539
|
+
}
|
|
540
|
+
else if (state.rounds >= r.maxRounds) {
|
|
541
|
+
verdict = 'stop';
|
|
542
|
+
why = 'max-rounds';
|
|
543
|
+
}
|
|
544
|
+
else if (last && last.hash === rev.hash && last.verdict === 'fix') {
|
|
545
|
+
verdict = 'stop';
|
|
546
|
+
why = 'no-progress';
|
|
547
|
+
}
|
|
548
|
+
else {
|
|
549
|
+
state.rounds++;
|
|
550
|
+
round = state.rounds;
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
state.last = { hash: rev.hash, verdict, human_why: why, at: r.started };
|
|
554
|
+
}
|
|
555
|
+
if (v.secrets.some((s) => s.committed) && why !== 'tamper')
|
|
556
|
+
why = 'secret-committed';
|
|
557
|
+
if (!why && rev.failOpen)
|
|
558
|
+
why = 'reviewer-failed';
|
|
559
|
+
v.verdict = verdict;
|
|
560
|
+
v.round = round;
|
|
561
|
+
v.human_why = why;
|
|
562
|
+
v.human_needed = why !== null;
|
|
563
|
+
v.fail_open = rev.failOpen;
|
|
564
|
+
v.next_action = nextAction(v, integ, base, r.event);
|
|
565
|
+
v.reasons = reasons(v);
|
|
566
|
+
}
|
|
567
|
+
/** Saves the session state, then writes the result file (section 11.1). */
|
|
568
|
+
function persist(r, v, d, sec, rev) {
|
|
569
|
+
const { repoRoot, warnings } = r;
|
|
570
|
+
if (d.state) {
|
|
571
|
+
try {
|
|
572
|
+
saveState(repoRoot, d.state);
|
|
573
|
+
}
|
|
574
|
+
catch (e) {
|
|
575
|
+
warnings.push(`could not save the session state (${String(e?.message || e).slice(0, 200)})`);
|
|
576
|
+
}
|
|
577
|
+
} // any: fs error, message only
|
|
578
|
+
const secretAsFindings = [
|
|
579
|
+
...v.secrets.map((s) => ({ severity: 'high', category: 'security', rule: s.rule, file: s.file, line: s.line, message: 'Secret detected (value redacted)' })),
|
|
580
|
+
...v.commit_secrets.map((s) => ({ severity: 'high', category: 'security', rule: s.rule, file: s.file, line: s.line, message: `Secret in commit ${s.commit.slice(0, 12)} of the pushed range (value redacted)` })),
|
|
581
|
+
];
|
|
582
|
+
const blockingN = v.findings.filter((f) => f.blocking).length;
|
|
583
|
+
const result = makeResult({
|
|
584
|
+
kind: 'agent-harness', hook: 'agent',
|
|
585
|
+
status: v.secrets.length || v.findings.length ? 'findings' : rev.failOpen ? 'error' : 'ok',
|
|
586
|
+
tool: v.reviewer?.tool ?? (sec.scan.version ? 'gitleaks' : 'none'), tool_version: rev.toolVersion ?? sec.scan.version,
|
|
587
|
+
model: v.reviewer?.model ?? null, commit: d.tip, tree: d.current,
|
|
588
|
+
branch: gitIn(repoRoot, ['symbolic-ref', '--quiet', '--short', 'HEAD']).stdout || null, upstream: d.upstreamLabel, base: d.reviewBase,
|
|
589
|
+
started_at: r.started, finished_at: r.now().toISOString(),
|
|
590
|
+
findings: [...secretAsFindings, ...v.findings.map(({ introduced_in_session: _i, blocking: _b, ...f }) => f)],
|
|
591
|
+
summary: rev.summary, score: rev.score, reason: warnings.length ? warnings.join('; ').slice(0, 2000) : null,
|
|
592
|
+
harness: {
|
|
593
|
+
client: r.opts.client ?? null, event: r.event, session: r.session, verdict: v.verdict, round: v.round, max_rounds: v.max_rounds, scope: r.scope,
|
|
594
|
+
secrets: v.secrets.length + v.commit_secrets.length, dropped_out_of_scope: v.dropped.out_of_scope, cached: v.diff.cached,
|
|
595
|
+
human_needed: v.human_needed, human_why: v.human_why, fail_open: v.fail_open, secrets_scanned: v.secrets_scanned,
|
|
596
|
+
block_on: r.cfg.block_on, blocking_findings: blockingN + v.secrets.length + v.commit_secrets.length, nonblocking_findings: v.findings.length - blockingN,
|
|
597
|
+
wall_s: Math.round((Date.now() - r.t0) / 100) / 10, cost_usd: v.reviewer?.cost_usd ?? null,
|
|
598
|
+
},
|
|
599
|
+
});
|
|
600
|
+
const written = safeWrite(repoRoot, result);
|
|
601
|
+
v.result_path = written ? relative(repoRoot, written) : null;
|
|
602
|
+
}
|
|
603
|
+
/**
|
|
604
|
+
* Runs one review. Throws only on an internal error (the CLI turns that into
|
|
605
|
+
* exit 1, adapters into "no decision").
|
|
606
|
+
*/
|
|
607
|
+
export function runHarnessReview(opts) {
|
|
608
|
+
const now = opts.now ?? (() => new Date());
|
|
609
|
+
const warnings = [];
|
|
610
|
+
const cfg = readHarnessConfig(opts.repoRoot, warnings);
|
|
611
|
+
// D4: block_on is the finish gate's. Say so at push only when the team changed it, and once per session.
|
|
612
|
+
const defaultBlockOn = cfg.block_on.length === 2 && cfg.block_on.includes('secret') && cfg.block_on.includes('high');
|
|
613
|
+
if (opts.event === 'pre-push' && cfg.block_on_set && !defaultBlockOn && (opts.noticeOnce?.('block-on') ?? true)) {
|
|
614
|
+
warnings.push(`harness.block_on (${cfg.block_on.join(', ')}) applies to the finish gate and direct reviews only; the push check blocks only on secrets, secret-scan failures and changes to the checks`);
|
|
615
|
+
}
|
|
616
|
+
const session = opts.session ?? null;
|
|
617
|
+
let maxRounds = opts.maxRounds ?? cfg.max_rounds;
|
|
618
|
+
if (maxRounds > HARNESS_MAX_ROUNDS_CEILING) {
|
|
619
|
+
warnings.push(`--max-rounds ${maxRounds} is above the ceiling of ${HARNESS_MAX_ROUNDS_CEILING}; using ${HARNESS_MAX_ROUNDS_CEILING}`);
|
|
620
|
+
maxRounds = HARNESS_MAX_ROUNDS_CEILING;
|
|
621
|
+
}
|
|
622
|
+
if (maxRounds < 1)
|
|
623
|
+
maxRounds = 1;
|
|
624
|
+
const scope = opts.scope ?? (session && opts.mode !== 'since' ? 'new-lines' : 'branch');
|
|
625
|
+
const head = revParse(opts.repoRoot, 'HEAD^{commit}');
|
|
626
|
+
if (!head) {
|
|
627
|
+
warnings.push('no commit yet (no HEAD): nothing to diff, no review');
|
|
628
|
+
const v = emptyVerdict({ session, scope, maxRounds: session ? maxRounds : 1, blockOn: cfg.block_on, warnings });
|
|
629
|
+
v.next_action = nextAction(v);
|
|
630
|
+
return v;
|
|
631
|
+
}
|
|
632
|
+
const r = {
|
|
633
|
+
opts, repoRoot: opts.repoRoot, cfg, warnings, session, maxRounds, scope, event: opts.event ?? 'manual',
|
|
634
|
+
started: now().toISOString(), t0: Date.now(), now, head, pathEnv: opts.pathEnv ?? process.env.PATH,
|
|
635
|
+
};
|
|
636
|
+
try {
|
|
637
|
+
ensureResultsExcluded(r.repoRoot);
|
|
638
|
+
}
|
|
639
|
+
catch { /* never fail a review over this */ }
|
|
640
|
+
ensureHarnessDir(r.repoRoot);
|
|
641
|
+
cleanupState(r.repoRoot, cfg.state_ttl_days);
|
|
642
|
+
const resolved = resolveDiffs(r);
|
|
643
|
+
const v = emptyVerdict({ session, scope: r.scope, maxRounds: session ? maxRounds : 1, blockOn: cfg.block_on, warnings });
|
|
644
|
+
if ('unknown' in resolved) {
|
|
645
|
+
// Fail closed at push, as for a secret scan with no verdict: what leaves the machine is not known.
|
|
646
|
+
warnings.push(`push held: ${resolved.unknown}`);
|
|
647
|
+
v.verdict = 'stop';
|
|
648
|
+
v.human_why = 'push-unknown';
|
|
649
|
+
v.human_needed = true;
|
|
650
|
+
v.next_action = nextAction(v);
|
|
651
|
+
v.reasons = reasons(v);
|
|
652
|
+
return v;
|
|
653
|
+
}
|
|
654
|
+
const d = resolved;
|
|
655
|
+
v.diff = { files: d.diff.files.length, added_lines: d.diff.addedLines, hash: null, cached: false };
|
|
656
|
+
if (!d.diff.files.length) {
|
|
657
|
+
v.secrets_scanned = true; // nothing to scan
|
|
658
|
+
v.next_action = 'No changes to review.';
|
|
659
|
+
if (d.state) {
|
|
660
|
+
d.state.last = null;
|
|
661
|
+
saveState(r.repoRoot, d.state);
|
|
662
|
+
}
|
|
663
|
+
return v;
|
|
664
|
+
}
|
|
665
|
+
const integ = integrityChecks(r, d);
|
|
666
|
+
const sec = secretScan(r, d);
|
|
667
|
+
v.secrets = sec.secrets;
|
|
668
|
+
v.commit_secrets = sec.history?.findings ?? [];
|
|
669
|
+
v.secrets_scanned = (sec.scan.status === 'ok' || sec.scan.status === 'findings') && !sec.scan.skipped.length && sec.history?.status !== 'error';
|
|
670
|
+
const rev = aiReview(r, d, skipReason(r, d, integ, sec));
|
|
671
|
+
v.diff.hash = rev.hash;
|
|
672
|
+
v.diff.cached = rev.cached;
|
|
673
|
+
v.reviewer = rev.reviewer;
|
|
674
|
+
scopeFindings(r, d, rev.findings, v);
|
|
675
|
+
decide(r, v, d.state, integ, sec, rev, d.reviewBase);
|
|
676
|
+
persist(r, v, d, sec, rev);
|
|
677
|
+
return v;
|
|
678
|
+
}
|
|
679
|
+
/** CLI exit code for a verdict: 0 pass, 3 fix, 4 stop (exit 2 is avoided on purpose, section 3). */
|
|
680
|
+
export function exitCodeFor(v) {
|
|
681
|
+
return v.verdict === 'pass' ? 0 : v.verdict === 'fix' ? 3 : 4;
|
|
682
|
+
}
|
|
683
|
+
//# sourceMappingURL=engine.js.map
|