argus-reviewer-e2e 0.1.3 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -5
- package/action/action.yml +17 -0
- package/action/sticky-comment.mjs +270 -90
- package/dist/api.d.ts +1 -0
- package/dist/api.js +1 -0
- package/dist/cli.d.ts +67 -0
- package/dist/cli.js +383 -49
- package/dist/config.d.ts +63 -2
- package/dist/config.js +108 -5
- package/dist/debug.d.ts +1 -0
- package/dist/debug.js +9 -3
- package/dist/evidence/ci.d.ts +3 -0
- package/dist/evidence/ci.js +3 -1
- package/dist/probe/queue.d.ts +4 -1
- package/dist/probe/queue.js +46 -8
- package/dist/review/adjudicate.d.ts +63 -0
- package/dist/review/adjudicate.js +111 -0
- package/dist/review/secrets.d.ts +88 -0
- package/dist/review/secrets.js +220 -0
- package/dist/review/triage.d.ts +76 -0
- package/dist/review/triage.js +163 -0
- package/dist/trust.d.ts +50 -0
- package/dist/trust.js +103 -0
- package/dist/vision/cost.d.ts +14 -1
- package/dist/vision/cost.js +13 -0
- package/dist/vision/decisions.d.ts +95 -0
- package/dist/vision/decisions.js +232 -0
- package/package.json +3 -2
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { type ExecFn } from '../detect.js';
|
|
2
|
+
import { DecisionClient } from '../vision/decisions.js';
|
|
3
|
+
/**
|
|
4
|
+
* Deterministic secrets scan over the PR's local merge-base diff,
|
|
5
|
+
* optionally adjudicated by the Decisions API (Jev). The lane is
|
|
6
|
+
* additive-only: findings are unioned into the review AFTER model
|
|
7
|
+
* synthesis so a prompt-injected synthesis can never erase them, and a
|
|
8
|
+
* Jev outage degrades to regex-only findings rather than silence.
|
|
9
|
+
*
|
|
10
|
+
* Masking contract: raw literals transit to Jev inside `state` (KTD9 —
|
|
11
|
+
* adjudication needs the shape, and the full diff already crosses to
|
|
12
|
+
* OpenRouter in the review call) and appear NOWHERE else — not in
|
|
13
|
+
* findings, comments, the report, or logs.
|
|
14
|
+
*/
|
|
15
|
+
export interface SecretCandidate {
|
|
16
|
+
file: string;
|
|
17
|
+
/** Line number in the post-change file. */
|
|
18
|
+
line: number;
|
|
19
|
+
patternClass: string;
|
|
20
|
+
/** Diff line text with every occurrence of the literal replaced by `***`. */
|
|
21
|
+
contextExcerpt: string;
|
|
22
|
+
/** Raw literal — Jev `state` only, never emitted. */
|
|
23
|
+
literal: string;
|
|
24
|
+
/** Full raw added-line text — Jev `state` only, never emitted. */
|
|
25
|
+
rawText: string;
|
|
26
|
+
}
|
|
27
|
+
export interface SecretScanRecord {
|
|
28
|
+
file: string;
|
|
29
|
+
line: number;
|
|
30
|
+
patternClass: string;
|
|
31
|
+
/** True when Jev answered for this candidate. */
|
|
32
|
+
adjudicated: boolean;
|
|
33
|
+
pLive?: number;
|
|
34
|
+
/** Jev scored below threshold — recorded for audit, not a finding. */
|
|
35
|
+
suppressed?: boolean;
|
|
36
|
+
}
|
|
37
|
+
export interface SecretsScanResult {
|
|
38
|
+
/** Findings to union into the review — messages are fully masked. */
|
|
39
|
+
findings: {
|
|
40
|
+
file: string;
|
|
41
|
+
line?: number;
|
|
42
|
+
severity: string;
|
|
43
|
+
category?: string;
|
|
44
|
+
message: string;
|
|
45
|
+
}[];
|
|
46
|
+
/** Audit records for report.secretsScan — literals never included. */
|
|
47
|
+
records: SecretScanRecord[];
|
|
48
|
+
/** Candidates past MAX_CANDIDATES — reported count-only, never sent to Jev. */
|
|
49
|
+
overflow: number;
|
|
50
|
+
/** Why the lane produced nothing (e.g. base unfetchable). */
|
|
51
|
+
skipped?: string;
|
|
52
|
+
}
|
|
53
|
+
export { MAX_CANDIDATES } from '../vision/decisions.js';
|
|
54
|
+
export declare const DEFAULT_SECRETS_THRESHOLD = 0.3;
|
|
55
|
+
/**
|
|
56
|
+
* Parse `git diff` text into secret candidates from added (`+`) lines.
|
|
57
|
+
* Removed/context lines are not scanned — a rotated-out-but-live secret
|
|
58
|
+
* in a `-` line is a deliberate open question (plan OQ), and context
|
|
59
|
+
* lines would re-flag pre-existing secrets the PR did not introduce.
|
|
60
|
+
*/
|
|
61
|
+
export declare function scanDiffForSecrets(diffText: string): SecretCandidate[];
|
|
62
|
+
/**
|
|
63
|
+
* Materialize the merge-base diff locally — the PR-files API `patch`
|
|
64
|
+
* field omits large/binary files, so the API diff is not a complete
|
|
65
|
+
* scan surface.
|
|
66
|
+
*/
|
|
67
|
+
export declare function materializeMergeBaseDiff(opts: {
|
|
68
|
+
cwd: string;
|
|
69
|
+
baseSha: string;
|
|
70
|
+
token?: string;
|
|
71
|
+
exec?: ExecFn;
|
|
72
|
+
}): Promise<{
|
|
73
|
+
diff: string;
|
|
74
|
+
} | {
|
|
75
|
+
skipped: string;
|
|
76
|
+
}>;
|
|
77
|
+
/**
|
|
78
|
+
* Full lane: scan candidates (capped), Jev-adjudicate when a client and
|
|
79
|
+
* threshold are available, and emit masked findings + audit records.
|
|
80
|
+
* `client === undefined` (decisionModel unset) or any DecisionError →
|
|
81
|
+
* every candidate unadjudicated — regex-only mode, never silence.
|
|
82
|
+
*/
|
|
83
|
+
export declare function scanSecrets(opts: {
|
|
84
|
+
diff: string;
|
|
85
|
+
threshold?: number;
|
|
86
|
+
client?: DecisionClient;
|
|
87
|
+
model?: string;
|
|
88
|
+
}): Promise<SecretsScanResult>;
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { defaultExec } from '../detect.js';
|
|
2
|
+
import { debug } from '../debug.js';
|
|
3
|
+
import { describeDecisionError, isNoulAnswer, MAX_CANDIDATES, } from '../vision/decisions.js';
|
|
4
|
+
// Owned by vision/decisions.ts — re-exported here so existing import
|
|
5
|
+
// paths (tests, lanes) keep resolving.
|
|
6
|
+
export { MAX_CANDIDATES } from '../vision/decisions.js';
|
|
7
|
+
export const DEFAULT_SECRETS_THRESHOLD = 0.3;
|
|
8
|
+
const PATTERNS = [
|
|
9
|
+
{
|
|
10
|
+
cls: 'private-key',
|
|
11
|
+
re: /-----BEGIN (?:RSA |EC |OPENSSH |PGP |DSA )?PRIVATE KEY(?: BLOCK)?-----/,
|
|
12
|
+
},
|
|
13
|
+
{ cls: 'aws-access-key', re: /\bAKIA[0-9A-Z]{16}\b/ },
|
|
14
|
+
{ cls: 'stripe-live', re: /\b(?:sk|rk)_live_[0-9a-zA-Z]{16,}\b/ },
|
|
15
|
+
{ cls: 'stripe-webhook-secret', re: /\bwhsec_[0-9a-zA-Z]{16,}\b/ },
|
|
16
|
+
{ cls: 'github-pat', re: /\b(?:gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{22,})\b/ },
|
|
17
|
+
{ cls: 'slack-token', re: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/ },
|
|
18
|
+
{
|
|
19
|
+
cls: 'jwt',
|
|
20
|
+
re: /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{5,}\b/,
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
cls: 'generic-assignment',
|
|
24
|
+
re: /(?:api[_-]?key|token|secret|passwd|password)\s*[:=]\s*['"]?([A-Za-z0-9/+_.=-]{12,})/i,
|
|
25
|
+
group: 1,
|
|
26
|
+
},
|
|
27
|
+
];
|
|
28
|
+
/**
|
|
29
|
+
* Parse `git diff` text into secret candidates from added (`+`) lines.
|
|
30
|
+
* Removed/context lines are not scanned — a rotated-out-but-live secret
|
|
31
|
+
* in a `-` line is a deliberate open question (plan OQ), and context
|
|
32
|
+
* lines would re-flag pre-existing secrets the PR did not introduce.
|
|
33
|
+
*/
|
|
34
|
+
export function scanDiffForSecrets(diffText) {
|
|
35
|
+
const out = [];
|
|
36
|
+
let file = '';
|
|
37
|
+
let newLine = 0;
|
|
38
|
+
// `+++ `/`--- ` are file headers only in the pre-hunk zone — inside a
|
|
39
|
+
// hunk they are added/removed content lines (`+` + `++ x`, `-` + `-- x`)
|
|
40
|
+
// and must not reset `file` or `inHunk`.
|
|
41
|
+
let inHunk = false;
|
|
42
|
+
for (const raw of diffText.split('\n')) {
|
|
43
|
+
if (raw.startsWith('diff --git')) {
|
|
44
|
+
inHunk = false;
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (raw.startsWith('@@')) {
|
|
48
|
+
inHunk = true;
|
|
49
|
+
const m = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(raw);
|
|
50
|
+
newLine = m !== null ? parseInt(m[1], 10) : 0;
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
if (!inHunk) {
|
|
54
|
+
if (raw.startsWith('+++ ')) {
|
|
55
|
+
const m = /^\+\+\+ b\/(.+)$/.exec(raw);
|
|
56
|
+
file = m?.[1] ?? '';
|
|
57
|
+
}
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
// Context lines consume a new-file line number; `-` lines don't.
|
|
61
|
+
if (raw.startsWith(' ')) {
|
|
62
|
+
newLine++;
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
if (!raw.startsWith('+') || file === '')
|
|
66
|
+
continue;
|
|
67
|
+
const text = raw.slice(1);
|
|
68
|
+
for (const { cls, re, group } of PATTERNS) {
|
|
69
|
+
const m = re.exec(text);
|
|
70
|
+
if (m === null)
|
|
71
|
+
continue;
|
|
72
|
+
const literal = group !== undefined ? (m[group] ?? m[0]) : m[0];
|
|
73
|
+
out.push({
|
|
74
|
+
file,
|
|
75
|
+
line: newLine,
|
|
76
|
+
patternClass: cls,
|
|
77
|
+
contextExcerpt: text.replaceAll(literal, '***'),
|
|
78
|
+
literal,
|
|
79
|
+
rawText: text,
|
|
80
|
+
});
|
|
81
|
+
break; // first matching class wins — one candidate per line
|
|
82
|
+
}
|
|
83
|
+
newLine++;
|
|
84
|
+
}
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
87
|
+
function maskFindingMessage(c, adjudicated) {
|
|
88
|
+
const verdict = adjudicated
|
|
89
|
+
? 'live-looking credential'
|
|
90
|
+
: 'secret-shaped literal (unadjudicated — decision model unavailable)';
|
|
91
|
+
return (`L${c.line}: ${adjudicated ? '🔴' : '🟡'} ${adjudicated ? 'bug' : 'risk'}: ` +
|
|
92
|
+
`${verdict} (${c.patternClass}) added in this PR at \`${c.file}\`. ` +
|
|
93
|
+
`Rotate it and purge it from history.`);
|
|
94
|
+
}
|
|
95
|
+
/** `git cat-file -e` + shallow-fetch fallback — mirrors probe/queue.ts. */
|
|
96
|
+
async function ensureBaseObject(exec, cwd, baseSha, token) {
|
|
97
|
+
const have = await exec('git', ['-C', cwd, 'cat-file', '-e', `${baseSha}^{commit}`], 15_000);
|
|
98
|
+
if (have.code === 0)
|
|
99
|
+
return true;
|
|
100
|
+
if (token === undefined)
|
|
101
|
+
return false;
|
|
102
|
+
// Auth rides env config like actions/checkout's extraheader — keeps the
|
|
103
|
+
// token out of process argv where co-tenant jobs could scrape /proc.
|
|
104
|
+
const fetched = await exec('git', ['-C', cwd, 'fetch', '--depth', '1', 'origin', baseSha], 60_000, {
|
|
105
|
+
GIT_CONFIG_COUNT: '1',
|
|
106
|
+
GIT_CONFIG_KEY_0: 'http.https://github.com/.extraheader',
|
|
107
|
+
GIT_CONFIG_VALUE_0: `AUTHORIZATION: basic ${Buffer.from(`x-access-token:${token}`).toString('base64')}`,
|
|
108
|
+
});
|
|
109
|
+
return fetched.code === 0;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Materialize the merge-base diff locally — the PR-files API `patch`
|
|
113
|
+
* field omits large/binary files, so the API diff is not a complete
|
|
114
|
+
* scan surface.
|
|
115
|
+
*/
|
|
116
|
+
export async function materializeMergeBaseDiff(opts) {
|
|
117
|
+
const exec = opts.exec ?? defaultExec;
|
|
118
|
+
const have = await ensureBaseObject(exec, opts.cwd, opts.baseSha, opts.token);
|
|
119
|
+
if (!have) {
|
|
120
|
+
return { skipped: `base ${opts.baseSha.slice(0, 12)} not available locally and unfetchable` };
|
|
121
|
+
}
|
|
122
|
+
// core.quotePath=false — the default C-escapes non-ASCII/odd-byte paths
|
|
123
|
+
// ("b/\"f\\303\\251e.ts\""), mangling `file` in findings.
|
|
124
|
+
const diff = await exec('git', ['-c', 'core.quotePath=false', '-C', opts.cwd, 'diff', `${opts.baseSha}..HEAD`], 60_000);
|
|
125
|
+
if (diff.code !== 0) {
|
|
126
|
+
return { skipped: `git diff failed: ${diff.stderr.trim().slice(0, 200)}` };
|
|
127
|
+
}
|
|
128
|
+
return { diff: diff.stdout };
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Full lane: scan candidates (capped), Jev-adjudicate when a client and
|
|
132
|
+
* threshold are available, and emit masked findings + audit records.
|
|
133
|
+
* `client === undefined` (decisionModel unset) or any DecisionError →
|
|
134
|
+
* every candidate unadjudicated — regex-only mode, never silence.
|
|
135
|
+
*/
|
|
136
|
+
export async function scanSecrets(opts) {
|
|
137
|
+
const threshold = opts.threshold ?? DEFAULT_SECRETS_THRESHOLD;
|
|
138
|
+
const all = scanDiffForSecrets(opts.diff);
|
|
139
|
+
const candidates = all.slice(0, MAX_CANDIDATES);
|
|
140
|
+
const overflow = all.length - candidates.length;
|
|
141
|
+
const pLiveByIdx = new Array(candidates.length);
|
|
142
|
+
let adjudicationFailed = false;
|
|
143
|
+
if (opts.client !== undefined && candidates.length > 0) {
|
|
144
|
+
try {
|
|
145
|
+
const questions = {};
|
|
146
|
+
candidates.forEach((c, i) => {
|
|
147
|
+
questions[`cand_${i}`] = {
|
|
148
|
+
type: 'noul',
|
|
149
|
+
instructions: `state[${i}]: is 'literal' a real, usable credential committed to source? ` +
|
|
150
|
+
'Answer no for documentation examples, placeholders, test fixtures, ' +
|
|
151
|
+
'and revoked or sample values.',
|
|
152
|
+
};
|
|
153
|
+
});
|
|
154
|
+
const state = candidates.map((c) => ({
|
|
155
|
+
file: c.file,
|
|
156
|
+
line: c.line,
|
|
157
|
+
lineText: c.rawText,
|
|
158
|
+
literal: c.literal,
|
|
159
|
+
}));
|
|
160
|
+
const { answers } = await opts.client.decide({
|
|
161
|
+
...(opts.model !== undefined ? { model: opts.model } : {}),
|
|
162
|
+
state,
|
|
163
|
+
questions,
|
|
164
|
+
});
|
|
165
|
+
candidates.forEach((_c, i) => {
|
|
166
|
+
const a = answers[`cand_${i}`];
|
|
167
|
+
pLiveByIdx[i] = a !== undefined && isNoulAnswer(a) ? a.noul : undefined;
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
catch (e) {
|
|
171
|
+
adjudicationFailed = true;
|
|
172
|
+
debug('secrets', `adjudication failed — degrading to regex-only: ${describeDecisionError(e)}`);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
const findings = [];
|
|
176
|
+
const records = [];
|
|
177
|
+
candidates.forEach((c, i) => {
|
|
178
|
+
const pLive = pLiveByIdx[i];
|
|
179
|
+
const adjudicated = pLive !== undefined && !adjudicationFailed;
|
|
180
|
+
if (adjudicated && pLive !== undefined && pLive < threshold) {
|
|
181
|
+
records.push({
|
|
182
|
+
file: c.file,
|
|
183
|
+
line: c.line,
|
|
184
|
+
patternClass: c.patternClass,
|
|
185
|
+
adjudicated: true,
|
|
186
|
+
pLive,
|
|
187
|
+
suppressed: true,
|
|
188
|
+
});
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
findings.push({
|
|
192
|
+
file: c.file,
|
|
193
|
+
line: c.line,
|
|
194
|
+
severity: adjudicated ? 'bug' : 'risk',
|
|
195
|
+
category: 'security',
|
|
196
|
+
message: maskFindingMessage(c, adjudicated),
|
|
197
|
+
});
|
|
198
|
+
records.push({
|
|
199
|
+
file: c.file,
|
|
200
|
+
line: c.line,
|
|
201
|
+
patternClass: c.patternClass,
|
|
202
|
+
adjudicated,
|
|
203
|
+
...(pLive !== undefined ? { pLive } : {}),
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
// Candidates past the cap were never adjudicated — a real secret could
|
|
207
|
+
// sit in the overflow. Surface that gap as a finding, not just a count.
|
|
208
|
+
if (overflow > 0) {
|
|
209
|
+
findings.push({
|
|
210
|
+
file: '-',
|
|
211
|
+
line: 0,
|
|
212
|
+
severity: 'risk',
|
|
213
|
+
category: 'security',
|
|
214
|
+
message: `L0: 🟡 risk: ${overflow} secret-shaped literal(s) exceeded the ` +
|
|
215
|
+
`${MAX_CANDIDATES}-candidate adjudication cap and were not evaluated — ` +
|
|
216
|
+
'review the diff for secrets manually.',
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
return { findings, records, overflow };
|
|
220
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { type DecisionClient } from '../vision/decisions.js';
|
|
2
|
+
declare const RISK_AREA_CRITERIA: {
|
|
3
|
+
readonly auth: "authentication, authorization, tokens, sessions, permissions";
|
|
4
|
+
readonly billing: "payments, invoices, usage metering, cost accounting";
|
|
5
|
+
readonly data: "persistence, migrations, integrity, serialization, caching";
|
|
6
|
+
readonly ops: "CI, deploy, infra, configuration, tooling, observability";
|
|
7
|
+
readonly none: "no meaningful risk area in this diff";
|
|
8
|
+
};
|
|
9
|
+
export declare const TRIAGE_AREAS: TriageArea[];
|
|
10
|
+
export type TriageArea = keyof typeof RISK_AREA_CRITERIA;
|
|
11
|
+
export interface TriageRecord {
|
|
12
|
+
mode: 'annotate' | 'route';
|
|
13
|
+
/** noul 0–1 — does this PR warrant careful review. */
|
|
14
|
+
needsDeepReview?: number;
|
|
15
|
+
/** score 1–5 blast-radius rubric. */
|
|
16
|
+
risk?: number;
|
|
17
|
+
topRiskArea?: TriageArea;
|
|
18
|
+
/** Choice-answer confidence when the API provides one. */
|
|
19
|
+
topRiskAreaConfidence?: number;
|
|
20
|
+
/** Chars of diff evidence Jev saw — route mode won't downgrade on 0. */
|
|
21
|
+
diffExcerptChars?: number;
|
|
22
|
+
/** decide() failed or answers failed validation — degrade-open marker. */
|
|
23
|
+
unadjudicated?: boolean;
|
|
24
|
+
/** Decision model that produced (or attempted) the record. */
|
|
25
|
+
model: string;
|
|
26
|
+
}
|
|
27
|
+
export interface TriageState {
|
|
28
|
+
title: string;
|
|
29
|
+
body: string;
|
|
30
|
+
files: string[];
|
|
31
|
+
totalFiles: number;
|
|
32
|
+
diffExcerpt: string;
|
|
33
|
+
}
|
|
34
|
+
export declare function buildTriageState(opts: {
|
|
35
|
+
title?: string | undefined;
|
|
36
|
+
body?: string | undefined;
|
|
37
|
+
files: {
|
|
38
|
+
filename: string;
|
|
39
|
+
patch?: string;
|
|
40
|
+
}[];
|
|
41
|
+
}): TriageState;
|
|
42
|
+
export declare function triagePr(opts: {
|
|
43
|
+
client: DecisionClient;
|
|
44
|
+
model?: string;
|
|
45
|
+
state: TriageState;
|
|
46
|
+
mode: 'annotate' | 'route';
|
|
47
|
+
}): Promise<TriageRecord>;
|
|
48
|
+
/**
|
|
49
|
+
* 'route' mode model selection — cheap tier only on a clear low-risk
|
|
50
|
+
* signal (risk ≤ 2 AND deep-review < 0.5) backed by actual diff
|
|
51
|
+
* evidence. Any ambiguity (missing fields, unadjudicated, unset
|
|
52
|
+
* lowRiskModel, or a title/body-only triage — fully attacker-steerable
|
|
53
|
+
* text) keeps the configured model: coverage stays constant and the
|
|
54
|
+
* expensive path is the default.
|
|
55
|
+
*/
|
|
56
|
+
export declare function routeModel(opts: {
|
|
57
|
+
record: TriageRecord | undefined;
|
|
58
|
+
configured: string;
|
|
59
|
+
lowRiskModel: string | undefined;
|
|
60
|
+
}): {
|
|
61
|
+
model: string;
|
|
62
|
+
reason: string;
|
|
63
|
+
};
|
|
64
|
+
/** U9 ordering signal consumed by the probe lane. */
|
|
65
|
+
export interface TriageAreaSignal {
|
|
66
|
+
area: TriageArea;
|
|
67
|
+
confidence: number;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* U9 — the probe lane's advisory ordering signal. Present only when
|
|
71
|
+
* triage adjudicated a real area with its confidence attached; 'none'
|
|
72
|
+
* is the null-area sentinel, not an ordering signal, and the
|
|
73
|
+
* confidence floor itself is queue policy (MIN_AREA_CONFIDENCE).
|
|
74
|
+
*/
|
|
75
|
+
export declare function triageAreaSignal(rec: TriageRecord | undefined): TriageAreaSignal | undefined;
|
|
76
|
+
export {};
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* U7 PR triage lane — one batched `decide` call before chunk review
|
|
3
|
+
* produces a typed-probability triage record (the pace two-round
|
|
4
|
+
* pattern). Jev routes and annotates, never gates: every chunk is still
|
|
5
|
+
* reviewed by a code model and the deterministic verdict stays
|
|
6
|
+
* authoritative. `decide` failure degrades open — the record lands with
|
|
7
|
+
* `unadjudicated` and routing keeps the configured (strong) model.
|
|
8
|
+
*
|
|
9
|
+
* PR title/body in `state` are untrusted text — Jev is the only
|
|
10
|
+
* consumer; they never reach the verdict path.
|
|
11
|
+
*/
|
|
12
|
+
import { debug } from '../debug.js';
|
|
13
|
+
import { describeDecisionError, isChoiceAnswer, isNoulAnswer, isScoreAnswer, JEV_DEFAULT_MODEL, } from '../vision/decisions.js';
|
|
14
|
+
// The choice criteria own the vocabulary — TRIAGE_AREAS derives from
|
|
15
|
+
// it so an offered option can never drift out of the record type.
|
|
16
|
+
const RISK_AREA_CRITERIA = {
|
|
17
|
+
auth: 'authentication, authorization, tokens, sessions, permissions',
|
|
18
|
+
billing: 'payments, invoices, usage metering, cost accounting',
|
|
19
|
+
data: 'persistence, migrations, integrity, serialization, caching',
|
|
20
|
+
ops: 'CI, deploy, infra, configuration, tooling, observability',
|
|
21
|
+
none: 'no meaningful risk area in this diff',
|
|
22
|
+
};
|
|
23
|
+
export const TRIAGE_AREAS = Object.keys(RISK_AREA_CRITERIA);
|
|
24
|
+
/** Diff excerpt bound — triage needs shape, not full fidelity. */
|
|
25
|
+
const MAX_DIFF_STATE_CHARS = 12_000;
|
|
26
|
+
/** Per-file bound — one huge patch must not starve every other file. */
|
|
27
|
+
const MAX_FILE_EXCERPT = 4_000;
|
|
28
|
+
const MAX_TITLE_CHARS = 300;
|
|
29
|
+
const MAX_BODY_CHARS = 2_000;
|
|
30
|
+
const MAX_FILE_LIST = 100;
|
|
31
|
+
export function buildTriageState(opts) {
|
|
32
|
+
let budget = MAX_DIFF_STATE_CHARS;
|
|
33
|
+
const excerpts = [];
|
|
34
|
+
for (const f of opts.files) {
|
|
35
|
+
if (budget <= 0)
|
|
36
|
+
break;
|
|
37
|
+
if (f.patch === undefined)
|
|
38
|
+
continue;
|
|
39
|
+
const take = f.patch.slice(0, Math.min(budget, MAX_FILE_EXCERPT));
|
|
40
|
+
excerpts.push(take);
|
|
41
|
+
budget -= take.length;
|
|
42
|
+
}
|
|
43
|
+
return {
|
|
44
|
+
title: (opts.title ?? '').slice(0, MAX_TITLE_CHARS),
|
|
45
|
+
body: (opts.body ?? '').slice(0, MAX_BODY_CHARS),
|
|
46
|
+
files: opts.files.slice(0, MAX_FILE_LIST).map((f) => f.filename),
|
|
47
|
+
totalFiles: opts.files.length,
|
|
48
|
+
diffExcerpt: excerpts.join('\n\n'),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
/** Question IDs — one spelling for builder and reader. */
|
|
52
|
+
const Q = { deep: 'needs_deep_review', risk: 'risk', area: 'top_risk_area' };
|
|
53
|
+
function buildTriageQuestions() {
|
|
54
|
+
return {
|
|
55
|
+
[Q.deep]: {
|
|
56
|
+
type: 'noul',
|
|
57
|
+
instructions: 'does this PR warrant careful review beyond a skim? yes for changes touching ' +
|
|
58
|
+
'auth, money, data integrity, concurrency, secrets handling, public APIs, or ' +
|
|
59
|
+
'irreversible ops; no for docs, comments, formatting, or metadata-only changes.',
|
|
60
|
+
},
|
|
61
|
+
[Q.risk]: {
|
|
62
|
+
type: 'score',
|
|
63
|
+
instructions: 'blast radius if this PR merges broken — pick the closest rubric level.',
|
|
64
|
+
criteria: [
|
|
65
|
+
'cosmetic only — docs, comments, formatting, metadata',
|
|
66
|
+
'minor — internal-only paths, limited blast radius',
|
|
67
|
+
'moderate — user-visible defects plausible',
|
|
68
|
+
'high — security-sensitive or data-handling paths touched',
|
|
69
|
+
'severe — auth, billing, or data-loss surface',
|
|
70
|
+
],
|
|
71
|
+
},
|
|
72
|
+
[Q.area]: {
|
|
73
|
+
type: 'choice',
|
|
74
|
+
instructions: 'the single subsystem most likely to hide a defect in this diff.',
|
|
75
|
+
criteria: RISK_AREA_CRITERIA,
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
export async function triagePr(opts) {
|
|
80
|
+
try {
|
|
81
|
+
const { answers, model } = await opts.client.decide({
|
|
82
|
+
...(opts.model !== undefined ? { model: opts.model } : {}),
|
|
83
|
+
state: opts.state,
|
|
84
|
+
questions: buildTriageQuestions(),
|
|
85
|
+
});
|
|
86
|
+
const deep = answers[Q.deep];
|
|
87
|
+
const risk = answers[Q.risk];
|
|
88
|
+
const area = answers[Q.area];
|
|
89
|
+
const rec = {
|
|
90
|
+
mode: opts.mode,
|
|
91
|
+
model,
|
|
92
|
+
diffExcerptChars: opts.state.diffExcerpt.length,
|
|
93
|
+
};
|
|
94
|
+
if (deep !== undefined && isNoulAnswer(deep))
|
|
95
|
+
rec.needsDeepReview = deep.noul;
|
|
96
|
+
if (risk !== undefined && isScoreAnswer(risk))
|
|
97
|
+
rec.risk = risk.score;
|
|
98
|
+
if (area !== undefined &&
|
|
99
|
+
isChoiceAnswer(area) &&
|
|
100
|
+
TRIAGE_AREAS.includes(area.choice)) {
|
|
101
|
+
rec.topRiskArea = area.choice;
|
|
102
|
+
if (area.confidence !== undefined)
|
|
103
|
+
rec.topRiskAreaConfidence = area.confidence;
|
|
104
|
+
}
|
|
105
|
+
// A call that answered none of the typed questions adjudicated nothing.
|
|
106
|
+
if (rec.needsDeepReview === undefined &&
|
|
107
|
+
rec.risk === undefined &&
|
|
108
|
+
rec.topRiskArea === undefined) {
|
|
109
|
+
rec.unadjudicated = true;
|
|
110
|
+
}
|
|
111
|
+
return rec;
|
|
112
|
+
}
|
|
113
|
+
catch (e) {
|
|
114
|
+
debug('triage', `adjudication failed — degrading to annotate: ${describeDecisionError(e)}`);
|
|
115
|
+
return { mode: opts.mode, model: opts.model ?? JEV_DEFAULT_MODEL, unadjudicated: true };
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* 'route' mode model selection — cheap tier only on a clear low-risk
|
|
120
|
+
* signal (risk ≤ 2 AND deep-review < 0.5) backed by actual diff
|
|
121
|
+
* evidence. Any ambiguity (missing fields, unadjudicated, unset
|
|
122
|
+
* lowRiskModel, or a title/body-only triage — fully attacker-steerable
|
|
123
|
+
* text) keeps the configured model: coverage stays constant and the
|
|
124
|
+
* expensive path is the default.
|
|
125
|
+
*/
|
|
126
|
+
export function routeModel(opts) {
|
|
127
|
+
const rec = opts.record;
|
|
128
|
+
if (rec === undefined ||
|
|
129
|
+
rec.mode !== 'route' ||
|
|
130
|
+
rec.unadjudicated === true ||
|
|
131
|
+
opts.lowRiskModel === undefined ||
|
|
132
|
+
rec.risk === undefined ||
|
|
133
|
+
rec.needsDeepReview === undefined ||
|
|
134
|
+
(rec.diffExcerptChars ?? 0) === 0) {
|
|
135
|
+
return { model: opts.configured, reason: 'no routing signal' };
|
|
136
|
+
}
|
|
137
|
+
if (rec.risk <= 2 && rec.needsDeepReview < 0.5) {
|
|
138
|
+
return {
|
|
139
|
+
model: opts.lowRiskModel,
|
|
140
|
+
reason: `low risk — risk=${rec.risk}, needsDeepReview=${rec.needsDeepReview.toFixed(2)}`,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
return {
|
|
144
|
+
model: opts.configured,
|
|
145
|
+
reason: `risk=${rec.risk}, needsDeepReview=${rec.needsDeepReview.toFixed(2)}`,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* U9 — the probe lane's advisory ordering signal. Present only when
|
|
150
|
+
* triage adjudicated a real area with its confidence attached; 'none'
|
|
151
|
+
* is the null-area sentinel, not an ordering signal, and the
|
|
152
|
+
* confidence floor itself is queue policy (MIN_AREA_CONFIDENCE).
|
|
153
|
+
*/
|
|
154
|
+
export function triageAreaSignal(rec) {
|
|
155
|
+
if (rec === undefined ||
|
|
156
|
+
rec.unadjudicated === true ||
|
|
157
|
+
rec.topRiskArea === undefined ||
|
|
158
|
+
rec.topRiskArea === 'none' ||
|
|
159
|
+
rec.topRiskAreaConfidence === undefined) {
|
|
160
|
+
return undefined;
|
|
161
|
+
}
|
|
162
|
+
return { area: rec.topRiskArea, confidence: rec.topRiskAreaConfidence };
|
|
163
|
+
}
|
package/dist/trust.d.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { PrMeta } from './evidence/ci.js';
|
|
2
|
+
/**
|
|
3
|
+
* Checkout trust — decided BEFORE `loadConfig` runs, because loading a
|
|
4
|
+
* PR-controlled `argus-reviewer.config.ts` executes arbitrary code beside
|
|
5
|
+
* `OPENROUTER_API_KEY`/`GITHUB_TOKEN` (#58).
|
|
6
|
+
*
|
|
7
|
+
* Trust keys on fork status of the checked-out tree, never on
|
|
8
|
+
* `author_association` — a MEMBER can author a hostile fork PR.
|
|
9
|
+
*/
|
|
10
|
+
export type Trust = 'trusted' | 'untrusted';
|
|
11
|
+
export interface TrustResult {
|
|
12
|
+
trust: Trust;
|
|
13
|
+
reason: string;
|
|
14
|
+
/**
|
|
15
|
+
* PR number derived from the event payload on `issue_comment` events
|
|
16
|
+
* (`issue.number` when the issue is a PR). Undefined otherwise — the
|
|
17
|
+
* caller's own repo/pr derivation still applies.
|
|
18
|
+
*/
|
|
19
|
+
pr: string | undefined;
|
|
20
|
+
}
|
|
21
|
+
export interface ResolveTrustOpts {
|
|
22
|
+
env: Record<string, string | undefined>;
|
|
23
|
+
/**
|
|
24
|
+
* Fetches PR metadata — required only on `issue_comment` (the payload has
|
|
25
|
+
* no `head.repo.fork`) and as a fallback when a `pull_request*` payload
|
|
26
|
+
* is unreadable. Injectable for tests.
|
|
27
|
+
*/
|
|
28
|
+
fetchMeta?: (repo: string, pr: string, token: string) => Promise<PrMeta | undefined>;
|
|
29
|
+
/** Injectable event-payload reader for tests. Defaults to node fs. */
|
|
30
|
+
readEventFile?: (path: string) => Promise<string>;
|
|
31
|
+
/** Human-readable note on the resolved decision (e.g. ctx.err). */
|
|
32
|
+
note?: (line: string) => void;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Resolves whether the checked-out tree may execute config code.
|
|
36
|
+
*
|
|
37
|
+
* - `pull_request*` events: fork status from the event payload — no token
|
|
38
|
+
* needed (`head.repo.fork`). Payload absent/unreadable → `fetchMeta`
|
|
39
|
+
* fallback → still unknown → untrusted.
|
|
40
|
+
* - `issue_comment`: `fetchMeta` on `issue.number`; unavailable → untrusted.
|
|
41
|
+
* - Any other present `GITHUB_EVENT_NAME` (`workflow_run`, `push`,
|
|
42
|
+
* `workflow_dispatch`, …): untrusted — unlisted CI events fail closed
|
|
43
|
+
* because privileged-CI-over-fork-checkout patterns (workflow_run over a
|
|
44
|
+
* fork SHA) land exactly there. Maintainers opt out explicitly with
|
|
45
|
+
* `ARGUS_TRUSTED=1`.
|
|
46
|
+
* - No event env at all (local run): trusted unless `ARGUS_UNTRUSTED=1`.
|
|
47
|
+
* - `ARGUS_UNTRUSTED=1` always wins; `ARGUS_TRUSTED=1` overrides event
|
|
48
|
+
* resolution but never `ARGUS_UNTRUSTED`.
|
|
49
|
+
*/
|
|
50
|
+
export declare function resolveTrust(opts: ResolveTrustOpts): Promise<TrustResult>;
|