@rigour-labs/core 6.5.3 → 6.6.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.
Files changed (70) hide show
  1. package/README.md +1 -1
  2. package/dist/context/automatic-index-cache.test.d.ts +1 -0
  3. package/dist/context/automatic-index-cache.test.js +30 -0
  4. package/dist/context/automatic-index.js +0 -4
  5. package/dist/context/cache-engine.d.ts +7 -38
  6. package/dist/context/cache-engine.js +7 -70
  7. package/dist/context/cache-engine.test.js +2 -46
  8. package/dist/context/index-bridge.d.ts +1 -7
  9. package/dist/context/index-bridge.js +1 -79
  10. package/dist/deep/risk.d.ts +6 -0
  11. package/dist/deep/risk.js +7 -1
  12. package/dist/deep/risk.test.js +12 -1
  13. package/dist/firewall/arbitration-token.d.ts +12 -3
  14. package/dist/firewall/arbitration-token.js +30 -23
  15. package/dist/firewall/attestation.d.ts +7 -2
  16. package/dist/firewall/attestation.js +49 -28
  17. package/dist/firewall/execution-receipt.d.ts +5 -0
  18. package/dist/firewall/execution-receipt.js +10 -1
  19. package/dist/firewall/trust-boundaries.test.d.ts +1 -0
  20. package/dist/firewall/trust-boundaries.test.js +64 -0
  21. package/dist/firewall/trusted-control.test.js +10 -0
  22. package/dist/gates/deep-analysis.d.ts +6 -0
  23. package/dist/gates/deep-analysis.js +8 -1
  24. package/dist/gates/trusted-reviews.test.d.ts +1 -0
  25. package/dist/gates/trusted-reviews.test.js +16 -0
  26. package/dist/hooks/checker.js +40 -0
  27. package/dist/hooks/checker.test.js +18 -0
  28. package/dist/hooks/index.d.ts +1 -0
  29. package/dist/hooks/index.js +1 -0
  30. package/dist/hooks/session-state.d.ts +8 -0
  31. package/dist/hooks/session-state.js +61 -0
  32. package/dist/hooks/stop-review.d.ts +6 -4
  33. package/dist/hooks/stop-review.js +5 -3
  34. package/dist/hooks/templates.js +1 -1
  35. package/dist/index.d.ts +7 -2
  36. package/dist/index.js +7 -2
  37. package/dist/review/agent-fixes.d.ts +25 -2
  38. package/dist/review/agent-fixes.js +27 -6
  39. package/dist/review/check-outcomes.d.ts +2 -1
  40. package/dist/review/check-outcomes.js +11 -2
  41. package/dist/review/effectiveness.d.ts +13 -0
  42. package/dist/review/git-diff.d.ts +3 -0
  43. package/dist/review/git-diff.js +5 -0
  44. package/dist/review/learning-events.d.ts +14 -0
  45. package/dist/review/learning-events.js +21 -0
  46. package/dist/review/migration-order.js +1 -1
  47. package/dist/review/quiet.d.ts +3 -2
  48. package/dist/review/quiet.js +10 -6
  49. package/dist/review/review.d.ts +4 -0
  50. package/dist/review/review.js +14 -2
  51. package/dist/review/review.test.js +19 -0
  52. package/dist/review/stories.d.ts +25 -0
  53. package/dist/review/stories.js +75 -0
  54. package/dist/review/stories.test.d.ts +1 -0
  55. package/dist/review/stories.test.js +58 -0
  56. package/dist/review/trusted-state.d.ts +6 -0
  57. package/dist/review/trusted-state.js +35 -0
  58. package/dist/storage/cache-cleanup.d.ts +19 -0
  59. package/dist/storage/cache-cleanup.js +90 -0
  60. package/dist/storage/cache-cleanup.test.d.ts +1 -0
  61. package/dist/storage/cache-cleanup.test.js +54 -0
  62. package/dist/storage/db.js +3 -1
  63. package/dist/storage/index.d.ts +1 -0
  64. package/dist/storage/index.js +1 -0
  65. package/dist/storage/local-encryption.js +2 -2
  66. package/dist/storage/team-store.js +2 -2
  67. package/dist/types/index.d.ts +5 -0
  68. package/dist/utils/user-state.d.ts +7 -0
  69. package/dist/utils/user-state.js +21 -0
  70. package/package.json +6 -6
