@integrity-labs/agt-cli 0.28.835 → 0.28.837

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.
@@ -4,10 +4,12 @@
4
4
  * copy is downstream. Delivered alongside `post-review-findings.mjs` by
5
5
  * `provisionReviewPoster()` (apps/cli/src/lib/review-poster-asset.ts).
6
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.
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.
11
13
  */
12
14
  /**
13
15
  * obiwan-auth — mint a GitHub App installation token for the review bot.
@@ -42,6 +44,13 @@
42
44
  import { createSign, randomUUID } from 'node:crypto';
43
45
  import { readFileSync, statSync } from 'node:fs';
44
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
+
45
54
  const b64url = (buf) =>
46
55
  Buffer.from(buf).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
47
56
 
@@ -134,6 +143,23 @@ export async function ghFetch(url, jwt, method = 'GET', fetchImpl = fetch, timeo
134
143
  export async function mintInstallationToken({ repo, appId, privateKey, request = ghFetch }) {
135
144
  const jwt = buildAppJwt({ appId, privateKey });
136
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
+
137
163
  const inst = await request(`https://api.github.com/repos/${repo}/installation`, jwt);
138
164
  if (inst.status === 404) {
139
165
  throw new Error(
@@ -158,18 +184,31 @@ export async function mintInstallationToken({ repo, appId, privateKey, request =
158
184
  if (tok.status !== 201 || !tok.body?.token) {
159
185
  throw new Error(`token exchange failed (${tok.status}): ${tok.body?.message ?? 'no token in response'}`);
160
186
  }
161
- return { token: tok.body.token, expiresAt: tok.body.expires_at, installationId: inst.body.id };
187
+ return {
188
+ token: tok.body.token,
189
+ expiresAt: tok.body.expires_at,
190
+ installationId: inst.body.id,
191
+ appSlug: app.body.slug,
192
+ };
162
193
  }
163
194
 
164
195
  /**
165
196
  * The token to authenticate as, or null to keep whatever `gh` is already using.
166
197
  *
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.
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.
173
212
  */
174
213
  /**
175
214
  * The two halves of the fallback diagnostic, named so the test can assert them
@@ -202,8 +241,25 @@ export async function resolveIdentity({ repo, env = process.env, mint = mintInst
202
241
  };
203
242
  }
204
243
  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}` };
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}` };
207
263
  }
208
264
 
209
265
  /** Never let a token reach a log or an error message intact. */
@@ -14,8 +14,10 @@
14
14
  *
15
15
  * Origin: the Smithers Claude Code plugin (~/.claude/plugins/smithers/scripts/).
16
16
  * That copy is now DOWNSTREAM of this one. Do NOT add an equality sync test:
17
- * this copy is expected to diverge (fail-closed identity with login
18
- * verification, and a `--min-severity` floor ENG-10001 items 2 and 3).
17
+ * this copy is expected to diverge, and already has — `refuseIdentity()` below
18
+ * makes a write without the VERIFIED review-bot identity impossible here, while
19
+ * the plugin copy stays permissive for the human who runs it. A `--min-severity`
20
+ * floor is the remaining planned divergence (ENG-10001).
19
21
  */
