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