@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.
- package/README.md +1 -1
- package/dist/context/automatic-index.d.ts +5 -0
- package/dist/context/automatic-index.js +24 -3
- package/dist/context/index-status.test.d.ts +1 -0
- package/dist/context/index-status.test.js +26 -0
- 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/dedupe-failures.test.d.ts +1 -0
- package/dist/gates/dedupe-failures.test.js +12 -0
- package/dist/gates/deep-analysis.d.ts +6 -0
- package/dist/gates/deep-analysis.js +8 -1
- package/dist/gates/deprecated-dependencies.d.ts +27 -0
- package/dist/gates/deprecated-dependencies.js +104 -0
- package/dist/gates/deprecated-dependencies.test.d.ts +1 -0
- package/dist/gates/deprecated-dependencies.test.js +55 -0
- package/dist/gates/runner.d.ts +7 -1
- package/dist/gates/runner.js +25 -11
- package/dist/gates/security-patterns-data.js +2 -1
- package/dist/gates/security-patterns-owasp.test.js +15 -0
- package/dist/gates/semantic-bugs.js +1 -1
- package/dist/gates/trusted-reviews.test.d.ts +1 -0
- package/dist/gates/trusted-reviews.test.js +16 -0
- package/dist/gates/unindexed-reads/coverage.d.ts +15 -0
- package/dist/gates/unindexed-reads/coverage.js +24 -0
- package/dist/gates/unindexed-reads/index.d.ts +20 -0
- package/dist/gates/unindexed-reads/index.js +98 -0
- package/dist/gates/unindexed-reads/queries.d.ts +17 -0
- package/dist/gates/unindexed-reads/queries.js +147 -0
- package/dist/gates/unindexed-reads/queries.test.d.ts +1 -0
- package/dist/gates/unindexed-reads/queries.test.js +54 -0
- package/dist/gates/unindexed-reads/schema.d.ts +38 -0
- package/dist/gates/unindexed-reads/schema.js +256 -0
- package/dist/gates/unindexed-reads/schema.test.d.ts +1 -0
- package/dist/gates/unindexed-reads/schema.test.js +81 -0
- package/dist/gates/unindexed-reads/sql.d.ts +21 -0
- package/dist/gates/unindexed-reads/sql.js +131 -0
- package/dist/gates/unindexed-reads/unindexed-reads.test.d.ts +1 -0
- package/dist/gates/unindexed-reads/unindexed-reads.test.js +90 -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 +6 -1
- package/dist/index.js +6 -1
- package/dist/inference/brain-placeholder.test.d.ts +1 -0
- package/dist/inference/brain-placeholder.test.js +28 -0
- package/dist/inference/llama-engine-checksum.test.d.ts +1 -0
- package/dist/inference/llama-engine-checksum.test.js +27 -0
- package/dist/inference/llama-engine.d.ts +4 -1
- package/dist/inference/llama-engine.js +15 -8
- package/dist/memory/recall.d.ts +37 -0
- package/dist/memory/recall.js +93 -0
- package/dist/memory/recall.test.d.ts +1 -0
- package/dist/memory/recall.test.js +36 -0
- package/dist/pattern-index/assess.d.ts +16 -0
- package/dist/pattern-index/assess.js +37 -0
- package/dist/pattern-index/embeddings.d.ts +19 -0
- package/dist/pattern-index/embeddings.js +16 -0
- package/dist/pattern-index/index.d.ts +3 -2
- package/dist/pattern-index/index.js +4 -2
- package/dist/pattern-index/indexer.js +15 -7
- package/dist/pattern-index/matcher.d.ts +6 -0
- package/dist/pattern-index/matcher.js +13 -3
- package/dist/pattern-index/pattern-reuse.test.d.ts +1 -0
- package/dist/pattern-index/pattern-reuse.test.js +76 -0
- package/dist/pattern-index/staleness.d.ts +2 -15
- package/dist/pattern-index/staleness.js +1 -78
- package/dist/pattern-index/types.d.ts +2 -0
- package/dist/review/agent-fixes.d.ts +4 -0
- package/dist/review/agent-fixes.js +10 -7
- package/dist/review/agent-fixes.test.js +4 -4
- package/dist/review/changed-lines.d.ts +2 -1
- package/dist/review/changed-lines.js +12 -2
- package/dist/review/check-outcomes.d.ts +32 -0
- package/dist/review/check-outcomes.js +90 -0
- package/dist/review/check-outcomes.test.d.ts +1 -0
- package/dist/review/check-outcomes.test.js +56 -0
- package/dist/review/git-diff.d.ts +3 -0
- package/dist/review/git-diff.js +5 -0
- package/dist/review/migration-order.d.ts +5 -0
- package/dist/review/migration-order.js +70 -0
- package/dist/review/migration-order.test.d.ts +1 -0
- package/dist/review/migration-order.test.js +62 -0
- package/dist/review/quiet.d.ts +5 -2
- package/dist/review/quiet.js +21 -8
- package/dist/review/review.d.ts +6 -0
- package/dist/review/review.js +20 -3
- package/dist/review/review.test.js +30 -0
- package/dist/review/trusted-state.d.ts +6 -0
- package/dist/review/trusted-state.js +35 -0
- package/dist/semantic/ast.d.ts +1 -0
- package/dist/semantic/ast.js +4 -0
- package/dist/semantic/learn/compile.js +3 -1
- package/dist/semantic/learn/learn.js +40 -5
- package/dist/semantic/types.d.ts +2 -0
- package/dist/services/context-scope-summary.test.d.ts +1 -0
- package/dist/services/context-scope-summary.test.js +17 -0
- package/dist/services/context-telemetry-service.js +2 -1
- package/dist/storage/context-telemetry.js +33 -19
- package/dist/storage/db.js +52 -1
- package/dist/storage/findings.js +1 -1
- package/dist/storage/fix-lessons.d.ts +13 -0
- package/dist/storage/fix-lessons.js +84 -0
- package/dist/storage/fix-lessons.test.d.ts +1 -0
- package/dist/storage/fix-lessons.test.js +61 -0
- package/dist/storage/index.d.ts +2 -1
- package/dist/storage/index.js +2 -1
- package/dist/storage/lessons.d.ts +16 -2
- package/dist/storage/lessons.js +27 -61
- package/dist/storage/lessons.test.js +1 -15
- package/dist/storage/local-memory.js +39 -5
- package/dist/storage/local-memory.test.d.ts +1 -0
- package/dist/storage/local-memory.test.js +55 -0
- package/dist/storage/patterns.d.ts +1 -1
- package/dist/storage/patterns.js +1 -1
- package/dist/storage/share-memory.test.d.ts +1 -0
- package/dist/storage/share-memory.test.js +34 -0
- package/dist/storage/team-vector-store.js +2 -1
- package/dist/storage/telemetry-scope.test.d.ts +1 -0
- package/dist/storage/telemetry-scope.test.js +38 -0
- package/dist/templates/universal-config.js +11 -0
- package/dist/types/index.d.ts +137 -0
- package/dist/types/index.js +15 -0
- package/dist/utils/user-state.d.ts +7 -0
- package/dist/utils/user-state.js +21 -0
- 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
|
-
|
|
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: '
|
|
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
|
|
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
|
|
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
|
+
});
|
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
|
+
});
|
|
@@ -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,
|
|
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 {};
|