@integrity-labs/agt-cli 0.28.834 → 0.28.836
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/dist/assets/review/obiwan-auth.mjs +271 -0
- package/dist/assets/review/post-review-findings.mjs +589 -0
- package/dist/bin/agt.js +3 -3
- package/dist/{chunk-LLIAJK4F.js → chunk-E6B43IJZ.js} +2 -2
- package/dist/lib/manager-worker.js +400 -347
- package/dist/lib/manager-worker.js.map +1 -1
- package/package.json +2 -2
- /package/dist/{chunk-LLIAJK4F.js.map → chunk-E6B43IJZ.js.map} +0 -0
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* VENDORED (ENG-10001) — THIS MONOREPO COPY IS CANONICAL; the Smithers plugin
|
|
4
|
+
* copy is downstream. Delivered alongside `post-review-findings.mjs` by
|
|
5
|
+
* `provisionReviewPoster()` (apps/cli/src/lib/review-poster-asset.ts).
|
|
6
|
+
*
|
|
7
|
+
* DIVERGED FROM THE ORIGIN, deliberately (ENG-10001). `resolveIdentity` now
|
|
8
|
+
* VERIFIES with GitHub that the App it minted for is the one named in
|
|
9
|
+
* `EXPECTED_APP_SLUG`, and throws when it is not; `post-review-findings.mjs`
|
|
10
|
+
* refuses to write anything at all without that verified identity. The plugin
|
|
11
|
+
* copy stays permissive because a human runs it and can see whose name ends up
|
|
12
|
+
* on the comment. This copy's only caller is an unattended agent, which cannot.
|
|
13
|
+
*/
|
|
14
|
+
/**
|
|
15
|
+
* obiwan-auth — mint a GitHub App installation token for the review bot.
|
|
16
|
+
*
|
|
17
|
+
* WHY AN APP AND NOT A PAT. Review findings posted with a personal token appear
|
|
18
|
+
* under a human's name. That is wrong twice: it attributes a machine's claims to
|
|
19
|
+
* a person, and it leaves the findings unfilterable — nobody can mute the bot
|
|
20
|
+
* without muting their colleague. An App has its own identity
|
|
21
|
+
* (`obi-wan-shinobi-reviewer[bot]` — the slug GitHub assigned, which is NOT a
|
|
22
|
+
* shortening of the display name),
|
|
23
|
+
* its own permissions, and a per-repo installation that can be revoked without
|
|
24
|
+
* touching anybody's account.
|
|
25
|
+
*
|
|
26
|
+
* WHY THERE IS NOTHING TO PASTE INTO A SHELL PROFILE. App auth is two hops: an
|
|
27
|
+
* RS256 JWT signed with the App's private key, exchanged for an installation
|
|
28
|
+
* token that expires in ONE HOUR. So a token in `.zshrc` is stale before it is
|
|
29
|
+
* useful. What belongs in a profile is the app id and a PATH to the key:
|
|
30
|
+
*
|
|
31
|
+
* export OBIWAN_APP_ID=4822599 # this App, not a placeholder
|
|
32
|
+
* export OBIWAN_PRIVATE_KEY=~/.config/obiwan/private-key.pem # chmod 600
|
|
33
|
+
*
|
|
34
|
+
* The PEM itself should not be inlined there. `.zshrc` is sourced by every
|
|
35
|
+
* shell, is routinely committed to a dotfiles repo, and is read by anything that
|
|
36
|
+
* can read the home directory — none of which is true of a 0600 file the user
|
|
37
|
+
* chose the location of.
|
|
38
|
+
*
|
|
39
|
+
* PERMISSIONS THE APP NEEDS: `pull_requests: write` (post review comments,
|
|
40
|
+
* resolve threads) and `contents: read` (read the diff). Deliberately NOT
|
|
41
|
+
* `contents: write` — this bot must never be able to push.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
import { createSign, randomUUID } from 'node:crypto';
|
|
45
|
+
import { readFileSync, statSync } from 'node:fs';
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The App this reviewer is, as GitHub spells it. `slug` — not the display name,
|
|
49
|
+
* not the app id — is what forms the comment author `<slug>[bot]`, so it is the
|
|
50
|
+
* only value that answers "whose name will be on the finding".
|
|
51
|
+
*/
|
|
52
|
+
export const EXPECTED_APP_SLUG = 'obi-wan-shinobi-reviewer';
|
|
53
|
+
|
|
54
|
+
const b64url = (buf) =>
|
|
55
|
+
Buffer.from(buf).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Resolve the private key from either an inline PEM or a path.
|
|
59
|
+
*
|
|
60
|
+
* A path is the supported form; an inline PEM is accepted because CI has no
|
|
61
|
+
* filesystem to point at. When it IS a path, the file's mode is checked: a key
|
|
62
|
+
* readable by the whole machine is a key that has already leaked, and the point
|
|
63
|
+
* of moving off a PAT was to stop spreading credentials around.
|
|
64
|
+
*/
|
|
65
|
+
export function resolvePrivateKey(value, { stat = statSync, read = readFileSync } = {}) {
|
|
66
|
+
if (!value) throw new Error('OBIWAN_PRIVATE_KEY is not set (path to the App private-key PEM, or the PEM itself)');
|
|
67
|
+
if (String(value).includes('-----BEGIN')) return String(value);
|
|
68
|
+
|
|
69
|
+
const path = String(value).replace(/^~(?=\/)/, process.env.HOME ?? '~');
|
|
70
|
+
const st = stat(path);
|
|
71
|
+
// eslint-disable-next-line no-bitwise
|
|
72
|
+
const openTo = st.mode & 0o077;
|
|
73
|
+
if (openTo !== 0) {
|
|
74
|
+
throw new Error(
|
|
75
|
+
`${path} is readable beyond its owner (mode ${(st.mode & 0o777).toString(8)}) — ` +
|
|
76
|
+
'refusing to use it. Run: chmod 600 ' + path,
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
const pem = String(read(path, 'utf8'));
|
|
80
|
+
if (!pem.includes('-----BEGIN')) {
|
|
81
|
+
throw new Error(`${path} does not look like a PEM private key`);
|
|
82
|
+
}
|
|
83
|
+
return pem;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The App JWT. GitHub caps its life at 10 minutes and rejects a future `iat`,
|
|
88
|
+
* so `iat` is backdated 60s to survive ordinary clock skew between this machine
|
|
89
|
+
* and GitHub — the documented remedy for the 401 this otherwise produces
|
|
90
|
+
* intermittently and confusingly.
|
|
91
|
+
*/
|
|
92
|
+
export function buildAppJwt({ appId, privateKey, now = Math.floor(Date.now() / 1000) }) {
|
|
93
|
+
if (!appId) throw new Error('OBIWAN_APP_ID is not set');
|
|
94
|
+
const header = { alg: 'RS256', typ: 'JWT' };
|
|
95
|
+
// GitHub rejects an `exp` more than 10 minutes ahead of ITS clock, so the
|
|
96
|
+
// margin is skew budget, not lifetime: at now+540 a host clock 60s fast is
|
|
97
|
+
// already refused with a 401 that names the token, not the clock. 480 buys
|
|
98
|
+
// two minutes of drift for a token used within seconds of minting.
|
|
99
|
+
const payload = { iat: now - 60, exp: now + 480, iss: String(appId) };
|
|
100
|
+
const signingInput = `${b64url(JSON.stringify(header))}.${b64url(JSON.stringify(payload))}`;
|
|
101
|
+
const sig = createSign('RSA-SHA256').update(signingInput).end().sign(privateKey);
|
|
102
|
+
return `${signingInput}.${b64url(sig)}`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Exchange the App JWT for an installation token scoped to one repository.
|
|
107
|
+
*
|
|
108
|
+
* OVER `fetch`, NOT `gh`, AND THE SCHEME IS THE REASON. `gh` sends
|
|
109
|
+
* `Authorization: token <value>`; a GitHub App JWT is only accepted as
|
|
110
|
+
* `Authorization: Bearer <jwt>`. Handing gh a perfectly valid JWT therefore
|
|
111
|
+
* returns `401 A JSON web token could not be decoded` — a message that points
|
|
112
|
+
* at the token's FORMAT, and it sent this ticket chasing the key, then the app
|
|
113
|
+
* id, then the clock, before the transport. The same JWT against the same
|
|
114
|
+
* endpoint over fetch with an explicit Bearer header returns 200.
|
|
115
|
+
*
|
|
116
|
+
* The installation token that comes back IS an ordinary token, so `gh` handles
|
|
117
|
+
* it normally from there; only these two hops need the Bearer scheme.
|
|
118
|
+
*
|
|
119
|
+
* A welcome consequence: no credential reaches a subprocess environment at all
|
|
120
|
+
* for these calls, and none has ever reached a command line. A process's argv
|
|
121
|
+
* is world-readable through `ps` and is copied verbatim into CI logs and SSM
|
|
122
|
+
* run history — one of the categories `/s:security-review` exists to flag.
|
|
123
|
+
*/
|
|
124
|
+
export async function ghFetch(url, jwt, method = 'GET', fetchImpl = fetch, timeoutMs = 15_000) {
|
|
125
|
+
// An unattended run has nobody to ctrl-C it. resolveIdentity() awaits two
|
|
126
|
+
// sequential mints before the caller reads PR state or posts anything, so a
|
|
127
|
+
// stalled socket here hangs the whole review with no output and no timeout of
|
|
128
|
+
// its own — indistinguishable from an agent that simply did no work.
|
|
129
|
+
const res = await fetchImpl(url, {
|
|
130
|
+
method,
|
|
131
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
132
|
+
headers: {
|
|
133
|
+
Authorization: `Bearer ${jwt}`,
|
|
134
|
+
Accept: 'application/vnd.github+json',
|
|
135
|
+
'X-GitHub-Api-Version': '2022-11-28',
|
|
136
|
+
'User-Agent': 'obi-wan-shinobi-reviewer',
|
|
137
|
+
},
|
|
138
|
+
});
|
|
139
|
+
const body = await res.json().catch(() => ({}));
|
|
140
|
+
return { status: res.status, body };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export async function mintInstallationToken({ repo, appId, privateKey, request = ghFetch }) {
|
|
144
|
+
const jwt = buildAppJwt({ appId, privateKey });
|
|
145
|
+
|
|
146
|
+
// WHO WE ACTUALLY ARE, asked of GitHub rather than inferred from config.
|
|
147
|
+
// `appId` is operator-supplied, and a wrong one does not fail — it mints a
|
|
148
|
+
// perfectly valid token for a DIFFERENT App, whose `[bot]` name then appears
|
|
149
|
+
// on the review. That is the outcome AC3 forbids, minus the tell: it does not
|
|
150
|
+
// look like a fallback, it looks like a working bot under the wrong name.
|
|
151
|
+
//
|
|
152
|
+
// This hop needs the JWT: an installation token cannot call `GET /app`.
|
|
153
|
+
const app = await request('https://api.github.com/app', jwt);
|
|
154
|
+
if (app.status !== 200 || !app.body?.slug) {
|
|
155
|
+
throw new Error(
|
|
156
|
+
`app identity lookup failed (${app.status}): ${app.body?.message ?? 'no slug in the response'}` +
|
|
157
|
+
(app.status === 401
|
|
158
|
+
? ' — a 401 here means the JWT did not verify: check OBIWAN_APP_ID matches the key you downloaded'
|
|
159
|
+
: ''),
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const inst = await request(`https://api.github.com/repos/${repo}/installation`, jwt);
|
|
164
|
+
if (inst.status === 404) {
|
|
165
|
+
throw new Error(
|
|
166
|
+
`the Obi Wan Shinobi App is not installed on ${repo} — install it on the repository ` +
|
|
167
|
+
'(App settings -> Install App), then retry',
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
if (inst.status !== 200 || !inst.body?.id) {
|
|
171
|
+
throw new Error(
|
|
172
|
+
`installation lookup failed (${inst.status}): ${inst.body?.message ?? 'unknown'}` +
|
|
173
|
+
(inst.status === 401
|
|
174
|
+
? ' — a 401 here means the JWT did not verify: check OBIWAN_APP_ID matches the key you downloaded'
|
|
175
|
+
: ''),
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const tok = await request(
|
|
180
|
+
`https://api.github.com/app/installations/${inst.body.id}/access_tokens`,
|
|
181
|
+
jwt,
|
|
182
|
+
'POST',
|
|
183
|
+
);
|
|
184
|
+
if (tok.status !== 201 || !tok.body?.token) {
|
|
185
|
+
throw new Error(`token exchange failed (${tok.status}): ${tok.body?.message ?? 'no token in response'}`);
|
|
186
|
+
}
|
|
187
|
+
return {
|
|
188
|
+
token: tok.body.token,
|
|
189
|
+
expiresAt: tok.body.expires_at,
|
|
190
|
+
installationId: inst.body.id,
|
|
191
|
+
appSlug: app.body.slug,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* The token to authenticate as, or null to keep whatever `gh` is already using.
|
|
197
|
+
*
|
|
198
|
+
* TWO OUTCOMES, AND THEY ARE NOT THE SAME KIND OF THING.
|
|
199
|
+
*
|
|
200
|
+
* An UNCONFIGURED App returns `kind: 'fallback'` rather than throwing, because
|
|
201
|
+
* the read-only modes are still useful without it — `--status` lists this
|
|
202
|
+
* reviewer's threads with any credentials at all. What the fallback must never
|
|
203
|
+
* do is write, and enforcing that is the CALLER's job: see `refuseIdentity` in
|
|
204
|
+
* `post-review-findings.mjs`, which is where the fail-closed decision lives so
|
|
205
|
+
* that it can distinguish a dry run from a post.
|
|
206
|
+
*
|
|
207
|
+
* A MISCONFIGURED App throws. Absence degrades; pointing at the wrong App is a
|
|
208
|
+
* mistake to be loud about, and a machine that would post under a name nobody
|
|
209
|
+
* chose should stop rather than narrow its mode. The cost is that `--status`
|
|
210
|
+
* also fails there, which is the right way round: the operator learns of the
|
|
211
|
+
* misconfiguration at the first command they run, not at the first post.
|
|
212
|
+
*/
|
|
213
|
+
/**
|
|
214
|
+
* The two halves of the fallback diagnostic, named so the test can assert them
|
|
215
|
+
* exactly rather than by prefix — a `startsWith` check passes while the
|
|
216
|
+
* replacement guidance, which is the only actionable part, silently rots.
|
|
217
|
+
*/
|
|
218
|
+
export const MIGRATION_GUIDANCE =
|
|
219
|
+
'the Kunai app was replaced by obi-wan-shinobi-reviewer (App 4822599). '
|
|
220
|
+
+ 'Rename to OBIWAN_APP_ID / OBIWAN_PRIVATE_KEY and point the key at the new PEM';
|
|
221
|
+
export const NOT_SET = 'OBIWAN_APP_ID / OBIWAN_PRIVATE_KEY not set';
|
|
222
|
+
|
|
223
|
+
export async function resolveIdentity({ repo, env = process.env, mint = mintInstallationToken } = {}) {
|
|
224
|
+
if (!env.OBIWAN_APP_ID || !env.OBIWAN_PRIVATE_KEY) {
|
|
225
|
+
// Name the ACTUAL cause. A machine still holding the retired KUNAI_* pair
|
|
226
|
+
// is one `.zshrc` edit from working, and "not set" sends its owner looking
|
|
227
|
+
// for a variable they believe they already have.
|
|
228
|
+
// PRESENT, not truthy (CodeRabbit, #159). `export KUNAI_APP_ID=` leaves an
|
|
229
|
+
// empty string, which is falsy — so the one legacy variable still sitting
|
|
230
|
+
// in a profile went unmentioned, and the operator got the generic message
|
|
231
|
+
// while a stale name was right there. An empty one is flagged as empty
|
|
232
|
+
// rather than merely named, so nobody goes hunting for a value that is not
|
|
233
|
+
// there.
|
|
234
|
+
const stale = ['KUNAI_APP_ID', 'KUNAI_PRIVATE_KEY']
|
|
235
|
+
.filter((k) => env[k] !== undefined && env[k] !== null)
|
|
236
|
+
.map((k) => (env[k] === '' ? `${k} (empty)` : k));
|
|
237
|
+
return {
|
|
238
|
+
kind: 'fallback',
|
|
239
|
+
token: null,
|
|
240
|
+
why: stale.length > 0 ? `found ${stale.join(' and ')} — ${MIGRATION_GUIDANCE}` : NOT_SET,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
const privateKey = resolvePrivateKey(env.OBIWAN_PRIVATE_KEY);
|
|
244
|
+
const { token, expiresAt, appSlug } = await mint({ repo, appId: env.OBIWAN_APP_ID, privateKey });
|
|
245
|
+
|
|
246
|
+
// A MISSING slug is a failure, not a pass. It means this token was minted by
|
|
247
|
+
// something that did not perform the `GET /app` hop — an older `mint`, or a
|
|
248
|
+
// future refactor that drops it — and reading "nothing to compare" as
|
|
249
|
+
// "nothing wrong" is exactly how a check that still exists stops checking.
|
|
250
|
+
if (appSlug !== EXPECTED_APP_SLUG) {
|
|
251
|
+
throw new Error(
|
|
252
|
+
`OBIWAN_APP_ID ${env.OBIWAN_APP_ID} authenticates as ` +
|
|
253
|
+
`${appSlug ? `"${appSlug}"` : 'an App whose identity was not verified'}, ` +
|
|
254
|
+
`not "${EXPECTED_APP_SLUG}" — refusing to post under an identity nobody chose. ` +
|
|
255
|
+
'Point OBIWAN_APP_ID and OBIWAN_PRIVATE_KEY at the Obi Wan Shinobi App.',
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// `why` is derived from what GitHub returned, never from the constant above.
|
|
260
|
+
// Printing the expected name would report the identity the code hoped for
|
|
261
|
+
// rather than the one it got, which is the whole failure this guards.
|
|
262
|
+
return { kind: 'app', token, expiresAt, appSlug, why: `${appSlug}[bot] via App ${env.OBIWAN_APP_ID}` };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Never let a token reach a log or an error message intact. */
|
|
266
|
+
export function redact(text, token) {
|
|
267
|
+
if (!token) return text;
|
|
268
|
+
return String(text).split(token).join('***');
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export const _internal = { b64url, randomUUID };
|