@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.
- package/dist/assets/review/obiwan-auth.mjs +215 -0
- package/dist/assets/review/post-review-findings.mjs +539 -0
- package/dist/bin/agt.js +3 -3
- package/dist/{chunk-LGVB4OTX.js → chunk-REBHUZ7N.js} +2 -2
- package/dist/lib/manager-worker.js +400 -347
- package/dist/lib/manager-worker.js.map +1 -1
- package/dist/mcp/computer-use-proxy.js +285 -0
- package/package.json +3 -3
- /package/dist/{chunk-LGVB4OTX.js.map → chunk-REBHUZ7N.js.map} +0 -0
|
@@ -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 };
|