@integrity-labs/agt-cli 0.28.833 → 0.28.835

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,215 @@
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
+ * Expected to diverge from the origin: `resolveIdentity` must gain a
8
+ * fail-closed unattended mode that verifies the token's login is
9
+ * `obi-wan-shinobi-reviewer[bot]` rather than announcing FALLBACK and posting
10
+ * anyway (ENG-10001 item 3). Not done yet — nothing calls this on a host.
11
+ */
12
+ /**
13
+ * obiwan-auth — mint a GitHub App installation token for the review bot.
14
+ *
15
+ * WHY AN APP AND NOT A PAT. Review findings posted with a personal token appear
16
+ * under a human's name. That is wrong twice: it attributes a machine's claims to
17
+ * a person, and it leaves the findings unfilterable — nobody can mute the bot
18
+ * without muting their colleague. An App has its own identity
19
+ * (`obi-wan-shinobi-reviewer[bot]` — the slug GitHub assigned, which is NOT a
20
+ * shortening of the display name),
21
+ * its own permissions, and a per-repo installation that can be revoked without
22
+ * touching anybody's account.
23
+ *
24
+ * WHY THERE IS NOTHING TO PASTE INTO A SHELL PROFILE. App auth is two hops: an
25
+ * RS256 JWT signed with the App's private key, exchanged for an installation
26
+ * token that expires in ONE HOUR. So a token in `.zshrc` is stale before it is
27
+ * useful. What belongs in a profile is the app id and a PATH to the key:
28
+ *
29
+ * export OBIWAN_APP_ID=4822599 # this App, not a placeholder
30
+ * export OBIWAN_PRIVATE_KEY=~/.config/obiwan/private-key.pem # chmod 600
31
+ *
32
+ * The PEM itself should not be inlined there. `.zshrc` is sourced by every
33
+ * shell, is routinely committed to a dotfiles repo, and is read by anything that
34
+ * can read the home directory — none of which is true of a 0600 file the user
35
+ * chose the location of.
36
+ *
37
+ * PERMISSIONS THE APP NEEDS: `pull_requests: write` (post review comments,
38
+ * resolve threads) and `contents: read` (read the diff). Deliberately NOT
39
+ * `contents: write` — this bot must never be able to push.
40
+ */
41
+
42
+ import { createSign, randomUUID } from 'node:crypto';
43
+ import { readFileSync, statSync } from 'node:fs';
44
+
45
+ const b64url = (buf) =>
46
+ Buffer.from(buf).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
47
+
48
+ /**
49
+ * Resolve the private key from either an inline PEM or a path.
50
+ *
51
+ * A path is the supported form; an inline PEM is accepted because CI has no
52
+ * filesystem to point at. When it IS a path, the file's mode is checked: a key
53
+ * readable by the whole machine is a key that has already leaked, and the point
54
+ * of moving off a PAT was to stop spreading credentials around.
55
+ */
56
+ export function resolvePrivateKey(value, { stat = statSync, read = readFileSync } = {}) {
57
+ if (!value) throw new Error('OBIWAN_PRIVATE_KEY is not set (path to the App private-key PEM, or the PEM itself)');
58
+ if (String(value).includes('-----BEGIN')) return String(value);
59
+
60
+ const path = String(value).replace(/^~(?=\/)/, process.env.HOME ?? '~');
61
+ const st = stat(path);
62
+ // eslint-disable-next-line no-bitwise
63
+ const openTo = st.mode & 0o077;
64
+ if (openTo !== 0) {
65
+ throw new Error(
66
+ `${path} is readable beyond its owner (mode ${(st.mode & 0o777).toString(8)}) — ` +
67
+ 'refusing to use it. Run: chmod 600 ' + path,
68
+ );
69
+ }
70
+ const pem = String(read(path, 'utf8'));
71
+ if (!pem.includes('-----BEGIN')) {
72
+ throw new Error(`${path} does not look like a PEM private key`);
73
+ }
74
+ return pem;
75
+ }
76
+
77
+ /**
78
+ * The App JWT. GitHub caps its life at 10 minutes and rejects a future `iat`,
79
+ * so `iat` is backdated 60s to survive ordinary clock skew between this machine
80
+ * and GitHub — the documented remedy for the 401 this otherwise produces
81
+ * intermittently and confusingly.
82
+ */
83
+ export function buildAppJwt({ appId, privateKey, now = Math.floor(Date.now() / 1000) }) {
84
+ if (!appId) throw new Error('OBIWAN_APP_ID is not set');
85
+ const header = { alg: 'RS256', typ: 'JWT' };
86
+ // GitHub rejects an `exp` more than 10 minutes ahead of ITS clock, so the
87
+ // margin is skew budget, not lifetime: at now+540 a host clock 60s fast is
88
+ // already refused with a 401 that names the token, not the clock. 480 buys
89
+ // two minutes of drift for a token used within seconds of minting.
90
+ const payload = { iat: now - 60, exp: now + 480, iss: String(appId) };
91
+ const signingInput = `${b64url(JSON.stringify(header))}.${b64url(JSON.stringify(payload))}`;
92
+ const sig = createSign('RSA-SHA256').update(signingInput).end().sign(privateKey);
93
+ return `${signingInput}.${b64url(sig)}`;
94
+ }
95
+
96
+ /**
97
+ * Exchange the App JWT for an installation token scoped to one repository.
98
+ *
99
+ * OVER `fetch`, NOT `gh`, AND THE SCHEME IS THE REASON. `gh` sends
100
+ * `Authorization: token <value>`; a GitHub App JWT is only accepted as
101
+ * `Authorization: Bearer <jwt>`. Handing gh a perfectly valid JWT therefore
102
+ * returns `401 A JSON web token could not be decoded` — a message that points
103
+ * at the token's FORMAT, and it sent this ticket chasing the key, then the app
104
+ * id, then the clock, before the transport. The same JWT against the same
105
+ * endpoint over fetch with an explicit Bearer header returns 200.
106
+ *
107
+ * The installation token that comes back IS an ordinary token, so `gh` handles
108
+ * it normally from there; only these two hops need the Bearer scheme.
109
+ *
110
+ * A welcome consequence: no credential reaches a subprocess environment at all
111
+ * for these calls, and none has ever reached a command line. A process's argv
112
+ * is world-readable through `ps` and is copied verbatim into CI logs and SSM
113
+ * run history — one of the categories `/s:security-review` exists to flag.
114
+ */
115
+ export async function ghFetch(url, jwt, method = 'GET', fetchImpl = fetch, timeoutMs = 15_000) {
116
+ // An unattended run has nobody to ctrl-C it. resolveIdentity() awaits two
117
+ // sequential mints before the caller reads PR state or posts anything, so a
118
+ // stalled socket here hangs the whole review with no output and no timeout of
119
+ // its own — indistinguishable from an agent that simply did no work.
120
+ const res = await fetchImpl(url, {
121
+ method,
122
+ signal: AbortSignal.timeout(timeoutMs),
123
+ headers: {
124
+ Authorization: `Bearer ${jwt}`,
125
+ Accept: 'application/vnd.github+json',
126
+ 'X-GitHub-Api-Version': '2022-11-28',
127
+ 'User-Agent': 'obi-wan-shinobi-reviewer',
128
+ },
129
+ });
130
+ const body = await res.json().catch(() => ({}));
131
+ return { status: res.status, body };
132
+ }
133
+
134
+ export async function mintInstallationToken({ repo, appId, privateKey, request = ghFetch }) {
135
+ const jwt = buildAppJwt({ appId, privateKey });
136
+
137
+ const inst = await request(`https://api.github.com/repos/${repo}/installation`, jwt);
138
+ if (inst.status === 404) {
139
+ throw new Error(
140
+ `the Obi Wan Shinobi App is not installed on ${repo} — install it on the repository ` +
141
+ '(App settings -> Install App), then retry',
142
+ );
143
+ }
144
+ if (inst.status !== 200 || !inst.body?.id) {
145
+ throw new Error(
146
+ `installation lookup failed (${inst.status}): ${inst.body?.message ?? 'unknown'}` +
147
+ (inst.status === 401
148
+ ? ' — a 401 here means the JWT did not verify: check OBIWAN_APP_ID matches the key you downloaded'
149
+ : ''),
150
+ );
151
+ }
152
+
153
+ const tok = await request(
154
+ `https://api.github.com/app/installations/${inst.body.id}/access_tokens`,
155
+ jwt,
156
+ 'POST',
157
+ );
158
+ if (tok.status !== 201 || !tok.body?.token) {
159
+ throw new Error(`token exchange failed (${tok.status}): ${tok.body?.message ?? 'no token in response'}`);
160
+ }
161
+ return { token: tok.body.token, expiresAt: tok.body.expires_at, installationId: inst.body.id };
162
+ }
163
+
164
+ /**
165
+ * The token to authenticate as, or null to keep whatever `gh` is already using.
166
+ *
167
+ * Falls back deliberately rather than throwing when the App is unconfigured:
168
+ * posting as the operator is a worse outcome than posting as the bot, but it is
169
+ * a far better one than not posting at all, and a half-configured machine
170
+ * should degrade rather than block. The CALLER is told which identity it got,
171
+ * so the fallback is never silent — see `post-review-findings.mjs`, which
172
+ * prints it before it posts anything.
173
+ */
174
+ /**
175
+ * The two halves of the fallback diagnostic, named so the test can assert them
176
+ * exactly rather than by prefix — a `startsWith` check passes while the
177
+ * replacement guidance, which is the only actionable part, silently rots.
178
+ */
179
+ export const MIGRATION_GUIDANCE =
180
+ 'the Kunai app was replaced by obi-wan-shinobi-reviewer (App 4822599). '
181
+ + 'Rename to OBIWAN_APP_ID / OBIWAN_PRIVATE_KEY and point the key at the new PEM';
182
+ export const NOT_SET = 'OBIWAN_APP_ID / OBIWAN_PRIVATE_KEY not set';
183
+
184
+ export async function resolveIdentity({ repo, env = process.env, mint = mintInstallationToken } = {}) {
185
+ if (!env.OBIWAN_APP_ID || !env.OBIWAN_PRIVATE_KEY) {
186
+ // Name the ACTUAL cause. A machine still holding the retired KUNAI_* pair
187
+ // is one `.zshrc` edit from working, and "not set" sends its owner looking
188
+ // for a variable they believe they already have.
189
+ // PRESENT, not truthy (CodeRabbit, #159). `export KUNAI_APP_ID=` leaves an
190
+ // empty string, which is falsy — so the one legacy variable still sitting
191
+ // in a profile went unmentioned, and the operator got the generic message
192
+ // while a stale name was right there. An empty one is flagged as empty
193
+ // rather than merely named, so nobody goes hunting for a value that is not
194
+ // there.
195
+ const stale = ['KUNAI_APP_ID', 'KUNAI_PRIVATE_KEY']
196
+ .filter((k) => env[k] !== undefined && env[k] !== null)
197
+ .map((k) => (env[k] === '' ? `${k} (empty)` : k));
198
+ return {
199
+ kind: 'fallback',
200
+ token: null,
201
+ why: stale.length > 0 ? `found ${stale.join(' and ')} — ${MIGRATION_GUIDANCE}` : NOT_SET,
202
+ };
203
+ }
204
+ const privateKey = resolvePrivateKey(env.OBIWAN_PRIVATE_KEY);
205
+ const { token, expiresAt } = await mint({ repo, appId: env.OBIWAN_APP_ID, privateKey });
206
+ return { kind: 'app', token, expiresAt, why: `obi-wan-shinobi-reviewer[bot] via App ${env.OBIWAN_APP_ID}` };
207
+ }
208
+
209
+ /** Never let a token reach a log or an error message intact. */
210
+ export function redact(text, token) {
211
+ if (!token) return text;
212
+ return String(text).split(token).join('***');
213
+ }
214
+
215
+ export const _internal = { b64url, randomUUID };