@@ -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(os.homedir(), '.rigour', KEY_FILE);
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(path.join(os.homedir(), '.rigour'));
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
- return expected === signature;
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: valid signature, PASS gates, bound commit/tree, fresh, non-workspace-key unless allowed.
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
- if (!bundle.treeDigest && (!bundle.filesUsed || bundle.filesUsed.length === 0)) {
172
- return { admit: false, reason: 'Attestation missing treeDigest and filesUsed' };
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 headSha = await getGitCommitSha(cwd);
183
- if (bundle.commitSha && headSha && bundle.commitSha !== headSha) {
184
- return {
185
- admit: false,
186
- reason: `Attestation commitSha ${bundle.commitSha.slice(0, 8)}≠ HEAD ${headSha.slice(0, 8)}`,
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
- const headTree = await getGitTreeDigest(cwd);
190
- if (bundle.treeDigest && headTree && bundle.treeDigest !== headTree) {
191
- return {
192
- admit: false,
193
- reason: `Attestation treeDigest mismatch vs HEAD tree`,
194
- };
195
- }
196
- return { admit: true, reason: 'Valid attestation with PASS gates and bound tree/commit' };
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
  }
@@ -1,4 +1,9 @@
1
1
  import type { ExecutionReceipt } from './types.js';
2
+ /**
3
+ * Another append holds the lock. On Windows, creating a file another process is still deleting
4
+ * fails with EPERM (or EACCES/EBUSY) rather than EEXIST; elsewhere those are real permission errors.
5
+ */
6
+ export declare function isLockContention(code: unknown, platform?: NodeJS.Platform): boolean;
2
7
  export declare function appendExecutionReceipt(cwd: string, input: Omit<ExecutionReceipt, 'version' | 'id' | 'repositoryId' | 'createdAt' | 'previousReceipt' | 'digest' | 'signature'>, controlRoot?: string): Promise<ExecutionReceipt>;
3
8
  export declare function listExecutionReceipts(cwd: string, limit?: number, controlRoot?: string): Promise<ExecutionReceipt[]>;
4
9
  export declare function verifyExecutionReceiptChain(cwd: string, controlRoot?: string): Promise<{
@@ -17,6 +17,15 @@ async function receiptKey(cwd, controlRoot) {
17
17
  await fs.writeFile(keyPath, key, { mode: 0o600 });
18
18
  return key;
19
19
  }
20
+ /**
21
+ * Another append holds the lock. On Windows, creating a file another process is still deleting
22
+ * fails with EPERM (or EACCES/EBUSY) rather than EEXIST; elsewhere those are real permission errors.
23
+ */
24
+ export function isLockContention(code, platform = process.platform) {
25
+ if (code === 'EEXIST')
26
+ return true;
27
+ return platform === 'win32' && (code === 'EPERM' || code === 'EACCES' || code === 'EBUSY');
28
+ }
20
29
  async function acquireReceiptLock(logPath) {
21
30
  const lockPath = `${logPath}.append-lock`;
22
31
  for (let attempt = 0; attempt < 100; attempt += 1) {
@@ -24,7 +33,7 @@ async function acquireReceiptLock(logPath) {
24
33
  return await fs.promises.open(lockPath, 'wx', 0o600);
25
34
  }
26
35
  catch (error) {
27
- if (error?.code !== 'EEXIST')
36
+ if (!isLockContention(error?.code))
28
37
  throw error;
29
38
  await delay(10);
30
39
  }
@@ -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
+ });
@@ -176,3 +176,13 @@ describe('action normalization and receipts', () => {
176
176
  expect(await verifyExecutionReceiptChain(cwd, root)).toMatchObject({ valid: true, count: 1 });
177
177
  });
178
178
  });
179
+ describe('receipt lock contention', () => {
180
+ it('retries while another append releases the lock, including Windows delete-pending errors', async () => {
181
+ const { isLockContention } = await import('./execution-receipt.js');
182
+ expect(isLockContention('EEXIST', 'linux')).toBe(true);
183
+ expect(isLockContention('EPERM', 'win32')).toBe(true);
184
+ expect(isLockContention('EBUSY', 'win32')).toBe(true);
185
+ expect(isLockContention('EPERM', 'linux')).toBe(false); // a real permission error elsewhere
186
+ expect(isLockContention('ENOENT', 'win32')).toBe(false);
187
+ });
188
+ });
@@ -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, reviewedKeys(cwd));
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
+ });
@@ -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;
@@ -244,6 +249,24 @@ function checkSecurityPatterns(lines, relPath, failures) {
244
249
  checkHardcodedSecrets(line, i, relPath, isTestFile, failures);
245
250
  checkCommandInjection(line, i, relPath, failures);
246
251
  }
