@rigour-labs/core 6.5.3 → 6.5.4
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/README.md +1 -1
- package/dist/deep/risk.d.ts +6 -0
- package/dist/deep/risk.js +7 -1
- package/dist/deep/risk.test.js +12 -1
- package/dist/firewall/arbitration-token.d.ts +12 -3
- package/dist/firewall/arbitration-token.js +30 -23
- package/dist/firewall/attestation.d.ts +7 -2
- package/dist/firewall/attestation.js +49 -28
- package/dist/firewall/trust-boundaries.test.d.ts +1 -0
- package/dist/firewall/trust-boundaries.test.js +64 -0
- package/dist/gates/deep-analysis.d.ts +6 -0
- package/dist/gates/deep-analysis.js +8 -1
- package/dist/gates/trusted-reviews.test.d.ts +1 -0
- package/dist/gates/trusted-reviews.test.js +16 -0
- package/dist/hooks/checker.js +22 -0
- package/dist/hooks/checker.test.js +8 -0
- package/dist/hooks/index.d.ts +1 -0
- package/dist/hooks/index.js +1 -0
- package/dist/hooks/session-state.d.ts +8 -0
- package/dist/hooks/session-state.js +61 -0
- package/dist/hooks/stop-review.d.ts +6 -4
- package/dist/hooks/stop-review.js +5 -3
- package/dist/hooks/templates.js +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/review/check-outcomes.d.ts +2 -1
- package/dist/review/check-outcomes.js +11 -2
- package/dist/review/git-diff.d.ts +3 -0
- package/dist/review/git-diff.js +5 -0
- package/dist/review/migration-order.js +1 -1
- package/dist/review/quiet.d.ts +3 -2
- package/dist/review/quiet.js +10 -6
- package/dist/review/review.d.ts +4 -0
- package/dist/review/review.js +14 -2
- package/dist/review/review.test.js +19 -0
- package/dist/review/trusted-state.d.ts +6 -0
- package/dist/review/trusted-state.js +35 -0
- package/dist/types/index.d.ts +5 -0
- package/dist/utils/user-state.d.ts +7 -0
- package/dist/utils/user-state.js +21 -0
- package/package.json +6 -6
package/README.md
CHANGED
|
@@ -46,7 +46,7 @@ Rigour's deep analysis is not a wrapper around a generic LLM. The model operates
|
|
|
46
46
|
2. **Interpret** — the model receives structured facts (not raw source), focuses on SOLID, design patterns, language idioms, architecture. Constrained input prevents hallucination.
|
|
47
47
|
3. **Verify** — every LLM finding is cross-referenced against all five signal streams. Wrong line numbers, phantom patterns, non-existent functions → discarded. Only verified findings with confidence scores reach the report.
|
|
48
48
|
|
|
49
|
-
The lite and deep tiers
|
|
49
|
+
Deep review is built for frontier models: your own key, or the coding agent's own model. The small local models (lite and deep tiers fine-tuned via the [DriftBench RLAIF pipeline](https://github.com/rigour-labs/driftbench); max is the stock Qwen2.5-Coder-7B) remain available offline but are no longer developed.
|
|
50
50
|
|
|
51
51
|
### Temporal Drift Engine (v5.1)
|
|
52
52
|
|
package/dist/deep/risk.d.ts
CHANGED
|
@@ -38,4 +38,10 @@ export declare function findFunction(cwd: string, file: string, name: string): {
|
|
|
38
38
|
start: number;
|
|
39
39
|
end: number;
|
|
40
40
|
} | undefined;
|
|
41
|
+
/**
|
|
42
|
+
* What a review record is bound to: the function's exact source. Collapsing whitespace once let
|
|
43
|
+
* `"a b"` and `"a b"` share a hash, so a string changed after review still counted as reviewed.
|
|
44
|
+
* Only line endings are normalised (a Windows checkout is the same code); any other change,
|
|
45
|
+
* formatting included, asks for a new review.
|
|
46
|
+
*/
|
|
41
47
|
export declare function functionHash(text: string): string;
|
package/dist/deep/risk.js
CHANGED
|
@@ -92,8 +92,14 @@ export function findFunction(cwd, file, name) {
|
|
|
92
92
|
visit(sourceFile);
|
|
93
93
|
return found;
|
|
94
94
|
}
|
|
95
|
+
/**
|
|
96
|
+
* What a review record is bound to: the function's exact source. Collapsing whitespace once let
|
|
97
|
+
* `"a b"` and `"a b"` share a hash, so a string changed after review still counted as reviewed.
|
|
98
|
+
* Only line endings are normalised (a Windows checkout is the same code); any other change,
|
|
99
|
+
* formatting included, asks for a new review.
|
|
100
|
+
*/
|
|
95
101
|
export function functionHash(text) {
|
|
96
|
-
return crypto.createHash('sha256').update(text.replace(/\
|
|
102
|
+
return crypto.createHash('sha256').update(text.replace(/\r\n/g, '\n')).digest('hex');
|
|
97
103
|
}
|
|
98
104
|
const GUARD = /\b(?:if|else|return|throw|break|continue|catch)\b|\?\?|\?\./g;
|
|
99
105
|
function guards(lines) {
|
package/dist/deep/risk.test.js
CHANGED
|
@@ -2,7 +2,7 @@ import fs from 'fs';
|
|
|
2
2
|
import os from 'os';
|
|
3
3
|
import path from 'path';
|
|
4
4
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
5
|
-
import { rankChangedFunctions, scoreRisk } from './risk.js';
|
|
5
|
+
import { functionHash, rankChangedFunctions, scoreRisk } from './risk.js';
|
|
6
6
|
import { routeFiles } from './router.js';
|
|
7
7
|
let dir;
|
|
8
8
|
beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'risk-')); });
|
|
@@ -61,3 +61,14 @@ describe('routeFiles', () => {
|
|
|
61
61
|
expect(stale.stats).toMatchObject({ routed: 1, already_reviewed: 0 });
|
|
62
62
|
});
|
|
63
63
|
});
|
|
64
|
+
describe('functionHash', () => {
|
|
65
|
+
it('changes when anything inside the function changes, strings included', () => {
|
|
66
|
+
const a = 'function f() { return "a b"; }';
|
|
67
|
+
const b = 'function f() { return "a b"; }';
|
|
68
|
+
expect(functionHash(a)).not.toBe(functionHash(b));
|
|
69
|
+
expect(functionHash('function f() { return `x ${y}`; }')).not.toBe(functionHash('function f() { return `x ${y}`; }'));
|
|
70
|
+
});
|
|
71
|
+
it('treats a Windows checkout of the same code as the same code', () => {
|
|
72
|
+
expect(functionHash('function f() {\r\n return 1;\r\n}')).toBe(functionHash('function f() {\n return 1;\n}'));
|
|
73
|
+
});
|
|
74
|
+
});
|
|
@@ -1,8 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* One-time arbitration tokens — bind Studio approve/reject to the
|
|
2
|
+
* One-time arbitration tokens — bind a Studio approve/reject to the MCP process that asked.
|
|
3
|
+
*
|
|
4
|
+
* The token lives only in the requesting process's memory and in a store outside the workspace
|
|
5
|
+
* (never in .rigour/ or the event log, which agents write). Studio consumes it once and signs the
|
|
6
|
+
* decision with it; the requester accepts only a decision whose signature verifies, so appending
|
|
7
|
+
* a decision line to .rigour/events.jsonl approves nothing.
|
|
3
8
|
*/
|
|
9
|
+
export type ArbitrationDecision = 'approve' | 'reject';
|
|
4
10
|
export declare function issueArbitrationToken(cwd: string, requestId: string, ttlMs?: number): Promise<string>;
|
|
5
11
|
/**
|
|
6
|
-
*
|
|
12
|
+
* Studio side: consume the request's token once and sign the human's decision with it.
|
|
13
|
+
* Null when the request is unknown, expired or already decided.
|
|
7
14
|
*/
|
|
8
|
-
export declare function
|
|
15
|
+
export declare function signArbitrationDecision(cwd: string, requestId: string, decision: ArbitrationDecision): Promise<string | null>;
|
|
16
|
+
/** Requester side: true only for a decision signed with the token this process issued. */
|
|
17
|
+
export declare function verifyArbitrationDecision(token: string, requestId: string, decision: unknown, proof: unknown): boolean;
|
|
@@ -1,26 +1,29 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* One-time arbitration tokens — bind Studio approve/reject to the
|
|
2
|
+
* One-time arbitration tokens — bind a Studio approve/reject to the MCP process that asked.
|
|
3
|
+
*
|
|
4
|
+
* The token lives only in the requesting process's memory and in a store outside the workspace
|
|
5
|
+
* (never in .rigour/ or the event log, which agents write). Studio consumes it once and signs the
|
|
6
|
+
* decision with it; the requester accepts only a decision whose signature verifies, so appending
|
|
7
|
+
* a decision line to .rigour/events.jsonl approves nothing.
|
|
3
8
|
*/
|
|
4
|
-
import { randomBytes, timingSafeEqual } from 'crypto';
|
|
9
|
+
import { createHmac, randomBytes, timingSafeEqual } from 'crypto';
|
|
5
10
|
import fs from 'fs-extra';
|
|
6
11
|
import path from 'path';
|
|
12
|
+
import { repoStateDir } from '../utils/user-state.js';
|
|
7
13
|
function storePath(cwd) {
|
|
8
|
-
return path.join(cwd, '
|
|
14
|
+
return path.join(repoStateDir(cwd), 'arbitration-tokens.json');
|
|
9
15
|
}
|
|
10
16
|
async function readStore(cwd) {
|
|
11
|
-
const p = storePath(cwd);
|
|
12
|
-
if (!(await fs.pathExists(p)))
|
|
13
|
-
return {};
|
|
14
17
|
try {
|
|
15
|
-
return await fs.readJson(
|
|
18
|
+
return await fs.readJson(storePath(cwd));
|
|
16
19
|
}
|
|
17
20
|
catch {
|
|
18
21
|
return {};
|
|
19
22
|
}
|
|
20
23
|
}
|
|
21
24
|
async function writeStore(cwd, store) {
|
|
22
|
-
await fs.ensureDir(path.
|
|
23
|
-
await fs.writeJson(storePath(cwd), store, { spaces: 2 });
|
|
25
|
+
await fs.ensureDir(path.dirname(storePath(cwd)), { mode: 0o700 });
|
|
26
|
+
await fs.writeJson(storePath(cwd), store, { spaces: 2, mode: 0o600 });
|
|
24
27
|
}
|
|
25
28
|
export async function issueArbitrationToken(cwd, requestId, ttlMs = 60_000) {
|
|
26
29
|
const token = randomBytes(24).toString('hex');
|
|
@@ -34,25 +37,29 @@ export async function issueArbitrationToken(cwd, requestId, ttlMs = 60_000) {
|
|
|
34
37
|
await writeStore(cwd, store);
|
|
35
38
|
return token;
|
|
36
39
|
}
|
|
40
|
+
function decisionProof(token, requestId, decision) {
|
|
41
|
+
return createHmac('sha256', token).update(`${requestId}:${decision}`).digest('hex');
|
|
42
|
+
}
|
|
37
43
|
/**
|
|
38
|
-
*
|
|
44
|
+
* Studio side: consume the request's token once and sign the human's decision with it.
|
|
45
|
+
* Null when the request is unknown, expired or already decided.
|
|
39
46
|
*/
|
|
40
|
-
export async function
|
|
41
|
-
if (!
|
|
42
|
-
return
|
|
47
|
+
export async function signArbitrationDecision(cwd, requestId, decision) {
|
|
48
|
+
if (!requestId)
|
|
49
|
+
return null;
|
|
43
50
|
const store = await readStore(cwd);
|
|
44
51
|
const entry = store[requestId];
|
|
45
52
|
if (!entry)
|
|
46
|
-
return
|
|
47
|
-
if (Date.now() > entry.expiresAt) {
|
|
48
|
-
delete store[requestId];
|
|
49
|
-
await writeStore(cwd, store);
|
|
50
|
-
return false;
|
|
51
|
-
}
|
|
52
|
-
const a = Buffer.from(entry.token);
|
|
53
|
-
const b = Buffer.from(token);
|
|
54
|
-
const ok = a.length === b.length && timingSafeEqual(a, b);
|
|
53
|
+
return null;
|
|
55
54
|
delete store[requestId];
|
|
56
55
|
await writeStore(cwd, store);
|
|
57
|
-
return
|
|
56
|
+
return Date.now() > entry.expiresAt ? null : decisionProof(entry.token, requestId, decision);
|
|
57
|
+
}
|
|
58
|
+
/** Requester side: true only for a decision signed with the token this process issued. */
|
|
59
|
+
export function verifyArbitrationDecision(token, requestId, decision, proof) {
|
|
60
|
+
if ((decision !== 'approve' && decision !== 'reject') || typeof proof !== 'string')
|
|
61
|
+
return false;
|
|
62
|
+
const expected = Buffer.from(decisionProof(token, requestId, decision));
|
|
63
|
+
const given = Buffer.from(proof);
|
|
64
|
+
return expected.length === given.length && timingSafeEqual(expected, given);
|
|
58
65
|
}
|
|
@@ -30,9 +30,14 @@ export declare function createAttestation(cwd: string, input: {
|
|
|
30
30
|
export declare function verifyAttestation(cwd: string, bundle: AttestationBundle): Promise<boolean>;
|
|
31
31
|
export declare function loadLatestAttestation(cwd: string): Promise<AttestationBundle | null>;
|
|
32
32
|
/**
|
|
33
|
-
* CI admission
|
|
33
|
+
* CI admission. Admits only a bundle that is signed with a key outside the workspace, records PASS
|
|
34
|
+
* gates, is fresh, is bound to this checkout (commit or tree, and at least one must be present and
|
|
35
|
+
* match), whose artifact digest recomputes from the files here, and, when the caller names the
|
|
36
|
+
* policy it requires, was produced under that policy.
|
|
34
37
|
*/
|
|
35
|
-
export declare function admitForCi(cwd: string
|
|
38
|
+
export declare function admitForCi(cwd: string, options?: {
|
|
39
|
+
policyHash?: string;
|
|
40
|
+
}): Promise<{
|
|
36
41
|
admit: boolean;
|
|
37
42
|
reason: string;
|
|
38
43
|
}>;
|
|
@@ -2,18 +2,18 @@
|
|
|
2
2
|
* Signed attestation bundles for completed transactions.
|
|
3
3
|
* Keys prefer env / home directory — not agent-writable workspace keys for CI admit.
|
|
4
4
|
*/
|
|
5
|
-
import { createHash, createHmac, randomBytes } from 'crypto';
|
|
5
|
+
import { createHash, createHmac, randomBytes, timingSafeEqual } from 'crypto';
|
|
6
6
|
import fs from 'fs-extra';
|
|
7
|
-
import os from 'os';
|
|
8
7
|
import path from 'path';
|
|
9
8
|
import { execa } from 'execa';
|
|
9
|
+
import { rigourUserDir } from '../utils/user-state.js';
|
|
10
10
|
const KEY_FILE = 'attestation.key';
|
|
11
11
|
export async function resolveAttestationKey(cwd) {
|
|
12
12
|
const envKey = process.env.RIGOUR_ATTESTATION_KEY;
|
|
13
13
|
if (envKey && envKey.length >= 32) {
|
|
14
14
|
return { key: Buffer.from(envKey, 'utf8'), source: 'env' };
|
|
15
15
|
}
|
|
16
|
-
const homeKeyPath = path.join(
|
|
16
|
+
const homeKeyPath = path.join(rigourUserDir(), KEY_FILE);
|
|
17
17
|
if (await fs.pathExists(homeKeyPath)) {
|
|
18
18
|
return { key: await fs.readFile(homeKeyPath), source: 'home' };
|
|
19
19
|
}
|
|
@@ -22,7 +22,7 @@ export async function resolveAttestationKey(cwd) {
|
|
|
22
22
|
return { key: await fs.readFile(workspaceKeyPath), source: 'workspace' };
|
|
23
23
|
}
|
|
24
24
|
// Create home key by default (outside agent workspace)
|
|
25
|
-
await fs.ensureDir(
|
|
25
|
+
await fs.ensureDir(rigourUserDir());
|
|
26
26
|
const key = randomBytes(32);
|
|
27
27
|
await fs.writeFile(homeKeyPath, key, { mode: 0o600 });
|
|
28
28
|
return { key, source: 'home' };
|
|
@@ -130,10 +130,11 @@ export async function createAttestation(cwd, input) {
|
|
|
130
130
|
export async function verifyAttestation(cwd, bundle) {
|
|
131
131
|
const { key } = await resolveAttestationKey(cwd);
|
|
132
132
|
const { signature, signedAt, ...rest } = bundle;
|
|
133
|
-
const expected = createHmac('sha256', key)
|
|
133
|
+
const expected = Buffer.from(createHmac('sha256', key)
|
|
134
134
|
.update(payloadForSign(rest) + signedAt)
|
|
135
|
-
.digest('hex');
|
|
136
|
-
|
|
135
|
+
.digest('hex'));
|
|
136
|
+
const given = Buffer.from(String(signature ?? ''));
|
|
137
|
+
return expected.length === given.length && timingSafeEqual(expected, given);
|
|
137
138
|
}
|
|
138
139
|
export async function loadLatestAttestation(cwd) {
|
|
139
140
|
const p = path.join(cwd, '.rigour', 'attestation-latest.json');
|
|
@@ -147,9 +148,12 @@ export async function loadLatestAttestation(cwd) {
|
|
|
147
148
|
}
|
|
148
149
|
}
|
|
149
150
|
/**
|
|
150
|
-
* CI admission
|
|
151
|
+
* CI admission. Admits only a bundle that is signed with a key outside the workspace, records PASS
|
|
152
|
+
* gates, is fresh, is bound to this checkout (commit or tree, and at least one must be present and
|
|
153
|
+
* match), whose artifact digest recomputes from the files here, and, when the caller names the
|
|
154
|
+
* policy it requires, was produced under that policy.
|
|
151
155
|
*/
|
|
152
|
-
export async function admitForCi(cwd) {
|
|
156
|
+
export async function admitForCi(cwd, options = {}) {
|
|
153
157
|
const { source } = await resolveAttestationKey(cwd);
|
|
154
158
|
if (source === 'workspace' && process.env.RIGOUR_ALLOW_WORKSPACE_ATTESTATION_KEY !== '1') {
|
|
155
159
|
return {
|
|
@@ -168,30 +172,47 @@ export async function admitForCi(cwd) {
|
|
|
168
172
|
if (bundle.gateResults.status !== 'PASS') {
|
|
169
173
|
return { admit: false, reason: `Gates not PASS (${bundle.gateResults.status})` };
|
|
170
174
|
}
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
if (!bundle.artifactDigest || bundle.artifactDigest.length < 16) {
|
|
175
|
-
return { admit: false, reason: 'Attestation artifactDigest missing or trivial' };
|
|
175
|
+
const requiredPolicy = options.policyHash ?? process.env.RIGOUR_REQUIRED_POLICY_HASH;
|
|
176
|
+
if (requiredPolicy && bundle.policyHash !== requiredPolicy) {
|
|
177
|
+
return { admit: false, reason: `Attestation policy ${bundle.policyHash} is not the required ${requiredPolicy}` };
|
|
176
178
|
}
|
|
177
179
|
const maxAgeMs = Number(process.env.RIGOUR_ATTESTATION_MAX_AGE_MS || 24 * 60 * 60 * 1000);
|
|
178
180
|
const age = Date.now() - Date.parse(bundle.signedAt);
|
|
179
181
|
if (!Number.isFinite(age) || age < 0 || age > maxAgeMs) {
|
|
180
182
|
return { admit: false, reason: 'Attestation expired or has invalid signedAt' };
|
|
181
183
|
}
|
|
182
|
-
const
|
|
183
|
-
if (
|
|
184
|
-
return {
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
};
|
|
184
|
+
const binding = await checkBinding(cwd, bundle);
|
|
185
|
+
if (binding)
|
|
186
|
+
return { admit: false, reason: binding };
|
|
187
|
+
if (await recomputeArtifactDigest(cwd, bundle) !== bundle.artifactDigest) {
|
|
188
|
+
return { admit: false, reason: 'Attestation artifactDigest does not match the files in this checkout' };
|
|
188
189
|
}
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
190
|
+
return { admit: true, reason: 'Valid attestation with PASS gates, bound to this checkout, artifacts verified' };
|
|
191
|
+
}
|
|
192
|
+
/** Why the bundle is not bound to this checkout, or null when it is. A bundle bound to nothing is refused. */
|
|
193
|
+
async function checkBinding(cwd, bundle) {
|
|
194
|
+
if (!bundle.commitSha && !bundle.treeDigest)
|
|
195
|
+
return 'Attestation is bound to no commit or tree';
|
|
196
|
+
const [headSha, headTree] = await Promise.all([getGitCommitSha(cwd), getGitTreeDigest(cwd)]);
|
|
197
|
+
if (bundle.commitSha && bundle.commitSha !== headSha) {
|
|
198
|
+
return `Attestation commitSha ${bundle.commitSha.slice(0, 8)} is not HEAD ${(headSha ?? 'none').slice(0, 8)}`;
|
|
199
|
+
}
|
|
200
|
+
if (bundle.treeDigest && bundle.treeDigest !== headTree)
|
|
201
|
+
return 'Attestation treeDigest mismatch vs HEAD tree';
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
/** The digest createAttestation would compute for this checkout: the named files' contents, or the tree. */
|
|
205
|
+
async function recomputeArtifactDigest(cwd, bundle) {
|
|
206
|
+
const files = bundle.filesUsed ?? [];
|
|
207
|
+
if (files.length === 0)
|
|
208
|
+
return createHash('sha256').update(`tree:${bundle.treeDigest ?? ''}`).digest('hex');
|
|
209
|
+
const contents = new Map();
|
|
210
|
+
for (const f of files) {
|
|
211
|
+
const abs = path.resolve(cwd, f);
|
|
212
|
+
if (!abs.startsWith(path.resolve(cwd) + path.sep))
|
|
213
|
+
continue; // never read outside the checkout
|
|
214
|
+
if (await fs.pathExists(abs))
|
|
215
|
+
contents.set(f, await fs.readFile(abs, 'utf-8'));
|
|
216
|
+
}
|
|
217
|
+
return computeArtifactDigest(files, contents);
|
|
197
218
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { execFileSync } from 'child_process';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
6
|
+
import { issueArbitrationToken, signArbitrationDecision, verifyArbitrationDecision } from './arbitration-token.js';
|
|
7
|
+
import { admitForCi, createAttestation } from './attestation.js';
|
|
8
|
+
let repo;
|
|
9
|
+
const git = (...args) => execFileSync('git', ['-C', repo, ...args], { encoding: 'utf8' });
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
repo = fs.mkdtempSync(path.join(os.tmpdir(), 'trust-'));
|
|
12
|
+
git('init', '-q', '-b', 'main');
|
|
13
|
+
git('config', 'user.email', 't@example.com');
|
|
14
|
+
git('config', 'user.name', 't');
|
|
15
|
+
git('config', 'commit.gpgsign', 'false');
|
|
16
|
+
fs.writeFileSync(path.join(repo, 'a.ts'), 'export const a = 1;\n');
|
|
17
|
+
git('add', '-A');
|
|
18
|
+
git('commit', '-qm', 'init');
|
|
19
|
+
});
|
|
20
|
+
afterEach(() => fs.rmSync(repo, { recursive: true, force: true }));
|
|
21
|
+
describe('human arbitration', () => {
|
|
22
|
+
it('accepts only a decision signed with the issued token, once', async () => {
|
|
23
|
+
const token = await issueArbitrationToken(repo, 'r1');
|
|
24
|
+
expect(fs.existsSync(path.join(repo, '.rigour', 'arbitration-tokens.json'))).toBe(false);
|
|
25
|
+
// A line any process can append to the event log carries no valid proof.
|
|
26
|
+
expect(verifyArbitrationDecision(token, 'r1', 'approve', undefined)).toBe(false);
|
|
27
|
+
expect(verifyArbitrationDecision(token, 'r1', 'approve', 'f'.repeat(64))).toBe(false);
|
|
28
|
+
const proof = await signArbitrationDecision(repo, 'r1', 'approve');
|
|
29
|
+
expect(verifyArbitrationDecision(token, 'r1', 'approve', proof)).toBe(true);
|
|
30
|
+
expect(verifyArbitrationDecision(token, 'r1', 'reject', proof)).toBe(false);
|
|
31
|
+
expect(await signArbitrationDecision(repo, 'r1', 'approve')).toBeNull();
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
describe('CI admission', () => {
|
|
35
|
+
const tx = (over = {}) => ({
|
|
36
|
+
id: 'tx1', agentId: 'agent', scope: ['**/*'], policyHash: 'policy-a', capabilitiesIssued: [], filesChanged: ['a.ts'], ...over,
|
|
37
|
+
});
|
|
38
|
+
const pass = { status: 'PASS', failedGates: [] };
|
|
39
|
+
it('admits a fresh, bound bundle whose files are unchanged', async () => {
|
|
40
|
+
await createAttestation(repo, { transaction: tx(), gateResults: pass });
|
|
41
|
+
expect(await admitForCi(repo)).toMatchObject({ admit: true });
|
|
42
|
+
});
|
|
43
|
+
it('refuses when an attested file differs from what was signed, even at the same commit', async () => {
|
|
44
|
+
await createAttestation(repo, { transaction: tx(), gateResults: pass });
|
|
45
|
+
fs.writeFileSync(path.join(repo, 'a.ts'), 'export const a = 2;\n'); // HEAD and its tree are unchanged
|
|
46
|
+
expect(await admitForCi(repo)).toMatchObject({ admit: false, reason: expect.stringContaining('artifactDigest') });
|
|
47
|
+
});
|
|
48
|
+
it('refuses a bundle produced under another policy than the one required', async () => {
|
|
49
|
+
await createAttestation(repo, { transaction: tx(), gateResults: pass });
|
|
50
|
+
expect(await admitForCi(repo, { policyHash: 'policy-b' })).toMatchObject({ admit: false });
|
|
51
|
+
expect(await admitForCi(repo, { policyHash: 'policy-a' })).toMatchObject({ admit: true });
|
|
52
|
+
});
|
|
53
|
+
it('refuses a bundle bound to no commit or tree', async () => {
|
|
54
|
+
const noRepo = fs.mkdtempSync(path.join(os.tmpdir(), 'trust-norepo-'));
|
|
55
|
+
try {
|
|
56
|
+
fs.writeFileSync(path.join(noRepo, 'a.ts'), 'export const a = 1;\n');
|
|
57
|
+
await createAttestation(noRepo, { transaction: tx(), gateResults: pass });
|
|
58
|
+
expect(await admitForCi(noRepo)).toMatchObject({ admit: false, reason: 'Attestation is bound to no commit or tree' });
|
|
59
|
+
}
|
|
60
|
+
finally {
|
|
61
|
+
fs.rmSync(noRepo, { recursive: true, force: true });
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
});
|
|
@@ -15,6 +15,7 @@ import { Gate, GateContext } from './base.js';
|
|
|
15
15
|
import { Failure, Provenance, DeepOptions } from '../types/index.js';
|
|
16
16
|
import type { InferenceUsage } from '../inference/types.js';
|
|
17
17
|
import { type RouterPolicy, type RouterStats } from '../deep/router.js';
|
|
18
|
+
import { reviewedKeys } from '../review/ledger.js';
|
|
18
19
|
import { type LessonMode } from '../review-learning/team-lessons.js';
|
|
19
20
|
export interface DeepGateConfig {
|
|
20
21
|
options: DeepOptions;
|
|
@@ -88,3 +89,8 @@ export declare class DeepAnalysisGate extends Gate {
|
|
|
88
89
|
/** Per-call options; unset config falls back to per-provider defaults. */
|
|
89
90
|
/** Per-call inference timeout: a local 7B reads a file with its reference pack far slower than the small tiers. */
|
|
90
91
|
export declare function defaultTimeout(options: DeepOptions): number;
|
|
92
|
+
/**
|
|
93
|
+
* Functions the router may skip as already reviewed: what agents recorded (the ledger and
|
|
94
|
+
* reviewed.json), unless the run is independent, as an enforcing check is.
|
|
95
|
+
*/
|
|
96
|
+
export declare function trustedReviews(cwd: string, options: Pick<DeepOptions, 'independent'>): ReturnType<typeof reviewedKeys>;
|
|
@@ -179,7 +179,7 @@ export class DeepAnalysisGate extends Gate {
|
|
|
179
179
|
const { options } = this.config;
|
|
180
180
|
if (!isCloud(options) || this.config.router?.enabled === false || !options.focusLines)
|
|
181
181
|
return facts;
|
|
182
|
-
const routed = routeFiles(cwd, facts.map(f => f.path), options.focusLines, options.removedLines, this.config.router,
|
|
182
|
+
const routed = routeFiles(cwd, facts.map(f => f.path), options.focusLines, options.removedLines, this.config.router, trustedReviews(cwd, options));
|
|
183
183
|
this.outcome.router = routed.stats;
|
|
184
184
|
if (routed.stats.files_skipped > 0) {
|
|
185
185
|
this.config.onProgress?.(` Router: ${routed.stats.routed} of ${routed.stats.functions} changed function(s) to the model; ${routed.stats.files_skipped} file(s) left to the gates.`);
|
|
@@ -302,3 +302,10 @@ async function withTimeout(promise, ms, message) {
|
|
|
302
302
|
clearTimeout(timer);
|
|
303
303
|
}
|
|
304
304
|
}
|
|
305
|
+
/**
|
|
306
|
+
* Functions the router may skip as already reviewed: what agents recorded (the ledger and
|
|
307
|
+
* reviewed.json), unless the run is independent, as an enforcing check is.
|
|
308
|
+
*/
|
|
309
|
+
export function trustedReviews(cwd, options) {
|
|
310
|
+
return options.independent ? [] : reviewedKeys(cwd);
|
|
311
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import os from 'os';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
5
|
+
import { trustedReviews } from './deep-analysis.js';
|
|
6
|
+
let cwd;
|
|
7
|
+
afterEach(() => fs.rmSync(cwd, { recursive: true, force: true }));
|
|
8
|
+
describe('trustedReviews', () => {
|
|
9
|
+
it('lets a normal run skip what agents recorded as reviewed, and an independent run skip nothing', () => {
|
|
10
|
+
cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'trusted-reviews-'));
|
|
11
|
+
fs.mkdirSync(path.join(cwd, '.rigour'));
|
|
12
|
+
fs.writeFileSync(path.join(cwd, '.rigour', 'reviewed.json'), JSON.stringify({ version: 1, entries: [{ file: 'src/a.ts', function: 'f', hash: 'h', reviewer: 'agent', verdict: 'no_issue' }] }));
|
|
13
|
+
expect(trustedReviews(cwd, {}).map(r => r.function)).toEqual(['f']);
|
|
14
|
+
expect(trustedReviews(cwd, { independent: true })).toEqual([]);
|
|
15
|
+
});
|
|
16
|
+
});
|
package/dist/hooks/checker.js
CHANGED
|
@@ -110,6 +110,11 @@ export async function runHookChecker(options) {
|
|
|
110
110
|
if (!resolved) {
|
|
111
111
|
continue;
|
|
112
112
|
}
|
|
113
|
+
// Before ignore patterns: rigour.yml commonly ignores .rigour/ for scanning, not for writes.
|
|
114
|
+
if (isRigourState(resolved.relPath)) {
|
|
115
|
+
failures.push(rigourStateFailure(resolved.relPath));
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
113
118
|
// Respect rigour.yml ignore patterns
|
|
114
119
|
if (isIgnored(resolved.relPath, ignorePatterns)) {
|
|
115
120
|
continue;
|
|
@@ -270,6 +275,23 @@ function checkCommandInjection(line, i, relPath, failures) {
|
|
|
270
275
|
});
|
|
271
276
|
}
|
|
272
277
|
}
|
|
278
|
+
// ── Rigour's own state (always on) ────────────────────────────────
|
|
279
|
+
/**
|
|
280
|
+
* .rigour/ holds what decides a review: dismissals, check outcomes, learned rules, the review
|
|
281
|
+
* ledger, operator scopes. Rigour writes it through its own commands and tools, never through
|
|
282
|
+
* the agent's edit tool, so an agent edit there is refused whatever rigour.yml says.
|
|
283
|
+
*/
|
|
284
|
+
function isRigourState(relPath) {
|
|
285
|
+
return relPath.replace(/\\/g, '/').startsWith('.rigour/');
|
|
286
|
+
}
|
|
287
|
+
function rigourStateFailure(relPath) {
|
|
288
|
+
return {
|
|
289
|
+
gate: 'file-guard',
|
|
290
|
+
file: relPath,
|
|
291
|
+
message: `BLOCKED: "${relPath}" is Rigour's own state. Change it with Rigour's commands (rigour dismiss, rigour_review_ack, rigour_remember), not by editing the file.`,
|
|
292
|
+
severity: 'critical',
|
|
293
|
+
};
|
|
294
|
+
}
|
|
273
295
|
// ── Protected Paths Enforcement (real-time) ───────────────────────
|
|
274
296
|
/**
|
|
275
297
|
* Block writes to protected paths defined in rigour.yml safety.protected_paths.
|
|
@@ -43,6 +43,14 @@ describe('runHookChecker', () => {
|
|
|
43
43
|
const optedIn = await runHookChecker({ cwd: testDir, files: [filePath] });
|
|
44
44
|
expect(optedIn.failures.some(f => f.gate === 'governance')).toBe(true);
|
|
45
45
|
});
|
|
46
|
+
it("refuses an agent edit to Rigour's own state, even when rigour.yml ignores .rigour/", async () => {
|
|
47
|
+
fs.writeFileSync(path.join(testDir, 'rigour.yml'), 'version: 1\nignore:\n - ".rigour/**"\n');
|
|
48
|
+
fs.mkdirSync(path.join(testDir, '.rigour'), { recursive: true });
|
|
49
|
+
fs.writeFileSync(path.join(testDir, '.rigour', 'dismissed.json'), '{"version":1,"entries":[]}');
|
|
50
|
+
const result = await runHookChecker({ cwd: testDir, files: ['.rigour/dismissed.json'] });
|
|
51
|
+
expect(result.status).toBe('fail');
|
|
52
|
+
expect(result.failures[0]).toMatchObject({ gate: 'file-guard', severity: 'critical' });
|
|
53
|
+
});
|
|
46
54
|
it('should detect file size violations', async () => {
|
|
47
55
|
const filePath = path.join(testDir, 'big.ts');
|
|
48
56
|
const lines = Array.from({ length: 100 }, (_, i) => `export const v${i} = ${i};`);
|
package/dist/hooks/index.d.ts
CHANGED
|
@@ -16,3 +16,4 @@ export { generateDLPHookFiles } from './dlp-templates.js';
|
|
|
16
16
|
export type { GeneratedDLPHookFile } from './dlp-templates.js';
|
|
17
17
|
export { stopReview, stopMessage, blocksStop, STOP_MAX_ATTEMPTS } from './stop-review.js';
|
|
18
18
|
export type { StopDecision } from './stop-review.js';
|
|
19
|
+
export { recordSessionBaseline, sessionBaseline, nextStopAttempt, clearStopAttempts } from './session-state.js';
|
package/dist/hooks/index.js
CHANGED
|
@@ -11,3 +11,4 @@ export { recordDLPFeedback, allowLastDLPBlock, writeDLPBlockManifest, getLearned
|
|
|
11
11
|
export { generateDLPHookFiles } from './dlp-templates.js';
|
|
12
12
|
// Stop review ("before you say done")
|
|
13
13
|
export { stopReview, stopMessage, blocksStop, STOP_MAX_ATTEMPTS } from './stop-review.js';
|
|
14
|
+
export { recordSessionBaseline, sessionBaseline, nextStopAttempt, clearStopAttempts } from './session-state.js';
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** Record where the session started, once; later calls keep the first commit. */
|
|
2
|
+
export declare function recordSessionBaseline(cwd: string, session: string): void;
|
|
3
|
+
/** The commit the session started from, if it is still in history (a rebase can drop it). */
|
|
4
|
+
export declare function sessionBaseline(cwd: string, session: string): string | undefined;
|
|
5
|
+
/** This stop's attempt number: blocks so far plus one. */
|
|
6
|
+
export declare function nextStopAttempt(cwd: string, session: string): number;
|
|
7
|
+
/** The agent finished cleanly: the next stop starts counting again. The baseline stays. */
|
|
8
|
+
export declare function clearStopAttempts(cwd: string, session: string): void;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the agent hooks remember per session, kept outside the workspace (utils/user-state.ts)
|
|
3
|
+
* so the agent being reviewed cannot rewrite it:
|
|
4
|
+
* - baseline: the commit HEAD was at when the session first edited a file. The stop review
|
|
5
|
+
* covers everything since then, so committing mid-session hides nothing.
|
|
6
|
+
* - attempts: how many times the stop review has blocked this session.
|
|
7
|
+
*/
|
|
8
|
+
import { spawnSync } from 'child_process';
|
|
9
|
+
import fs from 'fs';
|
|
10
|
+
import path from 'path';
|
|
11
|
+
import { repoStateDir } from '../utils/user-state.js';
|
|
12
|
+
/** Sessions untouched this long are forgotten, so the store stays small. */
|
|
13
|
+
const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
14
|
+
function storePath(cwd) {
|
|
15
|
+
return path.join(repoStateDir(cwd), 'sessions.json');
|
|
16
|
+
}
|
|
17
|
+
function readStore(cwd) {
|
|
18
|
+
try {
|
|
19
|
+
return JSON.parse(fs.readFileSync(storePath(cwd), 'utf8'));
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return {};
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
function update(cwd, session, change) {
|
|
26
|
+
const store = readStore(cwd);
|
|
27
|
+
const now = Date.now();
|
|
28
|
+
for (const [id, entry] of Object.entries(store))
|
|
29
|
+
if (now - entry.at > SESSION_TTL_MS)
|
|
30
|
+
delete store[id];
|
|
31
|
+
store[session] = { ...change(store[session] ?? { at: now }), at: now };
|
|
32
|
+
fs.mkdirSync(path.dirname(storePath(cwd)), { recursive: true, mode: 0o700 });
|
|
33
|
+
fs.writeFileSync(storePath(cwd), JSON.stringify(store), { mode: 0o600 });
|
|
34
|
+
return store[session];
|
|
35
|
+
}
|
|
36
|
+
/** Record where the session started, once; later calls keep the first commit. */
|
|
37
|
+
export function recordSessionBaseline(cwd, session) {
|
|
38
|
+
if (!session || readStore(cwd)[session]?.baseline)
|
|
39
|
+
return;
|
|
40
|
+
const head = spawnSync('git', ['rev-parse', '--verify', '--quiet', 'HEAD'], { cwd, encoding: 'utf8' });
|
|
41
|
+
if (head.status !== 0)
|
|
42
|
+
return; // no commits yet: the working-tree review already covers everything
|
|
43
|
+
update(cwd, session, entry => ({ ...entry, baseline: head.stdout.trim() }));
|
|
44
|
+
}
|
|
45
|
+
/** The commit the session started from, if it is still in history (a rebase can drop it). */
|
|
46
|
+
export function sessionBaseline(cwd, session) {
|
|
47
|
+
const baseline = readStore(cwd)[session]?.baseline;
|
|
48
|
+
if (!baseline)
|
|
49
|
+
return undefined;
|
|
50
|
+
const known = spawnSync('git', ['cat-file', '-e', `${baseline}^{commit}`], { cwd });
|
|
51
|
+
return known.status === 0 ? baseline : undefined;
|
|
52
|
+
}
|
|
53
|
+
/** This stop's attempt number: blocks so far plus one. */
|
|
54
|
+
export function nextStopAttempt(cwd, session) {
|
|
55
|
+
return update(cwd, session, entry => ({ ...entry, attempts: (entry.attempts ?? 0) + 1 })).attempts ?? 1;
|
|
56
|
+
}
|
|
57
|
+
/** The agent finished cleanly: the next stop starts counting again. The baseline stays. */
|
|
58
|
+
export function clearStopAttempts(cwd, session) {
|
|
59
|
+
if (readStore(cwd)[session]?.attempts)
|
|
60
|
+
update(cwd, session, entry => ({ ...entry, attempts: 0 }));
|
|
61
|
+
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The "before you say done" review: when an agent is about to stop, review
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* The "before you say done" review: when an agent is about to stop, review what
|
|
3
|
+
* the session changed (since its baseline commit, or the uncommitted change when
|
|
4
|
+
* none was recorded) and, if it introduced serious findings, tell the agent what
|
|
5
|
+
* to fix instead of letting it finish.
|
|
5
6
|
*
|
|
6
7
|
* Only findings that deserve it block: critical ones, and high ones that are
|
|
7
8
|
* proven (the semantic engine traced them) or security findings. A high
|
|
@@ -20,7 +21,8 @@ export interface StopDecision {
|
|
|
20
21
|
findings: Failure[];
|
|
21
22
|
reviewedFiles: string[];
|
|
22
23
|
}
|
|
23
|
-
|
|
24
|
+
/** `since`: the session's baseline commit (session-state.ts); committing after it hides nothing. */
|
|
25
|
+
export declare function stopReview(cwd: string, config: Config, attempt: number, since?: string): Promise<StopDecision>;
|
|
24
26
|
export declare function reviewAckMessage(items: ReviewTaskItem[], attempt: number): string;
|
|
25
27
|
/** Critical, or high and either proven by the semantic engine or a security finding. */
|
|
26
28
|
export declare function blocksStop(finding: Failure): boolean;
|
|
@@ -3,9 +3,11 @@ import { buildReviewTask } from '../review/review-task.js';
|
|
|
3
3
|
import { diffFromGit } from '../review/git-diff.js';
|
|
4
4
|
export const STOP_MAX_ATTEMPTS = 3;
|
|
5
5
|
const MAX_LISTED = 8;
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
const
|
|
6
|
+
/** `since`: the session's baseline commit (session-state.ts); committing after it hides nothing. */
|
|
7
|
+
export async function stopReview(cwd, config, attempt, since) {
|
|
8
|
+
const source = since ? { mode: 'since', commit: since } : { mode: 'working' };
|
|
9
|
+
const diff = diffFromGit(cwd, source);
|
|
10
|
+
const result = await reviewChange({ cwd, config, diff, source });
|
|
9
11
|
const blocking = result.findings.filter(blocksStop);
|
|
10
12
|
const unreviewed = config.hooks?.require_review_ack ? buildReviewTask(cwd, diff, config.gates.deep?.router).items : [];
|
|
11
13
|
const reviewed = { findings: result.findings, reviewedFiles: Object.keys(result.changedLines) };
|
package/dist/hooks/templates.js
CHANGED
package/dist/index.d.ts
CHANGED
|
@@ -11,8 +11,10 @@ export { FrontendSecretExposureGate } from './gates/frontend-secret-exposure.js'
|
|
|
11
11
|
export * from './utils/logger.js';
|
|
12
12
|
export { normalizeScopePatterns, isScoped } from './utils/scope.js';
|
|
13
13
|
export { deepAnalysisError } from './utils/deep-status.js';
|
|
14
|
+
export { rigourUserDir, repoStateDir } from './utils/user-state.js';
|
|
14
15
|
export { reviewChange, toReviewFinding, type ReviewInput, type ReviewResult, type ReviewFinding } from './review/review.js';
|
|
15
16
|
export { splitByChangedLines, type ChangedLineSplit } from './review/changed-lines.js';
|
|
17
|
+
export { isControlFile, mergeBaseOf, readStateFile } from './review/trusted-state.js';
|
|
16
18
|
export { dismissFinding, dismissedKeys, findingKey, isProven, quietSplit, DISMISSED_FILE } from './review/quiet.js';
|
|
17
19
|
export { isGeneratedFile } from './review/generated-files.js';
|
|
18
20
|
export { countUsage, durationBucket, flushDailyUsage, isTelemetryEnabled, readTelemetryState, setTelemetryEnabled, shouldAskTelemetry, telemetryToken, trackUsage } from './telemetry/telemetry.js';
|
package/dist/index.js
CHANGED
|
@@ -11,8 +11,10 @@ export { FrontendSecretExposureGate } from './gates/frontend-secret-exposure.js'
|
|
|
11
11
|
export * from './utils/logger.js';
|
|
12
12
|
export { normalizeScopePatterns, isScoped } from './utils/scope.js';
|
|
13
13
|
export { deepAnalysisError } from './utils/deep-status.js';
|
|
14
|
+
export { rigourUserDir, repoStateDir } from './utils/user-state.js';
|
|
14
15
|
export { reviewChange, toReviewFinding } from './review/review.js';
|
|
15
16
|
export { splitByChangedLines } from './review/changed-lines.js';
|
|
17
|
+
export { isControlFile, mergeBaseOf, readStateFile } from './review/trusted-state.js';
|
|
16
18
|
export { dismissFinding, dismissedKeys, findingKey, isProven, quietSplit, DISMISSED_FILE } from './review/quiet.js';
|
|
17
19
|
export { isGeneratedFile } from './review/generated-files.js';
|
|
18
20
|
export { countUsage, durationBucket, flushDailyUsage, isTelemetryEnabled, readTelemetryState, setTelemetryEnabled, shouldAskTelemetry, telemetryToken, trackUsage } from './telemetry/telemetry.js';
|
|
@@ -19,7 +19,8 @@ export declare function checkId(finding: {
|
|
|
19
19
|
}): string;
|
|
20
20
|
export declare function precisionOf(outcome: CheckOutcome): number;
|
|
21
21
|
export declare function isMuted(outcome: CheckOutcome | undefined): boolean;
|
|
22
|
-
|
|
22
|
+
/** Outcomes in the working tree, or at `ref` for an independent review (trusted-state.ts). */
|
|
23
|
+
export declare function readOutcomes(cwd: string, ref?: string): Record<string, CheckOutcome>;
|
|
23
24
|
export declare function recordOutcome(cwd: string, check: string, kind: keyof CheckOutcome): void;
|
|
24
25
|
/** Every check with an outcome, most dismissed first. */
|
|
25
26
|
export declare function checkPrecisions(cwd: string): CheckPrecision[];
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import fs from 'fs';
|
|
15
15
|
import path from 'path';
|
|
16
|
+
import { readStateFile } from './trusted-state.js';
|
|
16
17
|
const OUTCOMES_FILE = path.join('.rigour', 'check-outcomes.json');
|
|
17
18
|
const REPORTED_FILE = path.join('.rigour', 'reported-findings.json');
|
|
18
19
|
const MAX_REPORTED = 2000;
|
|
@@ -32,8 +33,16 @@ export function isMuted(outcome) {
|
|
|
32
33
|
return false;
|
|
33
34
|
return outcome.fixed + outcome.dismissed >= MUTE_MIN_OUTCOMES && precisionOf(outcome) < MUTE_BELOW;
|
|
34
35
|
}
|
|
35
|
-
|
|
36
|
-
|
|
36
|
+
/** Outcomes in the working tree, or at `ref` for an independent review (trusted-state.ts). */
|
|
37
|
+
export function readOutcomes(cwd, ref) {
|
|
38
|
+
if (!ref)
|
|
39
|
+
return readJson(path.join(cwd, OUTCOMES_FILE)) ?? {};
|
|
40
|
+
try {
|
|
41
|
+
return JSON.parse(readStateFile(cwd, OUTCOMES_FILE, ref) ?? '{}');
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return {};
|
|
45
|
+
}
|
|
37
46
|
}
|
|
38
47
|
export function recordOutcome(cwd, check, kind) {
|
|
39
48
|
const outcomes = readOutcomes(cwd);
|
package/dist/review/git-diff.js
CHANGED
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
* git does not track yet (what an agent has just written).
|
|
6
6
|
* - base: everything on this branch since it left `base` (the merge-base),
|
|
7
7
|
* including uncommitted changes (what a pull request contains).
|
|
8
|
+
* - since: everything after `commit`, committed or not, plus untracked files
|
|
9
|
+
* (what an agent session wrote, even if it committed along the way).
|
|
8
10
|
*/
|
|
9
11
|
import { spawnSync } from 'child_process';
|
|
10
12
|
export class GitDiffError extends Error {
|
|
@@ -14,6 +16,9 @@ export function diffFromGit(cwd, source = { mode: 'working' }) {
|
|
|
14
16
|
const mergeBase = git(cwd, ['merge-base', source.base, 'HEAD']).trim();
|
|
15
17
|
return git(cwd, ['diff', '--no-color', '--no-ext-diff', mergeBase]);
|
|
16
18
|
}
|
|
19
|
+
if (source.mode === 'since') {
|
|
20
|
+
return git(cwd, ['diff', '--no-color', '--no-ext-diff', source.commit]) + untrackedFiles(cwd).map(file => newFileDiff(cwd, file)).join('');
|
|
21
|
+
}
|
|
17
22
|
const tracked = hasHead(cwd) ? git(cwd, ['diff', '--no-color', '--no-ext-diff', 'HEAD']) : '';
|
|
18
23
|
return tracked + untrackedFiles(cwd).map(file => newFileDiff(cwd, file)).join('');
|
|
19
24
|
}
|
|
@@ -18,7 +18,7 @@ export function migrationOrderFailures(cwd, diff, source, config) {
|
|
|
18
18
|
const settings = config.gates.migration_order;
|
|
19
19
|
if (!settings?.enabled)
|
|
20
20
|
return [];
|
|
21
|
-
const base = source?.mode === 'base' ? source.base : 'HEAD';
|
|
21
|
+
const base = source?.mode === 'base' ? source.base : source?.mode === 'since' ? source.commit : 'HEAD';
|
|
22
22
|
const byDirectory = new Map();
|
|
23
23
|
for (const file of addedFiles(diff)) {
|
|
24
24
|
const directory = path.posix.dirname(file);
|
package/dist/review/quiet.d.ts
CHANGED
|
@@ -12,7 +12,8 @@ export interface QuietSplit {
|
|
|
12
12
|
/** Which gates the dismissed findings came from: where Rigour is wrong for this team. */
|
|
13
13
|
dismissedByGate: Record<string, number>;
|
|
14
14
|
}
|
|
15
|
-
|
|
16
|
-
export declare function
|
|
15
|
+
/** `trustedRef`: read dismissals and outcomes as of that commit, not as the change left them. */
|
|
16
|
+
export declare function quietSplit(cwd: string, findings: Failure[], includeHeuristics?: boolean, trustedRef?: string): QuietSplit;
|
|
17
|
+
export declare function dismissedKeys(cwd: string, ref?: string): Set<string>;
|
|
17
18
|
/** Record "not a bug" for a finding key; idempotent. */
|
|
18
19
|
export declare function dismissFinding(cwd: string, key: string, reason: string, at?: string): boolean;
|
package/dist/review/quiet.js
CHANGED
|
@@ -11,12 +11,15 @@
|
|
|
11
11
|
* blocking. A team that wants them back sets review.include_heuristics.
|
|
12
12
|
*
|
|
13
13
|
* A finding a person judged "not a bug" is dismissed by its key and never
|
|
14
|
-
* reported again (.rigour/dismissed.json, meant to be committed).
|
|
14
|
+
* reported again (.rigour/dismissed.json, meant to be committed). An
|
|
15
|
+
* independent review reads it from the base (trusted-state.ts), so a change
|
|
16
|
+
* cannot dismiss its own findings.
|
|
15
17
|
*/
|
|
16
18
|
import crypto from 'crypto';
|
|
17
19
|
import fs from 'fs';
|
|
18
20
|
import path from 'path';
|
|
19
21
|
import { checkId, isMuted, readOutcomes, recordOutcome, reportedCheck } from './check-outcomes.js';
|
|
22
|
+
import { readStateFile } from './trusted-state.js';
|
|
20
23
|
const PROVEN_GATES = new Set(['semantic-bugs', 'hallucinated-imports', 'security-patterns', 'deep-analysis', 'diff-tests']);
|
|
21
24
|
export const DISMISSED_FILE = path.join('.rigour', 'dismissed.json');
|
|
22
25
|
export function isProven(failure) {
|
|
@@ -26,9 +29,10 @@ export function isProven(failure) {
|
|
|
26
29
|
export function findingKey(failure) {
|
|
27
30
|
return crypto.createHash('sha256').update(`${failure.id}\u0000${failure.files?.[0] ?? ''}\u0000${failure.details}`).digest('hex').slice(0, 16);
|
|
28
31
|
}
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
const
|
|
32
|
+
/** `trustedRef`: read dismissals and outcomes as of that commit, not as the change left them. */
|
|
33
|
+
export function quietSplit(cwd, findings, includeHeuristics = false, trustedRef) {
|
|
34
|
+
const dismissed = dismissedKeys(cwd, trustedRef);
|
|
35
|
+
const outcomes = readOutcomes(cwd, trustedRef);
|
|
32
36
|
const split = { speaking: [], advisory: [], muted: 0, dismissed: 0, dismissedByGate: {} };
|
|
33
37
|
for (const finding of findings) {
|
|
34
38
|
if (dismissed.has(findingKey(finding))) {
|
|
@@ -44,9 +48,9 @@ export function quietSplit(cwd, findings, includeHeuristics = false) {
|
|
|
44
48
|
}
|
|
45
49
|
return split;
|
|
46
50
|
}
|
|
47
|
-
export function dismissedKeys(cwd) {
|
|
51
|
+
export function dismissedKeys(cwd, ref) {
|
|
48
52
|
try {
|
|
49
|
-
const parsed = JSON.parse(
|
|
53
|
+
const parsed = JSON.parse(readStateFile(cwd, DISMISSED_FILE, ref) ?? '');
|
|
50
54
|
return new Set(Array.isArray(parsed?.entries) ? parsed.entries.map((e) => e?.key).filter((k) => typeof k === 'string') : []);
|
|
51
55
|
}
|
|
52
56
|
catch {
|
package/dist/review/review.d.ts
CHANGED
|
@@ -12,6 +12,8 @@ export interface ReviewInput {
|
|
|
12
12
|
diffTests?: boolean;
|
|
13
13
|
/** Deep analysis; `focusLines` and `removedLines` are filled from the diff. */
|
|
14
14
|
deep?: Omit<DeepOptions, 'focusLines' | 'removedLines' | 'diff'>;
|
|
15
|
+
/** Read dismissals and check outcomes at this commit (the base), not as the change left them. */
|
|
16
|
+
trustedRef?: string;
|
|
15
17
|
}
|
|
16
18
|
export interface ReviewResult {
|
|
17
19
|
status: 'PASS' | 'FAIL' | 'ERROR';
|
|
@@ -33,6 +35,8 @@ export interface ReviewResult {
|
|
|
33
35
|
deepError?: string;
|
|
34
36
|
/** Gates that crashed instead of running; a proven one makes the result ERROR. */
|
|
35
37
|
gateErrors: string[];
|
|
38
|
+
/** rigour.yml or .rigour/ files this change edits: they steer the review, so they are called out. */
|
|
39
|
+
controlFilesChanged: string[];
|
|
36
40
|
}
|
|
37
41
|
export interface ReviewFinding {
|
|
38
42
|
id: string;
|
package/dist/review/review.js
CHANGED
|
@@ -19,12 +19,13 @@ import { checkId, rememberReported } from './check-outcomes.js';
|
|
|
19
19
|
import { diffFromGit } from './git-diff.js';
|
|
20
20
|
import { diffTestFailures } from './diff-test-findings.js';
|
|
21
21
|
import { migrationOrderFailures } from './migration-order.js';
|
|
22
|
+
import { isControlFile } from './trusted-state.js';
|
|
22
23
|
export async function reviewChange(input) {
|
|
23
24
|
const diff = input.diff ?? diffFromGit(input.cwd, input.source);
|
|
24
25
|
const changedLines = withoutGenerated(input.cwd, parseDiff(diff));
|
|
25
26
|
const targets = input.files?.length ? input.files : Object.keys(changedLines);
|
|
26
27
|
if (targets.length === 0) {
|
|
27
|
-
return { status: 'PASS', findings: [], fileFindings: [], contextFindings: [], advisory: [], muted: 0, dismissed: 0, dismissedByGate: {}, unlocated: 0, excludedOutsideChangedLines: 0, changedLines, report: null, gateErrors: [] };
|
|
28
|
+
return { status: 'PASS', findings: [], fileFindings: [], contextFindings: [], advisory: [], muted: 0, dismissed: 0, dismissedByGate: {}, unlocated: 0, excludedOutsideChangedLines: 0, changedLines, report: null, gateErrors: [], controlFilesChanged: controlFiles(diff) };
|
|
28
29
|
}
|
|
29
30
|
const deep = input.deep ? { ...input.deep, focusLines: changedLinesByFile(changedLines), removedLines: removedByFile(diff), diff } : undefined;
|
|
30
31
|
const report = await new GateRunner(input.config).run(input.cwd, await normalizeScopePatterns(input.cwd, targets), deep);
|
|
@@ -33,7 +34,7 @@ export async function reviewChange(input) {
|
|
|
33
34
|
report.failures.push(...migrationOrderFailures(input.cwd, diff, input.source, input.config));
|
|
34
35
|
const split = splitByChangedLines(report.failures, changedLines, deep ? changedFunctionSpans(input.cwd, changedLines) : {}, removedByFile(diff));
|
|
35
36
|
const deepError = deepAnalysisError(report);
|
|
36
|
-
const quiet = quietSplit(input.cwd, split.findings, input.config.review?.include_heuristics);
|
|
37
|
+
const quiet = quietSplit(input.cwd, split.findings, input.config.review?.include_heuristics, input.trustedRef);
|
|
37
38
|
rememberReported(input.cwd, [...quiet.speaking, ...quiet.advisory].map(f => ({ key: findingKey(f), check: checkId(f) })));
|
|
38
39
|
const gateErrors = crashedGates(report);
|
|
39
40
|
const provenCrashed = gateErrors.some(id => isProven({ id }));
|
|
@@ -51,9 +52,20 @@ export async function reviewChange(input) {
|
|
|
51
52
|
changedLines,
|
|
52
53
|
report,
|
|
53
54
|
gateErrors,
|
|
55
|
+
controlFilesChanged: controlFiles(diff),
|
|
54
56
|
...(deepError ? { deepError } : {}),
|
|
55
57
|
};
|
|
56
58
|
}
|
|
59
|
+
/** Every path the diff touches (including deletions, which parseDiff drops) that steers Rigour itself. */
|
|
60
|
+
function controlFiles(diff) {
|
|
61
|
+
const files = new Set();
|
|
62
|
+
for (const match of diff.matchAll(/^diff --git a\/(\S+) b\/(\S+)$/gm)) {
|
|
63
|
+
for (const file of [match[1], match[2]])
|
|
64
|
+
if (isControlFile(file))
|
|
65
|
+
files.add(file);
|
|
66
|
+
}
|
|
67
|
+
return [...files].sort();
|
|
68
|
+
}
|
|
57
69
|
function crashedGates(report) {
|
|
58
70
|
return Object.entries(report.summary).filter(([, status]) => status === 'ERROR').map(([id]) => id);
|
|
59
71
|
}
|
|
@@ -10,6 +10,7 @@ import { splitByChangedLines } from './changed-lines.js';
|
|
|
10
10
|
import { anchorInChangedFunction, changedFunctionSpans } from './changed-function-spans.js';
|
|
11
11
|
import { diffFromGit } from './git-diff.js';
|
|
12
12
|
import { reviewChange, toReviewFinding } from './review.js';
|
|
13
|
+
import { findingKey } from './quiet.js';
|
|
13
14
|
const LEAKY = [
|
|
14
15
|
'export async function notify(endpoint: string, signature: string) {',
|
|
15
16
|
" return fetch(endpoint, { method: 'POST', headers: { 'x-hook-signature': signature } });",
|
|
@@ -114,6 +115,24 @@ describe('git-backed review', () => {
|
|
|
114
115
|
expect(semantic.map(f => [f.files?.[0], f.line])).toEqual([['src/notify.ts', 2]]);
|
|
115
116
|
expect(toReviewFinding(semantic[0])).toMatchObject({ id: 'semantic-bugs', file: 'src/notify.ts', line: 2, severity: 'high' });
|
|
116
117
|
});
|
|
118
|
+
it('lets a change dismiss its own finding only when the review trusts the working tree', async () => {
|
|
119
|
+
write('src/old.ts', 'export const a = 1;\n');
|
|
120
|
+
write('.gitignore', '.rigour/*\n!.rigour/dismissed.json\n');
|
|
121
|
+
git('add', '-A');
|
|
122
|
+
git('commit', '-qm', 'init');
|
|
123
|
+
git('checkout', '-q', '-b', 'pr');
|
|
124
|
+
write('src/notify.ts', LEAKY);
|
|
125
|
+
const config = ConfigSchema.parse({ version: 1, gates: { semantic_bugs: { enabled: true } } });
|
|
126
|
+
const key = findingKey((await reviewChange({ cwd: repo, config })).findings[0]);
|
|
127
|
+
write('.rigour/dismissed.json', JSON.stringify({ version: 1, entries: [{ key, reason: 'trust me', at: 'now' }] }));
|
|
128
|
+
git('add', '-A');
|
|
129
|
+
git('commit', '-qm', 'pr');
|
|
130
|
+
const trusting = await reviewChange({ cwd: repo, config, source: { mode: 'base', base: 'main' } });
|
|
131
|
+
expect([trusting.status, trusting.dismissed]).toEqual(['PASS', 1]);
|
|
132
|
+
expect(trusting.controlFilesChanged).toEqual(['.rigour/dismissed.json']);
|
|
133
|
+
const independent = await reviewChange({ cwd: repo, config, source: { mode: 'base', base: 'main' }, trustedRef: 'main' });
|
|
134
|
+
expect([independent.status, independent.dismissed]).toEqual(['FAIL', 0]);
|
|
135
|
+
});
|
|
117
136
|
it('runs every gate in a git worktree, where .git is a file', async () => {
|
|
118
137
|
write('src/a.ts', 'export const a = 1;\n');
|
|
119
138
|
git('add', '-A');
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** Paths that steer Rigour itself; a change touching them is called out in the review. */
|
|
2
|
+
export declare function isControlFile(file: string): boolean;
|
|
3
|
+
/** A repository file's content at `ref`, or in the working tree when no ref is given; null when absent. */
|
|
4
|
+
export declare function readStateFile(cwd: string, rel: string, ref?: string): string | null;
|
|
5
|
+
/** The commit a branch left `base` at: the state a pull request is judged against. */
|
|
6
|
+
export declare function mergeBaseOf(cwd: string, base: string): string;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which copy of Rigour's own settings a review trusts.
|
|
3
|
+
*
|
|
4
|
+
* Dismissals, check outcomes and rigour.yml decide what a review reports. In the working tree they
|
|
5
|
+
* are whatever the change under review wrote, so a pull request could dismiss its own critical
|
|
6
|
+
* finding. An independent review reads them from the base instead (`ref`): a dismissal or setting
|
|
7
|
+
* added by the change takes effect once it is merged, the same rule CODEOWNERS follows.
|
|
8
|
+
*/
|
|
9
|
+
import { spawnSync } from 'child_process';
|
|
10
|
+
import fs from 'fs';
|
|
11
|
+
import path from 'path';
|
|
12
|
+
/** Paths that steer Rigour itself; a change touching them is called out in the review. */
|
|
13
|
+
export function isControlFile(file) {
|
|
14
|
+
return file === 'rigour.yml' || file.startsWith('.rigour/');
|
|
15
|
+
}
|
|
16
|
+
/** A repository file's content at `ref`, or in the working tree when no ref is given; null when absent. */
|
|
17
|
+
export function readStateFile(cwd, rel, ref) {
|
|
18
|
+
if (!ref) {
|
|
19
|
+
try {
|
|
20
|
+
return fs.readFileSync(path.join(cwd, rel), 'utf8');
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
const result = spawnSync('git', ['show', `${ref}:${rel.split(path.sep).join('/')}`], { cwd, encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 });
|
|
27
|
+
return result.status === 0 ? result.stdout : null;
|
|
28
|
+
}
|
|
29
|
+
/** The commit a branch left `base` at: the state a pull request is judged against. */
|
|
30
|
+
export function mergeBaseOf(cwd, base) {
|
|
31
|
+
const result = spawnSync('git', ['merge-base', base, 'HEAD'], { cwd, encoding: 'utf8' });
|
|
32
|
+
if (result.status !== 0)
|
|
33
|
+
throw new Error(`git merge-base ${base} HEAD failed: ${result.stderr.trim()}`);
|
|
34
|
+
return result.stdout.trim();
|
|
35
|
+
}
|
package/dist/types/index.d.ts
CHANGED
|
@@ -3643,6 +3643,11 @@ export interface DeepOptions {
|
|
|
3643
3643
|
}>>;
|
|
3644
3644
|
/** What the change intends (a PR description): reference for the stronger tiers. */
|
|
3645
3645
|
prBody?: string;
|
|
3646
|
+
/**
|
|
3647
|
+
* Review every risky changed function, ignoring the review ledger and reviewed.json. Those
|
|
3648
|
+
* record what an agent said it reviewed; an enforcing check reviews independently.
|
|
3649
|
+
*/
|
|
3650
|
+
independent?: boolean;
|
|
3646
3651
|
/** The change's unified diff (from reviewChange): a cloud agentic review reads the PR as a whole. */
|
|
3647
3652
|
diff?: string;
|
|
3648
3653
|
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** The user's Rigour directory: RIGOUR_HOME/.rigour when set (tests, sandboxes), else ~/.rigour. */
|
|
2
|
+
export declare function rigourUserDir(): string;
|
|
3
|
+
/**
|
|
4
|
+
* Per-repository state kept outside the workspace, where an agent editing the repository
|
|
5
|
+
* cannot rewrite it: arbitration tokens, stop-hook attempt counts. Keyed by the real path.
|
|
6
|
+
*/
|
|
7
|
+
export declare function repoStateDir(cwd: string): string;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import crypto from 'crypto';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
/** The user's Rigour directory: RIGOUR_HOME/.rigour when set (tests, sandboxes), else ~/.rigour. */
|
|
6
|
+
export function rigourUserDir() {
|
|
7
|
+
return path.join(process.env.RIGOUR_HOME || os.homedir(), '.rigour');
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Per-repository state kept outside the workspace, where an agent editing the repository
|
|
11
|
+
* cannot rewrite it: arbitration tokens, stop-hook attempt counts. Keyed by the real path.
|
|
12
|
+
*/
|
|
13
|
+
export function repoStateDir(cwd) {
|
|
14
|
+
let root = path.resolve(cwd);
|
|
15
|
+
try {
|
|
16
|
+
root = fs.realpathSync(root);
|
|
17
|
+
}
|
|
18
|
+
catch { /* not created yet: the resolved path is the key */ }
|
|
19
|
+
const key = crypto.createHash('sha256').update(root).digest('hex').slice(0, 16);
|
|
20
|
+
return path.join(rigourUserDir(), 'repos', key);
|
|
21
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rigour-labs/core",
|
|
3
|
-
"version": "6.5.
|
|
3
|
+
"version": "6.5.4",
|
|
4
4
|
"description": "Rigour's review engine: deterministic gates on changed lines, rules and lessons learned from your team's fixes, and per-check precision from what you fix versus dismiss, across TypeScript, JavaScript, Python, Go, Ruby and C#.",
|
|
5
5
|
"engines": {
|
|
6
6
|
"node": ">=22"
|
|
@@ -70,11 +70,11 @@
|
|
|
70
70
|
"pg": "^8.16.3",
|
|
71
71
|
"sqlite3": "^5.1.7",
|
|
72
72
|
"openai": "^4.104.0",
|
|
73
|
-
"@rigour-labs/brain-darwin-arm64": "6.5.
|
|
74
|
-
"@rigour-labs/brain-darwin-x64": "6.5.
|
|
75
|
-
"@rigour-labs/brain-linux-
|
|
76
|
-
"@rigour-labs/brain-linux-
|
|
77
|
-
"@rigour-labs/brain-win-x64": "6.5.
|
|
73
|
+
"@rigour-labs/brain-darwin-arm64": "6.5.4",
|
|
74
|
+
"@rigour-labs/brain-darwin-x64": "6.5.4",
|
|
75
|
+
"@rigour-labs/brain-linux-arm64": "6.5.4",
|
|
76
|
+
"@rigour-labs/brain-linux-x64": "6.5.4",
|
|
77
|
+
"@rigour-labs/brain-win-x64": "6.5.4"
|
|
78
78
|
},
|
|
79
79
|
"devDependencies": {
|
|
80
80
|
"@types/fs-extra": "^11.0.4",
|