20
22
  /**
21
23
  * post-review-findings — deliver `/s:code-review` and `/s:security-review`
@@ -416,6 +418,45 @@ export function refuseMode({ state, status, resolve, pr }) {
416
418
  return null;
417
419
  }
418
420
 
421
+ /**
422
+ * Whether this identity may WRITE. Deliberately a second function rather than
423
+ * another clause in refuseMode(): they gate different axes, and their read-only
424
+ * sets genuinely differ.
425
+ *
426
+ * refuseMode asks "may this MODE touch a PR in this STATE", and there a dry run
427
+ * counts as a write, because computing findings against a closed PR is pointless
428
+ * work. Here a dry run is a READ — it posts nothing, so who we are cannot matter
429
+ * — and refusing it would deny an operator the one command that shows what the
430
+ * misconfiguration would have done. So `willWrite` is `--post || --resolve`, and
431
+ * nothing else. Folding the two together would force one of those two readings
432
+ * onto the other.
433
+ *
434
+ * FAIL CLOSED, WITH NO OVERRIDE FLAG. There is deliberately no
435
+ * `--allow-fallback-identity`. This copy's only caller is an unattended agent,
436
+ * so any escape hatch is one the AGENT can take, and an agent writing under an
437
+ * identity nobody chose is the exact thing being guarded. A documented bypass of
438
+ * a fail-closed check is not a fail-closed check.
439
+ *
440
+ * AND THE FALLBACK IS NOT WHAT IT LOOKS LIKE ON A HOST. The obvious reading —
441
+ * "it would post as the operator" — is a workstation reading. ENG-7506
442
+ * materialises the Augmented App's installation token into the agent session as
443
+ * GITHUB_ACCESS_TOKEN and GITHUB_TOKEN, and `gh` reads GITHUB_TOKEN. So a
444
+ * fallback write from a managed agent posts as the PLATFORM's bot, on a review
445
+ * the platform did not make. Unattributable rather than misattributed, which is
446
+ * no better: nobody can mute it, and nobody can tell whose claim it is.
447
+ *
448
+ * Returns a refusal message, or null to proceed.
449
+ */
450
+ export function refuseIdentity({ kind, willWrite }) {
451
+ if (!willWrite || kind === 'app') return null;
452
+ return (
453
+ 'refusing to write as anything but the review bot: OBIWAN_APP_ID / OBIWAN_PRIVATE_KEY ' +
454
+ 'are not configured, so --post and --resolve would write under whatever credentials ' +
455
+ '`gh` happens to hold. Configure the App (see obiwan-auth.mjs), or drop --post to see ' +
456
+ 'what would have been posted.'
457
+ );
458
+ }
459
+
419
460
  async function main() {
420
461
  const a = parseArgs(process.argv.slice(2));
421
462
  if (!a.pr) throw new Error('--pr is required');
@@ -431,9 +472,18 @@ async function main() {
431
472
  console.log(
432
473
  identity.kind === 'app'
433
474
  ? `identity: ${identity.why} (token expires ${identity.expiresAt})`
434
- : `identity: FALLBACK — posting as your own gh account (${identity.why})`,
475
+ : `identity: FALLBACK — whatever \`gh\` already holds, which is NOT the review bot ` +
476
+ `(${identity.why}); reads are fine, --post and --resolve will refuse`,
435
477
  );
436
478
 
479
+ // Before the PR is even read, so a misconfigured machine learns it from the
480
+ // first line of output rather than after a full scan it cannot deliver.
481
+ const identityRefusal = refuseIdentity({
482
+ kind: identity.kind,
483
+ willWrite: Boolean(a.post) || Boolean(a.resolve),
484
+ });
485
+ if (identityRefusal) throw new Error(identityRefusal);
486
+
437
487
  // Read PR state BEFORE the --resolve branch, not after it (CodeRabbit, #156).
438
488
  // --resolve returned early, and resolveFinding posts a reply before it
439
489
  // resolves — so a merged PR could still take a bot comment through this door
package/dist/bin/agt.js CHANGED
@@ -40,7 +40,7 @@ import {
40
40
  success,
41
41
  table,
42
42
  warn
43
- } from "../chunk-REBHUZ7N.js";
43
+ } from "../chunk-PWCWBPNG.js";
44
44
  import {
45
45
  getProjectDir,
46
46
  isSessionResumeDisabled,
@@ -5467,7 +5467,7 @@ import { execFileSync, execSync } from "child_process";
5467
5467
  import { existsSync as existsSync11, realpathSync as realpathSync2 } from "fs";
5468
5468
  import chalk18 from "chalk";
5469
5469
  import ora16 from "ora";
5470
- var cliVersion = true ? "0.28.835" : "dev";
5470
+ var cliVersion = true ? "0.28.837" : "dev";
5471
5471
  async function fetchLatestVersion() {
5472
5472
  const host2 = getHost();
5473
5473
  if (!host2) return null;
@@ -6658,7 +6658,7 @@ function handleError(err) {
6658
6658
  }
6659
6659
 
6660
6660
  // src/bin/agt.ts
6661
- var cliVersion2 = true ? "0.28.835" : "dev";
6661
+ var cliVersion2 = true ? "0.28.837" : "dev";
6662
6662
  var program = new Command();
6663
6663
  program.name("agt").description("Augmented CLI \u2014 agent provisioning and management").version(cliVersion2).option("--json", "Emit machine-readable JSON output (suppress spinners and colors)").option("--skip-update-check", "Skip the automatic update check on startup");
6664
6664
  program.hook("preAction", async (thisCommand, actionCommand) => {
@@ -451,7 +451,7 @@ function orderTitlesForDescription(entries) {
451
451
  // ../../packages/core/dist/provisioning/frameworks/claudecode/index.js
452
452
  import { readFileSync as readFileSync4, writeFileSync as writeFileSync4, mkdirSync as mkdirSync3, existsSync as existsSync4, chmodSync as chmodSync4, readdirSync, rmSync as rmSync2, copyFileSync, lstatSync, realpathSync, symlinkSync, readlinkSync, renameSync as renameSync4, opendirSync } from "fs";
453
453
  import { join as join3, relative, dirname as dirname3 } from "path";
454
- import { homedir as homedir3 } from "os";
454
+ import { homedir as homedir3, tmpdir } from "os";
455
455
  import { execFile } from "child_process";
456
456
 
457
457
  // ../../packages/core/dist/integrations/xurl-config.js
@@ -1864,6 +1864,34 @@ function sweepScratchDir(codeName, now = Date.now()) {
1864
1864
  }
1865
1865
  return removed;
1866
1866
  }
1867
+ var STRANDED_ARTEFACT_TMP_PREFIXES = [
1868
+ "augmented-artefact-",
1869
+ "augmented-artefact-source-"
1870
+ ];
1871
+ function sweepStrandedArtefactTmpDirs(now = Date.now()) {
1872
+ const root = tmpdir();
1873
+ let entries;
1874
+ try {
1875
+ entries = readdirSync(root);
1876
+ } catch {
1877
+ return 0;
1878
+ }
1879
+ const cutoff = now - SCRATCH_RETENTION_DAYS * 24 * 60 * 60 * 1e3;
1880
+ let removed = 0;
1881
+ for (const entry of entries) {
1882
+ if (!STRANDED_ARTEFACT_TMP_PREFIXES.some((prefix) => entry.startsWith(prefix)))
1883
+ continue;
1884
+ const full = join3(root, entry);
1885
+ try {
1886
+ if (isFreshWithin(full, cutoff))
1887
+ continue;
1888
+ rmSync2(full, { recursive: true, force: true });
1889
+ removed += 1;
1890
+ } catch {
1891
+ }
1892
+ }
1893
+ return removed;
1894
+ }
1867
1895
  var SCRATCH_SCAN_ENTRY_BUDGET = 5e3;
1868
1896
  function isFreshWithin(path, cutoff) {
1869
1897
  let budget = SCRATCH_SCAN_ENTRY_BUDGET;
@@ -2051,6 +2079,7 @@ function deployArtifactsToProject(codeName, provisionDir) {
2051
2079
  mkdirSync3(getScratchDir(codeName), { recursive: true });
2052
2080
  mkdirSync3(getAgentTmpDir(codeName), { recursive: true });
2053
2081
  sweepScratchDir(codeName);
2082
+ sweepStrandedArtefactTmpDirs();
2054
2083
  } catch (err) {
2055
2084
  process.stderr.write(`[scratch] [ensure-or-sweep-failed] agent=${codeName} error=${err.message}
2056
2085
  `);
@@ -6537,7 +6566,7 @@ function exchangeFailureKind(err) {
6537
6566
  }
6538
6567
 
6539
6568
  // src/lib/api-client.ts
6540
- var agtCliVersion = true ? "0.28.835" : "dev";
6569
+ var agtCliVersion = true ? "0.28.837" : "dev";
6541
6570
  var lastConfigHash = null;
6542
6571
  function setConfigHash(hash) {
6543
6572
  lastConfigHash = hash && hash.length > 0 ? hash : null;
@@ -10895,4 +10924,4 @@ export {
10895
10924
  managerInstallSystemUnitCommand,
10896
10925
  managerUninstallSystemUnitCommand
10897
10926
  };
10898
- //# sourceMappingURL=chunk-REBHUZ7N.js.map
10927
+ //# sourceMappingURL=chunk-PWCWBPNG.js.map