252
+ if (!isTestFile)
253
+ checkVendorKeys(lines.join('\n'), relPath, failures);
254
+ }
255
+ /** Credential formats a vendor issues: unambiguous in code, unlike entropy or variable-name guesses. */
256
+ const VENDOR_KEYS = new Set(['aws_access_key', 'openai_key', 'anthropic_key', 'github_token', 'stripe_key', 'slack_token', 'sendgrid_key', 'private_key', 'private_key_full']);
257
+ /** A real vendor key written into code, whatever the variable is called. */
258
+ function checkVendorKeys(content, relPath, failures) {
259
+ for (const detection of scanInputForCredentials(content).detections) {
260
+ if (!VENDOR_KEYS.has(detection.type) || !detection.position)
261
+ continue;
262
+ const raw = content.slice(detection.position.start, detection.position.end);
263
+ if (/^pk_|_test_/.test(raw))
264
+ continue; // publishable and test keys are meant to be shared
265
+ const line = content.slice(0, detection.position.start).split('\n').length;
266
+ if (failures.some(f => f.gate === 'security-patterns' && f.line === line))
267
+ continue;
268
+ failures.push({ gate: 'security-patterns', file: relPath, message: `${detection.description} in code`, severity: 'critical', line });
269
+ }
247
270
  }
248
271
  function checkHardcodedSecrets(line, i, relPath, isTestFile, failures) {
249
272
  if (isTestFile) {
@@ -270,6 +293,23 @@ function checkCommandInjection(line, i, relPath, failures) {
270
293
  });
271
294
  }
272
295
  }
