external-review 1.0.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/LICENSE +21 -0
- package/README.md +311 -0
- package/bin/external-review.mjs +582 -0
- package/docs/PLAYBOOK.md +234 -0
- package/docs/PRIVACY.md +182 -0
- package/examples/prompts/data-integrity.txt +49 -0
- package/examples/prompts/second-opinion.txt +41 -0
- package/examples/prompts/user-journey.txt +47 -0
- package/package.json +44 -0
- package/skills/external-review/SKILL.md +198 -0
|
@@ -0,0 +1,582 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// external-review — pick a second model, know what it costs you, know where
|
|
3
|
+
// your code goes, and run a review with it.
|
|
4
|
+
//
|
|
5
|
+
// No dependencies on purpose: this reads your API key and syncs your source, so
|
|
6
|
+
// the whole thing should be auditable in one sitting.
|
|
7
|
+
|
|
8
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
9
|
+
import {
|
|
10
|
+
existsSync, readFileSync, writeFileSync, readdirSync, statSync,
|
|
11
|
+
mkdirSync, copyFileSync,
|
|
12
|
+
} from 'node:fs';
|
|
13
|
+
import { homedir, tmpdir } from 'node:os';
|
|
14
|
+
import { join, dirname } from 'node:path';
|
|
15
|
+
import { fileURLToPath } from 'node:url';
|
|
16
|
+
|
|
17
|
+
const OR = 'https://openrouter.ai/api/v1';
|
|
18
|
+
|
|
19
|
+
// ---------------------------------------------------------------- utilities
|
|
20
|
+
|
|
21
|
+
const C = process.stdout.isTTY
|
|
22
|
+
? { dim: (s) => `\x1b[2m${s}\x1b[0m`, b: (s) => `\x1b[1m${s}\x1b[0m`,
|
|
23
|
+
g: (s) => `\x1b[32m${s}\x1b[0m`, y: (s) => `\x1b[33m${s}\x1b[0m`,
|
|
24
|
+
r: (s) => `\x1b[31m${s}\x1b[0m`, c: (s) => `\x1b[36m${s}\x1b[0m` }
|
|
25
|
+
: new Proxy({}, { get: () => (s) => s });
|
|
26
|
+
|
|
27
|
+
const die = (msg) => { console.error(`${C.r('error')} ${msg}`); process.exit(1); };
|
|
28
|
+
const info = (msg) => console.error(C.dim(msg));
|
|
29
|
+
|
|
30
|
+
/** Read the OpenRouter key from the env or from opencode's auth store. */
|
|
31
|
+
function apiKey({ required = true } = {}) {
|
|
32
|
+
if (process.env.OPENROUTER_API_KEY) return process.env.OPENROUTER_API_KEY;
|
|
33
|
+
const authFile = join(homedir(), '.local/share/opencode/auth.json');
|
|
34
|
+
if (existsSync(authFile)) {
|
|
35
|
+
try {
|
|
36
|
+
const key = JSON.parse(readFileSync(authFile, 'utf8'))?.openrouter?.key;
|
|
37
|
+
if (key) return key;
|
|
38
|
+
} catch { /* fall through to the error below */ }
|
|
39
|
+
}
|
|
40
|
+
if (!required) return null;
|
|
41
|
+
die('no API key. Set OPENROUTER_API_KEY, or run `opencode auth login`.');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function api(path, { key = apiKey(), ...init } = {}) {
|
|
45
|
+
const res = await fetch(`${OR}${path}`, {
|
|
46
|
+
...init,
|
|
47
|
+
headers: { Authorization: `Bearer ${key}`, ...(init.headers || {}) },
|
|
48
|
+
});
|
|
49
|
+
if (!res.ok) die(`OpenRouter ${path} → HTTP ${res.status}`);
|
|
50
|
+
return res.json();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const num = (v) => (v == null ? null : Number(v));
|
|
54
|
+
const money = (v) => (v == null ? '—' : `$${Number(v).toFixed(4)}`);
|
|
55
|
+
|
|
56
|
+
/** Per-million-token price, which is how everyone actually compares models. */
|
|
57
|
+
function perM(pricing = {}) {
|
|
58
|
+
const p = num(pricing.prompt);
|
|
59
|
+
const c = num(pricing.completion);
|
|
60
|
+
if (p == null && c == null) return '—';
|
|
61
|
+
const fmt = (x) => (x == null ? '?' : x === 0 ? 'free' : `$${(x * 1e6).toFixed(2)}`);
|
|
62
|
+
return `${fmt(p)} in / ${fmt(c)} out`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const isFree = (m) => num(m?.pricing?.prompt) === 0 && num(m?.pricing?.completion) === 0;
|
|
66
|
+
|
|
67
|
+
// ------------------------------------------------------------------ doctor
|
|
68
|
+
|
|
69
|
+
async function cmdDoctor() {
|
|
70
|
+
let bad = 0;
|
|
71
|
+
const check = (ok, label, detail) => {
|
|
72
|
+
console.log(`${ok ? C.g(' ok ') : C.y(' warn ')} ${label}${detail ? C.dim(` ${detail}`) : ''}`);
|
|
73
|
+
if (!ok) bad++;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
console.log(C.b('\nexternal-review doctor\n'));
|
|
77
|
+
|
|
78
|
+
const key = apiKey({ required: false });
|
|
79
|
+
check(!!key, 'API key found',
|
|
80
|
+
key ? `${key.slice(0, 8)}…${key.slice(-4)}` : 'set OPENROUTER_API_KEY or run `opencode auth login`');
|
|
81
|
+
|
|
82
|
+
const runner = findRunner();
|
|
83
|
+
check(!!runner, 'a runner is installed', runner || 'install one: npm i -g opencode-ai');
|
|
84
|
+
|
|
85
|
+
for (const tool of ['rsync', 'ssh']) {
|
|
86
|
+
const ok = spawnSync('which', [tool]).status === 0;
|
|
87
|
+
check(ok, `${tool} available`, ok ? '' : 'needed only for the remote-machine workflow');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (key) {
|
|
91
|
+
const { data } = await api('/key', { key });
|
|
92
|
+
const tier = data.is_free_tier ? 'free tier' : 'paid';
|
|
93
|
+
check(true, `key reachable (${tier})`, `spent today: ${money(data.usage_daily)}`);
|
|
94
|
+
if (data.is_free_tier) {
|
|
95
|
+
console.log(C.dim(
|
|
96
|
+
'\n Free tier: OpenRouter caps free-model requests per day (50/day, or\n' +
|
|
97
|
+
' 1000/day once the account has ever purchased 10 credits). A long review\n' +
|
|
98
|
+
' is many requests. `external-review quota` shows where you stand.'));
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
console.log(bad ? C.y('\nSome checks want attention.\n') : C.g('\nReady.\n'));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function findRunner() {
|
|
106
|
+
for (const c of [join(homedir(), '.opencode/bin/opencode'), 'opencode']) {
|
|
107
|
+
if (c.startsWith('/') ? existsSync(c) : spawnSync('which', [c]).status === 0) return c;
|
|
108
|
+
}
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ------------------------------------------------------------------- quota
|
|
113
|
+
|
|
114
|
+
async function cmdQuota() {
|
|
115
|
+
const { data } = await api('/key');
|
|
116
|
+
console.log(C.b('\nAccount\n'));
|
|
117
|
+
const row = (k, v) => console.log(` ${k.padEnd(18)} ${v}`);
|
|
118
|
+
row('tier', data.is_free_tier ? C.y('free') : C.g('paid'));
|
|
119
|
+
row('spent today', money(data.usage_daily));
|
|
120
|
+
row('spent this week', money(data.usage_weekly));
|
|
121
|
+
row('spent this month', money(data.usage_monthly));
|
|
122
|
+
row('spent all time', money(data.usage));
|
|
123
|
+
|
|
124
|
+
if (data.limit != null) {
|
|
125
|
+
const pct = Math.round((num(data.limit_remaining) / num(data.limit)) * 100);
|
|
126
|
+
const bar = '█'.repeat(Math.max(0, Math.round(pct / 5))).padEnd(20, '░');
|
|
127
|
+
row('credit limit', money(data.limit));
|
|
128
|
+
row('remaining', `${money(data.limit_remaining)} ${pct > 25 ? C.g(bar) : C.y(bar)} ${pct}%`);
|
|
129
|
+
if (data.limit_reset) row('resets', data.limit_reset);
|
|
130
|
+
} else {
|
|
131
|
+
row('credit limit', C.dim('none set'));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (data.is_free_tier) {
|
|
135
|
+
console.log(C.y('\n Free-model request cap'));
|
|
136
|
+
console.log(C.dim(
|
|
137
|
+
' :free models 50 requests/day, and 20/minute.\n' +
|
|
138
|
+
' 1000/day once the account has EVER purchased 10\n' +
|
|
139
|
+
' credits — permanent, not a subscription.\n' +
|
|
140
|
+
' resets on the UTC day.\n\n' +
|
|
141
|
+
' The daily counter is ACCOUNT-WIDE across every :free model, so switching\n' +
|
|
142
|
+
' free models does not get you a fresh budget. It is not exposed by the\n' +
|
|
143
|
+
' API, which is why it is not shown above.\n\n' +
|
|
144
|
+
' STEALTH MODELS DRAW ON A SEPARATE POOL. An anonymous/cloaked preview\n' +
|
|
145
|
+
' model is not a :free model and has its own, much larger allowance —\n' +
|
|
146
|
+
' which is why a day can run far past 50 requests and then stop abruptly\n' +
|
|
147
|
+
' when you switch to a genuine :free model. The two errors differ:\n' +
|
|
148
|
+
' "Rate limit exceeded: free-models-per-day-stealth" → stealth pool\n' +
|
|
149
|
+
' "Rate limit exceeded: free-models-per-day" → the 50/day pool\n\n' +
|
|
150
|
+
' A whole-subsystem review is 40-150 requests. On the free tier that is\n' +
|
|
151
|
+
' ONE pass, maybe two. Add credits, or use stealth models, or expect to\n' +
|
|
152
|
+
' plan a day at a time.'));
|
|
153
|
+
}
|
|
154
|
+
console.log();
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ------------------------------------------------------------------ models
|
|
158
|
+
|
|
159
|
+
async function cmdModels(args) {
|
|
160
|
+
const wantFree = args.includes('--free');
|
|
161
|
+
const wantAll = args.includes('--all');
|
|
162
|
+
const limit = Number(argValue(args, '--limit') ?? (wantAll ? 1e9 : 25));
|
|
163
|
+
const minCtx = Number(argValue(args, '--min-context') ?? 60000);
|
|
164
|
+
|
|
165
|
+
const { data } = await api('/models');
|
|
166
|
+
let models = data.filter((m) => (num(m.context_length) ?? 0) >= minCtx);
|
|
167
|
+
if (wantFree) models = models.filter(isFree);
|
|
168
|
+
|
|
169
|
+
// A code review means reading a lot and writing a little, so rank by context
|
|
170
|
+
// first — a model that cannot hold the subsystem cannot review it.
|
|
171
|
+
models.sort((a, b) => (num(b.context_length) ?? 0) - (num(a.context_length) ?? 0));
|
|
172
|
+
|
|
173
|
+
console.log(C.b(`\n${models.length} model(s) with ≥${(minCtx / 1000) | 0}k context${wantFree ? ', free only' : ''}\n`));
|
|
174
|
+
console.log(C.dim(' context price / 1M tokens id'));
|
|
175
|
+
for (const m of models.slice(0, limit)) {
|
|
176
|
+
const ctx = `${Math.round((num(m.context_length) ?? 0) / 1000)}k`.padStart(7);
|
|
177
|
+
const price = perM(m.pricing).padEnd(26);
|
|
178
|
+
console.log(` ${ctx} ${isFree(m) ? C.g(price) : price} ${m.id}`);
|
|
179
|
+
}
|
|
180
|
+
if (models.length > limit) console.log(C.dim(`\n …${models.length - limit} more. --all to list them, --limit N to change.`));
|
|
181
|
+
console.log(C.dim('\n Next: `external-review providers <id>` to see who serves it and where.\n'));
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function argValue(args, flag) {
|
|
185
|
+
const i = args.indexOf(flag);
|
|
186
|
+
return i >= 0 ? args[i + 1] : undefined;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// --------------------------------------------------------------- providers
|
|
190
|
+
|
|
191
|
+
async function cmdProviders(args) {
|
|
192
|
+
const model = args.find((a) => !a.startsWith('-'));
|
|
193
|
+
if (!model) die('usage: external-review providers <model-id>');
|
|
194
|
+
|
|
195
|
+
const [{ data: detail }, { data: providers }] = await Promise.all([
|
|
196
|
+
api(`/models/${model}/endpoints`),
|
|
197
|
+
api('/providers'),
|
|
198
|
+
]);
|
|
199
|
+
const byName = new Map(providers.map((p) => [p.name, p]));
|
|
200
|
+
|
|
201
|
+
console.log(C.b(`\n${detail.name || model}\n`));
|
|
202
|
+
|
|
203
|
+
const endpoints = detail.endpoints || [];
|
|
204
|
+
|
|
205
|
+
// NO ENDPOINTS is an answer, not an error - and the most important one this
|
|
206
|
+
// command can give. Stealth and cloaked models publish no provider at all, so
|
|
207
|
+
// there is nothing to look up: you cannot learn who runs the machine your
|
|
208
|
+
// source is about to be sent to.
|
|
209
|
+
if (endpoints.length === 0) {
|
|
210
|
+
console.log(C.y(' This model does not disclose its providers.\n'));
|
|
211
|
+
console.log(C.dim(
|
|
212
|
+
' OpenRouter lists no endpoints for it, which is characteristic of a\n' +
|
|
213
|
+
' STEALTH or CLOAKED model: an unreleased model shipped under an\n' +
|
|
214
|
+
' anonymous name to gather real-world usage before launch.\n\n' +
|
|
215
|
+
' So the questions this command exists to answer — who operates it, from\n' +
|
|
216
|
+
' where, under which policy — have no available answer. What IS known is\n' +
|
|
217
|
+
' the arrangement: these models are offered free because prompts and\n' +
|
|
218
|
+
' completions are logged and used to improve them. That is their purpose,\n' +
|
|
219
|
+
' not a side effect.\n\n' +
|
|
220
|
+
' They are genuinely good at review work and frequently frontier-class.\n' +
|
|
221
|
+
' Use one with code you would not mind training a model. For anything\n' +
|
|
222
|
+
' else, pick a model that names its providers.\n'));
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
console.log(C.dim(' Your prompt is sent to ONE of these, chosen per request.\n'));
|
|
227
|
+
|
|
228
|
+
for (const ep of endpoints) {
|
|
229
|
+
const p = byName.get(ep.provider_name) || {};
|
|
230
|
+
const hq = p.headquarters || '?';
|
|
231
|
+
const dcs = Array.isArray(p.datacenters) && p.datacenters.length
|
|
232
|
+
? p.datacenters.join(', ')
|
|
233
|
+
: C.dim('not published');
|
|
234
|
+
console.log(` ${C.c(ep.provider_name)}`);
|
|
235
|
+
console.log(` headquarters ${hq}`);
|
|
236
|
+
console.log(` datacenters ${dcs}`);
|
|
237
|
+
if (p.privacy_policy_url) console.log(` privacy ${C.dim(p.privacy_policy_url)}`);
|
|
238
|
+
if (p.terms_of_service_url) console.log(` terms ${C.dim(p.terms_of_service_url)}`);
|
|
239
|
+
console.log();
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
console.log(C.dim(
|
|
243
|
+
' These are facts from OpenRouter, not a judgement. Where a provider is\n' +
|
|
244
|
+
' based and where it runs its hardware may or may not matter for your code —\n' +
|
|
245
|
+
' that depends on your obligations, not on ours. Read the linked policy.\n\n' +
|
|
246
|
+
' To pin a single provider, use OpenRouter\'s `provider.order` routing, or\n' +
|
|
247
|
+
' choose a model with only one endpoint.\n'));
|
|
248
|
+
|
|
249
|
+
// Detected from the endpoints' own PRICING, not from the id string: the
|
|
250
|
+
// canonical slug this command takes has no `:free` suffix, so matching on the
|
|
251
|
+
// name missed exactly the models the warning is for.
|
|
252
|
+
const anyFree = endpoints.some((e) => isFree(e));
|
|
253
|
+
if (anyFree) {
|
|
254
|
+
console.log(C.y(' Before you send source to this one'));
|
|
255
|
+
console.log(C.dim(
|
|
256
|
+
' Free and stealth endpoints are free because of what they may do with\n' +
|
|
257
|
+
' your data. OpenRouter will not route to them at all unless you have\n' +
|
|
258
|
+
' enabled, in Settings → Privacy:\n\n' +
|
|
259
|
+
' "Enable free endpoints that may train on inputs"\n' +
|
|
260
|
+
' "Enable free endpoints that may publish prompts"\n\n' +
|
|
261
|
+
' If free models work for you, those are ON, and the code you send may be\n' +
|
|
262
|
+
' trained on and published. A stealth model is an unreleased model shipped\n' +
|
|
263
|
+
' anonymously to gather real usage — logging your prompts IS its purpose.\n\n' +
|
|
264
|
+
' If that is not acceptable for this code, use Zero Data Retention: a\n' +
|
|
265
|
+
' privacy-settings toggle, a per-key guardrail, or `"zdr": true` per\n' +
|
|
266
|
+
' request. It blocks storage and training, and it removes most free\n' +
|
|
267
|
+
' endpoints — which is the trade being made either way.\n'));
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
// -------------------------------------------------------------------- scan
|
|
273
|
+
|
|
274
|
+
/* Content-based secret detection.
|
|
275
|
+
*
|
|
276
|
+
* The exclusion list catches a credential that lives in a FILE NAMED like a
|
|
277
|
+
* credential. It does nothing about the far commoner case: a live key pasted
|
|
278
|
+
* into an ordinary source file. `config.js` matches no pattern, and ships.
|
|
279
|
+
*
|
|
280
|
+
* Patterns are deliberately high-signal. A scanner that cries wolf gets
|
|
281
|
+
* `--force`d past on the second run and then protects nobody, so anything
|
|
282
|
+
* heuristic enough to fire on real code is left out. This finds keys with
|
|
283
|
+
* distinctive prefixes and real private-key blocks; it will NOT find every
|
|
284
|
+
* secret, and the report says so rather than implying a clean bill of health.
|
|
285
|
+
*/
|
|
286
|
+
/* A private key SPANS LINES, so the per-line scan below cannot see it: the
|
|
287
|
+
* header is on one line and the base64 on the next. It also needs that base64
|
|
288
|
+
* to be present at all - a lone header is almost always a PARSER stripping it,
|
|
289
|
+
* which is what a real repo's only false positive of this class turned out to
|
|
290
|
+
* be. So it gets its own whole-text pattern, matched separately.
|
|
291
|
+
*/
|
|
292
|
+
const PRIVATE_KEY_BLOCK =
|
|
293
|
+
/-----BEGIN (?:RSA |EC |OPENSSH |PGP )?PRIVATE KEY-----[\r\n\\]+\s*[A-Za-z0-9+/=]{20,}/;
|
|
294
|
+
|
|
295
|
+
const SECRET_PATTERNS = [
|
|
296
|
+
['AWS access key id', /\bAKIA[0-9A-Z]{16}\b/],
|
|
297
|
+
['GitHub token', /\bgh[pousr]_[A-Za-z0-9]{36,}\b/],
|
|
298
|
+
['Slack token', /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/],
|
|
299
|
+
['Stripe live key', /\bsk_live_[A-Za-z0-9]{20,}\b/],
|
|
300
|
+
['OpenAI-style key', /\bsk-[A-Za-z0-9]{20,}\b/],
|
|
301
|
+
['OpenRouter key', /\bsk-or-v1-[A-Za-z0-9]{20,}\b/],
|
|
302
|
+
['Google API key', /\bAIza[0-9A-Za-z_-]{35}\b/],
|
|
303
|
+
['Anthropic key', /\bsk-ant-[A-Za-z0-9_-]{20,}\b/],
|
|
304
|
+
['JWT', /\bey[A-Za-z0-9_-]{10,}\.ey[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/],
|
|
305
|
+
['connection string with password', /\b[a-z][a-z0-9+.-]*:\/\/[^\s:@/]+:[^\s:@/]+@[^\s/]+/],
|
|
306
|
+
];
|
|
307
|
+
|
|
308
|
+
const SCAN_SKIP_DIRS = new Set([
|
|
309
|
+
'.git', 'node_modules', 'build', 'dist', 'target', '.venv', 'venv',
|
|
310
|
+
'__pycache__', '.dart_tool', '.gradle', 'Pods', 'vendor', '.next',
|
|
311
|
+
]);
|
|
312
|
+
|
|
313
|
+
const SCAN_SKIP_EXT = new Set([
|
|
314
|
+
'.png', '.jpg', '.jpeg', '.gif', '.webp', '.ico', '.svg', '.pdf', '.zip',
|
|
315
|
+
'.gz', '.tar', '.mp4', '.mov', '.mp3', '.woff', '.woff2', '.ttf', '.otf',
|
|
316
|
+
'.jks', '.keystore', '.p12', '.der', '.apk', '.aab', '.so', '.dylib',
|
|
317
|
+
]);
|
|
318
|
+
|
|
319
|
+
/* Files whose "secrets" are public by design.
|
|
320
|
+
*
|
|
321
|
+
* Firebase client config (API key, app id, sender id) is an IDENTIFIER, not a
|
|
322
|
+
* credential: Google documents it as safe to embed, and access is gated by app
|
|
323
|
+
* signature / bundle id and security rules, not by the key. Every mobile repo
|
|
324
|
+
* has these committed, so flagging them trains people to ignore the scanner -
|
|
325
|
+
* which costs more than the warning is worth.
|
|
326
|
+
*/
|
|
327
|
+
const PUBLIC_BY_DESIGN = [
|
|
328
|
+
/(^|\/)google-services\.json$/,
|
|
329
|
+
/(^|\/)GoogleService-Info\.plist$/,
|
|
330
|
+
/(^|\/)firebase_options\.dart$/,
|
|
331
|
+
/(^|\/)firebase-config\.(js|ts|json)$/,
|
|
332
|
+
];
|
|
333
|
+
|
|
334
|
+
function scanTree(root) {
|
|
335
|
+
const hits = [];
|
|
336
|
+
const walk = (dir) => {
|
|
337
|
+
let entries;
|
|
338
|
+
try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
339
|
+
for (const e of entries) {
|
|
340
|
+
const full = join(dir, e.name);
|
|
341
|
+
if (e.isDirectory()) {
|
|
342
|
+
if (!SCAN_SKIP_DIRS.has(e.name)) walk(full);
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
345
|
+
if (!e.isFile()) continue;
|
|
346
|
+
const dot = e.name.lastIndexOf('.');
|
|
347
|
+
if (dot > 0 && SCAN_SKIP_EXT.has(e.name.slice(dot).toLowerCase())) continue;
|
|
348
|
+
const rel = full.slice(root.length + 1);
|
|
349
|
+
if (PUBLIC_BY_DESIGN.some((re) => re.test(rel))) continue;
|
|
350
|
+
let text;
|
|
351
|
+
try {
|
|
352
|
+
if (statSync(full).size > 2_000_000) continue; // not source
|
|
353
|
+
text = readFileSync(full, 'utf8');
|
|
354
|
+
} catch { continue; }
|
|
355
|
+
if (text.includes('\u0000')) continue; // binary
|
|
356
|
+
|
|
357
|
+
const pem = PRIVATE_KEY_BLOCK.exec(text);
|
|
358
|
+
if (pem) {
|
|
359
|
+
hits.push({
|
|
360
|
+
file: rel,
|
|
361
|
+
line: text.slice(0, pem.index).split('\n').length,
|
|
362
|
+
label: 'private key block',
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const lines = text.split('\n');
|
|
367
|
+
for (let i = 0; i < lines.length; i++) {
|
|
368
|
+
for (const [label, re] of SECRET_PATTERNS) {
|
|
369
|
+
if (re.test(lines[i])) {
|
|
370
|
+
hits.push({ file: rel, line: i + 1, label });
|
|
371
|
+
break;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
};
|
|
377
|
+
walk(root);
|
|
378
|
+
return hits;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function cmdScan(args) {
|
|
382
|
+
const root = argValue(args, '--in') ?? process.cwd();
|
|
383
|
+
const hits = scanTree(root);
|
|
384
|
+
|
|
385
|
+
if (hits.length === 0) {
|
|
386
|
+
console.log(C.g(`\n no high-signal secrets found in ${root}`));
|
|
387
|
+
console.log(C.dim(
|
|
388
|
+
'\n This is NOT a clean bill of health. It looks for credentials with\n' +
|
|
389
|
+
' distinctive shapes - key prefixes, private-key blocks, passworded\n' +
|
|
390
|
+
' connection strings. A bare 32-character token in a config file looks\n' +
|
|
391
|
+
' exactly like any other string and cannot be found this way.\n' +
|
|
392
|
+
' Read your own diff before sending it somewhere.\n'));
|
|
393
|
+
return hits;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
console.log(C.r(`\n ${hits.length} possible secret(s) in ${root}\n`));
|
|
397
|
+
for (const h of hits) {
|
|
398
|
+
console.log(` ${C.y(h.label.padEnd(28))} ${h.file}:${h.line}`);
|
|
399
|
+
}
|
|
400
|
+
console.log(C.dim(
|
|
401
|
+
'\n Filename exclusions would NOT stop these - they are inside ordinary\n' +
|
|
402
|
+
' source files. Before sending this tree to a model:\n' +
|
|
403
|
+
' - move the value to an environment variable, or\n' +
|
|
404
|
+
' - add the file with --exclude, or\n' +
|
|
405
|
+
' - confirm it is a placeholder / already-public identifier.\n' +
|
|
406
|
+
' If a live credential has already been sent, rotate it. A provider\n' +
|
|
407
|
+
' retention policy is not a recall.\n'));
|
|
408
|
+
return hits;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// -------------------------------------------------------------------- sync
|
|
412
|
+
|
|
413
|
+
const DEFAULT_EXCLUDES = [
|
|
414
|
+
'.git/', 'node_modules/', 'build/', 'dist/', 'target/', '.venv/', 'venv/',
|
|
415
|
+
'__pycache__/', '.dart_tool/', '.gradle/', 'Pods/',
|
|
416
|
+
// Anything that is or holds a credential. Extend with --exclude.
|
|
417
|
+
'.env', '.env.*', '*.pem', '*.der', '*.key', '*.jks', '*.keystore', '*.p12',
|
|
418
|
+
'*.mobileprovision', 'id_rsa*', '*.crt',
|
|
419
|
+
'secrets.*', '*.secrets.*', 'credentials.*', 'service-account*.json',
|
|
420
|
+
];
|
|
421
|
+
|
|
422
|
+
/** Paths that must NOT exist in the synced copy, verified after the sync. */
|
|
423
|
+
function verifyList(extra) {
|
|
424
|
+
return ['.env', '.git', ...extra];
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function cmdSync(args) {
|
|
428
|
+
const dest = argValue(args, '--to');
|
|
429
|
+
const src = argValue(args, '--from') ?? process.cwd();
|
|
430
|
+
const extra = args.filter((a, i) => args[i - 1] === '--exclude');
|
|
431
|
+
if (!dest) die('usage: external-review sync --to user@host:~/review-dir [--from .] [--exclude PATH]');
|
|
432
|
+
|
|
433
|
+
// SCAN BEFORE SENDING, and refuse by default. This is the whole reason the
|
|
434
|
+
// command exists rather than telling people to run rsync themselves: the
|
|
435
|
+
// moment a review copy leaves the machine is the last moment anything can be
|
|
436
|
+
// done about a key inside it.
|
|
437
|
+
if (!args.includes('--skip-scan')) {
|
|
438
|
+
const hits = cmdScan(['--in', src]);
|
|
439
|
+
if (hits.length && !args.includes('--force')) {
|
|
440
|
+
die('refusing to sync. Resolve the findings above, or pass --force if ' +
|
|
441
|
+
'every one is a placeholder or an already-public identifier.');
|
|
442
|
+
}
|
|
443
|
+
if (hits.length) info('--force given; syncing anyway');
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
const excludes = [...DEFAULT_EXCLUDES, ...extra].flatMap((e) => ['--exclude', e]);
|
|
447
|
+
info(`syncing ${src} → ${dest}`);
|
|
448
|
+
const r = spawnSync('rsync', ['-az', '--delete', ...excludes, `${src}/`, dest], { stdio: 'inherit' });
|
|
449
|
+
if (r.status !== 0) die('rsync failed');
|
|
450
|
+
|
|
451
|
+
// VERIFY, do not assume. An exclusion that silently did not match is the
|
|
452
|
+
// whole risk this command exists to manage.
|
|
453
|
+
const [userHost, remoteDir] = splitDest(dest);
|
|
454
|
+
if (userHost) {
|
|
455
|
+
const checks = verifyList(extra)
|
|
456
|
+
.map((f) => `if [ -e "${f}" ]; then echo "LEAKED: ${f}"; fi`)
|
|
457
|
+
.join('; ');
|
|
458
|
+
const out = spawnSync('ssh', [userHost, `cd ${remoteDir} && { ${checks}; } ; echo VERIFY_DONE`], { encoding: 'utf8' });
|
|
459
|
+
const leaked = (out.stdout || '').split('\n').filter((l) => l.startsWith('LEAKED:'));
|
|
460
|
+
if (leaked.length) {
|
|
461
|
+
console.error(C.r('\nSecrets reached the review copy:'));
|
|
462
|
+
leaked.forEach((l) => console.error(` ${l}`));
|
|
463
|
+
die('delete the remote copy and re-sync with the right --exclude flags');
|
|
464
|
+
}
|
|
465
|
+
console.log(C.g('\n verified: no excluded path is present in the copy\n'));
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function splitDest(dest) {
|
|
470
|
+
const m = /^([^:]+):(.+)$/.exec(dest);
|
|
471
|
+
return m ? [m[1], m[2]] : [null, dest];
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// --------------------------------------------------------------------- run
|
|
475
|
+
|
|
476
|
+
function cmdRun(args) {
|
|
477
|
+
const promptFile = argValue(args, '--prompt');
|
|
478
|
+
const model = argValue(args, '--model');
|
|
479
|
+
const cwd = argValue(args, '--in') ?? process.cwd();
|
|
480
|
+
const out = argValue(args, '--out') ?? join(tmpdir(), `review-${Date.now()}.md`);
|
|
481
|
+
if (!promptFile || !model) {
|
|
482
|
+
die('usage: external-review run --prompt FILE --model ID [--in DIR] [--out FILE]');
|
|
483
|
+
}
|
|
484
|
+
if (!existsSync(promptFile)) die(`no such prompt file: ${promptFile}`);
|
|
485
|
+
|
|
486
|
+
const runner = findRunner();
|
|
487
|
+
if (!runner) die('no runner found. Install one: npm i -g opencode-ai');
|
|
488
|
+
|
|
489
|
+
const prompt = readFileSync(promptFile, 'utf8');
|
|
490
|
+
info(`model ${model}`);
|
|
491
|
+
info(`scope ${cwd}`);
|
|
492
|
+
info(`output ${out}`);
|
|
493
|
+
|
|
494
|
+
const child = spawn(runner, ['run', '-m', model, prompt], {
|
|
495
|
+
cwd, stdio: ['ignore', 'pipe', 'inherit'],
|
|
496
|
+
});
|
|
497
|
+
let buf = '';
|
|
498
|
+
child.stdout.on('data', (d) => { buf += d; process.stdout.write(d); });
|
|
499
|
+
child.on('close', (code) => {
|
|
500
|
+
writeFileSync(out, buf);
|
|
501
|
+
console.error(code === 0 ? C.g(`\nwrote ${out}`) : C.y(`\nrunner exited ${code}; partial output in ${out}`));
|
|
502
|
+
process.exit(code ?? 0);
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
// ----------------------------------------------------------- install-skill
|
|
508
|
+
|
|
509
|
+
/* Copy the skill to where the assistant looks for it.
|
|
510
|
+
*
|
|
511
|
+
* A command rather than a documented `cp`, because the path depends on how the
|
|
512
|
+
* package was installed: a GLOBAL install puts it under the npm root, a local
|
|
513
|
+
* one under ./node_modules, and running from a clone puts it next to this file.
|
|
514
|
+
* The README shipped the local path beside the global install instruction,
|
|
515
|
+
* which is a paper cut on the very first thing a new user does.
|
|
516
|
+
*/
|
|
517
|
+
function cmdInstallSkill(args) {
|
|
518
|
+
const global = args.includes('--global');
|
|
519
|
+
const dest = join(global ? homedir() : process.cwd(),
|
|
520
|
+
'.claude', 'skills', 'external-review');
|
|
521
|
+
|
|
522
|
+
// Resolve relative to THIS file, so it works from a global install, a local
|
|
523
|
+
// one, or a git clone without knowing which.
|
|
524
|
+
const src = join(dirname(fileURLToPath(import.meta.url)),
|
|
525
|
+
'..', 'skills', 'external-review');
|
|
526
|
+
if (!existsSync(join(src, 'SKILL.md'))) {
|
|
527
|
+
die(`cannot find the skill next to the CLI (looked in ${src})`);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
if (existsSync(join(dest, 'SKILL.md')) && !args.includes('--force')) {
|
|
531
|
+
die(`${dest} already exists. Pass --force to overwrite it.`);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
mkdirSync(dest, { recursive: true });
|
|
535
|
+
copyFileSync(join(src, 'SKILL.md'), join(dest, 'SKILL.md'));
|
|
536
|
+
|
|
537
|
+
console.log(C.g(`\n installed → ${dest}`));
|
|
538
|
+
console.log(C.dim(
|
|
539
|
+
`\n ${global ? 'Available in every project.' : 'Available in this project.'}` +
|
|
540
|
+
`${global ? '' : ' Use --global for every project.'}\n` +
|
|
541
|
+
' Now ask your assistant to "review this with a second model".\n'));
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// -------------------------------------------------------------------- help
|
|
545
|
+
|
|
546
|
+
const HELP = `
|
|
547
|
+
${C.b('external-review')} — run a code review with a second, independent model.
|
|
548
|
+
|
|
549
|
+
${C.b('Commands')}
|
|
550
|
+
install-skill [--global] put the skill where your assistant will find it
|
|
551
|
+
doctor check your setup and say what is missing
|
|
552
|
+
quota spend so far, credit limit, and the free-tier cap
|
|
553
|
+
models [--free] [--all] candidate models, ranked by context window
|
|
554
|
+
[--min-context N] [--limit N]
|
|
555
|
+
providers <model-id> who actually serves that model, and from where
|
|
556
|
+
scan [--in DIR] find credentials INSIDE source files, which no
|
|
557
|
+
filename exclusion can catch
|
|
558
|
+
sync --to HOST:DIR copy your source to a review machine, secrets
|
|
559
|
+
[--from DIR] [--exclude PATH] excluded — then VERIFY they are absent
|
|
560
|
+
run --prompt FILE --model ID [--in DIR] [--out FILE]
|
|
561
|
+
|
|
562
|
+
${C.b('Typical first run')}
|
|
563
|
+
external-review doctor
|
|
564
|
+
external-review models --free
|
|
565
|
+
external-review providers <the-one-you-liked>
|
|
566
|
+
external-review run --prompt ./review.txt --model <id> --out findings.md
|
|
567
|
+
|
|
568
|
+
${C.dim('Docs, and the review prompts that actually found bugs:')}
|
|
569
|
+
${C.dim('https://github.com/yevgavrikov/claude-external-review')}
|
|
570
|
+
`;
|
|
571
|
+
|
|
572
|
+
// -------------------------------------------------------------------- main
|
|
573
|
+
|
|
574
|
+
const [cmd, ...rest] = process.argv.slice(2);
|
|
575
|
+
const run = {
|
|
576
|
+
doctor: cmdDoctor, quota: cmdQuota, models: cmdModels,
|
|
577
|
+
providers: cmdProviders, scan: cmdScan, sync: cmdSync, run: cmdRun,
|
|
578
|
+
'install-skill': cmdInstallSkill,
|
|
579
|
+
}[cmd];
|
|
580
|
+
|
|
581
|
+
if (!run) { console.log(HELP); process.exit(cmd ? 1 : 0); }
|
|
582
|
+
await run(rest);
|