@node9/proxy 2.29.0 → 2.30.1

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.
Files changed (3) hide show
  1. package/dist/cli.js +1407 -151
  2. package/dist/cli.mjs +1407 -151
  3. package/package.json +1 -1
package/dist/cli.mjs CHANGED
@@ -19438,19 +19438,24 @@ async function postCostBatches(apiUrl, apiKey, machineId, entries) {
19438
19438
  } catch {
19439
19439
  }
19440
19440
  if (typeof stored === "number" && stored < batch.length) {
19441
- fs35.appendFileSync(
19442
- HOOK_DEBUG_LOG,
19443
- `[cost-sync] dropped ${batch.length - stored} of ${batch.length} rows
19444
- `
19445
- );
19441
+ debugLog(`[cost-sync] dropped ${batch.length - stored} of ${batch.length} rows
19442
+ `);
19446
19443
  }
19447
19444
  }
19448
19445
  } catch (err2) {
19449
- fs35.appendFileSync(HOOK_DEBUG_LOG, `[cost-sync] ${safeMessage(err2)}
19446
+ debugLog(`[cost-sync] ${safeMessage(err2)}
19450
19447
  `);
19451
19448
  }
19452
19449
  }
19453
19450
  }
19451
+ function debugLog(line) {
19452
+ try {
19453
+ fs35.mkdirSync(path36.dirname(HOOK_DEBUG_LOG), { recursive: true });
19454
+ fs35.appendFileSync(HOOK_DEBUG_LOG, line);
19455
+ } catch (err2) {
19456
+ console.error(`[cost-sync] could not write ${HOOK_DEBUG_LOG}: ${safeMessage(err2)}`);
19457
+ }
19458
+ }
19454
19459
  async function syncCost() {
19455
19460
  const creds = getCredentials();
19456
19461
  if (!creds?.apiKey || !creds?.apiUrl) return;
@@ -32332,7 +32337,7 @@ var require_diagnostics = __commonJS({
32332
32337
  proxyConnected: diagnosticsChannel.channel("undici:proxy:connected")
32333
32338
  };
32334
32339
  var isTrackingClientEvents = false;
32335
- function trackClientEvents(debugLog = undiciDebugLog) {
32340
+ function trackClientEvents(debugLog2 = undiciDebugLog) {
32336
32341
  if (isTrackingClientEvents) {
32337
32342
  return;
32338
32343
  }
@@ -32347,7 +32352,7 @@ var require_diagnostics = __commonJS({
32347
32352
  const {
32348
32353
  connectParams: { version: version2, protocol, port, host }
32349
32354
  } = evt;
32350
- debugLog(
32355
+ debugLog2(
32351
32356
  "connecting to %s%s using %s%s",
32352
32357
  host,
32353
32358
  port ? `:${port}` : "",
@@ -32362,7 +32367,7 @@ var require_diagnostics = __commonJS({
32362
32367
  const {
32363
32368
  connectParams: { version: version2, protocol, port, host }
32364
32369
  } = evt;
32365
- debugLog(
32370
+ debugLog2(
32366
32371
  "connected to %s%s using %s%s",
32367
32372
  host,
32368
32373
  port ? `:${port}` : "",
@@ -32378,7 +32383,7 @@ var require_diagnostics = __commonJS({
32378
32383
  connectParams: { version: version2, protocol, port, host },
32379
32384
  error
32380
32385
  } = evt;
32381
- debugLog(
32386
+ debugLog2(
32382
32387
  "connection to %s%s using %s%s errored - %s",
32383
32388
  host,
32384
32389
  port ? `:${port}` : "",
@@ -32394,12 +32399,12 @@ var require_diagnostics = __commonJS({
32394
32399
  const {
32395
32400
  request: { method, path: path91, origin }
32396
32401
  } = evt;
32397
- debugLog("sending request to %s %s%s", method, origin, path91);
32402
+ debugLog2("sending request to %s %s%s", method, origin, path91);
32398
32403
  }
32399
32404
  );
32400
32405
  }
32401
32406
  var isTrackingRequestEvents = false;
32402
- function trackRequestEvents(debugLog = undiciDebugLog) {
32407
+ function trackRequestEvents(debugLog2 = undiciDebugLog) {
32403
32408
  if (isTrackingRequestEvents) {
32404
32409
  return;
32405
32410
  }
@@ -32415,7 +32420,7 @@ var require_diagnostics = __commonJS({
32415
32420
  request: { method, path: path91, origin },
32416
32421
  response: { statusCode }
32417
32422
  } = evt;
32418
- debugLog(
32423
+ debugLog2(
32419
32424
  "received response to %s %s%s - HTTP %d",
32420
32425
  method,
32421
32426
  origin,
@@ -32430,7 +32435,7 @@ var require_diagnostics = __commonJS({
32430
32435
  const {
32431
32436
  request: { method, path: path91, origin }
32432
32437
  } = evt;
32433
- debugLog("trailers received from %s %s%s", method, origin, path91);
32438
+ debugLog2("trailers received from %s %s%s", method, origin, path91);
32434
32439
  }
32435
32440
  );
32436
32441
  diagnosticsChannel.subscribe(
@@ -32440,7 +32445,7 @@ var require_diagnostics = __commonJS({
32440
32445
  request: { method, path: path91, origin },
32441
32446
  error
32442
32447
  } = evt;
32443
- debugLog(
32448
+ debugLog2(
32444
32449
  "request to %s %s%s errored - %s",
32445
32450
  method,
32446
32451
  origin,
@@ -32451,7 +32456,7 @@ var require_diagnostics = __commonJS({
32451
32456
  );
32452
32457
  }
32453
32458
  var isTrackingWebSocketEvents = false;
32454
- function trackWebSocketEvents(debugLog = websocketDebuglog) {
32459
+ function trackWebSocketEvents(debugLog2 = websocketDebuglog) {
32455
32460
  if (isTrackingWebSocketEvents) {
32456
32461
  return;
32457
32462
  }
@@ -32465,9 +32470,9 @@ var require_diagnostics = __commonJS({
32465
32470
  (evt) => {
32466
32471
  if (evt.address != null) {
32467
32472
  const { address, port } = evt.address;
32468
- debugLog("connection opened %s%s", address, port ? `:${port}` : "");
32473
+ debugLog2("connection opened %s%s", address, port ? `:${port}` : "");
32469
32474
  } else {
32470
- debugLog("connection opened");
32475
+ debugLog2("connection opened");
32471
32476
  }
32472
32477
  }
32473
32478
  );
@@ -32475,7 +32480,7 @@ var require_diagnostics = __commonJS({
32475
32480
  "undici:websocket:close",
32476
32481
  (evt) => {
32477
32482
  const { websocket, code: code2, reason } = evt;
32478
- debugLog(
32483
+ debugLog2(
32479
32484
  "closed connection to %s - %s %s",
32480
32485
  websocket.url,
32481
32486
  code2,
@@ -32486,19 +32491,19 @@ var require_diagnostics = __commonJS({
32486
32491
  diagnosticsChannel.subscribe(
32487
32492
  "undici:websocket:socket_error",
32488
32493
  (err2) => {
32489
- debugLog("connection errored - %s", err2.message);
32494
+ debugLog2("connection errored - %s", err2.message);
32490
32495
  }
32491
32496
  );
32492
32497
  diagnosticsChannel.subscribe(
32493
32498
  "undici:websocket:ping",
32494
32499
  (evt) => {
32495
- debugLog("ping received");
32500
+ debugLog2("ping received");
32496
32501
  }
32497
32502
  );
32498
32503
  diagnosticsChannel.subscribe(
32499
32504
  "undici:websocket:pong",
32500
32505
  (evt) => {
32501
- debugLog("pong received");
32506
+ debugLog2("pong received");
32502
32507
  }
32503
32508
  );
32504
32509
  }
@@ -64547,6 +64552,97 @@ function isZwLegitScript(cp) {
64547
64552
  return cp >= 3584 && cp <= 3711 || cp >= 3712 && cp <= 3839 || cp >= 4096 && cp <= 4255 || cp >= 6016 && cp <= 6143 || cp >= 12352 && cp <= 12543 || cp >= 13312 && cp <= 40959 || cp >= 44032 && cp <= 55215;
64548
64553
  }
64549
64554
  var isAsciiWordChar = (ch) => !!ch && /[A-Za-z0-9]/.test(ch);
64555
+ var LOOKALIKE = {
64556
+ \u0430: "a",
64557
+ \u0435: "e",
64558
+ \u043E: "o",
64559
+ \u0440: "p",
64560
+ \u0441: "c",
64561
+ \u0443: "y",
64562
+ \u0445: "x",
64563
+ \u0456: "i",
64564
+ \u0458: "j",
64565
+ \u0455: "s",
64566
+ "\u0501": "d",
64567
+ "\u051B": "q",
64568
+ "\u051D": "w",
64569
+ \u0410: "A",
64570
+ \u0412: "B",
64571
+ \u0415: "E",
64572
+ \u041A: "K",
64573
+ \u041C: "M",
64574
+ \u041D: "H",
64575
+ \u041E: "O",
64576
+ \u0420: "P",
64577
+ \u0421: "C",
64578
+ \u0422: "T",
64579
+ \u0425: "X",
64580
+ \u0406: "I",
64581
+ \u0408: "J",
64582
+ \u0405: "S",
64583
+ "\u050C": "G",
64584
+ "\u051A": "Q",
64585
+ "\u051C": "W",
64586
+ \u03BF: "o",
64587
+ \u03B1: "a",
64588
+ \u03C1: "p",
64589
+ \u03C4: "t",
64590
+ \u03BD: "v",
64591
+ \u03C5: "u",
64592
+ \u03BA: "k",
64593
+ \u03C7: "x",
64594
+ \u03B9: "i",
64595
+ \u0391: "A",
64596
+ \u0392: "B",
64597
+ \u0395: "E",
64598
+ \u0397: "H",
64599
+ \u0399: "I",
64600
+ \u039A: "K",
64601
+ \u039C: "M",
64602
+ \u039D: "N",
64603
+ \u039F: "O",
64604
+ \u03A1: "P",
64605
+ \u03A4: "T",
64606
+ \u03A7: "X",
64607
+ \u03A5: "Y",
64608
+ \u0396: "Z",
64609
+ // Armenian (review, round 2)
64610
+ "\u0585": "o",
64611
+ "\u057D": "u",
64612
+ "\u0570": "h",
64613
+ "\u0578": "n",
64614
+ "\u0581": "g",
64615
+ "\u0566": "q"
64616
+ };
64617
+ var MIXED_WORD_RE = /[A-Za-z\u0370-\u03ff\u0400-\u04ff\u0500-\u052f\u0530-\u058f]{4,60}/g;
64618
+ var FOREIGN_LETTER_RE = /[\u0370-\u03ff\u0400-\u04ff\u0500-\u052f\u0530-\u058f]/;
64619
+ var GREEK_RE = /[\u0370-\u03ff]/;
64620
+ var EXPLAINED_LINE_RE = /cyrillic|greek|armenian|homoglyph|confusable|look-?alike/i;
64621
+ var HOMOGLYPH_MAX_LINE = 2e3;
64622
+ function homoglyphWord(content) {
64623
+ const lines = content.split("\n");
64624
+ for (let i = 0; i < lines.length; i++) {
64625
+ const text = lines[i];
64626
+ if (text.length > HOMOGLYPH_MAX_LINE) continue;
64627
+ const visible = text.replace(/<!--[\s\S]*?(?:-->|$)/g, "");
64628
+ if (EXPLAINED_LINE_RE.test(visible)) continue;
64629
+ for (const m of text.matchAll(MIXED_WORD_RE)) {
64630
+ const chars = [...m[0]];
64631
+ const latin = chars.filter((c) => /[A-Za-z]/.test(c)).length;
64632
+ const foreign = chars.filter((c) => FOREIGN_LETTER_RE.test(c));
64633
+ if (latin < 2 || !foreign.some((c) => c in LOOKALIKE) || foreign.length > latin) continue;
64634
+ const lead = chars.findIndex((c) => !FOREIGN_LETTER_RE.test(c));
64635
+ if (lead > 0 && chars.slice(0, lead).every((c) => GREEK_RE.test(c)) && lead === foreign.length)
64636
+ continue;
64637
+ return {
64638
+ folded: chars.map((c) => LOOKALIKE[c] ?? c).join(""),
64639
+ count: foreign.length,
64640
+ line: i + 1
64641
+ };
64642
+ }
64643
+ }
64644
+ return null;
64645
+ }
64550
64646
  function suspiciousZeroWidth(text) {
64551
64647
  let n = 0;
64552
64648
  let first = -1;
@@ -64790,6 +64886,21 @@ var lineAt = (text, i) => i >= 0 ? lineAtIndex(text, i) : void 0;
64790
64886
  function analyzeInstructionFile(path91, content) {
64791
64887
  const findings = [];
64792
64888
  const decoded = decodeSuspiciousBase64(content);
64889
+ const glyph = homoglyphWord(content);
64890
+ if (glyph)
64891
+ findings.push(
64892
+ mk2(
64893
+ "CI-6.homoglyph",
64894
+ "advisory",
64895
+ "A word in an agent instruction file is spelled with look-alike letters",
64896
+ [
64897
+ `\`${glyph.folded}\` is written with ${glyph.count} non-Latin look-alike letter(s): it reads the same to a reviewer, but a search, a replace or a policy check on the real word misses it`
64898
+ ],
64899
+ "Spell the word with plain Latin letters, or explain on the same line why the look-alikes are there.",
64900
+ path91,
64901
+ glyph.line
64902
+ )
64903
+ );
64793
64904
  if (TAG_CHARS.test(content))
