@rigour-labs/core 6.5.2 → 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.
Files changed (138) hide show
  1. package/README.md +1 -1
  2. package/dist/context/automatic-index.d.ts +5 -0
  3. package/dist/context/automatic-index.js +24 -3
  4. package/dist/context/index-status.test.d.ts +1 -0
  5. package/dist/context/index-status.test.js +26 -0
  6. package/dist/deep/risk.d.ts +6 -0
  7. package/dist/deep/risk.js +7 -1
  8. package/dist/deep/risk.test.js +12 -1
  9. package/dist/firewall/arbitration-token.d.ts +12 -3
  10. package/dist/firewall/arbitration-token.js +30 -23
  11. package/dist/firewall/attestation.d.ts +7 -2
  12. package/dist/firewall/attestation.js +49 -28
  13. package/dist/firewall/trust-boundaries.test.d.ts +1 -0
  14. package/dist/firewall/trust-boundaries.test.js +64 -0
  15. package/dist/gates/dedupe-failures.test.d.ts +1 -0
  16. package/dist/gates/dedupe-failures.test.js +12 -0
  17. package/dist/gates/deep-analysis.d.ts +6 -0
  18. package/dist/gates/deep-analysis.js +8 -1
  19. package/dist/gates/deprecated-dependencies.d.ts +27 -0
  20. package/dist/gates/deprecated-dependencies.js +104 -0
  21. package/dist/gates/deprecated-dependencies.test.d.ts +1 -0
  22. package/dist/gates/deprecated-dependencies.test.js +55 -0
  23. package/dist/gates/runner.d.ts +7 -1
  24. package/dist/gates/runner.js +25 -11
  25. package/dist/gates/security-patterns-data.js +2 -1
  26. package/dist/gates/security-patterns-owasp.test.js +15 -0
  27. package/dist/gates/semantic-bugs.js +1 -1
  28. package/dist/gates/trusted-reviews.test.d.ts +1 -0
  29. package/dist/gates/trusted-reviews.test.js +16 -0
  30. package/dist/gates/unindexed-reads/coverage.d.ts +15 -0
  31. package/dist/gates/unindexed-reads/coverage.js +24 -0
  32. package/dist/gates/unindexed-reads/index.d.ts +20 -0
  33. package/dist/gates/unindexed-reads/index.js +98 -0
  34. package/dist/gates/unindexed-reads/queries.d.ts +17 -0
  35. package/dist/gates/unindexed-reads/queries.js +147 -0
  36. package/dist/gates/unindexed-reads/queries.test.d.ts +1 -0
  37. package/dist/gates/unindexed-reads/queries.test.js +54 -0
  38. package/dist/gates/unindexed-reads/schema.d.ts +38 -0
  39. package/dist/gates/unindexed-reads/schema.js +256 -0
  40. package/dist/gates/unindexed-reads/schema.test.d.ts +1 -0
  41. package/dist/gates/unindexed-reads/schema.test.js +81 -0
  42. package/dist/gates/unindexed-reads/sql.d.ts +21 -0
  43. package/dist/gates/unindexed-reads/sql.js +131 -0
  44. package/dist/gates/unindexed-reads/unindexed-reads.test.d.ts +1 -0
  45. package/dist/gates/unindexed-reads/unindexed-reads.test.js +90 -0
  46. package/dist/hooks/checker.js +22 -0
  47. package/dist/hooks/checker.test.js +8 -0
  48. package/dist/hooks/index.d.ts +1 -0
  49. package/dist/hooks/index.js +1 -0
  50. package/dist/hooks/session-state.d.ts +8 -0
  51. package/dist/hooks/session-state.js +61 -0
  52. package/dist/hooks/stop-review.d.ts +6 -4
  53. package/dist/hooks/stop-review.js +5 -3
  54. package/dist/hooks/templates.js +1 -1
  55. package/dist/index.d.ts +6 -1
  56. package/dist/index.js +6 -1
  57. package/dist/inference/brain-placeholder.test.d.ts +1 -0
  58. package/dist/inference/brain-placeholder.test.js +28 -0
  59. package/dist/inference/llama-engine-checksum.test.d.ts +1 -0
  60. package/dist/inference/llama-engine-checksum.test.js +27 -0
  61. package/dist/inference/llama-engine.d.ts +4 -1
  62. package/dist/inference/llama-engine.js +15 -8
  63. package/dist/memory/recall.d.ts +37 -0
  64. package/dist/memory/recall.js +93 -0
  65. package/dist/memory/recall.test.d.ts +1 -0
  66. package/dist/memory/recall.test.js +36 -0
  67. package/dist/pattern-index/assess.d.ts +16 -0
  68. package/dist/pattern-index/assess.js +37 -0
  69. package/dist/pattern-index/embeddings.d.ts +19 -0
  70. package/dist/pattern-index/embeddings.js +16 -0
  71. package/dist/pattern-index/index.d.ts +3 -2
  72. package/dist/pattern-index/index.js +4 -2
  73. package/dist/pattern-index/indexer.js +15 -7
  74. package/dist/pattern-index/matcher.d.ts +6 -0
  75. package/dist/pattern-index/matcher.js +13 -3
  76. package/dist/pattern-index/pattern-reuse.test.d.ts +1 -0
  77. package/dist/pattern-index/pattern-reuse.test.js +76 -0
  78. package/dist/pattern-index/staleness.d.ts +2 -15
  79. package/dist/pattern-index/staleness.js +1 -78
  80. package/dist/pattern-index/types.d.ts +2 -0
  81. package/dist/review/agent-fixes.d.ts +4 -0
  82. package/dist/review/agent-fixes.js +10 -7
  83. package/dist/review/agent-fixes.test.js +4 -4
  84. package/dist/review/changed-lines.d.ts +2 -1
  85. package/dist/review/changed-lines.js +12 -2
  86. package/dist/review/check-outcomes.d.ts +32 -0
  87. package/dist/review/check-outcomes.js +90 -0
  88. package/dist/review/check-outcomes.test.d.ts +1 -0
  89. package/dist/review/check-outcomes.test.js +56 -0
  90. package/dist/review/git-diff.d.ts +3 -0
  91. package/dist/review/git-diff.js +5 -0
  92. package/dist/review/migration-order.d.ts +5 -0
  93. package/dist/review/migration-order.js +70 -0
  94. package/dist/review/migration-order.test.d.ts +1 -0
  95. package/dist/review/migration-order.test.js +62 -0
  96. package/dist/review/quiet.d.ts +5 -2
  97. package/dist/review/quiet.js +21 -8
  98. package/dist/review/review.d.ts +6 -0
  99. package/dist/review/review.js +20 -3
  100. package/dist/review/review.test.js +30 -0
  101. package/dist/review/trusted-state.d.ts +6 -0
  102. package/dist/review/trusted-state.js +35 -0
  103. package/dist/semantic/ast.d.ts +1 -0
  104. package/dist/semantic/ast.js +4 -0
  105. package/dist/semantic/learn/compile.js +3 -1
  106. package/dist/semantic/learn/learn.js +40 -5
  107. package/dist/semantic/types.d.ts +2 -0
  108. package/dist/services/context-scope-summary.test.d.ts +1 -0
  109. package/dist/services/context-scope-summary.test.js +17 -0
  110. package/dist/services/context-telemetry-service.js +2 -1
  111. package/dist/storage/context-telemetry.js +33 -19
  112. package/dist/storage/db.js +52 -1
  113. package/dist/storage/findings.js +1 -1
  114. package/dist/storage/fix-lessons.d.ts +13 -0
  115. package/dist/storage/fix-lessons.js +84 -0
  116. package/dist/storage/fix-lessons.test.d.ts +1 -0
  117. package/dist/storage/fix-lessons.test.js +61 -0
  118. package/dist/storage/index.d.ts +2 -1
  119. package/dist/storage/index.js +2 -1
  120. package/dist/storage/lessons.d.ts +16 -2
  121. package/dist/storage/lessons.js +27 -61
  122. package/dist/storage/lessons.test.js +1 -15
  123. package/dist/storage/local-memory.js +39 -5
  124. package/dist/storage/local-memory.test.d.ts +1 -0
  125. package/dist/storage/local-memory.test.js +55 -0
  126. package/dist/storage/patterns.d.ts +1 -1
  127. package/dist/storage/patterns.js +1 -1
  128. package/dist/storage/share-memory.test.d.ts +1 -0
  129. package/dist/storage/share-memory.test.js +34 -0
  130. package/dist/storage/team-vector-store.js +2 -1
  131. package/dist/storage/telemetry-scope.test.d.ts +1 -0
  132. package/dist/storage/telemetry-scope.test.js +38 -0
  133. package/dist/templates/universal-config.js +11 -0
  134. package/dist/types/index.d.ts +137 -0
  135. package/dist/types/index.js +15 -0
  136. package/dist/utils/user-state.d.ts +7 -0
  137. package/dist/utils/user-state.js +21 -0
  138. package/package.json +7 -7
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
- Both model tiers (lite sidecar + pro code-specialized) are fine-tuned via the [DriftBench RLAIF pipeline](https://github.com/rigour-labs/driftbench) where the five signal streams serve as the teacher signal.
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
 
@@ -8,6 +8,11 @@ export interface AutomaticIndexStatus {
8
8
  updatedAt: string;
9
9
  }
10
10
  export declare function mergeSemanticEmbeddings(latest: PatternIndex, enriched: PatternIndex): PatternIndex;
11
+ /**
12
+ * What an explicit `rigour index` (CLI or MCP) chose, so automatic indexing and Studio's health
13
+ * agree with it: 'disabled' for --no-semantic, else ready or degraded by what was embedded.
14
+ */
15
+ export declare function recordIndexChoice(cwd: string, index: PatternIndex, semantic: boolean): Promise<void>;
11
16
  export declare function getAutomaticIndexStatus(cwd: string): Promise<AutomaticIndexStatus>;
12
17
  export declare function ensureAutomaticIndex(cwd: string, options?: {
13
18
  backgroundSemantic?: boolean;
@@ -1,6 +1,7 @@
1
1
  import fs from 'fs-extra';
2
2
  import path from 'path';
3
3
  import { PatternIndexer, getDefaultIndexPath, loadPatternIndex, savePatternIndex } from '../pattern-index/indexer.js';
4
+ import { EMBEDDING_TEXT_VERSION } from '../pattern-index/embeddings.js';
4
5
  import { affectedDependents, buildDependencyGraph, updateDependencyGraph } from './dependency-graph.js';
5
6
  import { syncIndexToContextCache } from './index-bridge.js';
6
7
  const enrichmentJobs = new Map();
@@ -33,15 +34,35 @@ async function writeStatus(cwd, status) {
33
34
  await fs.ensureDir(path.join(cwd, '.rigour'));
34
35
  await fs.writeJson(statusPath(cwd), status, { spaces: 2 });
35
36
  }
37
+ /** Embedded, and from the current embedding text (an older one is re-embedded on enrichment). */
38
+ function hasCurrentEmbeddings(index) {
39
+ return index.embeddingTextVersion === EMBEDDING_TEXT_VERSION
40
+ && index.patterns.some(pattern => (pattern.embedding?.length ?? 0) > 0);
41
+ }
42
+ /**
43
+ * What an explicit `rigour index` (CLI or MCP) chose, so automatic indexing and Studio's health
44
+ * agree with it: 'disabled' for --no-semantic, else ready or degraded by what was embedded.
45
+ */
46
+ export async function recordIndexChoice(cwd, index, semantic) {
47
+ const embedded = index.patterns.filter(pattern => (pattern.embedding?.length ?? 0) > 0).length;
48
+ await writeStatus(cwd, {
49
+ structural: 'ready',
50
+ graph: 'ready',
51
+ semantic: !semantic ? 'disabled' : embedded > 0 ? 'ready' : 'degraded',
52
+ ...(semantic && embedded === 0 ? { message: 'Local embedding model unavailable; text matching remains active.' } : {}),
53
+ updatedAt: new Date().toISOString(),
54
+ });
55
+ }
36
56
  export async function getAutomaticIndexStatus(cwd) {
37
57
  try {
38
58
  return await fs.readJson(statusPath(cwd));
39
59
  }
40
60
  catch {
61
+ // No status yet is not an opt-out: only `rigour index --no-semantic` records 'disabled'.
41
62
  return {
42
63
  structural: 'missing',
43
64
  graph: 'missing',
44
- semantic: 'disabled',
65
+ semantic: 'warming',
45
66
  updatedAt: new Date().toISOString(),
46
67
  };
47
68
  }
@@ -97,7 +118,7 @@ export async function ensureAutomaticIndex(cwd, options = {}) {
97
118
  if (!(await fs.pathExists(path.join(cwd, '.rigour', 'dependency-graph.json')))) {
98
119
  await buildDependencyGraph(cwd, index);
99
120
  }
100
- const hasEmbeddings = index.patterns.some((pattern) => (pattern.embedding?.length ?? 0) > 0);
121
+ const hasEmbeddings = hasCurrentEmbeddings(index);
101
122
  await writeStatus(cwd, {
102
123
  structural: 'ready',
103
124
  graph: 'ready',
@@ -130,7 +151,7 @@ export async function updateAutomaticIndexForFiles(cwd, changedFiles) {
130
151
  await buildDependencyGraph(cwd, index);
131
152
  await writeStatus(cwd, {
132
153
  structural: 'ready', graph: 'ready',
133
- semantic: index.patterns.some(pattern => (pattern.embedding?.length ?? 0) > 0)
154
+ semantic: hasCurrentEmbeddings(index)
134
155
  ? 'ready'
135
156
  : previousStatus.semantic === 'disabled' ? 'disabled' : 'warming',
136
157
  message: `${normalizedChangedFiles.length} changed file(s); ${affectedFiles.length} affected file(s) refreshed without a full scan.`,
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,26 @@
1
+ import fs from 'fs';
2
+ import os from 'os';
3
+ import path from 'path';
4
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
5
+ import { getAutomaticIndexStatus, recordIndexChoice } from './automatic-index.js';
6
+ let cwd;
7
+ beforeEach(() => { cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'index-status-')); });
8
+ afterEach(() => fs.rmSync(cwd, { recursive: true, force: true }));
9
+ const index = (embedded) => ({
10
+ version: '1.0.0', lastUpdated: '', rootDir: cwd, files: [],
11
+ patterns: [{ id: 'a', type: 'function', name: 'a', file: 'a.ts', line: 1, endLine: 1, signature: '', description: '', keywords: [], hash: 'a', exported: true, usageCount: 0, indexedAt: '', ...(embedded ? { embedding: [0.1] } : {}) }],
12
+ stats: { totalPatterns: 1, totalFiles: 1, byType: {}, indexDurationMs: 0 },
13
+ });
14
+ describe('semantic index status', () => {
15
+ it('treats no status yet as warming, not as an opt-out', async () => {
16
+ expect((await getAutomaticIndexStatus(cwd)).semantic).toBe('warming');
17
+ });
18
+ it('records what an explicit index chose, so automatic indexing and Studio agree', async () => {
19
+ await recordIndexChoice(cwd, index(false), false);
20
+ expect((await getAutomaticIndexStatus(cwd)).semantic).toBe('disabled');
21
+ await recordIndexChoice(cwd, index(true), true);
22
+ expect((await getAutomaticIndexStatus(cwd)).semantic).toBe('ready');
23
+ await recordIndexChoice(cwd, index(false), true);
24
+ expect((await getAutomaticIndexStatus(cwd)).semantic).toBe('degraded');
25
+ });
26
+ });
@@ -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(/\s+/g, ' ').trim()).digest('hex');
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) {
@@ -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 requesting MCP process.
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
- * Consume a one-time token. Returns false if missing, expired, or mismatched.
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 consumeArbitrationToken(cwd: string, requestId: string, token: string | undefined): Promise<boolean>;
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 requesting MCP process.
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, '.rigour', 'arbitration-tokens.json');
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(p);
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.join(cwd, '.rigour'));
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
- * Consume a one-time token. Returns false if missing, expired, or mismatched.
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 consumeArbitrationToken(cwd, requestId, token) {
41
- if (!token || !requestId)
42
- return false;
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 false;
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 ok;
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: valid signature, PASS gates, bound commit/tree, fresh, non-workspace-key unless allowed.
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): Promise<{
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(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
  }
@@ -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
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,12 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { dedupeFailures } from './runner.js';
3
+ const finding = (title, line = 3) => ({ id: 'semantic-bugs', title, details: '', severity: 'high', provenance: 'traditional', files: ['src/a.ts'], line });
4
+ describe('dedupeFailures', () => {
5
+ it('keeps a learned rule that fires on the same line as a built-in rule of the same gate', () => {
6
+ const kept = dedupeFailures([finding('credential-redirect'), finding('learned-rule: require-option-redirect')]);
7
+ expect(kept.map(f => f.title)).toEqual(['credential-redirect', 'learned-rule: require-option-redirect']);
8
+ });
9
+ it('still drops the same rule reported twice on one line', () => {
10
+ expect(dedupeFailures([finding('credential-redirect'), finding('credential-redirect'), finding('credential-redirect', 9)])).toHaveLength(2);
11
+ });
12
+ });
@@ -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,27 @@
1
+ import { Gate, type GateContext } from './base.js';
2
+ import type { Failure } from '../types/index.js';
3
+ export interface DeprecatedDependenciesConfig {
4
+ enabled?: boolean;
5
+ registry?: string;
6
+ }
7
+ type Fetch = (url: string, init?: {
8
+ signal?: AbortSignal;
9
+ }) => Promise<{
10
+ ok: boolean;
11
+ json(): Promise<any>;
12
+ }>;
13
+ export declare class DeprecatedDependenciesGate extends Gate {
14
+ private config;
15
+ private fetchImpl;
16
+ constructor(config?: DeprecatedDependenciesConfig, fetchImpl?: Fetch);
17
+ run(context: GateContext): Promise<Failure[]>;
18
+ /** Deprecation messages for installed versions, from the day's cache and then the registry. */
19
+ private deprecations;
20
+ /** The version's deprecation message, null when it is not deprecated, undefined when the registry did not answer. */
21
+ private lookup;
22
+ }
23
+ /** Installed versions from package-lock.json, else node_modules; names with neither are left out. */
24
+ export declare function installedVersions(cwd: string, names: string[]): Promise<Map<string, string>>;
25
+ /** The package.json line declaring a dependency, so review can tell whether the change touched it. */
26
+ export declare function lineOfDependency(manifestText: string, name: string): number | undefined;
27
+ export {};