296
+ // ── Rigour's own state (always on) ────────────────────────────────
297
+ /**
298
+ * .rigour/ holds what decides a review: dismissals, check outcomes, learned rules, the review
299
+ * ledger, operator scopes. Rigour writes it through its own commands and tools, never through
300
+ * the agent's edit tool, so an agent edit there is refused whatever rigour.yml says.
301
+ */
302
+ function isRigourState(relPath) {
303
+ return relPath.replace(/\\/g, '/').startsWith('.rigour/');
304
+ }
305
+ function rigourStateFailure(relPath) {
306
+ return {
307
+ gate: 'file-guard',
308
+ file: relPath,
309
+ 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.`,
310
+ severity: 'critical',
311
+ };
312
+ }
273
313
  // ── Protected Paths Enforcement (real-time) ───────────────────────
274
314
  /**
275
315
  * Block writes to protected paths defined in rigour.yml safety.protected_paths.
@@ -43,6 +43,24 @@ 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
+ });
54
+ it('catches a real vendor key whatever it is named, and leaves publishable and test keys alone', async () => {
55
+ // Built at runtime: a key-shaped literal in this source would be (rightly) refused by secret scanning.
56
+ const fake = (prefix) => `${prefix}_${'51HxQ'}${'abcdefghijklmnopqrstuv'}`;
57
+ fs.writeFileSync(path.join(testDir, 'pay.ts'), `export const stripe = {\n value: "${fake('sk_live')}",\n};\n`);
58
+ fs.writeFileSync(path.join(testDir, 'public.ts'), `export const key = "${fake('pk_live')}";\nexport const t = "${fake('sk_test')}";\n`);
59
+ const leaked = await runHookChecker({ cwd: testDir, files: ['pay.ts'] });
60
+ expect(leaked.failures).toEqual([expect.objectContaining({ gate: 'security-patterns', line: 2, severity: 'critical', message: 'Stripe API key detected in code' })]);
61
+ const fine = await runHookChecker({ cwd: testDir, files: ['public.ts'] });
62
+ expect(fine.failures.filter(f => f.gate === 'security-patterns')).toEqual([]);
63
+ });
46
64
  it('should detect file size violations', async () => {
47
65
  const filePath = path.join(testDir, 'big.ts');
48
66
  const lines = Array.from({ length: 100 }, (_, i) => `export const v${i} = ${i};`);
@@ -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';
@@ -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 the
3
- * uncommitted change and, if it introduced serious findings, tell the agent
4
- * what to fix instead of letting it finish.
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
- export declare function stopReview(cwd: string, config: Config, attempt: number): Promise<StopDecision>;
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
- export async function stopReview(cwd, config, attempt) {
7
- const diff = diffFromGit(cwd, { mode: 'working' });
8
- const result = await reviewChange({ cwd, config, diff });
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) };
@@ -47,7 +47,7 @@ function generateClaudeHooks(checkerCommand) {
47
47
  hooks: [
48
48
  {
49
49
  type: "command",
50
- command: `${checkerCommand} --files "$TOOL_INPUT_file_path"`,
50
+ command: `${checkerCommand} --stdin`,
51
51
  }
52
52
  ]
53
53
  }
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';
@@ -27,8 +29,10 @@ export { readLessons, writeLessons, promoteLesson, matchLessons, lessonText, les
27
29
  export { lessonsForDiff, lessonsSection, type LessonMode } from './review-learning/team-lessons.js';
28
30
  export { readRepoRules, rulesForDiff, rulesSection, splitRules, type RepoRule } from './review-learning/repo-rules.js';
29
31
  export { diffFromGit, GitDiffError, type DiffSource } from './review/git-diff.js';
30
- export { checkPrecisions, checkId, precisionOf, isMuted, MUTE_MIN_OUTCOMES, MUTE_BELOW, type CheckPrecision, type CheckOutcome } from './review/check-outcomes.js';
31
- export { recordReviewOutcome, listResolvedFixes, removeResolvedFix, openFindingCount, type ResolvedFix, type FixCapture } from './review/agent-fixes.js';
32
+ export { checkPrecisions, checkId, precisionOf, isMuted, readOutcomes, MUTE_MIN_OUTCOMES, MUTE_BELOW, type CheckPrecision, type CheckOutcome } from './review/check-outcomes.js';
33
+ export { recordReviewOutcome, listResolvedFixes, removeResolvedFix, openFindingCount, listOpenFindings, type ResolvedFix, type FixCapture } from './review/agent-fixes.js';
34
+ export { appendStory, readStories, compactDiff, STORIES_FILE, type Story, type CatchStage } from './review/stories.js';
35
+ export { recordLessonsServed, recordPrCatches, recordReuseSuggested, type LessonChannel } from './review/learning-events.js';
32
36
  export { computeEffectiveness, readAgentEvents, appendAgentEvent, findingKeys, type AgentEvent, type ReviewEffectiveness } from './review/effectiveness.js';
33
37
  export { parseDiff, changedLinesByFile } from './utils/diff.js';
34
38
  export { FileScanner } from './utils/scanner.js';
@@ -44,6 +48,7 @@ export { isModelCached, getModelsDir, getModelInfo, getCachedModel, ensureModel
44
48
  export { installLlamaEngine, probeBinary, managedEnginePath, LLAMA_RELEASE_TAG } from './inference/llama-engine.js';
45
49
  export { SidecarProvider } from './inference/sidecar-provider.js';
46
50
  export { extractFacts, factsToPromptString } from './deep/fact-extractor.js';
51
+ export { cleanContextCache, deadCacheRows, type CacheCleanupReport } from './storage/index.js';
47
52
  export { openDatabase, isSQLiteAvailable, compactDatabase, getDatabaseSize, resetDatabase, insertScan, insertFindings, getRecentScans, getScoreTrendFromDB, getTopIssues, reinforcePattern, getStrongPatterns } from './storage/index.js';
48
53
  export type { RigourDB, CompactResult } from './storage/index.js';
49
54
  export { checkLocalPatterns, persistAndReinforce, getProjectStats } from './storage/index.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';
@@ -27,8 +29,10 @@ export { readLessons, writeLessons, promoteLesson, matchLessons, lessonText, les
27
29
  export { lessonsForDiff, lessonsSection } from './review-learning/team-lessons.js';
28
30
  export { readRepoRules, rulesForDiff, rulesSection, splitRules } from './review-learning/repo-rules.js';
29
31
  export { diffFromGit, GitDiffError } from './review/git-diff.js';
30
- export { checkPrecisions, checkId, precisionOf, isMuted, MUTE_MIN_OUTCOMES, MUTE_BELOW } from './review/check-outcomes.js';
31
- export { recordReviewOutcome, listResolvedFixes, removeResolvedFix, openFindingCount } from './review/agent-fixes.js';
32
+ export { checkPrecisions, checkId, precisionOf, isMuted, readOutcomes, MUTE_MIN_OUTCOMES, MUTE_BELOW } from './review/check-outcomes.js';
33
+ export { recordReviewOutcome, listResolvedFixes, removeResolvedFix, openFindingCount, listOpenFindings } from './review/agent-fixes.js';
34
+ export { appendStory, readStories, compactDiff, STORIES_FILE } from './review/stories.js';
35
+ export { recordLessonsServed, recordPrCatches, recordReuseSuggested } from './review/learning-events.js';
32
36
  export { computeEffectiveness, readAgentEvents, appendAgentEvent, findingKeys } from './review/effectiveness.js';
33
37
  export { parseDiff, changedLinesByFile } from './utils/diff.js';
34
38
  export { FileScanner } from './utils/scanner.js';
@@ -45,6 +49,7 @@ export { installLlamaEngine, probeBinary, managedEnginePath, LLAMA_RELEASE_TAG }
45
49
  export { SidecarProvider } from './inference/sidecar-provider.js';
46
50
  export { extractFacts, factsToPromptString } from './deep/fact-extractor.js';
47
51
  // Storage (SQLite Brain)
52
+ export { cleanContextCache, deadCacheRows } from './storage/index.js';
48
53
  export { openDatabase, isSQLiteAvailable, compactDatabase, getDatabaseSize, resetDatabase, insertScan, insertFindings, getRecentScans, getScoreTrendFromDB, getTopIssues, reinforcePattern, getStrongPatterns } from './storage/index.js';
49
54
  // Local Project Memory (hybrid intelligence — SQLite-backed per-project learning)
50
55
  export { checkLocalPatterns, persistAndReinforce, getProjectStats } from './storage/index.js';