64794
64905
  findings.push(
64795
64906
  mk2(
@@ -64928,13 +65039,17 @@ function analyzeInstructionFile(path91, content) {
64928
65039
  }
64929
65040
 
64930
65041
  // src/ci-check/route.ts
65042
+ var MCP_CONFIG_RE = /\.mcp\.json$|\.cursor\/mcp\.json$|(^|\/)\.vscode\/mcp\.json$|(^|\/)\.kiro\/settings\/mcp\.json$|(^|\/)\.gemini\/settings\.json$|(^|\/)opencode\.json$|(^|\/)\.opencode\/config\.json$/;
65043
+ var ISSUE_FORM_RE = /^\.github\/ISSUE_TEMPLATE\/[^/]+\.(ya?ml|md)$/i;
64931
65044
  function routeOf(path91, skillDirs, appDirs = /* @__PURE__ */ new Set()) {
64932
65045
  if (path91 === SUPPRESSIONS_FILE) return "suppressions";
64933
65046
  if (/\.github\/workflows\/.+\.ya?ml$/.test(path91)) return "workflow";
65047
+ if (ISSUE_FORM_RE.test(path91)) return "issue-form";
65048
+ if (path91 === "package.json" || path91 === "pyproject.toml") return "manifest";
64934
65049
  if (/\.claude\/settings(\.local)?\.json$/.test(path91)) return "agent-config";
64935
65050
  if (isHookScript(path91)) return "hook-script";
64936
65051
  if (isSkillScript(path91, skillDirs)) return "skill-script";
64937
- if (/\.mcp\.json$|\.cursor\/mcp\.json$/.test(path91)) return "mcp";
65052
+ if (MCP_CONFIG_RE.test(path91)) return "mcp";
64938
65053
  if (/(^|\/)\.codex\/config\.toml$/.test(path91)) return "codex";
64939
65054
  if (isInstructionFile(path91, skillDirs, appDirs)) return "instruction";
64940
65055
  if (isAppDoc(path91, skillDirs, appDirs)) return "app-doc";
@@ -64966,6 +65081,11 @@ var SURFACE_FILES = [
64966
65081
  ".claude/settings.local.json",
64967
65082
  ".mcp.json",
64968
65083
  ".cursor/mcp.json",
65084
+ ".vscode/mcp.json",
65085
+ ".kiro/settings/mcp.json",
65086
+ ".gemini/settings.json",
65087
+ "opencode.json",
65088
+ ".opencode/config.json",
64969
65089
  ".codex/config.toml",
64970
65090
  // CI-6: agent instruction files (auto-loaded into the agent's system prompt).
64971
65091
  "CLAUDE.md",
@@ -64977,7 +65097,7 @@ var SURFACE_FILES = [
64977
65097
  ".clinerules"
64978
65098
  ];
64979
65099
  var WORKFLOW_DIR = ".github/workflows";
64980
- var CONFIG_FILE_RE = /(^|\/)\.claude\/settings(\.local)?\.json$|(^|\/)\.mcp\.json$|(^|\/)\.cursor\/mcp\.json$|(^|\/)\.codex\/config\.toml$/;
65100
+ var CONFIG_FILE_RE = /(^|\/)\.claude\/settings(\.local)?\.json$|(^|\/)\.mcp\.json$|(^|\/)\.cursor\/mcp\.json$|(^|\/)\.codex\/config\.toml$|(^|\/)\.vscode\/mcp\.json$|(^|\/)\.kiro\/settings\/mcp\.json$|(^|\/)\.gemini\/settings\.json$|(^|\/)opencode\.json$|(^|\/)\.opencode\/config\.json$/;
64981
65101
  function selectSurface(paths) {
64982
65102
  const skillDirs = skillDirsOf(paths);
64983
65103
  const appDirs = appSkillDirsOf(paths, skillDirs);
@@ -65126,6 +65246,7 @@ async function listWorkflowPaths(owner, repo, notes) {
65126
65246
  return json.filter((e) => e.type === "file" && /\.ya?ml$/.test(e.name)).map((e) => e.path);
65127
65247
  }
65128
65248
  var ROOT_WORKFLOW_RE = /^\.github\/workflows\/[^/]+\.ya?ml$/;
65249
+ var MAX_ISSUE_FORMS = 40;
65129
65250
  function kindOfMode(mode) {
65130
65251
  if (mode === "120000") return "link";
65131
65252
  if (/^100[0-7]{3}$/.test(mode)) return "file";
@@ -65335,8 +65456,15 @@ function planSurface(paths, notes, truncated) {
65335
65456
  const present = new Set(paths);
65336
65457
  const root = SURFACE_FILES.filter((p) => present.has(p));
65337
65458
  const workflows = paths.filter((p) => ROOT_WORKFLOW_RE.test(p));
65459
+ const allForms = paths.filter((p) => ISSUE_FORM_RE.test(p));
65460
+ const forms = allForms.slice(0, MAX_ISSUE_FORMS);
65461
+ if (allForms.length > MAX_ISSUE_FORMS)
65462
+ notes.push(
65463
+ `${allForms.length} issue forms; read the first ${MAX_ISSUE_FORMS} (a label an unread form applies is not known): results ${INCOMPLETE}.`
65464
+ );
65465
+ forms.push(...["package.json", "pyproject.toml"].filter((p) => present.has(p)));
65338
65466
  const nested = pickSurfacePaths(paths, truncated, notes, { files: Number.POSITIVE_INFINITY });
65339
- return [.../* @__PURE__ */ new Set([...root, ...workflows, ...nested])];
65467
+ return [.../* @__PURE__ */ new Set([...root, ...workflows, ...forms, ...nested])];
65340
65468
  }
65341
65469
  function planReads(l, R, opts = {}) {
65342
65470
  const notes = [];
@@ -65402,7 +65530,7 @@ function planReads(l, R, opts = {}) {
65402
65530
  function capNested(reads, cap, notes) {
65403
65531
  if (cap === void 0) return reads;
65404
65532
  const rootSet = new Set(SURFACE_FILES);
65405
- const isRoot = (r) => rootSet.has(r.rel) || ROOT_WORKFLOW_RE.test(r.rel);
65533
+ const isRoot = (r) => rootSet.has(r.rel) || ROOT_WORKFLOW_RE.test(r.rel) || ISSUE_FORM_RE.test(r.rel) || r.rel === "package.json" || r.rel === "pyproject.toml";
65406
65534
  const nested = reads.filter((r) => !isRoot(r));
65407
65535
  if (nested.length < cap) return reads;
65408
65536
  const keep = new Set(nested.slice(0, cap));
@@ -65498,6 +65626,45 @@ function readLocalFile(abs, rel) {
65498
65626
  if (fd !== void 0) fs78.closeSync(fd);
65499
65627
  }
65500
65628
  }
65629
+ function readSmallFile(p, max) {
65630
+ let fd;
65631
+ try {
65632
+ fd = fs78.openSync(p, fs78.constants.O_RDONLY | (fs78.constants.O_NOFOLLOW ?? 0));
65633
+ const st = fs78.fstatSync(fd);
65634
+ if (!st.isFile() || st.size > max) return null;
65635
+ const buf = Buffer.alloc(st.size);
65636
+ const n = fs78.readSync(fd, buf, 0, st.size, 0);
65637
+ return buf.subarray(0, n).toString("utf8");
65638
+ } catch {
65639
+ return null;
65640
+ } finally {
65641
+ if (fd !== void 0) fs78.closeSync(fd);
65642
+ }
65643
+ }
65644
+ function localGitHubOwner(root) {
65645
+ let cfg = readSmallFile(path75.join(root, ".git", "config"), 1e6);
65646
+ if (cfg === null) {
65647
+ const pointer = readSmallFile(path75.join(root, ".git"), 4096);
65648
+ const m = pointer && /^gitdir:\s*(.+?)\s*$/m.exec(pointer);
65649
+ if (!m) return void 0;
65650
+ let gitDir = path75.resolve(root, m[1]);
65651
+ const common = readSmallFile(path75.join(gitDir, "commondir"), 4096);
65652
+ if (common !== null) gitDir = path75.resolve(gitDir, common.trim());
65653
+ cfg = readSmallFile(path75.join(gitDir, "config"), 1e6);
65654
+ if (cfg === null) return void 0;
65655
+ }
65656
+ let inOrigin = false;
65657
+ for (const line of cfg.split(/\r?\n/)) {
65658
+ const sec = /^\s*\[\s*([^\]\s]+)(?:\s+"([^"]*)")?\s*\]/.exec(line);
65659
+ if (sec) {
65660
+ inOrigin = sec[1].toLowerCase() === "remote" && sec[2] === "origin";
65661
+ continue;
65662
+ }
65663
+ const url = inOrigin && /^\s*url\s*=\s*(.+?)\s*$/i.exec(line);
65664
+ if (url) return /github\.com[:/]([\w.-]{1,100})\//i.exec(url[1])?.[1];
65665
+ }
65666
+ return void 0;
65667
+ }
65501
65668
  function readLocalTree(dir) {
65502
65669
  const root = dir.replace(/^~/, process.env.HOME ?? "~");
65503
65670
  const notes = [];
@@ -65527,8 +65694,10 @@ function readLocalTree(dir) {
65527
65694
  }
65528
65695
  });
65529
65696
  }
65697
+ const owner = localGitHubOwner(root);
65530
65698
  return {
65531
65699
  source: root,
65700
+ ...owner ? { owner } : {},
65532
65701
  files,
65533
65702
  notes,
65534
65703
  paths: plan.paths,
@@ -65641,8 +65810,10 @@ function readGitRefTree(dir, ref) {
65641
65810
  }
65642
65811
  files.push({ path: rel, content, route });
65643
65812
  }
65813
+ const owner = localGitHubOwner(root);
65644
65814
  return {
65645
65815
  source: `${root}@${ref}`,
65816
+ ...owner ? { owner } : {},
65646
65817
  files,
65647
65818
  notes,
65648
65819
  paths: plan.paths,
@@ -65742,7 +65913,13 @@ async function fetchGitHubTree(owner, repo, onProgress) {
65742
65913
  const listed2 = await listTree(owner, repo, notes);
65743
65914
  if (!listed2) {
65744
65915
  const workflowPaths = await listWorkflowPaths(owner, repo, notes);
65745
- const allPaths = [.../* @__PURE__ */ new Set([...SURFACE_FILES, ...workflowPaths])];
65916
+ const allPaths = [
65917
+ .../* @__PURE__ */ new Set([...SURFACE_FILES, ...workflowPaths, "package.json", "pyproject.toml"])
65918
+ ];
65919
+ if (!notes.includes(RATE_LIMIT_NOTE) && !notes.includes(NETWORK_NOTE))
65920
+ notes.push(
65921
+ `the repository tree could not be listed; only the root files and the workflows were read: nested agent files ${INCOMPLETE}.`
65922
+ );
65746
65923
  const fetched2 = await pooled(
65747
65924
  allPaths,
65748
65925
  FETCH_CONCURRENCY,
@@ -65750,6 +65927,7 @@ async function fetchGitHubTree(owner, repo, onProgress) {
65750
65927
  );
65751
65928
  return {
65752
65929
  source: `${owner}/${repo}`,
65930
+ owner,
65753
65931
  files: fetched2.filter((f) => !!f),
65754
65932
  notes
65755
65933
  };
@@ -65805,6 +65983,7 @@ async function fetchGitHubTree(owner, repo, onProgress) {
65805
65983
  notes.push(...session.closingNotes());
65806
65984
  return {
65807
65985
  source: `${owner}/${repo}`,
65986
+ owner,
65808
65987
  files: fetched.filter((f) => !!f),
65809
65988
  notes,
65810
65989
  paths: plan.paths,
@@ -65815,7 +65994,7 @@ async function fetchGitHubTree(owner, repo, onProgress) {
65815
65994
  notes.push(
65816
65995
  `fetch degraded (${err2?.message ?? "network error"}): results ${INCOMPLETE} (the repo could not be fetched).`
65817
65996
  );
65818
- return { source: `${owner}/${repo}`, files: [], notes };
65997
+ return { source: `${owner}/${repo}`, owner, files: [], notes };
65819
65998
  }
65820
65999
  }
65821
66000
  async function fetchTree(input, onProgress) {
@@ -65978,7 +66157,15 @@ function explainGrant(f) {
65978
66157
  const git = f.broad.some((a) => /^Bash\(git:/.test(a));
65979
66158
  const files = f.broad.some((a) => /^Write|^Edit$/.test(a));
65980
66159
  const template = TEMPLATE_PATH_RE.test(f.file);
65981
- const what = f.bareShell ? "run commands and change files" : sentence([git ? "run git commands" : "", files ? "change files" : ""].filter(Boolean), true);
66160
+ const runner = f.broad.some((a) => !/^Bash\(git:|^Write|^Edit$/.test(a));
66161
+ const what = f.bareShell ? "run commands and change files" : sentence(
66162
+ [
66163
+ runner ? "run programs" : "",
66164
+ git ? "run git commands" : "",
66165
+ files ? "change files" : ""
66166
+ ].filter(Boolean),
66167
+ true
66168
+ );
65982
66169
  const saw = [];
65983
66170
  if (template) saw.push("This is a template: every project made from it gets these permissions.");
65984
66171
  saw.push(
@@ -66018,14 +66205,17 @@ function npxPackage(argv) {
66018
66205
  return null;
66019
66206
  }
66020
66207
  function explainMcp(f) {
66021
- const pkg = npxPackage(f.argv);
66022
- const what = pkg ? code(pkg) : "the package";
66208
+ const pkg = f.pkg ?? npxPackage(f.argv);
66209
+ const registry = f.registry ?? "npm";
66210
+ const image = registry === "the image registry";
66211
+ const what = pkg ? `the newest ${code(pkg)}` : image ? "the newest image" : "the newest version of the package";
66212
+ const example = !pkg ? null : image ? `${pkg}@sha256:\u2026` : registry === "PyPI" ? `${pkg}==x.y.z` : registry === "the Go module proxy" ? `${pkg}@vX.Y.Z` : `${pkg}@x.y.z`;
66023
66213
  return {
66024
66214
  headline: `MCP server ${code(f.name, 60)} installs whatever version is newest, every time`,
66025
- happens: `Each run downloads the latest ${what} from npm. If that package is ever taken over, the next version runs on every developer's computer.`,
66215
+ happens: `Each run downloads ${what} from ${registry}. If it is ever taken over, the next version runs on every developer's computer.`,
66026
66216
  saw: [`It starts with ${code(f.argv, 120)}.`],
66027
66217
  fix: [
66028
- pkg ? `Pin an exact version, for example ${code(`${pkg}@x.y.z`)}.` : "Pin an exact version."
66218
+ example ? `Pin an exact version, for example ${code(example)}.` : "Pin an exact version."
66029
66219
  ]
66030
66220
  };
66031
66221
  }
@@ -66049,6 +66239,65 @@ function parseYamlStrict(text) {
66049
66239
  return doc.toJS();
66050
66240
  }
66051
66241
 
66242
+ // src/ci-check/frontmatter.ts
66243
+ function parseFrontmatter(content) {
66244
+ const text = content.replace(/^/, "").replace(/^\s*\n/, "");
66245
+ if (!/^---[ \t]*\r?\n/.test(text)) return null;
66246
+ const close = /\r?\n---[ \t]*(\r?\n|$)/.exec(text.slice(3));
66247
+ if (!close) return null;
66248
+ const block = text.slice(3, 3 + close.index);
66249
+ try {
66250
+ const parsed = parseYamlStrict(block);
66251
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
66252
+ } catch {
66253
+ return salvageTopLevel(block);
66254
+ }
66255
+ }
66256
+ function salvageTopLevel(block) {
66257
+ const out = {};
66258
+ const lines = block.split(/\r?\n/);
66259
+ for (let i = 0; i < lines.length; i++) {
66260
+ const m = /^([A-Za-z0-9_-]+):[ \t]*(.*)$/.exec(lines[i]);
66261
+ if (!m) continue;
66262
+ const [, key, rest] = m;
66263
+ if (rest.trim()) {
66264
+ out[key] = rest.trim();
66265
+ continue;
66266
+ }
66267
+ const items = [];
66268
+ while (i + 1 < lines.length && /^[ \t]+-[ \t]+/.test(lines[i + 1])) {
66269
+ items.push(lines[++i].replace(/^[ \t]+-[ \t]+/, "").trim());
66270
+ }
66271
+ if (items.length) out[key] = items;
66272
+ }
66273
+ return Object.keys(out).length ? out : null;
66274
+ }
66275
+ function splitGrants(value) {
66276
+ const out = [];
66277
+ let depth = 0;
66278
+ let cur = "";
66279
+ for (const ch of value) {
66280
+ if (ch === "(") depth++;
66281
+ else if (ch === ")") depth = Math.max(0, depth - 1);
66282
+ if (depth === 0 && (ch === "," || /\s/.test(ch))) {
66283
+ if (cur) out.push(cur);
66284
+ cur = "";
66285
+ continue;
66286
+ }
66287
+ cur += ch;
66288
+ }
66289
+ if (cur) out.push(cur);
66290
+ return out;
66291
+ }
66292
+ function allowedToolsOf(fm) {
66293
+ const raw = fm?.["allowed-tools"];
66294
+ if (typeof raw === "string") return splitGrants(raw);
66295
+ if (Array.isArray(raw)) {
66296
+ return raw.filter((x) => typeof x === "string").map((x) => x.trim()).filter(Boolean);
66297
+ }
66298
+ return [];
66299
+ }
66300
+
66052
66301
  // src/ci-check/types.ts
66053
66302
  var SEVERITY_RANK2 = {
66054
66303
  critical: 4,
@@ -66057,11 +66306,28 @@ var SEVERITY_RANK2 = {
66057
66306
  advisory: 1
66058
66307
  };
66059
66308
 
66309
+ // src/ci-check/listing.ts
66310
+ var SAFE_LIST_FIELD_RE = /^(?:number|url|html_url|id|node_id|sha|state|createdat|updatedat|closedat|mergedat|headrefname|baserefname|isdraft|headrefoid|login|user|author_association)$/i;
66311
+ function sanitizedListing(t) {
66312
+ if (t.length > 5e4) return false;
66313
+ if (/\|\s*grep\s+-[a-zA-Z]*o/.test(t)) return true;
66314
+ const fieldsSafe = (expr, fieldRe) => {
66315
+ const fields = [...expr.matchAll(fieldRe)].map((m) => m[1]);
66316
+ return fields.length > 0 && fields.every((f) => SAFE_LIST_FIELD_RE.test(f));
66317
+ };
66318
+ const jq = [
66319
+ ...t.matchAll(/(?:--jq|\s-q)[\s=]+(?:'([^']{0,500})'|"([^"]{0,500})"|([^\s'"]{1,200}))/g)
66320
+ ].map((m) => m[1] ?? m[2] ?? m[3]);
66321
+ if (jq.length) return jq.every((e) => fieldsSafe(e, /\.([A-Za-z_]\w{0,60})/g));
66322
+ const json = [...t.matchAll(/--json[\s=]+['"]?([\w,]{1,500})/g)].map((m) => m[1]);
66323
+ return json.length > 0 && json.every((e) => fieldsSafe(e, /([A-Za-z_]\w{0,60})/g));
66324
+ }
66325
+
66060
66326
  // src/ci-check/workflows.ts
66061
66327
  var AGENT_ACTION_RE = /(anthropics\/claude-code(-base)?-action|anthropics\/claude-code|^\s*[\w.-]{1,100}\/claude-code-action@|openai\/codex|codex-action|run-?aider|aider-?action|google-github-actions\/run-gemini)/i;
66062
66328
  var BROAD_TOOL_RE = /(^|["\s,])(Bash|Write|Edit)(\s|,|"|$)|Bash\(\s*\*|Bash\((curl|wget|git push|git commit|git config|git:|rm|sh|bash|eval|npx|pip)/i;
66063
66329
  var EXFIL_RCE_RE = /(^|["\s,])Bash(\s|,|"|$)|Bash\(\s*\*|Bash\((curl|wget|sh[\s):]|eval|rm[\s):]|git push)/i;
66064
- var GH_WRITE_TOOL_RE = /Bash\(\s*(gh api|gh:|gh (pr|issue) (comment|edit|merge|close|review|create|ready|lock|reopen)|git push|git:)/i;
66330
+ var GH_WRITE_TOOL_RE = /Bash\(\s*(gh api|gh:|gh (pr|issue) (comment|edit|merge|close|review|create|ready|lock|reopen)|gh (pr|issue)\s*[:*)]|git push|git:)/i;
66065
66331
  var EGRESS_TOOL_RE = /(^|["\s,])Bash(\s|,|"|$)|Bash\(\s*\*|Bash\(\s*(curl|wget|python|python3|node|deno|bun|ruby|perl|php|pip|pipx|npx|nc|ncat|ssh|scp|http|gh api)|WebFetch|WebSearch/i;
66066
66332
  var MEMBERSHIP_CHECK_RE = /orgs\/[^/'"\s]+\/memberships\/|teams\/[^/'"\s]+\/memberships\/|getCollaboratorPermissionLevel|collaborators\/[^/'"\s]+\/permission|checkMembershipForUser|getMembershipForUser/i;
66067
66333
  var STRANGER_ISSUE_TYPES = /* @__PURE__ */ new Set(["opened", "edited", "reopened", "closed"]);
@@ -66117,12 +66383,69 @@ function keyStrangerFirable(on, key) {
66117
66383
  return false;
66118
66384
  }
66119
66385
  }
66386
+ var AUTO_LABELS = /* @__PURE__ */ new Set();
66387
+ function issueFormLabels(content) {
66388
+ const text = content.length > 2e5 ? content.slice(0, 2e5) : content;
66389
+ let value;
66390
+ let parsed = false;
66391
+ if (/^\uFEFF?\s*---[ \t]*\r?\n/.test(text)) {
66392
+ const fm = parseFrontmatter(text);
66393
+ parsed = fm !== null;
66394
+ value = fm?.["labels"];
66395
+ } else {
66396
+ try {
66397
+ const doc = parseYamlStrict(text);
66398
+ parsed = true;
66399
+ if (doc && typeof doc === "object" && !Array.isArray(doc))
66400
+ value = doc["labels"];
66401
+ } catch {
66402
+ }
66403
+ }
66404
+ const raw = parsed ? Array.isArray(value) ? value : typeof value === "string" ? value.split(",") : [] : labelLines(text);
66405
+ return raw.filter((l) => typeof l === "string").map(
66406
+ (l) => l.trim().replace(/^['"]|['"]$/g, "").trim().toLowerCase()
66407
+ ).filter((l) => l && l.length <= 100);
66408
+ }
66409
+ function labelLines(text) {
66410
+ const out = [];
66411
+ const lines = text.split(/\r?\n/);
66412
+ for (let i = 0; i < lines.length; i++) {
66413
+ const m = /^labels:[ \t]*([^#]*)/.exec(lines[i]);
66414
+ if (!m) continue;
66415
+ const inline = m[1].trim();
66416
+ if (inline) {
66417
+ out.push(...inline.replace(/^\[|\]$/g, "").split(","));
66418
+ } else {
66419
+ for (let j = i + 1; j < lines.length && /^\s*-\s/.test(lines[j]); j++)
66420
+ out.push(lines[j].replace(/^\s*-\s/, "").replace(/\s#.*$/, ""));
66421
+ }
66422
+ }
66423
+ return out;
66424
+ }
66425
+ var CALLERS = /* @__PURE__ */ new WeakMap();
66120
66426
  function triggerReach(wf, raw) {
66121
66427
  const on = onObject(wf, raw) ?? {};
66122
66428
  const keys = triggerKeys(wf, raw);
66123
- const firable = keys.filter((k) => keyStrangerFirable(on, k));
66429
+ const firable = keys.filter(
66430
+ (k) => keyStrangerFirable(on, k) && !(/^workflow_run$/i.test(k) && upstreamRunTrusted(wf, raw)) || /^issues$/i.test(k) && testsAutoLabel(allIfText(wf))
66431
+ );
66124
66432
  const untrusted = firable.length > 0;
66125
66433
  const reusable = !untrusted && keys.some((k) => /^workflow_call$/i.test(k));
66434
+ const callers = CALLERS.get(wf);
66435
+ const open = (callers ?? []).filter((c) => c.untrusted && !c.gated && !c.dispatched);
66436
+ const unresolved = (callers ?? []).some(
66437
+ (c) => c.untrusted && !c.gated && c.dispatched || c.reusable && !c.gated
66438
+ );
66439
+ if (reusable && callers?.length && (open.length || !unresolved)) {
66440
+ return {
66441
+ keys: [...new Set(open.flatMap((c) => c.keys))],
66442
+ untrusted: open.length > 0,
66443
+ reusable: false,
66444
+ secretExposed: open.some((c) => c.secretExposed),
66445
+ privileged: open.some((c) => c.privileged),
66446
+ workflowRun: open.some((c) => c.workflowRun)
66447
+ };
66448
+ }
66126
66449
  const secretExposed = firable.some((t) => /pull_request_target|workflow_run/i.test(t));
66127
66450
  const privileged = untrusted && firable.some(
66128
66451
  (t) => /pull_request_target|pull_request_review|workflow_run|issue|discussion/i.test(t)
@@ -66133,6 +66456,162 @@ function triggerReach(wf, raw) {
66133
66456
  function jobList(wf) {
66134
66457
  return Object.values(wf.jobs ?? {}).filter((j) => j != null);
66135
66458
  }
66459
+ var ENV_REF_RE = /\$\{\{\s*env(?:\.([A-Za-z_][A-Za-z0-9_]{0,100})|\[\s*['"]([A-Za-z_][A-Za-z0-9_]{0,100})['"]\s*\])\s*\}\}/g;
66460
+ var ENV_REF_HOPS = 3;
66461
+ var RESOLVED_MAX_LEN = 1e5;
66462
+ function resolveEnvRefs(v, envs) {
66463
+ const holderOf = (name) => envs.find((e) => e != null && typeof e === "object" && name in e);
66464
+ for (let hop = 0; hop < ENV_REF_HOPS && v.includes("env"); hop++) {
66465
+ let projected = v.length;
66466
+ let any = false;
66467
+ for (const m of v.matchAll(ENV_REF_RE)) {
66468
+ const name = m[1] ?? m[2];
66469
+ const holder = holderOf(name);
66470
+ if (holder) {
66471
+ any = true;
66472
+ projected += str(holder[name]).length - m[0].length;
66473
+ }
66474
+ if (projected > RESOLVED_MAX_LEN) return v;
66475
+ }
66476
+ if (!any) break;
66477
+ v = v.replace(ENV_REF_RE, (whole, a, b) => {
66478
+ const name = a ?? b;
66479
+ const holder = holderOf(name);
66480
+ return holder ? str(holder[name]) : whole;
66481
+ });
66482
+ }
66483
+ return v;
66484
+ }
66485
+ function ifNeverTrue(v) {
66486
+ if (v === false) return true;
66487
+ if (typeof v !== "string") return false;
66488
+ const never = (x2) => {
66489
+ switch (x2.k) {
66490
+ case "atom":
66491
+ return /^false$/i.test(x2.t.trim());
66492
+ case "not":
66493
+ return false;
66494
+ case "and":
66495
+ return x2.c.some(never);
66496
+ case "or":
66497
+ return x2.c.every(never);
66498
+ }
66499
+ };
66500
+ const x = parseIf(v);
66501
+ return x !== null && never(x);
66502
+ }
66503
+ function normalizeWorkflow(raw) {
66504
+ const wf = raw;
66505
+ const wfEnv = raw.env;
66506
+ const jobs = wf.jobs;
66507
+ if (!jobs || typeof jobs !== "object") return wf;
66508
+ const wfEnvEvent = EVENT_CONTEXT_RE.test(str(wfEnv));
66509
+ for (const [name, job] of Object.entries(jobs)) {
66510
+ if (job == null || typeof job !== "object") continue;
66511
+ if (ifNeverTrue(job.if)) {
66512
+ delete jobs[name];
66513
+ continue;
66514
+ }
66515
+ if (!Array.isArray(job.steps)) continue;
66516
+ job.steps = job.steps.map(
66517
+ (st) => st != null && typeof st === "object" && !Array.isArray(st) ? { ...st } : st
66518
+ );
66519
+ const scopeEnv = {
66520
+ ...wfEnv && typeof wfEnv === "object" ? wfEnv : {},
66521
+ ...job.env && typeof job.env === "object" ? job.env : {}
66522
+ };
66523
+ const scopeEnvEvent = wfEnvEvent || EVENT_CONTEXT_RE.test(str(job.env));
66524
+ for (const step of job.steps) {
66525
+ if (step == null || typeof step !== "object") continue;
66526
+ step.scopeEnv = scopeEnv;
66527
+ step.scopeEnvEvent = scopeEnvEvent;
66528
+ if (step.with == null || typeof step.with !== "object") continue;
66529
+ const out = {};
66530
+ for (const [k, v] of Object.entries(step.with)) {
66531
+ const key = k.toLowerCase();
66532
+ if (key in out) continue;
66533
+ out[key] = typeof v === "string" ? resolveEnvRefs(v, [step.env, job.env, wfEnv]) : v;
66534
+ }
66535
+ step.with = out;
66536
+ }
66537
+ }
66538
+ return wf;
66539
+ }
66540
+ var LOADED = null;
66541
+ function loadWorkflow(content) {
66542
+ if (LOADED !== null && LOADED.content === content) return LOADED;
66543
+ const raw = parseYamlStrict(content) ?? {};
66544
+ const wf = raw && typeof raw === "object" ? normalizeWorkflow(raw) : raw;
66545
+ LOADED = { content, raw, wf };
66546
+ return LOADED;
66547
+ }
66548
+ var LOCAL_WORKFLOW_USES_RE = /^\s*\.\/\.github\/workflows\/([\w.-]{1,200}\.ya?ml)\s*$/i;
66549
+ function localCallsOf(path91, content, autoLabels, trustedRunSources) {
66550
+ AUTO_LABELS = autoLabels ?? /* @__PURE__ */ new Set();
66551
+ TRUSTED_RUN_SOURCES = trustedRunSources ?? /* @__PURE__ */ new Set();
66552
+ try {
66553
+ return localCallsOfInner(path91, content);
66554
+ } finally {
66555
+ AUTO_LABELS = /* @__PURE__ */ new Set();
66556
+ TRUSTED_RUN_SOURCES = /* @__PURE__ */ new Set();
66557
+ }
66558
+ }
66559
+ function localCallsOfInner(path91, content) {
66560
+ let raw;
66561
+ try {
66562
+ raw = loadWorkflow(content).raw;
66563
+ } catch {
66564
+ return [];
66565
+ }
66566
+ if (!raw || typeof raw !== "object") return [];
66567
+ const wf = raw;
66568
+ CALLERS.delete(wf);
66569
+ const reach = triggerReach(wf, raw);
66570
+ const out = [];
66571
+ for (const job of jobList(wf)) {
66572
+ const m = LOCAL_WORKFLOW_USES_RE.exec(str(job.uses));
66573
+ if (!m) continue;
66574
+ out.push({
66575
+ target: `.github/workflows/${m[1]}`,
66576
+ caller: {
66577
+ file: path91,
66578
+ keys: reach.keys,
66579
+ untrusted: reach.untrusted,
66580
+ secretExposed: reach.secretExposed,
66581
+ privileged: reach.privileged,
66582
+ workflowRun: reach.workflowRun,
66583
+ gated: jobGated(job, wf, raw),
66584
+ dispatched: dispatchedByGatedJob(job, wf, raw),
66585
+ reusable: reach.reusable
66586
+ }
66587
+ });
66588
+ }
66589
+ return out;
66590
+ }
66591
+ function dispatchedByGatedJob(job, wf, raw) {
66592
+ const names = [...str(job.if).matchAll(/needs\.([\w-]{1,100})\.outputs\./g)].map((m) => m[1]);
66593
+ if (!names.length) return false;
66594
+ const jobs = wf.jobs ?? {};
66595
+ const label3 = labelTypeConfigured(wf, raw);
66596
+ const someBranchGated = (x) => {
66597
+ switch (x.k) {
66598
+ case "atom":
66599
+ return exprGated(x, label3);
66600
+ case "not":
66601
+ return false;
66602
+ case "and":
66603
+ case "or":
66604
+ return x.c.some(someBranchGated);
66605
+ }
66606
+ };
66607
+ return names.every((n) => {
66608
+ const up = jobs[n];
66609
+ if (up == null) return false;
66610
+ if (jobGated(up, wf, raw)) return true;
66611
+ const x = parseIf(str(up.if));
66612
+ return x !== null && someBranchGated(x);
66613
+ });
66614
+ }
66136
66615
  function allSteps(wf) {
66137
66616
  const out = [];
66138
66617
  for (const job of jobList(wf)) {
@@ -66140,11 +66619,47 @@ function allSteps(wf) {
66140
66619
  }
66141
66620
  return out;
66142
66621
  }
66622
+ var AGENT_CLI_RE = new RegExp(
66623
+ [
66624
+ // The binary at a command start, then its "do work" flag anywhere on the same command
66625
+ // (`claude --model x -p`, `claude "$Q" --print`, `npx @anthropic-ai/claude-code -p`): the
66626
+ // flag had to come first (review, round 2). Bounded: at most 300 characters per command.
66627
+ String.raw`(?:^|[\s|;&(])(?:claude|@anthropic-ai/claude-code(?:@[\w.-]{1,40})?)(?=\s)[^\n;&|]{0,300}?\s(?:-p|--print|--continue|--resume)(?=[\s"'=]|$)`,
66628
+ String.raw`(?:^|[\s|;&(])(?:codex|@openai/codex(?:@[\w.-]{1,40})?)(?=\s)[^\n;&|]{0,300}?\sexec\b`,
66629
+ String.raw`(?:^|[\s|;&(])(?:gemini|@google/gemini-cli(?:@[\w.-]{1,40})?)(?=\s)[^\n;&|]{0,300}?\s(?:-p|--prompt)(?=[\s"'=]|$)`,
66630
+ String.raw`(?:^|[\s|;&(])copilot(?=\s)[^\n;&|]{0,300}?\s(?:-p|--prompt|--allow-all-tools)(?=[\s"'=]|$)`,
66631
+ String.raw`\b(?:aider\s|opencode\s+run\b|goose\s+run\b|kilo\s+run\b|pi\s+--print\b|llmproc\s|acpx\s|harbor\s+(?:check|run)\b)`,
66632
+ String.raw`api\.devin\.ai/|software-agent-sdk|\bagent_script\.py\b`
66633
+ ].join("|"),
66634
+ "i"
66635
+ );
66636
+ var RUN_FUEL_ENV_RE = /^(ANTHROPIC_API_KEY|ANTHROPIC_AUTH_TOKEN|CLAUDE_CODE_OAUTH_TOKEN|OPENAI_API_KEY|GEMINI_API_KEY|GOOGLE_API_KEY|KILO_API_KEY|OPENCODE_API_KEY|DEVIN_API_KEY|LLM_API_KEY|AWS_BEARER_TOKEN_BEDROCK)$/i;
66637
+ var EVENT_CONTEXT_RE = /github\.event|context\.payload/;
66638
+ function isRunAgent(step) {
66639
+ return typeof step.run === "string" && !step.uses && AGENT_CLI_RE.test(step.run);
66640
+ }
66641
+ var FUEL_WITH_KEY_RE = /^(anthropic[_-]api[_-]key|anthropic[_-]auth[_-]token|claude[_-]code[_-]oauth[_-]token|claude[_-]oauth[_-]token|openai[_-]api[_-]key|gemini[_-]api[_-]key|google[_-]api[_-]key)$/i;
66642
+ var AGENT_PATH_RE = /(?:^|[/_.-])(claude|codex|gemini|opencode|goose|aider|kilo(?:code)?)(?:$|[/_.-])/i;
66643
+ var PROMPT_INPUT_RE = /^(prompt|direct_prompt|prompt_file|prompt-file|agent)$/i;
66644
+ function isWrapperAgent(step) {
66645
+ const uses = str(step.uses);
66646
+ const w = step.with ?? {};
66647
+ if (Object.keys(w).some((k) => FUEL_WITH_KEY_RE.test(k))) return true;
66648
+ const env = { ...step.scopeEnv ?? {}, ...step.env ?? {} };
66649
+ const fuelEnv = Object.keys(env).some((k) => RUN_FUEL_ENV_RE.test(k));
66650
+ if (/^\s*\.\//.test(uses)) return fuelEnv;
66651
+ const pathPart = uses.replace(/^\s*[^/@\s]{1,100}\//, "").replace(/@.*$/, "");
66652
+ return AGENT_PATH_RE.test(pathPart) && (Object.keys(w).some((k) => PROMPT_INPUT_RE.test(k)) || fuelEnv);
66653
+ }
66143
66654
  function isAgentStep(step) {
66144
66655
  if (step.uses && AGENT_ACTION_RE.test(step.uses)) return true;
66145
- if (step.run && /\b(claude|aider|codex|gemini)\b/i.test(step.run) && /github\.event/.test(step.run))
66656
+ if (step.uses && isWrapperAgent(step)) return true;
66657
+ if (typeof step.run !== "string" || !step.run) return false;
66658
+ if (/\b(claude|aider|codex|gemini)\b/i.test(step.run) && /github\.event/.test(step.run))
66146
66659
  return true;
66147
- return false;
66660
+ if (!AGENT_CLI_RE.test(step.run)) return false;
66661
+ const env = { ...step.scopeEnv ?? {}, ...step.env ?? {} };
66662
+ return !!step.scopeEnvEvent || EVENT_CONTEXT_RE.test(str(step.env)) || Object.keys(env).some((k) => RUN_FUEL_ENV_RE.test(k));
66148
66663
  }
66149
66664
  function allowedToolsFromArgs(claudeArgs) {
66150
66665
  let out = "";
@@ -66164,12 +66679,47 @@ function collectTools(steps) {
66164
66679
  let s = "";
66165
66680
  for (const st of steps) {
66166
66681
  const w = st.with ?? {};
66167
- s += " " + allowedToolsFromArgs(str(w["claude_args"]));
66168
- s += " " + str(w["allowed_tools"]) + " " + str(w["allowedTools"]);
66682
+ const args = str(w["claude_args"]) + " " + str(w["codex-args"]) + " " + str(w["codex_args"]);
66683
+ s += " " + allowedToolsFromArgs(args);
66684
+ s += " " + str(w["allowed_tools"]) + " " + str(w["allowedtools"]);
66169
66685
  s += " " + allowedToolsFromSettings(str(w["settings"]));
66686
+ if (PERMISSION_BYPASS_RE.test(args) || /^danger-full-access$/i.test(str(w["sandbox"])))
66687
+ s += " Bash";
66688
+ if (st.run && isRunAgent(st)) {
66689
+ s += " " + allowedToolsFromArgs(st.run);
66690
+ if (PERMISSION_BYPASS_RE.test(st.run)) s += " Bash";
66691
+ s += " " + permissionEnvGrants({ ...st.scopeEnv ?? {}, ...st.env ?? {} });
66692
+ }
66170
66693
  }
66171
66694
  return s;
66172
66695
  }
66696
+ var PERMISSION_BYPASS_RE = /--dangerously-skip-permissions|--permission-mode[=\s]+["']?bypassPermissions|--dangerously-bypass-approvals-and-sandbox|--allow-all-tools|--yolo\b/i;
66697
+ function permissionEnvGrants(env) {
66698
+ let out = "";
66699
+ for (const key of ["OPENCODE_PERMISSION", "KILO_PERMISSION"]) {
66700
+ const raw = str(env[key]);
66701
+ if (!raw || raw.length > 2e4) continue;
66702
+ let cfg;
66703
+ try {
66704
+ cfg = JSON.parse(raw);
66705
+ } catch {
66706
+ continue;
66707
+ }
66708
+ if (!cfg || typeof cfg !== "object") continue;
66709
+ const c = cfg;
66710
+ const bash = c["bash"];
66711
+ if (bash === "allow") out += " Bash";
66712
+ else if (bash && typeof bash === "object") {
66713
+ for (const [pat, v] of Object.entries(bash)) {
66714
+ if (v !== "allow") continue;
66715
+ const cmd = pat.replace(/\*+\s*$/, "").trim();
66716
+ out += cmd ? ` Bash(${cmd}:*)` : " Bash";
66717
+ }
66718
+ }
66719
+ if (c["webfetch"] === "allow") out += " WebFetch";
66720
+ }
66721
+ return out;
66722
+ }
66173
66723
  function toolTokens(blob) {
66174
66724
  const out = [];
66175
66725
  let buf = "";
@@ -66202,11 +66752,21 @@ function matchedBroadTools(blob) {
66202
66752
  }
66203
66753
  return out;
66204
66754
  }
66755
+ var GH_PR_CHECKOUT_RE = /\bgh\s+pr\s+checkout\b/;
66756
+ var FETCH_PULL_RE = /\bgit\s+fetch\b[^\n]{0,200}\bpull\/[^\s/]{1,80}\/(?:head|merge)\b/;
66757
+ var GIT_CHECKOUT_RE = /\bgit\s+(?:checkout|switch)\b/;
66758
+ function runChecksOutHead(run) {
66759
+ if (GH_PR_CHECKOUT_RE.test(run)) return true;
66760
+ const m = FETCH_PULL_RE.exec(run);
66761
+ return m !== null && GIT_CHECKOUT_RE.test(run.slice(m.index));
66762
+ }
66205
66763
  function untrustedHeadCheckout(steps) {
66206
66764
  for (const step of steps) {
66765
+ if (!step.uses && typeof step.run === "string" && runChecksOutHead(step.run)) return "root";
66207
66766
  if (!step.uses || !/actions\/checkout/.test(step.uses)) continue;
66208
66767
  const ref = str(step.with?.["ref"]);
66209
- if (/pull_request\.head|head[._]sha|head_ref|expected_head|workflow_run\.head|inputs\.[\w]*head/i.test(
66768
+ if (/refs\/pull\//.test(ref)) return step.with?.["path"] ? "subdir" : "root";
66769
+ if (/pull_request\.head|head[._-]sha|head_ref|expected_head|workflow_run\.head|inputs(?:\.|\[\s*['"])[\w-]*head/i.test(
66210
66770
  ref
66211
66771
  )) {
66212
66772
  return step.with?.["path"] ? "subdir" : "root";
@@ -66216,22 +66776,61 @@ function untrustedHeadCheckout(steps) {
66216
66776
  }
66217
66777
  function promptTakesUntrusted(steps) {
66218
66778
  for (const st of steps) {
66219
- const blob = str(st.with?.["prompt"]) + " " + str(st.with?.["direct_prompt"]) + " " + collectTools([st]);
66779
+ const blob = str(st.with?.["prompt"]) + " " + str(st.with?.["direct_prompt"]) + " " + collectTools([st]) + // T.2a: a run-step agent's prompt is its command line and the env it reads.
66780
+ (isRunAgent(st) ? " " + st.run + " " + str(st.env) + " " + str(st.scopeEnv) : "");
66220
66781
  if (/github\.event\.(issue|comment|pull_request|review)\.(body|title)/i.test(blob)) return true;
66221
- if (/pull_request\.number|\/code-review|\/review/i.test(blob)) return true;
66782
+ if (/pull_request\.number|(?<![\w./-])\/(?:code-)?review(?![\w-])/i.test(blob)) return true;
66222
66783
  }
66223
66784
  return false;
66224
66785
  }
66225
66786
  var ITEM_READ_TOOL_RE = /mcp__github__(get_issue|get_issue_comments|get_pull_request|get_pull_request_diff|get_pull_request_files|get_pull_request_comments|get_pull_request_reviews|search_issues|list_issues)\b|Bash\(\s*gh\s+(issue|pr)\s+(view|diff|list)\b|Bash\(\s*gh\s+(api|search)\b|Bash\(\s*gh\s*:/i;
66226
66787
  var ITEM_HANDLE_RE = /github\.event\.(issue|pull_request|discussion)\.number|github\.event\.number\b|github\.event\.comment\.id/i;
66227
66788
  var PAYLOAD_TEXT_RE = /github\.event\.(issue|comment|pull_request|review|discussion)\.(body|title)|context\.payload\.(issue|comment|pull_request|review|discussion)\b|GITHUB_EVENT_PATH|github\.event_path/i;
66789
+ var RUN_ARTIFACT_RE = /gh\s+run\s+download\b|\/runs\/\$\{\{\s*github\.event\.workflow_run\.id\s*\}\}\/(?:artifacts|logs)/i;
66790
+ var TRUSTED_RUN_SOURCES = /* @__PURE__ */ new Set();
66791
+ var UPSTREAM_TRUSTED = false;
66792
+ function upstreamRunTrusted(wf, raw) {
66793
+ const on = onObject(wf, raw);
66794
+ const run = on?.["workflow_run"];
66795
+ const names = Array.isArray(run?.workflows) ? run.workflows.map(String) : [];
66796
+ return names.length > 0 && names.every((n) => TRUSTED_RUN_SOURCES.has(n));
66797
+ }
66798
+ function runSourceOf(path91, content, autoLabels) {
66799
+ AUTO_LABELS = autoLabels ?? /* @__PURE__ */ new Set();
66800
+ try {
66801
+ let raw;
66802
+ try {
66803
+ raw = loadWorkflow(content).raw;
66804
+ } catch {
66805
+ return null;
66806
+ }
66807
+ if (!raw || typeof raw !== "object") return null;
66808
+ const wf = raw;
66809
+ CALLERS.delete(wf);
66810
+ const reach = triggerReach(wf, raw);
66811
+ const name = typeof raw["name"] === "string" && raw["name"] ? raw["name"] : path91;
66812
+ return { name, trusted: !reach.untrusted && !reach.reusable };
66813
+ } finally {
66814
+ AUTO_LABELS = /* @__PURE__ */ new Set();
66815
+ }
66816
+ }
66817
+ function downloadsRunArtifacts(st) {
66818
+ if (UPSTREAM_TRUSTED) return false;
66819
+ if (/actions\/download-artifact/i.test(str(st.uses)))
66820
+ return /workflow_run\.id/.test(str(st.with?.["run-id"]) + " " + str(st.with?.["run_id"]));
66821
+ return RUN_ARTIFACT_RE.test(str(st.run) + " " + str(st.with?.["script"]));
66822
+ }
66823
+ var STEP_OUTPUT_REF_RE = /steps\.([\w-]{1,100})\.outputs\./g;
66228
66824
  function jobReadsUntrustedItem(job) {
66229
66825
  const steps = job.steps ?? [];
66230
66826
  const agent = steps.filter(isAgentStep);
66231
66827
  if (!agent.length) return null;
66232
66828
  if (ITEM_READ_TOOL_RE.test(collectTools(agent))) return "tool";
66233
66829
  for (const st of agent)
66234
- if (ITEM_HANDLE_RE.test(str(st.with) + " " + str(st.env) + " " + str(job.env))) return "handle";
66830
+ if (ITEM_HANDLE_RE.test(
66831
+ str(st.with) + " " + str(st.env) + " " + str(job.env) + " " + (isRunAgent(st) ? str(st.run) : "")
66832
+ ))
66833
+ return "handle";
66235
66834
  for (let i = 0; i < steps.length; i++) {
66236
66835
  const st = steps[i];
66237
66836
  if (!isAgentStep(st) || !str(st.with?.["prompt_file"] ?? st.with?.["prompt-file"])) continue;
@@ -66239,6 +66838,20 @@ function jobReadsUntrustedItem(job) {
66239
66838
  if (earlier.some((p) => PAYLOAD_TEXT_RE.test(str(p.run) + " " + str(p.with) + " " + str(p.env))))
66240
66839
  return "prompt-file";
66241
66840
  }
66841
+ const firstDownload = steps.findIndex(downloadsRunArtifacts);
66842
+ if (firstDownload >= 0 && steps.slice(firstDownload + 1).some(isAgentStep)) return "artifact";
66843
+ for (let i = 0; i < steps.length; i++) {
66844
+ const st = steps[i];
66845
+ if (!isAgentStep(st)) continue;
66846
+ const input = str(st.with?.["prompt"]) + " " + str(st.with?.["direct_prompt"]) + " " + str(st.with?.["prompt_file"] ?? st.with?.["prompt-file"]);
66847
+ const ids = /* @__PURE__ */ new Set();
66848
+ for (const m of input.matchAll(STEP_OUTPUT_REF_RE)) ids.add(m[1]);
66849
+ for (const id of ids) {
66850
+ const src = steps.slice(0, i).find((p) => p.id === id);
66851
+ if (src && PAYLOAD_TEXT_RE.test(str(src.run) + " " + str(src.with) + " " + str(src.env)))
66852
+ return "step-output";
66853
+ }
66854
+ }
66242
66855
  return null;
66243
66856
  }
66244
66857
  var NONCONTAINS_GATE_RE = new RegExp(
@@ -66251,19 +66864,46 @@ var NONCONTAINS_GATE_RE = new RegExp(
66251
66864
  ].join("|"),
66252
66865
  "i"
66253
66866
  );
66254
- var PERMISSION_OUTPUT_GATE_RE = /steps\.[\w-]+\.outputs\.[\w-]*(permission|allowed|authoriz|is[_-]?(admin|member|maintainer|collaborator))[\w-]*\s*==\s*['"]?(true|admin|write|maintain)/i;
66867
+ var PERMISSION_OUTPUT_RE = /steps\.[\w-]{1,100}\.outputs\.([\w-]{1,200})\s*==\s*['"]?(?:true|admin|write|maintain)/gi;
66868
+ var PERMISSION_OUTPUT_NAME_RE = /permission|allowed|authoriz|is[_-]?(?:admin|member|maintainer|collaborator)/i;
66869
+ var PERMISSION_OUTPUT_GATE_RE = {
66870
+ test(ifs) {
66871
+ for (const m of ifs.matchAll(PERMISSION_OUTPUT_RE))
66872
+ if (PERMISSION_OUTPUT_NAME_RE.test(m[1])) return true;
66873
+ return false;
66874
+ }
66875
+ };
66255
66876
  var CONTAINS_GATE_RE = /contains\((?:[^()]|\([^()]*\))*(login|actor|association|OWNER|MEMBER|COLLABORATOR)/i;
66256
66877
  var NEGATED_CONTAINS_RE = /!\s*\(?\s*contains\((?:[^()]|\([^()]*\))*(login|actor|association|OWNER|MEMBER|COLLABORATOR)/i;
66257
66878
  function labelTypeConfigured(wf, raw) {
66258
66879
  const on = wf.on ?? raw["on"] ?? raw[true];
66259
66880
  const prTypes = (t) => t && Array.isArray(t.types) ? t.types.map(String) : [];
66260
- return prTypes(on?.["pull_request_target"]).includes("labeled") || prTypes(on?.["pull_request"]).includes("labeled");
66881
+ const pr = prTypes(on?.["pull_request_target"]).includes("labeled") || prTypes(on?.["pull_request"]).includes("labeled");
66882
+ const onObj = onObject(wf, raw) ?? {};
66883
+ const issues = on != null && typeof on === "object" && "issues" in on && triggerKeys(wf, raw).every((k) => /^issues$/i.test(k) || !keyStrangerFirable(onObj, k));
66884
+ return { pr, issues };
66885
+ }
66886
+ var EVENT_LABEL_GATE_RE = /\blabel\.name\s*==\s*'|\b(?:contains|startsWith)\(\s*github\.event\.label\.name\b/i;
66887
+ var ISSUE_LABEL_GATE_RE = /(?<!!\s{0,5}\(?\s{0,5})contains\(\s*github\.event\.issue\.labels\.\*\.name\s*,\s*'[^']{1,100}'\s*\)(?!\s*(?:==\s*false|!=\s*true))/i;
66888
+ var PR_LABEL_GATE_RE = /(?<!!\s{0,5}\(?\s{0,5})contains\(\s*github\.event\.(?:pull_request|issue)\.labels\.\*\.name\s*,\s*'[^']{1,100}'\s*\)(?!\s*(?:==\s*false|!=\s*true))/i;
66889
+ var NONWRITE_SET_RE = /contains\(\s*(?:fromjson\(\s*['"]\[[^\]]{0,400}|'[^']{0,400})\b(CONTRIBUTOR|FIRST_TIME_CONTRIBUTOR|FIRST_TIMER|NONE|MANNEQUIN)\b/i;
66890
+ function allIfText(wf) {
66891
+ const s = jobList(wf).map((j) => str(j.if) + " " + (j.steps ?? []).map((st) => str(st.if)).join(" ")).join(" ");
66892
+ return s.length > IF_MAX_LEN ? "" : s;
66261
66893
  }
66262
- var PR_LABEL_GATE_RE = /(?<!!\s{0,5}\(?\s{0,5})contains\(\s*github\.event\.pull_request\.labels\.\*\.name\s*,\s*'[^']{1,100}'\s*\)(?!\s*(?:==\s*false|!=\s*true))/i;
66263
- function ifsAreGated(ifs, labelConfigured) {
66264
- const containsGate = CONTAINS_GATE_RE.test(ifs) && !NEGATED_CONTAINS_RE.test(ifs);
66265
- const gated = NONCONTAINS_GATE_RE.test(ifs) || containsGate;
66266
- const labelGated = labelConfigured && (/event\.label|label\.name/i.test(ifs) || PR_LABEL_GATE_RE.test(ifs));
66894
+ function testsAutoLabel(ifs) {
66895
+ if (AUTO_LABELS.size === 0) return false;
66896
+ for (const m of ifs.matchAll(
66897
+ /labels\.\*\.name\s*,\s*'([^']{1,100})'|label\.name\s*==\s*'([^']{1,100})'/gi
66898
+ ))
66899
+ if (AUTO_LABELS.has((m[1] ?? m[2]).toLowerCase())) return true;
66900
+ return false;
66901
+ }
66902
+ function ifsAreGated(ifs, label3) {
66903
+ const m = maskLiterals(ifs);
66904
+ const containsGate = CONTAINS_GATE_RE.test(m) && !NEGATED_CONTAINS_RE.test(m) && !NONWRITE_SET_RE.test(ifs);
66905
+ const gated = NONCONTAINS_GATE_RE.test(m) || containsGate;
66906
+ const labelGated = ((label3.pr || label3.issues) && EVENT_LABEL_GATE_RE.test(m) || label3.pr && PR_LABEL_GATE_RE.test(m) || label3.issues && ISSUE_LABEL_GATE_RE.test(m)) && !testsAutoLabel(ifs);
66267
66907
  return gated || labelGated;
66268
66908
  }
66269
66909
  function hasStepMembershipGate(job) {
@@ -66284,6 +66924,7 @@ function parseIf(src) {
66284
66924
  let e = src.trim();
66285
66925
  const wrapped = /^\$\{\{([\s\S]*)\}\}$/.exec(e);
66286
66926
  if (wrapped) e = wrapped[1].trim();
66927
+ if (e.includes("${{")) return null;
66287
66928
  if (!e || e.length > IF_MAX_LEN) return null;
66288
66929
  let i = 0;
66289
66930
  let depth = 0;
@@ -66369,12 +67010,21 @@ function stepIfText(steps) {
66369
67010
  const s = steps.map((st) => str(st.if)).join(" ");
66370
67011
  return s.length > IF_MAX_LEN ? "" : s;
66371
67012
  }
67013
+ function maskLiterals(ifs) {
67014
+ return ifs.replace(
67015
+ /(==|!=)?(\s*)'(?:[^']|'')*'/g,
67016
+ (m, op, sp) => op ? m : `${sp}'_'`
67017
+ );
67018
+ }
67019
+ var CHAINED_COMPARE_RE = /(?:==|!=)[^=!]*(?:==|!=)/;
66372
67020
  function gatedText(ifs, labelConfigured) {
66373
- return ifsAreGated(ifs, labelConfigured) || ASSIGNEE_GATE_RE.test(ifs) || PERMISSION_OUTPUT_GATE_RE.test(ifs);
67021
+ const masked = maskLiterals(ifs);
67022
+ return ifsAreGated(ifs, labelConfigured) || ASSIGNEE_GATE_RE.test(masked) || PERMISSION_OUTPUT_GATE_RE.test(masked);
66374
67023
  }
66375
67024
  function exprGated(x, labelConfigured) {
66376
67025
  switch (x.k) {
66377
67026
  case "atom":
67027
+ if (CHAINED_COMPARE_RE.test(maskLiterals(x.t))) return false;
66378
67028
  return gatedText(x.t, labelConfigured) || TRUSTED_EVENT_ATOM_RE.test(x.t);
66379
67029
  case "not":
66380
67030
  return false;
@@ -66410,11 +67060,43 @@ function jobOnWorkflowRun(job, workflowRun) {
66410
67060
  }
66411
67061
  function jobActorGate(job, wf, raw) {
66412
67062
  const labelConfigured = labelTypeConfigured(wf, raw);
66413
- const stepIfs = stepIfText(job.steps ?? []);
66414
- return jobIfGated(str(job.if), labelConfigured) || gatedText(stepIfs, labelConfigured) || hasStepMembershipGate(job);
67063
+ const agent = (job.steps ?? []).filter(isAgentStep);
67064
+ const ifTotal = agent.reduce((n, s) => n + str(s.if).length, 0);
67065
+ const agentStepsGated = agent.length > 0 && ifTotal <= IF_MAX_LEN && agent.every((s) => {
67066
+ const x = parseIf(str(s.if));
67067
+ return x !== null && exprGated(x, labelConfigured);
67068
+ });
67069
+ return jobIfGated(str(job.if), labelConfigured) || agentStepsGated || hasStepMembershipGate(job);
67070
+ }
67071
+ var ACTOR_ALLOWLIST_RE = /\.includes\(\s*(?:context\.actor|github\.actor|context\.payload\.sender\.login|[\w.]{0,40}user\.login|login)\b|\b(?:context\.actor|github\.actor)\s*===?\s*['"]/;
67072
+ var ACTOR_ALLOWLIST_G = new RegExp(ACTOR_ALLOWLIST_RE.source, "g");
67073
+ var BLOCK_LIST_NAME_RE = /block|deny|ban|spam|abuse|exclu|ignore/i;
67074
+ function scriptAllowsByActor(code2) {
67075
+ if (code2.length > 2e5) return false;
67076
+ for (const m of code2.matchAll(ACTOR_ALLOWLIST_G)) {
67077
+ const at = m.index ?? 0;
67078
+ const before = code2.slice(Math.max(0, at - 80), at);
67079
+ if (m[0].startsWith(".includes")) {
67080
+ const list = /([\w$.]{1,60})$/.exec(before)?.[1] ?? "";
67081
+ if (BLOCK_LIST_NAME_RE.test(list)) continue;
67082
+ }
67083
+ const negated = /!\s{0,3}\(?\s{0,3}[\w$.]{0,60}$/.test(before);
67084
+ const ternary = /^[^?;\n]{0,150}\?\s{0,5}['"`]?(true|false)\b/.exec(code2.slice(at, at + 200));
67085
+ if (!ternary || ternary[1] === "true" !== negated) return true;
67086
+ }
67087
+ return false;
67088
+ }
67089
+ function readsOutputPositively(readerIf, name) {
67090
+ const ref = `needs\\.${escapeRe(name)}\\.outputs\\.[\\w-]{1,100}`;
67091
+ if (!new RegExp(ref).test(readerIf)) return false;
67092
+ const no = `'(?:false|no|0|)'(?![\\w'])`;
67093
+ return !new RegExp(
67094
+ `!\\s{0,5}\\(?\\s{0,5}(?:fromjson\\(\\s{0,5})?${ref}|${ref}\\s{0,5}\\)?\\s{0,5}(?:!=\\s{0,5}(?!${no})|==\\s{0,5}${no})`,
67095
+ "i"
67096
+ ).test(readerIf);
66415
67097
  }
66416
67098
  var STATUS_FN_RE = /\b(success|failure|always|cancelled)\s*\(/i;
66417
- var FAILING_STEP_RE = /\bexit\s+[1-9]|core\.setFailed|process\.exit\(\s*[1-9]/;
67099
+ var FAILING_STEP_RE = /\bexit\s+[1-9]|core\.setFailed|process\.exit\(\s*[1-9]|actions\.cancelWorkflowRun\b/;
66418
67100
  function needsOf(job) {
66419
67101
  return (Array.isArray(job.needs) ? job.needs : job.needs ? [job.needs] : []).map(String);
66420
67102
  }
@@ -66426,13 +67108,18 @@ function upstreamJobGates(name, dep, wf, raw, readerIf) {
66426
67108
  const label3 = labelTypeConfigured(wf, raw);
66427
67109
  if (depIf && jobIfGated(depIf, label3)) return true;
66428
67110
  const steps = dep.steps ?? [];
66429
- const stepGated = ifsAreGated(stepIfText(steps), label3) || hasStepMembershipGate(dep);
67111
+ if (readerIf !== null && readsOutputPositively(readerIf, name) && steps.some((st) => {
67112
+ const code2 = str(st.run) + " " + str(st.with?.["script"]);
67113
+ return MEMBERSHIP_CHECK_RE.test(code2) || scriptAllowsByActor(code2);
67114
+ }))
67115
+ return true;
67116
+ const stepGated = ifsAreGated(stepIfText(steps), label3) || hasStepMembershipGate(dep) || steps.some((st) => MEMBERSHIP_CHECK_RE.test(str(st.with?.["script"])));
66430
67117
  if (!stepGated) return false;
66431
67118
  if (steps.some(
66432
67119
  (st) => FAILING_STEP_RE.test(str(st.run)) || FAILING_STEP_RE.test(str(st.with?.["script"]))
66433
67120
  ))
66434
67121
  return true;
66435
- return readerIf !== null && new RegExp(`needs\\.${escapeRe(name)}\\.outputs\\.`).test(readerIf);
67122
+ return readerIf !== null && readsOutputPositively(readerIf, name);
66436
67123
  }
66437
67124
  function gatingAncestors(job, wf, raw) {
66438
67125
  const out = /* @__PURE__ */ new Set();
@@ -66462,16 +67149,23 @@ function jobGated(job, wf, raw) {
66462
67149
  }
66463
67150
  function bypassArmed(steps) {
66464
67151
  return steps.some(
66465
- (s) => str(s.with?.["allowed_non_write_users"]) === "*" && (!!str(s.with?.["github_token"]) || !!str(s.env?.["OVERRIDE_GITHUB_TOKEN"]))
67152
+ (s) => nonWriteOpen(str(s.with?.["allowed_non_write_users"])) && (!!str(s.with?.["github_token"]) || !!str(s.env?.["OVERRIDE_GITHUB_TOKEN"]))
66466
67153
  );
66467
67154
  }
67155
+ var ACTOR_BOUND_RE = /\$\{\{[^}]{0,200}\b(github\.event\.[\w.]{0,80}\b(?:user|sender)\.login|github\.event\.sender\.login|github\.actor|github\.triggering_actor)\b/i;
67156
+ function nonWriteOpen(v) {
67157
+ const t = v.trim();
67158
+ if (t === "*") return true;
67159
+ return t.length <= 1e3 && ACTOR_BOUND_RE.test(t);
67160
+ }
66468
67161
  function hasImplicitActorGate(agentSteps, bypassActive, workflowRun = false) {
66469
67162
  if (bypassActive) return false;
66470
67163
  return agentSteps.some((s) => {
66471
67164
  const build = claudeActionBuild(s.uses);
66472
67165
  if (!build) return false;
67166
+ if (!build.official) return false;
66473
67167
  if (!workflowRun) return true;
66474
- return build.official && workflowRunChecked(build.ref);
67168
+ return workflowRunChecked(build.ref);
66475
67169
  });
66476
67170
  }
66477
67171
  function claudeActionBuild(uses) {
@@ -66583,14 +67277,28 @@ function severityFromScore(score) {
66583
67277
  if (score >= 1) return "advisory";
66584
67278
  return null;
66585
67279
  }
66586
- function analyzeWorkflow(path91, content) {
67280
+ function analyzeWorkflow(path91, content, callers, autoLabels, trustedRunSources) {
67281
+ AUTO_LABELS = autoLabels ?? /* @__PURE__ */ new Set();
67282
+ TRUSTED_RUN_SOURCES = trustedRunSources ?? /* @__PURE__ */ new Set();
67283
+ try {
67284
+ return analyzeWorkflowInner(path91, content, callers);
67285
+ } finally {
67286
+ AUTO_LABELS = /* @__PURE__ */ new Set();
67287
+ TRUSTED_RUN_SOURCES = /* @__PURE__ */ new Set();
67288
+ UPSTREAM_TRUSTED = false;
67289
+ }
67290
+ }
67291
+ function analyzeWorkflowInner(path91, content, callers) {
66587
67292
  let raw;
66588
67293
  try {
66589
- raw = parseYamlStrict(content) ?? {};
67294
+ raw = loadWorkflow(content).raw;
66590
67295
  } catch {
66591
67296
  return null;
66592
67297
  }
66593
67298
  const wf = raw;
67299
+ if (callers?.length) CALLERS.set(wf, callers);
67300
+ else CALLERS.delete(wf);
67301
+ UPSTREAM_TRUSTED = upstreamRunTrusted(wf, raw);
66594
67302
  const steps = allSteps(wf).map((s) => s.step);
66595
67303
  const agentSteps = steps.filter(isAgentStep);
66596
67304
  const agentJobs = [
@@ -66609,17 +67317,19 @@ function analyzeWorkflow(path91, content) {
66609
67317
  } = triggerReach(wf, raw);
66610
67318
  const nonWrite = str(agentSteps.map((s) => s.with?.["allowed_non_write_users"]).find(Boolean));
66611
67319
  const nonWriteStar = bypassArmed(agentSteps);
66612
- const starWithoutToken = nonWrite === "*" && !nonWriteStar;
66613
- const nonWriteList = !!nonWrite && nonWrite !== "*";
67320
+ const starWithoutToken = nonWriteOpen(nonWrite) && !nonWriteStar;
67321
+ const nonWriteList = !!nonWrite && !nonWriteOpen(nonWrite);
67322
+ const nonWriteActor = nonWrite.trim() !== "*" && nonWriteOpen(nonWrite);
66614
67323
  const untrustedTrigger = forkInput || reusable;
66615
67324
  const bypassActive = nonWriteStar && untrustedTrigger;
66616
- const head = untrustedHeadCheckout(steps);
67325
+ const head = agentSteps.length && agentSteps.every(readOnlySandbox) ? null : untrustedHeadCheckout(steps);
66617
67326
  const promptUntrusted = promptTakesUntrusted(agentSteps);
66618
67327
  const injJobs = injectableJobs(wf, raw, untrustedTrigger);
66619
67328
  const powerJobs = injJobs.length ? injJobs : agentJobs;
66620
67329
  const powerSteps = injJobs.flatMap((j) => (j.steps ?? []).filter(isAgentStep));
66621
67330
  const scopedSteps = powerSteps.length ? powerSteps : agentSteps;
66622
- const toolsBlob = collectTools(scopedSteps);
67331
+ const headActionRuns = head === "root" && powerSteps.some((st) => /^\s*\.\//.test(str(st.uses)));
67332
+ const toolsBlob = collectTools(scopedSteps) + (headActionRuns ? " Bash" : "");
66623
67333
  const itemRead = forkInput ? injJobs.map(jobReadsUntrustedItem).find(Boolean) ?? null : null;
66624
67334
  const reach = untrustedTrigger && injJobs.length ? Math.max(
66625
67335
  head === "root" ? 3 : head === "subdir" ? 1 : 0,
@@ -66667,28 +67377,40 @@ function analyzeWorkflow(path91, content) {
66667
67377
  severity = "medium";
66668
67378
  if (!severity) severity = "advisory";
66669
67379
  const signals = [];
67380
+ const openCallers = (callers ?? []).filter((c) => c.untrusted && !c.gated && !c.dispatched);
67381
+ if (openCallers.length)
67382
+ signals.push(
67383
+ `called by ${openCallers.map((c) => `${safeText(c.file, 120)} (${c.keys.map((k) => safeText(k, 40)).join(", ")})`).join("; ")}`
67384
+ );
66670
67385
  if (secretExposed)
66671
67386
  signals.push(
66672
- `runs with base-repo secrets (${triggers.filter((t) => /target|workflow_run/i.test(t)).join(", ")})`
67387
+ `runs with base-repo secrets (${triggers.filter((t) => /target|workflow_run/i.test(t)).map((t) => safeText(t, 40)).join(", ")})`
67388
+ );
67389
+ else if (forkInput)
67390
+ signals.push(
67391
+ `triggered by untrusted input (${triggers.map((t) => safeText(t, 40)).join(", ")})`
66673
67392
  );
66674
- else if (forkInput) signals.push(`triggered by untrusted input (${triggers.join(", ")})`);
66675
67393
  else if (reusable)
66676
67394
  signals.push(
66677
- reusableLoadedGun ? `reusable workflow (${triggers.join(", ")}) that checks out an untrusted head / ingests untrusted input: exploitable the moment a caller wires a fork trigger (reachability depends on the caller, but this workflow is built to process attacker input)` : `reusable workflow (${triggers.join(", ")}): no untrusted trigger of its own; reachability depends on the caller's trigger + actor gate`
67395
+ reusableLoadedGun ? `reusable workflow (${triggers.map((t) => safeText(t, 40)).join(", ")}) that checks out an untrusted head / ingests untrusted input: exploitable the moment a caller wires a fork trigger (reachability depends on the caller, but this workflow is built to process attacker input)` : `reusable workflow (${triggers.map((t) => safeText(t, 40)).join(", ")}): no untrusted trigger of its own; reachability depends on the caller's trigger + actor gate`
66678
67396
  );
66679
67397
  if (forkInput && !privileged && !reusable)
66680
67398
  signals.push(
66681
67399
  "triggered by `pull_request`: fork PRs run with a read-only token (lower risk than pull_request_target)"
66682
67400
  );
66683
67401
  if (head === "root") signals.push("checks out the untrusted PR head into the workspace root");
67402
+ if (headActionRuns)
67403
+ signals.push(
67404
+ "runs the checked-out head's own action (`uses: ./\u2026`): the PR author's code runs with the job's secrets"
67405
+ );
66684
67406
  if (head === "subdir") signals.push("checks out the untrusted PR head into an isolated subdir");
67407
+ const thirdParty = agentSteps.map((st) => claudeActionBuild(st.uses)).find((b) => b !== null && !b.official);
67408
+ if (thirdParty && !implicitGate)
67409
+ signals.push(
67410
+ `third-party build of claude-code-action (\`${safeText(thirdParty.owner, 40)}\`): not assumed to check the actor's write access like the official action`
67411
+ );
66685
67412
  if (workflowRunJobs.length) {
66686
67413
  const builds = workflowRunJobs.flatMap((j) => (j.steps ?? []).filter(isAgentStep)).map((st) => claudeActionBuild(st.uses)).filter((b) => b !== null);
66687
- const thirdParty = builds.find((b) => !b.official);
66688
- if (thirdParty)
66689
- signals.push(
66690
- `third-party build of claude-code-action (\`${safeText(thirdParty.owner, 40)}\`): on workflow_run it is not assumed to check the actor (the official action does from v1.0.185)`
66691
- );
66692
67414
  const old = builds.find((b) => b.official && !workflowRunChecked(b.ref));
66693
67415
  if (old)
66694
67416
  signals.push(
@@ -66702,7 +67424,7 @@ function analyzeWorkflow(path91, content) {
66702
67424
  if (promptUntrusted) signals.push("feeds untrusted PR/issue text to the agent");
66703
67425
  else if (itemRead)
66704
67426
  signals.push(
66705
- itemRead === "prompt-file" ? "an earlier step writes untrusted PR/issue text into the prompt file the agent reads" : itemRead === "tool" ? "the agent fetches PR/issue text itself with a read tool (the text never appears in the prompt)" : "the agent is given the PR/issue number and reads the text itself (the text never appears in the prompt)"
67427
+ itemRead === "prompt-file" ? "an earlier step writes untrusted PR/issue text into the prompt file the agent reads" : itemRead === "artifact" ? "the job downloads the artifacts of the triggering run (produced by the PR) before the agent runs" : itemRead === "step-output" ? "the agent's prompt is an earlier step's output, built from untrusted PR/issue text" : itemRead === "tool" ? "the agent fetches PR/issue text itself with a read tool (the text never appears in the prompt)" : "the agent is given the PR/issue number and reads the text itself (the text never appears in the prompt)"
66706
67428
  );
66707
67429
  if (broadTools) {
66708
67430
  const names = matchedBroadTools(toolsBlob);
@@ -66712,7 +67434,7 @@ function analyzeWorkflow(path91, content) {
66712
67434
  }
66713
67435
  if (bypassActive)
66714
67436
  signals.push(
66715
- 'allowed_non_write_users: "*" with github_token, so any user can trigger the agent'
67437
+ nonWriteActor ? "allowed_non_write_users is the triggering user's own login, with github_token, so any user can trigger the agent" : 'allowed_non_write_users: "*" with github_token, so any user can trigger the agent'
66716
67438
  );
66717
67439
  if (elevated) signals.push("elevated permissions (contents/id-token: write)");
66718
67440
  if (pat) signals.push("a static PAT is exposed to the agent (recoverable via injection)");
@@ -66806,7 +67528,7 @@ function analyzeWorkflow(path91, content) {
66806
67528
  return finding;
66807
67529
  }
66808
67530
  var AGENT_FUEL_RE = /^(ANTHROPIC_API_KEY|ANTHROPIC_AUTH_TOKEN|ANTHROPIC_BASE_URL|CLAUDE_CODE_OAUTH_TOKEN|OPENAI_API_KEY|OPENAI_BASE_URL|GEMINI_API_KEY|GOOGLE_API_KEY|GITHUB_TOKEN)$/i;
66809
- var FUEL_INPUT_KEYS = /^(anthropic_api_key|anthropic_auth_token|anthropic_base_url|claude_code_oauth_token|openai_api_key|openai_base_url|gemini_api_key|google_api_key)$/i;
67531
+ var FUEL_INPUT_KEYS = /^(anthropic[_-]api[_-]key|anthropic[_-]auth[_-]token|anthropic[_-]base[_-]url|claude[_-]code[_-]oauth[_-]token|claude[_-]oauth[_-]token|openai[_-]api[_-]key|openai[_-]base[_-]url|gemini[_-]api[_-]key|google[_-]api[_-]key)$/i;
66810
67532
  function fuelSecretNames(agentSteps) {
66811
67533
  const out = /* @__PURE__ */ new Set();
66812
67534
  const add = (v) => {
@@ -66879,14 +67601,28 @@ function evalAgentJob(job, wf, raw, untrustedTrigger, reusable) {
66879
67601
  if (reusable && !loadedGun && SEVERITY_RANK2[severity] > SEVERITY_RANK2.medium) severity = "medium";
66880
67602
  return { severity, secrets, injectable, canReadEnv };
66881
67603
  }
66882
- function analyzeWorkflowSecrets(path91, content) {
67604
+ function analyzeWorkflowSecrets(path91, content, callers, autoLabels, trustedRunSources) {
67605
+ AUTO_LABELS = autoLabels ?? /* @__PURE__ */ new Set();
67606
+ TRUSTED_RUN_SOURCES = trustedRunSources ?? /* @__PURE__ */ new Set();
67607
+ try {
67608
+ return analyzeWorkflowSecretsInner(path91, content, callers);
67609
+ } finally {
67610
+ AUTO_LABELS = /* @__PURE__ */ new Set();
67611
+ TRUSTED_RUN_SOURCES = /* @__PURE__ */ new Set();
67612
+ UPSTREAM_TRUSTED = false;
67613
+ }
67614
+ }
67615
+ function analyzeWorkflowSecretsInner(path91, content, callers) {
66883
67616
  let raw;
66884
67617
  try {
66885
- raw = parseYamlStrict(content) ?? {};
67618
+ raw = loadWorkflow(content).raw;
66886
67619
  } catch {
66887
67620
  return null;
66888
67621
  }
66889
67622
  const wf = raw;
67623
+ if (callers?.length) CALLERS.set(wf, callers);
67624
+ else CALLERS.delete(wf);
67625
+ UPSTREAM_TRUSTED = upstreamRunTrusted(wf, raw);
66890
67626
  const all = allSteps(wf);
66891
67627
  if (!all.some((s) => isAgentStep(s.step))) return null;
66892
67628
  const { untrusted: forkInput, reusable } = triggerReach(wf, raw);
@@ -66918,70 +67654,285 @@ function analyzeWorkflowSecrets(path91, content) {
66918
67654
  })
66919
67655
  };
66920
67656
  }
66921
-
66922
- // src/ci-check/agent-config.ts
66923
- import path76 from "path";
66924
-
66925
- // src/ci-check/frontmatter.ts
66926
- function parseFrontmatter(content) {
66927
- const text = content.replace(/^/, "").replace(/^\s*\n/, "");
66928
- if (!/^---[ \t]*\r?\n/.test(text)) return null;
66929
- const close = /\r?\n---[ \t]*(\r?\n|$)/.exec(text.slice(3));
66930
- if (!close) return null;
66931
- const block = text.slice(3, 3 + close.index);
67657
+ var COMMIT_PIN_RE = /[#@][0-9a-f]{40}\b/i;
67658
+ function marketplaceOwner(market) {
67659
+ if (/(^|[/\\])\.\.([/\\]|$)/.test(market)) return void 0;
67660
+ return /^(?:https?:\/\/(?:www\.)?|git@|ssh:\/\/git@)?github\.com[:/]([\w.-]{1,100})\/[\w.-]{1,100}(?:\.git)?\/?(?:[#@].*)?$/i.exec(
67661
+ market
67662
+ )?.[1] ?? /^([\w.-]{1,100})\/[\w.-]{1,100}(?:[#@].*)?$/.exec(market)?.[1];
67663
+ }
67664
+ function analyzeWorkflowPlugins(path91, content, owner) {
67665
+ let raw;
66932
67666
  try {
66933
- const parsed = parseYamlStrict(block);
66934
- return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
67667
+ raw = loadWorkflow(content).raw;
66935
67668
  } catch {
66936
- return salvageTopLevel(block);
67669
+ return [];
67670
+ }
67671
+ if (!raw || typeof raw !== "object") return [];
67672
+ const wf = raw;
67673
+ const out = [];
67674
+ const seen = /* @__PURE__ */ new Set();
67675
+ for (const { step } of allSteps(wf)) {
67676
+ if (!claudeActionBuild(step.uses)) continue;
67677
+ if (!str(step.with?.["plugins"]).trim()) continue;
67678
+ for (const m of str(step.with?.["plugin_marketplaces"]).split(/[\s,]+/)) {
67679
+ const market = m.trim();
67680
+ if (!market || COMMIT_PIN_RE.test(market)) continue;
67681
+ if (/^\.{1,2}(\/|$)/.test(market)) continue;
67682
+ const mo = marketplaceOwner(market);
67683
+ if (mo && mo.toLowerCase() === "anthropics") continue;
67684
+ if (owner && mo && mo.toLowerCase() === owner.toLowerCase()) continue;
67685
+ if (seen.has(market)) continue;
67686
+ seen.add(market);
67687
+ out.push({
67688
+ check: "CI-3",
67689
+ rule: "CI-3.plugin-unpinned",
67690
+ locator: safeText(market, 200),
67691
+ dimension: "mcp",
67692
+ severity: "medium",
67693
+ title: "Agent installs plugins from a third-party marketplace on every run",
67694
+ file: path91,
67695
+ signals: [
67696
+ `\`plugin_marketplaces: ${safeText(market, 120)}\` is not pinned to a commit: whoever controls it changes the code and prompts the agent runs`
67697
+ ],
67698
+ fix: "Pin the marketplace to a commit (`<url>#<sha>`), or vendor the plugin into this repository."
67699
+ });
67700
+ }
66937
67701
  }
67702
+ return out;
66938
67703
  }
66939
- function salvageTopLevel(block) {
66940
- const out = {};
66941
- const lines = block.split(/\r?\n/);
66942
- for (let i = 0; i < lines.length; i++) {
66943
- const m = /^([A-Za-z0-9_-]+):[ \t]*(.*)$/.exec(lines[i]);
66944
- if (!m) continue;
66945
- const [, key, rest] = m;
66946
- if (rest.trim()) {
66947
- out[key] = rest.trim();
66948
- continue;
67704
+ var ITEM_LIST_RE = /gh\s+(?:issue|pr)\s+(?:list|view)\b|gh\s+search\b|gh\s+api\s+[^\n]{0,80}\/(?:issues|pulls)\b|\blist_issues\b|\bsearch_issues\b|issues\.listForRepo|pulls\.list\b|(?<!\b(?:and|to|then|or|will|should|must|may|can|please|you|also)\s)\bopen (?:issues|pull requests)\b/i;
67705
+ var WEB_READ_RE = /(^|["\s,])(WebFetch|WebSearch)(\(|\s|,|"|$)/;
67706
+ function readsItemList(text) {
67707
+ if (text.length > 2e5) return ITEM_LIST_RE.test(text);
67708
+ return text.replace(/\\\r?\n/g, " ").split("\n").some((l) => ITEM_LIST_RE.test(l) && !sanitizedListing(l));
67709
+ }
67710
+ function analyzeTrustedReaders(path91, content, autoLabels, callers, trustedRunSources) {
67711
+ AUTO_LABELS = autoLabels ?? /* @__PURE__ */ new Set();
67712
+ TRUSTED_RUN_SOURCES = trustedRunSources ?? /* @__PURE__ */ new Set();
67713
+ try {
67714
+ return analyzeTrustedReadersInner(path91, content, callers);
67715
+ } finally {
67716
+ AUTO_LABELS = /* @__PURE__ */ new Set();
67717
+ TRUSTED_RUN_SOURCES = /* @__PURE__ */ new Set();
67718
+ }
67719
+ }
67720
+ function keysWithCallers(keys, callers) {
67721
+ return [.../* @__PURE__ */ new Set([...keys, ...(callers ?? []).flatMap((c) => c.keys)])];
67722
+ }
67723
+ function analyzeTrustedReadersInner(path91, content, callers) {
67724
+ let raw;
67725
+ try {
67726
+ raw = loadWorkflow(content).raw;
67727
+ } catch {
67728
+ return [];
67729
+ }
67730
+ if (!raw || typeof raw !== "object") return [];
67731
+ const wf = raw;
67732
+ if (callers?.length) CALLERS.set(wf, callers);
67733
+ else CALLERS.delete(wf);
67734
+ const reach = triggerReach(wf, raw);
67735
+ if (reach.untrusted || reach.reusable) return [];
67736
+ const keys = keysWithCallers(reach.keys, callers);
67737
+ if (!keys.some((k) => /^schedule$/i.test(k))) return [];
67738
+ for (const job of jobList(wf)) {
67739
+ const jobIf = parseIf(str(job.if));
67740
+ if (jobIf !== null && !exprAllowsEvent(jobIf, "schedule")) continue;
67741
+ const steps = job.steps ?? [];
67742
+ const agentIdx = steps.findIndex(isAgentStep);
67743
+ if (agentIdx < 0) continue;
67744
+ const agent = steps.filter(isAgentStep);
67745
+ const tools = collectTools(agent);
67746
+ const promptText = agent.map(
67747
+ (st) => str(st.with?.["prompt"]) + " " + str(st.with?.["direct_prompt"]) + " " + (isRunAgent(st) ? str(st.run) : "")
67748
+ ).join(" ");
67749
+ const earlier = steps.slice(0, agentIdx).filter((st) => !isAgentStep(st)).map((st) => str(st.run) + " " + str(st.with?.["script"]));
67750
+ const reads = [];
67751
+ if (ITEM_READ_TOOL_RE.test(tools) || readsItemList(promptText))
67752
+ reads.push("reads open issues or pull requests");
67753
+ else if (earlier.some(readsItemList))
67754
+ reads.push("an earlier step lists open issues or pull requests for it");
67755
+ if (WEB_READ_RE.test(tools)) reads.push("fetches third-party web content");
67756
+ if (!reads.length) continue;
67757
+ const withheld = agent.every(
67758
+ (st) => str(st.with?.["github_token"]).trim() === "''" || "github_token" in (st.with ?? {}) && str(st.with?.["github_token"]).trim() === "" || "GITHUB_TOKEN" in (st.env ?? {}) && str(st.env?.["GITHUB_TOKEN"]).trim() === ""
67759
+ );
67760
+ const checkouts = steps.slice(0, agentIdx).filter((st) => /actions\/checkout/i.test(str(st.uses)));
67761
+ const persisted = checkouts.some((st) => str(st.with?.["persist-credentials"]) !== "false");
67762
+ const checkoutPat = checkouts.some(
67763
+ (st) => /secrets\.(?!GITHUB_TOKEN\b)[A-Za-z_]/i.test(str(st.with?.["token"]))
67764
+ );
67765
+ const pat = usesPatIn([job]) || checkoutPat;
67766
+ const permWrite = jobsHaveWrite(
67767
+ wf,
67768
+ [job],
67769
+ /["']?(contents|issues|pull-requests)["']?\s*:\s*["']?write/i
67770
+ );
67771
+ const handsToken = agent.some(
67772
+ (st) => !!claudeActionBuild(st.uses) || !!str(st.with?.["github_token"]).trim()
67773
+ );
67774
+ const write2 = pat || permWrite && !withheld && (handsToken || persisted);
67775
+ if (!write2) continue;
67776
+ const writes = pat ? "a static PAT" : "a write token";
67777
+ return [
67778
+ {
67779
+ check: "CI-2",
67780
+ rule: "CI-2.trusted-trigger-reader",
67781
+ dimension: "workflows",
67782
+ severity: "advisory",
67783
+ title: "Agent on a trusted trigger reads content strangers write, with a write token",
67784
+ file: path91,
67785
+ signals: [
67786
+ `runs on ${keys.map((k) => safeText(k, 40)).join(", ")}: no stranger starts it`,
67787
+ ...reads.map((r) => `the agent ${r}`),
67788
+ `it holds ${writes}: injected text in that content can act with it`
67789
+ ],
67790
+ fix: "Give the agent a read-only token and apply its output in a separate step, or filter the items to trusted authors first."
67791
+ }
67792
+ ];
67793
+ }
67794
+ return [];
67795
+ }
67796
+ var FORK_PR_TRIGGER_RE = /^(pull_request_target|issue_comment|workflow_run)$/i;
67797
+ var SAME_REPO_GATE_RE = new RegExp(
67798
+ [
67799
+ String.raw`head(?:\.repo|_repository)\.full_name\s*==\s*github\.(?:repository\b|event\.(?:pull_request\.base\.repo|repository)\.full_name)`,
67800
+ String.raw`github\.repository\s*==\s*github\.event\.(?:pull_request\.head\.repo|workflow_run\.head_repository)\.full_name`,
67801
+ String.raw`head(?:\.repo|_repository)\.fork\s*==\s*false`,
67802
+ String.raw`head(?:\.repo|_repository)\.owner\.login\s*==\s*github\.repository_owner`,
67803
+ String.raw`workflow_run\.pull_requests\[0\]`
67804
+ ].join("|"),
67805
+ "i"
67806
+ );
67807
+ var NOT_FORK_ATOM_RE = /^github\.event\.(?:pull_request\.head\.repo|workflow_run\.head_repository)\.fork$/i;
67808
+ var TRUSTED_AUTHOR_GATE_RE = /\b(?:pull_request|issue)\.user\.login\s*==|github\.event\.(?:pull_request|issue)\.author_association\s*==\s*['"](?:OWNER|MEMBER|COLLABORATOR)|contains\(\s*fromjson\(\s*['"][^'"]{0,200}\b(?:OWNER|MEMBER|COLLABORATOR)\b[^'"]{0,200}['"]\s*\)\s*,\s*github\.event\.(?:pull_request|issue)\.author_association/i;
67809
+ function requiresAtom(src, re, events, notRe = NOT_FORK_ATOM_RE) {
67810
+ const x = parseIf(src);
67811
+ if (!x) return false;
67812
+ const req = (e) => {
67813
+ if (events?.length && events.every((ev) => !exprAllowsEvent(e, ev))) return true;
67814
+ switch (e.k) {
67815
+ case "atom": {
67816
+ const m = maskLiterals(e.t);
67817
+ return re.test(m) && !CHAINED_COMPARE_RE.test(m);
67818
+ }
67819
+ case "not":
67820
+ return e.c.k === "atom" && notRe.test(e.c.t.trim());
67821
+ case "and":
67822
+ return e.c.some(req);
67823
+ case "or":
67824
+ return e.c.every(req);
66949
67825
  }
66950
- const items = [];
66951
- while (i + 1 < lines.length && /^[ \t]+-[ \t]+/.test(lines[i + 1])) {
66952
- items.push(lines[++i].replace(/^[ \t]+-[ \t]+/, "").trim());
67826
+ };
67827
+ return req(x);
67828
+ }
67829
+ var PR_EVENTS = [
67830
+ "pull_request_target",
67831
+ "pull_request",
67832
+ "pull_request_review",
67833
+ "pull_request_review_comment"
67834
+ ];
67835
+ function headRefEvents(heads) {
67836
+ const evs = /* @__PURE__ */ new Set();
67837
+ for (const st of heads) {
67838
+ if (st.uses == null) return null;
67839
+ const m = /^\s*\$\{\{([\s\S]{1,500})\}\}\s*$/.exec(str(st.with?.["ref"]));
67840
+ if (!m) return null;
67841
+ for (const part of m[1].split("||").map((x) => x.trim())) {
67842
+ if (/^github\.event\.workflow_run\.[\w.]+$/.test(part)) evs.add("workflow_run");
67843
+ else if (/^github\.event\.pull_request\.[\w.]+$/.test(part))
67844
+ PR_EVENTS.forEach((e) => evs.add(e));
67845
+ else if (!/^(?:github\.(?:sha|ref|ref_name|event\.repository\.default_branch)|'[^']*')$/.test(part))
67846
+ return null;
66953
67847
  }
66954
- if (items.length) out[key] = items;
66955
67848
  }
66956
- return Object.keys(out).length ? out : null;
67849
+ return evs.size ? [...evs] : null;
66957
67850
  }
66958
- function splitGrants(value) {
66959
- const out = [];
66960
- let depth = 0;
66961
- let cur = "";
66962
- for (const ch of value) {
66963
- if (ch === "(") depth++;
66964
- else if (ch === ")") depth = Math.max(0, depth - 1);
66965
- if (depth === 0 && (ch === "," || /\s/.test(ch))) {
66966
- if (cur) out.push(cur);
66967
- cur = "";
66968
- continue;
67851
+ function readOnlySandbox(st) {
67852
+ const args = str(st.with?.["codex-args"]) + " " + str(st.with?.["codex_args"]);
67853
+ return /^read-only$/i.test(str(st.with?.["sandbox"])) && !PERMISSION_BYPASS_RE.test(args);
67854
+ }
67855
+ function requiresTrustedAuthor(src) {
67856
+ const x = parseIf(src);
67857
+ if (!x) return false;
67858
+ const req = (e) => {
67859
+ switch (e.k) {
67860
+ case "atom":
67861
+ return TRUSTED_AUTHOR_GATE_RE.test(e.t) && !NONWRITE_SET_RE.test(e.t) && !CHAINED_COMPARE_RE.test(maskLiterals(e.t));
67862
+ case "not":
67863
+ return false;
67864
+ case "and":
67865
+ return e.c.some(req);
67866
+ case "or":
67867
+ return e.c.every(req);
66969
67868
  }
66970
- cur += ch;
67869
+ };
67870
+ return req(x);
67871
+ }
67872
+ function analyzeForkCodeOnTrust(path91, content, callers, autoLabels, trustedRunSources) {
67873
+ AUTO_LABELS = autoLabels ?? /* @__PURE__ */ new Set();
67874
+ TRUSTED_RUN_SOURCES = trustedRunSources ?? /* @__PURE__ */ new Set();
67875
+ try {
67876
+ return analyzeForkCodeOnTrustInner(path91, content, callers);
67877
+ } finally {
67878
+ AUTO_LABELS = /* @__PURE__ */ new Set();
67879
+ TRUSTED_RUN_SOURCES = /* @__PURE__ */ new Set();
66971
67880
  }
66972
- if (cur) out.push(cur);
66973
- return out;
66974
67881
  }
66975
- function allowedToolsOf(fm) {
66976
- const raw = fm?.["allowed-tools"];
66977
- if (typeof raw === "string") return splitGrants(raw);
66978
- if (Array.isArray(raw)) {
66979
- return raw.filter((x) => typeof x === "string").map((x) => x.trim()).filter(Boolean);
67882
+ function analyzeForkCodeOnTrustInner(path91, content, callers) {
67883
+ let raw;
67884
+ try {
67885
+ raw = loadWorkflow(content).raw;
67886
+ } catch {
67887
+ return [];
67888
+ }
67889
+ if (!raw || typeof raw !== "object") return [];
67890
+ const wf = raw;
67891
+ if (callers?.length) CALLERS.set(wf, callers);
67892
+ else CALLERS.delete(wf);
67893
+ const reach = triggerReach(wf, raw);
67894
+ const forkKeys = keysWithCallers(reach.keys, callers).filter((k) => FORK_PR_TRIGGER_RE.test(k));
67895
+ if (!forkKeys.length) return [];
67896
+ const inj = new Set(injectableJobs(wf, raw, reach.untrusted || reach.reusable));
67897
+ for (const job of jobList(wf)) {
67898
+ if (inj.has(job)) continue;
67899
+ const steps = job.steps ?? [];
67900
+ const agent = steps.filter(isAgentStep);
67901
+ if (!agent.length) continue;
67902
+ if (untrustedHeadCheckout(steps) !== "root") continue;
67903
+ if (agent.every(readOnlySandbox)) continue;
67904
+ if (requiresAtom(str(job.if), SAME_REPO_GATE_RE)) continue;
67905
+ if (agent.every((st) => requiresAtom(str(st.if), SAME_REPO_GATE_RE))) continue;
67906
+ const heads = steps.filter((st) => untrustedHeadCheckout([st]) === "root");
67907
+ if (heads.length && heads.every((st) => requiresAtom(str(st.if), SAME_REPO_GATE_RE))) continue;
67908
+ const evs = headRefEvents(heads);
67909
+ if (evs && !forkKeys.some((k) => evs.includes(k.toLowerCase()))) continue;
67910
+ if (evs && requiresAtom(str(job.if), SAME_REPO_GATE_RE, evs)) continue;
67911
+ if (requiresTrustedAuthor(str(job.if))) continue;
67912
+ const holdsSecret = /secrets\./i.test(str(job.steps) + " " + str(job.env));
67913
+ if (!holdsSecret && !jobsHaveWrite(wf, [job], /:\s*["']?write/i)) continue;
67914
+ return [
67915
+ {
67916
+ check: "CI-2",
67917
+ rule: "CI-2.fork-code-on-trust",
67918
+ dimension: "workflows",
67919
+ severity: "advisory",
67920
+ title: "A maintainer's action runs the agent inside the fork's code",
67921
+ file: path91,
67922
+ signals: [
67923
+ `triggered by ${forkKeys.map((k) => safeText(k, 40)).join(", ")}, behind a gate a maintainer passes (a comment, a label, a push to the PR branch, a reopen)`,
67924
+ "checks out the untrusted PR head into the workspace root",
67925
+ "the agent loads the fork's CLAUDE.md, .claude/settings.json hooks, .mcp.json and build scripts next to the job's secrets"
67926
+ ],
67927
+ fix: "Check out the base branch and give the agent the diff, or check the PR head out into a subdirectory the agent does not start in; do not let it run the fork's build or hooks."
67928
+ }
67929
+ ];
66980
67930
  }
66981
67931
  return [];
66982
67932
  }
66983
67933
 
66984
67934
  // src/ci-check/agent-config.ts
67935
+ import path76 from "path";
66985
67936
  function asStrings(v) {
66986
67937
  return Array.isArray(v) ? v.filter((x) => typeof x === "string") : [];
66987
67938
  }
@@ -66999,11 +67950,32 @@ function hookCommands(hooks) {
66999
67950
  return out;
67000
67951
  }
67001
67952
  var OPEN_RUNNER_RE = new RegExp(
67002
- String.raw`^Bash\((?:[A-Z_][A-Z0-9_]{0,40}=\S{0,200} )*(?:/[\w./-]{0,80}/)?` + String.raw`(python(?:3(?:\.\d{1,2})?)?|node|deno(?: run)?|bun(?: x)?|ruby|perl|php|uv run|uvx|npx|npm exec|pnpm dlx|yarn dlx|pipx|docker run|` + String.raw`curl|wget|nc|ncat|ssh|scp|rsync|sh|bash|zsh|eval|source|xargs|env|sudo|gh)` + String.raw`(?: --?[A-Za-z][\w-]{0,30}){0,4} ?['"]?(?::\*| \*|\*)\)$`,
67953
+ String.raw`^Bash\((?:[A-Z_][A-Z0-9_]{0,40}=\S{0,200} )*(?:/[\w./-]{0,80}/)?` + String.raw`(python(?:3(?:\.\d{1,2})?)?|node|deno(?: run)?|bun(?: x)?|ruby|perl|php|uv run|uvx|npx|npm exec|pnpm dlx|yarn dlx|pipx|docker run|` + String.raw`curl|wget|nc|ncat|ssh|scp|rsync|sh|bash|zsh|eval|source|xargs|env|sudo|gh|` + // PowerShell, and the cmdlets that run a string or a program (a PowerShell grant is read
67954
+ // as Bash, review round 2).
67955
+ String.raw`pwsh|powershell|invoke-expression|iex|invoke-command|icm|start-process|invoke-webrequest|iwr|invoke-restmethod|irm)` + String.raw`(?: --?[A-Za-z][\w-]{0,30}){0,4} ?['"]?(?::\*| \*|\*)\)$`,
67003
67956
  "i"
67004
67957
  );
67005
67958
  var OPEN_RUNNER_MAX = 300;
67006
67959
  var INFO_FLAG_RE = /\s--(version|help)\b/;
67960
+ var EXTRA_RUNNER_RE = new RegExp(
67961
+ String.raw`^Bash\((?:[A-Z_][A-Z0-9_]{0,40}=\S{0,200} )*(?:/[\w./-]{0,80}/)?` + String.raw`(awk|gawk|mawk|sed|tee|tar|make(?! -n\b)|vim|vi|nvim|less|man|timeout|dd|install|` + String.raw`dotnet (?:run|script)|cargo run|go run|mvn exec|gradle run|npm start|tmux|` + String.raw`rm|git (?:push|config|submodule|filter-branch)|gh (?:repo|secret|auth|release))` + String.raw`(?: --?[A-Za-z][\w-]{0,30}){0,4} ?['"]?(?::\*| \*|\*)\)$`,
67962
+ "i"
67963
+ );
67964
+ var EXEC_ESCAPE_RE = new RegExp(
67965
+ String.raw`^Bash\((?:[A-Z_][A-Z0-9_]{0,40}=\S{0,200} )*(?:/[\w./-]{0,80}/)?` + String.raw`(find|make -n)` + String.raw`(?: --?[A-Za-z][\w-]{0,30}){0,4} ?['"]?(?::\*| \*|\*)\)$`,
67966
+ "i"
67967
+ );
67968
+ function execEscape(grant) {
67969
+ if (grant.length > OPEN_RUNNER_MAX) return false;
67970
+ return EXEC_ESCAPE_RE.test(
67971
+ grant.replace(/^PowerShell(?=$|\()/, "Bash").replace(/\s+/g, " ").replace(/\(\s+/, "(")
67972
+ );
67973
+ }
67974
+ var MCP_EXEC_RE = /^mcp__ide__executeCode$|^mcp__[\w-]{1,80}__(?:execute|exec|run)_(?:code|command|shell|sql|query)$|^mcp__[\w-]{1,80}__(?:ctx_)?(?:batch_)?execute$|^mcp__[\w-]{1,80}__(?:bash|shell|terminal)$/;
67975
+ function extraRunner(grant) {
67976
+ if (grant.length > OPEN_RUNNER_MAX || INFO_FLAG_RE.test(grant)) return false;
67977
+ return EXTRA_RUNNER_RE.test(grant.replace(/\s+/g, " ").replace(/\(\s+/, "(")) || MCP_EXEC_RE.test(grant);
67978
+ }
67007
67979
  function openRunner(grant) {
67008
67980
  if (grant.length > OPEN_RUNNER_MAX || INFO_FLAG_RE.test(grant)) return false;
67009
67981
  return OPEN_RUNNER_RE.test(grant.replace(/\s+/g, " ").replace(/\(\s+/, "("));
@@ -67011,13 +67983,15 @@ function openRunner(grant) {
67011
67983
  function gradeBroadGrant(allow, deny, opts = {}) {
67012
67984
  const denySupported = opts.denySupported ?? true;
67013
67985
  const scope = opts.scope ?? "for everyone who opens this repo with the agent";
67014
- const broad = allow.filter(
67015
- (a) => /^Bash$|^Bash\(\s*\*|^Bash\(git:|^Write\(\s*\*|^Write$|^Edit$/.test(a) || openRunner(a)
67016
- );
67986
+ const asBash = (a) => a.replace(/^PowerShell(?=$|\()/, "Bash");
67987
+ const broad = allow.filter((raw) => {
67988
+ const a = asBash(raw);
67989
+ return /^Bash$|^Bash\(\s*\*|^Bash\(git:|^Write\(\s*\*|^Write$|^Edit$/.test(a) || openRunner(a) || opts.settings === true && extraRunner(a);
67990
+ });
67017
67991
  if (broad.length === 0) return null;
67018
- const hasBackstop = deny.some((d) => /Bash|Write|Edit/.test(d));
67019
- const bashBackstop = deny.some((d) => /^Bash\b/.test(d));
67020
- const bareShell = broad.some((a) => /^Bash$|^Bash\(\s*\*/.test(a));
67992
+ const hasBackstop = deny.some((d) => /Bash|PowerShell|Write|Edit/.test(d));
67993
+ const bashBackstop = deny.some((d) => /^(?:Bash|PowerShell)\b/.test(d));
67994
+ const bareShell = broad.some((a) => /^Bash$|^Bash\(\s*\*/.test(asBash(a)));
67021
67995
  const high = bareShell && !bashBackstop;
67022
67996
  const signals = [`broad allow(s): ${broad.slice(0, 5).join(", ")}`];
67023
67997
  if (high)
@@ -67034,7 +68008,7 @@ function gradeBroadGrant(allow, deny, opts = {}) {
67034
68008
  );
67035
68009
  if (broad.some((a) => /^Write|^Edit$/.test(a)))
67036
68010
  signals.push("`Write`/`Edit` pre-approve file changes without a prompt");
67037
- if (broad.some(openRunner))
68011
+ if (broad.some((a) => openRunner(asBash(a))))
67038
68012
  signals.push(
67039
68013
  "an interpreter, network tool, shell runner or `gh` grant ending in a wildcard runs any code or request an injected instruction writes, without a prompt"
67040
68014
  );
@@ -67147,9 +68121,12 @@ function analyzeAgentConfig(path91, content, tree) {
67147
68121
  }
67148
68122
  }
67149
68123
  }
67150
- const allow = asStrings(cfg.permissions?.allow);
68124
+ const allow = [
68125
+ ...asStrings(cfg.permissions?.allow),
68126
+ ...asStrings(cfg.permissions?.allowedTools)
68127
+ ];
67151
68128
  const deny = asStrings(cfg.permissions?.deny);
67152
- const grade = gradeBroadGrant(allow, deny);
68129
+ const grade = gradeBroadGrant(allow, deny, { settings: true });
67153
68130
  if (grade) {
67154
68131
  const { high, signals } = grade;
67155
68132
  const at = jsonValueIndex(content, grade.broad[0]);
@@ -67174,9 +68151,47 @@ function analyzeAgentConfig(path91, content, tree) {
67174
68151
  })
67175
68152
  });
67176
68153
  }
68154
+ const escapes = allow.filter(execEscape);
68155
+ if (escapes.length) {
68156
+ const at = jsonValueIndex(content, escapes[0]);
68157
+ findings.push({
68158
+ ...at >= 0 ? { line: lineAtIndex(content, at) } : {},
68159
+ check: "CI-1",
68160
+ rule: "CI-1.exec-escape-grant",
68161
+ dimension: "toolRules",
68162
+ severity: "medium",
68163
+ title: "Committed agent config pre-approves a command that can run other programs",
68164
+ file: path91,
68165
+ signals: [
68166
+ `pre-approved: ${escapes.slice(0, 5).map((e) => `\`${safeText(e, 80)}\``).join(", ")}`,
68167
+ "`find -exec` and `make -n --eval` run other programs without a prompt"
68168
+ ],
68169
+ fix: "Pre-approve the exact command you need (for example `Bash(find . -name *.ts)` or `Bash(make test)`), not every argument."
68170
+ });
68171
+ }
67177
68172
  return findings;
67178
68173
  }
67179
68174
  var GRANT_CARRIER_RE = /(^|\/)[Ss][Kk][Ii][Ll][Ll]\.md$|(^|\/)\.claude\/commands\/.+\.md$/;
68175
+ var ROOT_SURFACE_RE = /^\.(claude|agents|github|codex|cursor|gemini)\//;
68176
+ var ITEM_TEXT_RE = /gh\s+(?:pr|issue)\s+(?:view|diff|list)\b|gh\s+pr\s+checkout\b|git\s+fetch\s+[^\n]{0,80}\bpull\/|gh\s+api\s+graphql\b|gh\s+api\s+[^\n]{0,120}\/(?:pulls|issues)\b|mcp__github__(?:get_issue|get_pull_request|list_issues|search_issues)|--json\s+[^\n]{0,120}\b(?:body|title)\b/;
68177
+ var PR_TEXT_RE = /gh\s+pr\s+(?:view|diff)\b|gh\s+api\s+[^\n]{0,120}\/pulls\b/;
68178
+ var WEB_TEXT_RE = /\bsearch the web\b|\bfetch (?:the )?(?:url|page)\b/i;
68179
+ function skillReadsUntrusted(path91, content, allow, broad) {
68180
+ if (!ROOT_SURFACE_RE.test(path91)) return null;
68181
+ const body = content.replace(/^---[\s\S]*?\n---/, "");
68182
+ const runsCode = broad.map((a) => a.replace(/^PowerShell(?=$|\()/, "Bash")).some((a) => /^Bash$|^Bash\(\s*\*/.test(a) || openRunner(a) && !/^Bash\(\s*gh\b/.test(a));
68183
+ const webGrant = allow.some((a) => /^(WebFetch|WebSearch)\b/.test(a));
68184
+ const reads = (re) => body.replace(/\\\r?\n/g, " ").split("\n").some((l) => re.test(l) && !sanitizedListing(l));
68185
+ if (runsCode && reads(ITEM_TEXT_RE))
68186
+ return "it reads pull request or issue text that strangers write, with a grant that runs code";
68187
+ if (runsCode && (webGrant || WEB_TEXT_RE.test(body)))
68188
+ return "it reads web content anyone can publish, with a grant that runs code";
68189
+ const writes = allow.some((a) => /^(Write|Edit|MultiEdit)$/.test(a));
68190
+ const ghOrGit = allow.some((a) => /^Bash\(\s*(gh|git)\b/.test(a));
68191
+ if (writes && ghOrGit && reads(PR_TEXT_RE))
68192
+ return "it reads pull request text that strangers write, and may edit files and run git/gh on it";
68193
+ return null;
68194
+ }
67180
68195
  function analyzeSkillGrants(path91, content) {
67181
68196
  if (!GRANT_CARRIER_RE.test(path91)) return [];
67182
68197
  const allow = allowedToolsOf(parseFrontmatter(content));
@@ -67188,6 +68203,21 @@ function analyzeSkillGrants(path91, content) {
67188
68203
  if (!grade) return [];
67189
68204
  const kind = /commands\//.test(path91) ? "slash command" : "skill";
67190
68205
  const at = content.search(/^allowed-tools\s*:/m);
68206
+ const untrusted = skillReadsUntrusted(path91, content, allow, grade.broad);
68207
+ if (untrusted)
68208
+ return [
68209
+ {
68210
+ check: "CI-1",
68211
+ rule: "CI-1.skill-untrusted-input",
68212
+ ...at >= 0 ? { line: lineAtIndex(content, at) } : {},
68213
+ dimension: "toolRules",
68214
+ severity: "advisory",
68215
+ title: `Committed ${kind} runs code or writes with text strangers control`,
68216
+ file: path91,
68217
+ signals: [...grade.signals, untrusted],
68218
+ fix: "Drop the shell or interpreter grant from a skill that reads PRs, issues or the web, or have it treat that text as data in a read-only step first."
68219
+ }
68220
+ ];
67191
68221
  return [
67192
68222
  {
67193
68223
  check: "CI-1",
@@ -67209,27 +68239,178 @@ function analyzeSkillGrants(path91, content) {
67209
68239
 
67210
68240
  // src/ci-check/mcp.ts
67211
68241
  init_dist();
67212
- var LOCAL_RUNNER_RE = /^npx\s+(?:-y\s+|--yes\s+)?(?:tsx|ts-node|node)\s+(?:\.{1,2}\/|\$\{CLAUDE_PLUGIN_ROOT\}\/)?[\w@./-]+\.(?:m?[jt]s|cjs)(?:\s+[^\s;&|`$<>()]+)*$/;
67213
- function analyzeMcp(path91, content) {
68242
+ var LOCAL_RUNNER_RE = /^(?:npx|pnpx|bunx)\s+(?:-y\s+|--yes\s+)?(?:tsx|ts-node|node)\s+(?:\.{1,2}\/|\$\{CLAUDE_PLUGIN_ROOT\}\/)?[\w@./-]+\.(?:m?[jt]s|cjs)(?:\s+[^\s;&|`$<>()]+)*$/;
68243
+ var LAUNCHERS = [
68244
+ { re: /\bnpx\b/, name: "npx", registry: "npm" },
68245
+ { re: /\bpnpx\b|\bpnpm\s+dlx\b/, name: "pnpm dlx", registry: "npm" },
68246
+ { re: /\byarn\s+dlx\b/, name: "yarn dlx", registry: "npm" },
68247
+ { re: /\bnpm\s+(?:exec|x)\b/, name: "npm exec", registry: "npm" },
68248
+ { re: /\bbunx\b|\bbun\s+x\b/, name: "bunx", registry: "npm" },
68249
+ { re: /\buvx\b|\buv\s+tool\s+run\b/, name: "uvx", registry: "PyPI" },
68250
+ { re: /\bpipx\s+run\b/, name: "pipx run", registry: "PyPI" }
68251
+ ];
68252
+ var PINNED_RE = /@v?\d|@[0-9a-f]{40}\b|==\s*\d/i;
68253
+ var LOCAL_FROM_RE = /(?:^|\s)--from(?:\s+|=)(?:\.{1,2}(?:\/\S*)?|\/\S*|~\/\S*)(?:\s|$)/;
68254
+ var RUN_VALUE_FLAGS = new Set(
68255
+ [
68256
+ "-a --attach --add-host --annotation --blkio-weight --blkio-weight-device --cap-add",
68257
+ "--cap-drop --cgroup-parent --cgroupns --cgroups --cgroup-conf --cidfile --cpu-period",
68258
+ "--cpu-quota --cpu-rt-period --cpu-rt-runtime -c --cpu-shares --cpus --cpuset-cpus",
68259
+ "--cpuset-mems --device --device-cgroup-rule --device-read-bps --device-read-iops",
68260
+ "--device-write-bps --device-write-iops --dns --dns-option --dns-search --domainname",
68261
+ "--entrypoint -e --env --env-file --expose --gpus --group-add --health-cmd",
68262
+ "--health-interval --health-retries --health-start-interval --health-start-period",
68263
+ "--health-timeout -h --hostname --ip --ip6 --ipc --isolation --kernel-memory -l --label",
68264
+ "--label-file --link --link-local-ip --log-driver --log-opt --mac-address -m --memory",
68265
+ "--memory-reservation --memory-swap --memory-swappiness --mount --name --network --net",
68266
+ "--network-alias --oom-score-adj --pid --pids-limit --platform -p --publish --pull",
68267
+ "--restart --runtime --security-opt --shm-size --stop-signal --stop-timeout",
68268
+ "--storage-opt --sysctl --tmpfs --ulimit -u --user --userns --uts -v --volume",
68269
+ "--volume-driver --volumes-from -w --workdir --secret --pod --arch --os --variant",
68270
+ "--timezone --umask --requires --uidmap --gidmap --subuidname --subgidname --hooks-dir",
68271
+ "--init-path --seccomp-policy --sdnotify --personality --preserve-fds --chrootdirs"
68272
+ ].join(" ").split(" ")
68273
+ );
68274
+ function runImage(tokens) {
68275
+ const i = tokens.findIndex(
68276
+ (t, k) => /(^|\/)(docker|podman|nerdctl)$/.test(t) && (tokens[k + 1] === "run" || tokens[k + 1] === "container" && tokens[k + 2] === "run")
68277
+ );
68278
+ if (i < 0) return null;
68279
+ const from = tokens[i + 1] === "run" ? i + 2 : i + 3;
68280
+ for (let k = from; k < tokens.length; k++) {
68281
+ const t = tokens[k];
68282
+ if (t.startsWith("-")) {
68283
+ if (!t.includes("=") && RUN_VALUE_FLAGS.has(t)) k++;
68284
+ continue;
68285
+ }
68286
+ return t;
68287
+ }
68288
+ return null;
68289
+ }
68290
+ function launchedPackages(argv) {
68291
+ const t = argv.split(/\s+/).filter(Boolean);
68292
+ const i = t.findIndex((x) => /(^|\/)(npx|pnpx|bunx|uvx|dlx|exec|x|run)$/.test(x));
68293
+ if (i < 0) return [];
68294
+ const bare = (v) => v.startsWith("@") ? v.replace(/^(@[^/]+\/[^@]+).*$/, "$1") : v.replace(/(==|@).*$/, "");
68295
+ const flagged = [];
68296
+ let positional = null;
68297
+ for (let k = i + 1; k < t.length; k++) {
68298
+ const tok = t[k];
68299
+ const eq = /^(--from|-p|--package)=(.*)$/.exec(tok);
68300
+ if (eq) {
68301
+ flagged.push(eq[2]);
68302
+ continue;
68303
+ }
68304
+ if (/^(--from|-p|--package)$/.test(tok)) {
68305
+ if (t[k + 1]) flagged.push(t[++k]);
68306
+ continue;
68307
+ }
68308
+ if (tok.startsWith("-")) continue;
68309
+ positional ??= tok;
68310
+ }
68311
+ const names = flagged.length ? flagged : positional ? [positional] : [];
68312
+ return names.map((n) => bare(n).toLowerCase()).filter(Boolean);
68313
+ }
68314
+ var SHELL_RE = /(?:^|\/)(?:sh|bash|zsh|dash|cmd|pwsh|powershell)(?:\.exe)?$/i;
68315
+ var SHELL_OP_RE = /[;&|`$<>()]/;
68316
+ function launchesOwnPackage(argv, own) {
68317
+ if (own.size === 0) return false;
68318
+ if (SHELL_OP_RE.test(argv) || SHELL_RE.test(argv.split(/\s+/)[0] ?? "")) return false;
68319
+ const l = LAUNCHERS.find((x) => x.re.test(argv));
68320
+ if (!l) return false;
68321
+ const reg = l.registry === "PyPI" ? "pypi" : "npm";
68322
+ const pkgs = launchedPackages(argv);
68323
+ return pkgs.length > 0 && pkgs.every((p) => own.has(`${reg}:${p}`) || own.has(p));
68324
+ }
68325
+ function unpinnedLauncher(argv, own) {
68326
+ if (LOCAL_RUNNER_RE.test(argv)) return null;
68327
+ if (!SHELL_OP_RE.test(argv) && LOCAL_FROM_RE.test(argv)) return null;
68328
+ if (launchesOwnPackage(argv, own)) return null;
68329
+ const image = runImage(argv.split(/\s+/));
68330
+ if (image) {
68331
+ if (image.includes("@sha256:")) return null;
68332
+ const tail = image.slice(image.lastIndexOf("/") + 1);
68333
+ const tag = tail.includes(":") ? tail.slice(tail.indexOf(":") + 1) : "";
68334
+ return !tag || tag === "latest" ? { launcher: "docker run", registry: "the image registry" } : null;
68335
+ }
68336
+ const goMod = /\bgo\s+run\s+(?:-\S+\s+)*([^\s@]+)@(\S+)/.exec(argv);
68337
+ if (goMod)
68338
+ return /^v\d/.test(goMod[2]) || /^[0-9a-f]{40}$/.test(goMod[2]) ? null : { launcher: "go run", registry: "the Go module proxy" };
68339
+ const l = LAUNCHERS.find((x) => x.re.test(argv));
68340
+ if (!l) return null;
68341
+ return /@latest\b/.test(argv) || !PINNED_RE.test(argv) ? { launcher: l.name, registry: l.registry } : null;
68342
+ }
68343
+ function ownPackageNames(manifests) {
68344
+ const out = /* @__PURE__ */ new Set();
68345
+ for (const m of manifests) {
68346
+ if (m.path === "package.json") {
68347
+ try {
68348
+ const pkg = JSON.parse(m.content);
68349
+ if (typeof pkg.name === "string" && pkg.name && pkg.private !== true)
68350
+ out.add(`npm:${pkg.name.toLowerCase()}`);
68351
+ } catch {
68352
+ }
68353
+ } else if (m.path === "pyproject.toml") {
68354
+ const sec = /^\[(project|tool\.poetry)\]\s*$([\s\S]*?)(?=^\[|(?![\s\S]))/m.exec(m.content);
68355
+ const name = sec && /^name\s*=\s*["']([^"']{1,200})["']/m.exec(sec[2]);
68356
+ if (name) {
68357
+ out.add(`pypi:${name[1].toLowerCase()}`);
68358
+ out.add(`pypi:${name[1].toLowerCase().replace(/_/g, "-")}`);
68359
+ }
68360
+ }
68361
+ }
68362
+ return out;
68363
+ }
68364
+ function analyzeMcp(path91, content, own = /* @__PURE__ */ new Set()) {
67214
68365
  let cfg;
67215
68366
  try {
67216
68367
  cfg = JSON.parse(content);
67217
68368
  } catch {
67218
68369
  return [];
67219
68370
  }
67220
- return analyzeMcpServers(cfg.mcpServers ?? {}, path91, content);
68371
+ if (!cfg || typeof cfg !== "object") return [];
68372
+ const sections = [];
68373
+ const obj = (x) => x && typeof x === "object" && !Array.isArray(x) ? x : {};
68374
+ sections.push(["mcpServers", obj(cfg.mcpServers)]);
68375
+ sections.push(["servers", obj(cfg.servers)]);
68376
+ const opencode = {};
68377
+ for (const [name, s] of Object.entries(obj(cfg.mcp))) {
68378
+ if (!s || typeof s !== "object") continue;
68379
+ const o = s;
68380
+ const cmd = Array.isArray(o.command) ? o.command.map(String) : [];
68381
+ opencode[name] = {
68382
+ command: cmd[0],
68383
+ args: cmd.slice(1),
68384
+ url: o.url,
68385
+ env: obj(o.environment),
68386
+ disabled: o.enabled === false
68387
+ };
68388
+ }
68389
+ sections.push(["mcp", opencode]);
68390
+ const count = /* @__PURE__ */ new Map();
68391
+ for (const [, sec] of sections)
68392
+ for (const name of Object.keys(sec)) count.set(name, (count.get(name) ?? 0) + 1);
68393
+ const servers = {};
68394
+ for (const [section, sec] of sections)
68395
+ for (const [name, spec] of Object.entries(sec)) {
68396
+ if (!spec || typeof spec !== "object") continue;
68397
+ const key = (count.get(name) ?? 0) > 1 ? `${section}.${name}` : name;
68398
+ servers[key] = { ...spec, anchor: name };
68399
+ }
68400
+ return analyzeMcpServers(servers, path91, content, own);
67221
68401
  }
67222
- function analyzeMcpServers(servers, path91, content = "") {
68402
+ function analyzeMcpServers(servers, path91, content = "", own = /* @__PURE__ */ new Set()) {
67223
68403
  const findings = [];
67224
68404
  for (const [name, srv] of Object.entries(servers ?? {})) {
67225
68405
  if (!srv || srv.disabled) continue;
67226
68406
  const argv = [srv.command, ...Array.isArray(srv.args) ? srv.args.map(String) : []].join(" ");
67227
- const esc = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
68407
+ const esc = (srv.anchor ?? name).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
67228
68408
  const at = content.search(
67229
68409
  new RegExp(`"${esc}"\\s*:|^[ \\t]*\\[mcp_servers\\.(?:"${esc}"|${esc})\\]`, "m")
67230
68410
  );
67231
68411
  const line = at >= 0 ? lineAtIndex(content, at) : void 0;
67232
- if (/\bnpx\b/.test(argv) && (/@latest\b/.test(argv) || !/@\d/.test(argv)) && !LOCAL_RUNNER_RE.test(argv)) {
68412
+ const why = unpinnedLauncher(argv, own);
68413
+ if (why) {
67233
68414
  findings.push({
67234
68415
  check: "CI-3",
67235
68416
  rule: "CI-3.mcp-unpinned",
@@ -67239,9 +68420,16 @@ function analyzeMcpServers(servers, path91, content = "") {
67239
68420
  title: `MCP server "${name}" runs an unpinned executable`,
67240
68421
  file: path91,
67241
68422
  ...line ? { line } : {},
67242
- signals: [`\`${safeText(argv, 120)}\`: unversioned/@latest npx`],
68423
+ signals: [
68424
+ why.launcher === "npx" ? `\`${safeText(argv, 120)}\`: unversioned/@latest npx` : `\`${safeText(argv, 120)}\`: unversioned/latest ${why.launcher}`
68425
+ ],
67243
68426
  fix: "Pin the MCP server package to an exact version so a PR (or a registry compromise) can\u2019t swap the toolchain.",
67244
- explain: explainMcp({ name, argv })
68427
+ explain: explainMcp({
68428
+ name,
68429
+ argv,
68430
+ registry: why.registry,
68431
+ pkg: why.launcher === "docker run" ? runImage(argv.split(/\s+/))?.replace(/:[^/:]*$/, "") ?? null : why.launcher === "go run" ? /\bgo\s+run\s+(?:-\S+\s+)*([^\s@]+)@/.exec(argv)?.[1] ?? null : launchedPackages(argv)[0] ?? null
68432
+ })
67245
68433
  });
67246
68434
  }
67247
68435
  for (const [k, v] of Object.entries(srv.env ?? {})) {
@@ -67270,7 +68458,7 @@ function analyzeMcpServers(servers, path91, content = "") {
67270
68458
 
67271
68459
  // src/ci-check/codex.ts
67272
68460
  import { parse as parseToml7 } from "smol-toml";
67273
- function analyzeCodexConfig(path91, content) {
68461
+ function analyzeCodexConfig(path91, content, own = /* @__PURE__ */ new Set()) {
67274
68462
  let cfg;
67275
68463
  try {
67276
68464
  cfg = parseToml7(content);
@@ -67278,7 +68466,7 @@ function analyzeCodexConfig(path91, content) {
67278
68466
  return [];
67279
68467
  }
67280
68468
  const findings = [];
67281
- findings.push(...analyzeMcpServers(cfg.mcp_servers ?? {}, path91, content));
68469
+ findings.push(...analyzeMcpServers(cfg.mcp_servers ?? {}, path91, content, own));
67282
68470
  const sandbox = typeof cfg.sandbox_mode === "string" ? cfg.sandbox_mode : "";
67283
68471
  const approval = typeof cfg.approval_policy === "string" ? cfg.approval_policy : "";
67284
68472
  const fullAccess = /danger-full-access/i.test(sandbox);
@@ -67315,10 +68503,13 @@ var UNREAD_RULES = /* @__PURE__ */ new Set([
67315
68503
  "CI-1.hook-script.unscanned-size",
67316
68504
  "CI-6.skill-script.unscanned-size"
67317
68505
  ]);
67318
- var CONCEALMENT = /* @__PURE__ */ new Set(["CI-6.unicode-tag-chars", "CI-6.bidi-override"]);
68506
+ var CONCEALMENT = /* @__PURE__ */ new Set(["CI-6.unicode-tag-chars", "CI-6.bidi-override", "CI-6.homoglyph"]);
67319
68507
  function tierOf(f) {
67320
68508
  const { rule, severity } = f;
67321
68509
  if (rule === "CI-1.skill-allowed-tools") return "review";
68510
+ if (rule === "CI-1.exec-escape-grant") return "review";
68511
+ if (rule === "CI-2.trusted-trigger-reader" || rule === "CI-2.fork-code-on-trust" || rule === "CI-1.skill-untrusted-input")
68512
+ return "alert";
67322
68513
  if (UNREAD_RULES.has(rule)) return "review";
67323
68514
  if (rule.startsWith("CI-2.") || rule.startsWith("CI-4."))
67324
68515
  return severity === "advisory" ? "note" : "alert";
@@ -67344,6 +68535,8 @@ function reviewQuestion(rule) {
67344
68535
  return "Does this hook exist on every machine that runs it?";
67345
68536
  if (/hidden-chars/.test(rule))
67346
68537
  return "Why does this script hold invisible characters? A list of them to detect is fine.";
68538
+ if (/exec-escape/.test(rule))
68539
+ return "Does the agent need this command with any arguments, or only one exact form?";
67347
68540
  if (/unscanned/.test(rule))
67348
68541
  return "node9 did not read this script (too large, or outside the folders it reads). What does it run?";
67349
68542
  return "Is this an instruction the agent follows, or text for a person?";
@@ -67683,12 +68876,59 @@ function scanTree(tree) {
67683
68876
  }
67684
68877
  return named.get(dir).has(docPath);
67685
68878
  };
68879
+ const callersOf = /* @__PURE__ */ new Map();
68880
+ const autoLabels = /* @__PURE__ */ new Set();
68881
+ const routeOfFile = (file) => file.route !== void 0 ? file.route : routeOf(file.path, skillDirs, appDirs);
68882
+ const manifests = [];
68883
+ for (const file of tree.files) {
68884
+ if (routeOfFile(file) !== "manifest") continue;
68885
+ try {
68886
+ manifests.push({ path: file.path, content: file.content });
68887
+ } catch {
68888
+ }
68889
+ }
68890
+ const ownPackages = ownPackageNames(manifests);
68891
+ for (const file of tree.files) {
68892
+ if (routeOfFile(file) !== "issue-form") continue;
68893
+ try {
68894
+ for (const l of issueFormLabels(file.content)) autoLabels.add(l);
68895
+ } catch {
68896
+ }
68897
+ }
68898
+ const runSources = /* @__PURE__ */ new Map();
68899
+ for (const file of tree.files) {
68900
+ if (routeOfFile(file) !== "workflow") continue;
68901
+ try {
68902
+ const src = runSourceOf(file.path, file.content, autoLabels);
68903
+ if (src) runSources.set(src.name, (runSources.get(src.name) ?? true) && src.trusted);
68904
+ } catch {
68905
+ }
68906
+ }
68907
+ const trustedRunSources = new Set([...runSources].filter(([, t]) => t).map(([n]) => n));
68908
+ for (const file of tree.files) {
68909
+ if (routeOfFile(file) !== "workflow") continue;
68910
+ try {
68911
+ const content = file.content;
68912
+ for (const { target, caller } of localCallsOf(
68913
+ file.path,
68914
+ content,
68915
+ autoLabels,
68916
+ trustedRunSources
68917
+ )) {
68918
+ const list = callersOf.get(target) ?? [];
68919
+ list.push(caller);
68920
+ callersOf.set(target, list);
68921
+ }
68922
+ } catch {
68923
+ }
68924
+ }
67686
68925
  for (const file of tree.files) {
67687
68926
  let route = file.route !== void 0 ? file.route : routeOf(file.path, skillDirs, appDirs);
67688
68927
  if (route === "app-doc") {
67689
68928
  if (!namedBy(file.path)) continue;
67690
68929
  route = "instruction";
67691
68930
  }
68931
+ if (route === "issue-form" || route === "manifest") continue;
67692
68932
  inspected.push(file.path);
67693
68933
  if (file.path === SUPPRESSIONS_FILE) continue;
67694
68934
  const testKind = route && CONTENT_ROUTES.has(route) ? testPathKind(file.path, route) : "none";
@@ -67698,10 +68938,24 @@ function scanTree(tree) {
67698
68938
  const content = file.content;
67699
68939
  switch (route) {
67700
68940
  case "workflow": {
67701
- const f = analyzeWorkflow(file.path, content);
68941
+ const callers = callersOf.get(file.path);
68942
+ const f = analyzeWorkflow(file.path, content, callers, autoLabels, trustedRunSources);
67702
68943
  if (f) findings.push(f);
67703
- const s = analyzeWorkflowSecrets(file.path, content);
68944
+ const s = analyzeWorkflowSecrets(
68945
+ file.path,
68946
+ content,
68947
+ callers,
68948
+ autoLabels,
68949
+ trustedRunSources
68950
+ );
67704
68951
  if (s) findings.push(s);
68952
+ findings.push(...analyzeWorkflowPlugins(file.path, content, tree.owner));
68953
+ findings.push(
68954
+ ...analyzeTrustedReaders(file.path, content, autoLabels, callers, trustedRunSources)
68955
+ );
68956
+ findings.push(
68957
+ ...analyzeForkCodeOnTrust(file.path, content, callers, autoLabels, trustedRunSources)
68958
+ );
67705
68959
  break;
67706
68960
  }
67707
68961
  case "agent-config":
@@ -67714,10 +68968,10 @@ function scanTree(tree) {
67714
68968
  findings.push(...analyzeScript(file.path, content, "CI-6.skill-script"));
67715
68969
  break;
67716
68970
  case "mcp":
67717
- findings.push(...analyzeMcp(file.path, content));
68971
+ findings.push(...analyzeMcp(file.path, content, ownPackages));
67718
68972
  break;
67719
68973
  case "codex":
67720
- findings.push(...analyzeCodexConfig(file.path, content));
68974
+ findings.push(...analyzeCodexConfig(file.path, content, ownPackages));
67721
68975
  break;
67722
68976
  case "instruction":
67723
68977
  findings.push(...analyzeInstructionFile(file.path, content));
@@ -67725,7 +68979,9 @@ function scanTree(tree) {
67725
68979
  break;
67726
68980
  }
67727
68981
  } catch (err2) {
67728
- notes.push(`checker degraded on ${file.path}: ${err2?.message ?? "error"}`);
68982
+ notes.push(
68983
+ `checker degraded on ${file.path}: ${err2?.message ?? "error"}; results may be INCOMPLETE`
68984
+ );
67729
68985
  }
67730
68986
  if (testKind === "alerts-only") {
67731
68987
  const kept = findings.splice(before).filter((f) => tierOf(f) === "alert");