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.
@@ -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
+ }
@@ -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>;