@node9/proxy 1.59.0 → 1.60.0

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 +382 -75
  2. package/dist/cli.mjs +382 -75
  3. package/package.json +1 -1
package/dist/cli.mjs CHANGED
@@ -52978,6 +52978,28 @@ var SURFACE_FILES = [
52978
52978
  ".clinerules"
52979
52979
  ];
52980
52980
  var WORKFLOW_DIR = ".github/workflows";
52981
+ var SURFACE_BASENAME = /(^|\/)(CLAUDE|AGENTS|GEMINI)\.md$|(^|\/)\.cursorrules$|(^|\/)\.(windsurf|cline)rules$|(^|\/)copilot-instructions\.md$|(^|\/)\.claude\/settings(\.local)?\.json$|(^|\/)\.mcp\.json$|(^|\/)\.cursor\/mcp\.json$|(^|\/)\.codex\/config\.toml$/;
52982
+ var IGNORE_HARD = /(^|\/)(node_modules|vendor|\.git|\.next|\.venv|site-packages)\//;
52983
+ var IGNORE_SOFT = /(^|\/)(dist|build|out|target)\//;
52984
+ var isIgnoredDir = (relSlash) => IGNORE_HARD.test(relSlash) || IGNORE_SOFT.test(relSlash);
52985
+ var MAX_SURFACE_FILES = 200;
52986
+ function pickSurfacePaths(paths, truncated, notes) {
52987
+ const surface = paths.filter((p) => SURFACE_BASENAME.test(p) && !IGNORE_HARD.test(p));
52988
+ const matched = surface.filter((p) => !IGNORE_SOFT.test(p));
52989
+ const softSkipped = surface.filter((p) => IGNORE_SOFT.test(p));
52990
+ const capped = matched.slice(0, MAX_SURFACE_FILES);
52991
+ if (truncated || matched.length > MAX_SURFACE_FILES) {
52992
+ notes.push(
52993
+ `repo tree is large/truncated \u2014 some agent-surface files may be INCOMPLETE (scanned ${capped.length} of ${matched.length}${truncated ? "+" : ""}).`
52994
+ );
52995
+ }
52996
+ if (softSkipped.length) {
52997
+ notes.push(
52998
+ `skipped ${softSkipped.length} agent-surface file(s) under a build-output dir (dist/build/out/target), e.g. ${softSkipped.slice(0, 3).join(", ")} \u2014 if any is a real committed config, move it out of the build dir to have it scanned.`
52999
+ );
53000
+ }
53001
+ return capped;
53002
+ }
52981
53003
  async function pooled(items, limit, fn) {
52982
53004
  const out = new Array(items.length);
52983
53005
  let next = 0;
@@ -53069,13 +53091,37 @@ async function listWorkflowPaths(owner, repo, notes) {
53069
53091
  if (status !== 200 || !Array.isArray(json)) return [];
53070
53092
  return json.filter((e) => e.type === "file" && /\.ya?ml$/.test(e.name)).map((e) => e.path);
53071
53093
  }
53094
+ var ROOT_WORKFLOW_RE = /^\.github\/workflows\/[^/]+\.ya?ml$/;
53095
+ async function listSurfaceTree(owner, repo, notes) {
53096
+ const url = `https://api.github.com/repos/${owner}/${repo}/git/trees/HEAD?recursive=1`;
53097
+ const { status, json } = await ghGet(url);
53098
+ if (status === 403 || status === 429) {
53099
+ if (!notes.includes(RATE_LIMIT_NOTE)) notes.push(RATE_LIMIT_NOTE);
53100
+ return null;
53101
+ }
53102
+ if (status === 0) {
53103
+ if (!notes.includes(NETWORK_NOTE)) notes.push(NETWORK_NOTE);
53104
+ return null;
53105
+ }
53106
+ if (status !== 200 || !json || typeof json !== "object") return null;
53107
+ const tree = json;
53108
+ if (!Array.isArray(tree.tree)) return null;
53109
+ const blobs = tree.tree.filter((e) => e.type === "blob" && typeof e.path === "string").map((e) => e.path);
53110
+ return {
53111
+ surface: pickSurfacePaths(blobs, !!tree.truncated, notes),
53112
+ workflows: blobs.filter((p) => ROOT_WORKFLOW_RE.test(p))
53113
+ };
53114
+ }
53072
53115
  var FETCH_CONCURRENCY = 8;
53073
53116
  async function fetchGitHubTree(owner, repo, onProgress) {
53074
53117
  const notes = [];
53075
53118
  try {
53076
- onProgress?.({ phase: "listing workflows", done: 0, total: 1 });
53077
- const workflowPaths = await listWorkflowPaths(owner, repo, notes);
53078
- const allPaths = [...SURFACE_FILES, ...workflowPaths];
53119
+ onProgress?.({ phase: "discovering agent surface", done: 0, total: 1 });
53120
+ const discovered = await listSurfaceTree(owner, repo, notes);
53121
+ const workflowPaths = discovered ? discovered.workflows : await listWorkflowPaths(owner, repo, notes);
53122
+ const allPaths = [
53123
+ .../* @__PURE__ */ new Set([...SURFACE_FILES, ...discovered?.surface ?? [], ...workflowPaths])
53124
+ ];
53079
53125
  let done = 0;
53080
53126
  const fetched = await pooled(allPaths, FETCH_CONCURRENCY, async (p) => {
53081
53127
  const f = await fetchOne(owner, repo, p, notes);
@@ -53084,7 +53130,9 @@ async function fetchGitHubTree(owner, repo, onProgress) {
53084
53130
  });
53085
53131
  return { source: `${owner}/${repo}`, files: fetched.filter((f) => !!f), notes };
53086
53132
  } catch (err2) {
53087
- notes.push(`fetch degraded: ${err2?.message ?? "network error"}`);
53133
+ notes.push(
53134
+ `fetch degraded: ${err2?.message ?? "network error"} \u2014 results may be INCOMPLETE (the repo could not be fetched).`
53135
+ );
53088
53136
  return { source: `${owner}/${repo}`, files: [], notes };
53089
53137
  }
53090
53138
  }
@@ -53101,7 +53149,42 @@ function readLocalTree(dir) {
53101
53149
  } catch {
53102
53150
  }
53103
53151
  };
53104
- for (const p of SURFACE_FILES) add(p);
53152
+ const seen = /* @__PURE__ */ new Set();
53153
+ const collect = (rel) => {
53154
+ if (seen.has(rel)) return;
53155
+ seen.add(rel);
53156
+ add(rel);
53157
+ };
53158
+ for (const p of SURFACE_FILES) collect(p);
53159
+ const matches = [];
53160
+ const MAX_DIRS = 5e3;
53161
+ let dirsVisited = 0;
53162
+ const walk = (relDir) => {
53163
+ if (matches.length >= MAX_SURFACE_FILES || dirsVisited >= MAX_DIRS) return;
53164
+ dirsVisited++;
53165
+ let entries;
53166
+ try {
53167
+ entries = fs60.readdirSync(path57.join(root, relDir), { withFileTypes: true });
53168
+ } catch {
53169
+ return;
53170
+ }
53171
+ for (const e of entries) {
53172
+ if (matches.length >= MAX_SURFACE_FILES || dirsVisited >= MAX_DIRS) return;
53173
+ const rel = relDir ? `${relDir}/${e.name}` : e.name;
53174
+ if (e.isDirectory()) {
53175
+ if (isIgnoredDir(`${rel}/`)) continue;
53176
+ walk(rel);
53177
+ } else if (e.isFile() && SURFACE_BASENAME.test(rel)) {
53178
+ matches.push(rel);
53179
+ }
53180
+ }
53181
+ };
53182
+ walk("");
53183
+ if (matches.length >= MAX_SURFACE_FILES || dirsVisited >= MAX_DIRS)
53184
+ notes.push(
53185
+ `repo is large \u2014 some agent-surface files may be INCOMPLETE (capped at ${MAX_SURFACE_FILES} files / ${MAX_DIRS} dirs).`
53186
+ );
53187
+ for (const rel of matches) collect(rel);
53105
53188
  const wfDir = path57.join(root, WORKFLOW_DIR);
53106
53189
  try {
53107
53190
  if (fs60.existsSync(wfDir)) {
@@ -53142,6 +53225,18 @@ var AGENT_ACTION_RE = /(anthropics\/claude-code(-base)?-action|anthropics\/claud
53142
53225
  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;
53143
53226
  var EXFIL_RCE_RE = /(^|["\s,])Bash(\s|,|"|$)|Bash\(\s*\*|Bash\((curl|wget|sh[\s):]|eval|rm[\s):]|git push)/i;
53144
53227
  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;
53228
+ 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;
53229
+ var MEMBERSHIP_CHECK_RE = /orgs\/[^/'"\s]+\/memberships\/|teams\/[^/'"\s]+\/memberships\/|getCollaboratorPermissionLevel|collaborators\/[^/'"\s]+\/permission|checkMembershipForUser|getMembershipForUser/i;
53230
+ var STRANGER_ISSUE_TYPES = /* @__PURE__ */ new Set(["opened", "edited", "reopened", "closed"]);
53231
+ var STRANGER_PR_TYPES = /* @__PURE__ */ new Set([
53232
+ "opened",
53233
+ "edited",
53234
+ "reopened",
53235
+ "closed",
53236
+ "synchronize",
53237
+ "ready_for_review",
53238
+ "converted_to_draft"
53239
+ ]);
53145
53240
  function str(v) {
53146
53241
  return typeof v === "string" ? v : v == null ? "" : JSON.stringify(v);
53147
53242
  }
@@ -53152,9 +53247,57 @@ function triggerKeys(wf, raw) {
53152
53247
  if (on && typeof on === "object") return Object.keys(on);
53153
53248
  return [];
53154
53249
  }
53250
+ function onObject(wf, raw) {
53251
+ return wf.on ?? raw["on"] ?? raw[true];
53252
+ }
53253
+ function activityTypes(node) {
53254
+ return node && Array.isArray(node.types) ? node.types.map(String) : [];
53255
+ }
53256
+ function keyStrangerFirable(on, key) {
53257
+ switch (key) {
53258
+ case "issue_comment":
53259
+ case "pull_request_review":
53260
+ case "pull_request_review_comment":
53261
+ case "workflow_run":
53262
+ case "discussion":
53263
+ case "discussion_comment":
53264
+ case "fork":
53265
+ case "watch":
53266
+ case "public":
53267
+ return true;
53268
+ case "pull_request":
53269
+ case "pull_request_target": {
53270
+ const ts = activityTypes(on[key]);
53271
+ return ts.length ? ts.some((t) => STRANGER_PR_TYPES.has(t)) : true;
53272
+ }
53273
+ case "issues": {
53274
+ const ts = activityTypes(on["issues"]);
53275
+ return ts.length ? ts.some((t) => STRANGER_ISSUE_TYPES.has(t)) : true;
53276
+ }
53277
+ // workflow_call / workflow_dispatch / schedule / push / create / delete / …
53278
+ // are NOT stranger-firable (require write access, or run in a trusted context).
53279
+ default:
53280
+ return false;
53281
+ }
53282
+ }
53283
+ function triggerReach(wf, raw) {
53284
+ const on = onObject(wf, raw) ?? {};
53285
+ const keys = triggerKeys(wf, raw);
53286
+ const firable = keys.filter((k) => keyStrangerFirable(on, k));
53287
+ const untrusted = firable.length > 0;
53288
+ const reusable = !untrusted && keys.some((k) => /^workflow_call$/i.test(k));
53289
+ const secretExposed = firable.some((t) => /pull_request_target|workflow_run/i.test(t));
53290
+ const privileged = untrusted && firable.some(
53291
+ (t) => /pull_request_target|pull_request_review|workflow_run|issue|discussion/i.test(t)
53292
+ );
53293
+ return { keys, untrusted, reusable, secretExposed, privileged };
53294
+ }
53295
+ function jobList(wf) {
53296
+ return Object.values(wf.jobs ?? {}).filter((j) => j != null);
53297
+ }
53155
53298
  function allSteps(wf) {
53156
53299
  const out = [];
53157
- for (const job of Object.values(wf.jobs ?? {})) {
53300
+ for (const job of jobList(wf)) {
53158
53301
  for (const step of job.steps ?? []) out.push({ job, step });
53159
53302
  }
53160
53303
  return out;
@@ -53209,63 +53352,100 @@ function promptTakesUntrusted(steps) {
53209
53352
  }
53210
53353
  return false;
53211
53354
  }
53212
- var ACTOR_GATE_RE = /author_association|FIRST_TIME_CONTRIBUTOR|collaborator|\b(MEMBER|OWNER)\b|permission|github\.actor\s*==|user\.login\s*==|==\s*['"](write|admin|maintain)|contains\([^)]*(login|actor|association)|head\.repo\.full_name\s*==\s*github\.repository/i;
53355
+ var NONCONTAINS_GATE_RE = new RegExp(
53356
+ [
53357
+ String.raw`==\s*['"]?(OWNER|MEMBER|COLLABORATOR)\b`,
53358
+ String.raw`==\s*['"](write|admin|maintain)`,
53359
+ String.raw`github\.actor\s*==`,
53360
+ String.raw`user\.login\s*==`,
53361
+ String.raw`head\.repo\.full_name\s*==\s*github\.repository`
53362
+ ].join("|"),
53363
+ "i"
53364
+ );
53365
+ 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;
53366
+ var CONTAINS_GATE_RE = /contains\((?:[^()]|\([^()]*\))*(login|actor|association|OWNER|MEMBER|COLLABORATOR)/i;
53367
+ var NEGATED_CONTAINS_RE = /!\s*\(?\s*contains\((?:[^()]|\([^()]*\))*(login|actor|association|OWNER|MEMBER|COLLABORATOR)/i;
53213
53368
  function labelTypeConfigured(wf, raw) {
53214
53369
  const on = wf.on ?? raw["on"] ?? raw[true];
53215
53370
  const prTypes = (t) => t && Array.isArray(t.types) ? t.types.map(String) : [];
53216
53371
  return prTypes(on?.["pull_request_target"]).includes("labeled") || prTypes(on?.["pull_request"]).includes("labeled");
53217
53372
  }
53218
53373
  function ifsAreGated(ifs, labelConfigured) {
53219
- const gated = ACTOR_GATE_RE.test(ifs);
53374
+ const containsGate = CONTAINS_GATE_RE.test(ifs) && !NEGATED_CONTAINS_RE.test(ifs);
53375
+ const gated = NONCONTAINS_GATE_RE.test(ifs) || containsGate;
53220
53376
  const labelGated = labelConfigured && /event\.label|label\.name/i.test(ifs);
53221
53377
  return gated || labelGated;
53222
53378
  }
53223
53379
  function hasActorGate(wf, raw) {
53224
- const ifs = [
53225
- wf.jobs ? Object.values(wf.jobs).map((j) => j.if) : [],
53226
- allSteps(wf).map((s) => s.step.if)
53227
- ].flat().map(str).join(" ");
53380
+ const ifs = [jobList(wf).map((j) => j.if), allSteps(wf).map((s) => s.step.if)].flat().map(str).join(" ");
53228
53381
  return ifsAreGated(ifs, labelTypeConfigured(wf, raw));
53229
53382
  }
53383
+ function hasStepMembershipGate(job) {
53384
+ const steps = job.steps ?? [];
53385
+ const gateIds = steps.filter((s) => s.id && MEMBERSHIP_CHECK_RE.test(str(s.run))).map((s) => s.id);
53386
+ if (!gateIds.length) return false;
53387
+ return steps.some(
53388
+ (s) => isAgentStep(s) && gateIds.some(
53389
+ (id) => new RegExp(`steps\\.${id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\.outputs\\.`).test(
53390
+ str(s.if)
53391
+ )
53392
+ )
53393
+ );
53394
+ }
53230
53395
  function jobActorGate(job, wf, raw) {
53231
53396
  const ifs = [job.if, ...(job.steps ?? []).map((s) => s.if)].map(str).join(" ");
53232
- return ifsAreGated(ifs, labelTypeConfigured(wf, raw)) || /assignee\.login\s*==|event\.assignee\b/i.test(ifs);
53397
+ return ifsAreGated(ifs, labelTypeConfigured(wf, raw)) || /assignee\.login\s*==|event\.assignee\b/i.test(ifs) || PERMISSION_OUTPUT_GATE_RE.test(ifs) || // [2] job-scoped permission-check-output gate
53398
+ hasStepMembershipGate(job);
53233
53399
  }
53234
53400
  function hasImplicitActorGate(agentSteps, bypassActive) {
53235
53401
  if (bypassActive) return false;
53236
53402
  return agentSteps.some((s) => /anthropics\/claude-code-action@/i.test(s.uses ?? ""));
53237
53403
  }
53238
- function injectableAgentSteps(wf, raw, untrustedTrigger) {
53404
+ function injectableJobs(wf, raw, untrustedTrigger) {
53239
53405
  const out = [];
53240
- for (const job of Object.values(wf.jobs ?? {})) {
53406
+ for (const job of jobList(wf)) {
53241
53407
  const a = (job.steps ?? []).filter(isAgentStep);
53242
53408
  if (!a.length) continue;
53243
53409
  const jobStar = str(a.map((s) => s.with?.["allowed_non_write_users"]).find(Boolean)) === "*";
53244
- const jobBypass = jobStar && untrustedTrigger;
53245
- if (jobActorGate(job, wf, raw) || hasImplicitActorGate(a, jobBypass)) continue;
53246
- out.push(...a);
53410
+ if (jobActorGate(job, wf, raw) || hasImplicitActorGate(a, jobStar && untrustedTrigger))
53411
+ continue;
53412
+ out.push(job);
53247
53413
  }
53248
53414
  return out;
53249
53415
  }
53250
- function permsElevated(wf, agentJobs) {
53251
- const check = (p) => {
53252
- const s = str(p);
53253
- return /["']?(contents|id-token|packages)["']?\s*:\s*["']?write/i.test(s);
53254
- };
53255
- return check(wf.permissions) || agentJobs.some((j) => check(j.permissions));
53416
+ function usesPatIn(jobs) {
53417
+ for (const j of jobs)
53418
+ for (const step of j.steps ?? []) {
53419
+ const gt = str(step.with?.["github_token"]);
53420
+ if (/secrets\./i.test(gt) && !/secrets\.GITHUB_TOKEN/i.test(gt)) return true;
53421
+ }
53422
+ return false;
53256
53423
  }
53257
- function hasGithubWritePerm(wf, agentJobs) {
53258
- const check = (p) => /["']?(contents|pull-requests|issues|packages|actions|deployments)["']?\s*:\s*["']?write/i.test(
53259
- str(p)
53424
+ function jobPerms(wf, job) {
53425
+ return job.permissions != null ? str(job.permissions) : str(wf.permissions);
53426
+ }
53427
+ function jobsHaveWrite(wf, jobs, rx) {
53428
+ return jobs.some((j) => {
53429
+ const p = jobPerms(wf, j);
53430
+ return /\bwrite-all\b/i.test(p) || rx.test(p);
53431
+ });
53432
+ }
53433
+ function hasIdTokenWrite(wf, jobs) {
53434
+ const rx = /["']?id-token["']?\s*:\s*["']?write/i;
53435
+ return jobs.some((j) => rx.test(jobPerms(wf, j)));
53436
+ }
53437
+ function hasPrWritePerm(wf, jobs) {
53438
+ return jobsHaveWrite(wf, jobs, /["']?pull-requests["']?\s*:\s*["']?write/i);
53439
+ }
53440
+ function hasCodeWritePerm(wf, agentJobs) {
53441
+ return jobsHaveWrite(
53442
+ wf,
53443
+ agentJobs,
53444
+ /["']?(contents|packages|actions|deployments)["']?\s*:\s*["']?write/i
53260
53445
  );
53261
- return check(wf.permissions) || agentJobs.some((j) => check(j.permissions));
53262
53446
  }
53263
- function usesPat(wf) {
53264
- for (const { step } of allSteps(wf)) {
53265
- const gt = str(step.with?.["github_token"]);
53266
- if (/secrets\./i.test(gt) && !/secrets\.GITHUB_TOKEN/i.test(gt)) return true;
53267
- }
53268
- return false;
53447
+ function hasMetaWritePerm(wf, agentJobs) {
53448
+ return jobsHaveWrite(wf, agentJobs, /["']?(pull-requests|issues)["']?\s*:\s*["']?write/i);
53269
53449
  }
53270
53450
  function hasEnvDeny(steps) {
53271
53451
  return steps.some((s) => /"?mode"?\s*:\s*"?deny/i.test(str(s.with?.["settings"])));
@@ -53298,33 +53478,38 @@ function analyzeWorkflow(path70, content) {
53298
53478
  )
53299
53479
  ];
53300
53480
  if (agentSteps.length === 0) return null;
53301
- const triggers = triggerKeys(wf, raw);
53302
- const secretExposed = triggers.some((t) => /pull_request_target|workflow_run/i.test(t));
53303
- const forkInput = triggers.some((t) => /issue|pull_request|workflow_call/i.test(t));
53304
- const privileged = triggers.some(
53305
- (t) => /pull_request_target|pull_request_review|workflow_run|workflow_call|issue|discussion/i.test(t)
53306
- );
53481
+ const {
53482
+ keys: triggers,
53483
+ untrusted: forkInput,
53484
+ secretExposed,
53485
+ reusable,
53486
+ privileged
53487
+ } = triggerReach(wf, raw);
53307
53488
  const nonWrite = str(agentSteps.map((s) => s.with?.["allowed_non_write_users"]).find(Boolean));
53308
53489
  const nonWriteStar = nonWrite === "*";
53309
53490
  const nonWriteList = !!nonWrite && nonWrite !== "*";
53310
- const untrustedTrigger = secretExposed || forkInput;
53491
+ const untrustedTrigger = forkInput || reusable;
53311
53492
  const bypassActive = nonWriteStar && untrustedTrigger;
53312
53493
  const head = untrustedHeadCheckout(steps);
53313
53494
  const promptUntrusted = promptTakesUntrusted(agentSteps);
53314
- const reach = untrustedTrigger ? Math.max(
53495
+ const injJobs = injectableJobs(wf, raw, untrustedTrigger);
53496
+ const powerJobs = injJobs.length ? injJobs : agentJobs;
53497
+ const powerSteps = injJobs.flatMap((j) => (j.steps ?? []).filter(isAgentStep));
53498
+ const scopedSteps = powerSteps.length ? powerSteps : agentSteps;
53499
+ const toolsBlob = collectTools(scopedSteps);
53500
+ const reach = untrustedTrigger && injJobs.length ? Math.max(
53315
53501
  head === "root" ? 3 : head === "subdir" ? 1 : 0,
53316
53502
  promptUntrusted ? 2 : 0,
53317
53503
  bypassActive ? 2 : 0
53318
53504
  ) : 0;
53319
- const powerSteps = injectableAgentSteps(wf, raw, untrustedTrigger);
53320
- const scopedSteps = powerSteps.length ? powerSteps : agentSteps;
53321
- const toolsBlob = collectTools(scopedSteps);
53322
53505
  const broadTools = BROAD_TOOL_RE.test(toolsBlob);
53323
- const elevated = permsElevated(wf, agentJobs);
53324
- const pat = usesPat(wf);
53506
+ const egressTool = EGRESS_TOOL_RE.test(toolsBlob);
53507
+ const elevated = hasCodeWritePerm(wf, powerJobs) || hasIdTokenWrite(wf, powerJobs) && egressTool;
53508
+ const pat = usesPatIn(powerJobs);
53325
53509
  const power = (broadTools ? 2 : 0) + (bypassActive ? 1 : 0) + (elevated ? 1 : 0) + (pat ? 1 : 0);
53326
53510
  const explicitGate = hasActorGate(wf, raw);
53327
53511
  const implicitGate = hasImplicitActorGate(agentSteps, bypassActive);
53512
+ const membershipGated = injJobs.length === 0 && jobList(wf).some((j) => (j.steps ?? []).some(isAgentStep) && hasStepMembershipGate(j));
53328
53513
  const gate = explicitGate || implicitGate;
53329
53514
  const envDeny = hasEnvDeny(agentSteps);
53330
53515
  const pinned = agentActionsPinned(agentSteps);
@@ -53333,7 +53518,7 @@ function analyzeWorkflow(path70, content) {
53333
53518
  if (envDeny) score -= 1;
53334
53519
  if (pinned) score -= 1;
53335
53520
  score = Math.max(0, score);
53336
- if (score === 0 && !secretExposed) return null;
53521
+ if (score === 0 && !secretExposed && !reusable) return null;
53337
53522
  let severity = severityFromScore(score);
53338
53523
  if (gate && severity && severity !== "advisory") severity = "advisory";
53339
53524
  if (reach === 0 && severity && severity !== "advisory") severity = "advisory";
@@ -53341,9 +53526,20 @@ function analyzeWorkflow(path70, content) {
53341
53526
  severity = "medium";
53342
53527
  const exfilOrRce = EXFIL_RCE_RE.test(toolsBlob);
53343
53528
  const githubWriteTool = GH_WRITE_TOOL_RE.test(toolsBlob);
53344
- const canDamage = exfilOrRce || (hasGithubWritePerm(wf, agentJobs) || pat) && githubWriteTool;
53529
+ const rce = exfilOrRce;
53530
+ const codeWrite = pat || hasCodeWritePerm(wf, powerJobs) && githubWriteTool;
53531
+ const metaWrite = hasMetaWritePerm(wf, powerJobs) && githubWriteTool;
53532
+ const canDamage = rce || codeWrite || metaWrite;
53345
53533
  if (!canDamage && severity && SEVERITY_RANK2[severity] > SEVERITY_RANK2.medium) severity = "medium";
53346
- if (untrustedTrigger && !privileged && severity && severity !== "advisory") severity = "advisory";
53534
+ if (canDamage && !rce && !codeWrite && severity === "critical") severity = "high";
53535
+ const issueOnlyWrite = metaWrite && !hasPrWritePerm(wf, powerJobs) && !hasCodeWritePerm(wf, powerJobs);
53536
+ if (issueOnlyWrite && !rce && severity && SEVERITY_RANK2[severity] > SEVERITY_RANK2.medium)
53537
+ severity = "medium";
53538
+ if (untrustedTrigger && !privileged && !reusable && severity && severity !== "advisory")
53539
+ severity = "advisory";
53540
+ const reusableLoadedGun = reusable && (head === "root" || promptUntrusted);
53541
+ if (reusable && !reusableLoadedGun && severity && SEVERITY_RANK2[severity] > SEVERITY_RANK2.medium)
53542
+ severity = "medium";
53347
53543
  if (!severity) severity = "advisory";
53348
53544
  const signals = [];
53349
53545
  if (secretExposed)
@@ -53351,7 +53547,11 @@ function analyzeWorkflow(path70, content) {
53351
53547
  `runs with base-repo secrets (${triggers.filter((t) => /target|workflow_run/i.test(t)).join(", ")})`
53352
53548
  );
53353
53549
  else if (forkInput) signals.push(`triggered by untrusted input (${triggers.join(", ")})`);
53354
- if (untrustedTrigger && !privileged)
53550
+ else if (reusable)
53551
+ signals.push(
53552
+ reusableLoadedGun ? `reusable workflow (${triggers.join(", ")}) that checks out an untrusted head / ingests untrusted input \u2014 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(", ")}) \u2014 no untrusted trigger of its own; reachability depends on the caller's trigger + actor gate`
53553
+ );
53554
+ if (forkInput && !privileged && !reusable)
53355
53555
  signals.push(
53356
53556
  "triggered by `pull_request` \u2014 fork PRs run with a read-only token (lower risk than pull_request_target)"
53357
53557
  );
@@ -53363,8 +53563,14 @@ function analyzeWorkflow(path70, content) {
53363
53563
  if (elevated) signals.push("elevated permissions (contents/id-token: write)");
53364
53564
  if (pat) signals.push("a static PAT is exposed to the agent (recoverable via injection)");
53365
53565
  if (!gate && reach > 0) signals.push("no effective actor gate");
53566
+ const noExplicitPerms = wf.permissions == null && jobList(wf).every((j) => j.permissions == null);
53567
+ if (noExplicitPerms && reach > 0 && (broadTools || githubWriteTool))
53568
+ signals.push(
53569
+ "no explicit `permissions:` \u2014 the token defaults to the repo/org setting, which may grant write; set it explicitly to read-only"
53570
+ );
53366
53571
  const mitigations = [];
53367
- if (explicitGate) mitigations.push("actor-gated (maintainer/label/write-user required)");
53572
+ if (explicitGate || membershipGated)
53573
+ mitigations.push("actor-gated (maintainer/label/write-user required)");
53368
53574
  else if (implicitGate)
53369
53575
  mitigations.push("claude-code-action gates the agent to write-access users by default");
53370
53576
  if (head === "subdir") mitigations.push("untrusted head isolated in a subdir, not root");
@@ -53417,12 +53623,12 @@ function agentReachableSecrets(wf, agentSteps, agentJobs) {
53417
53623
  found.set(name, classifySecret(name));
53418
53624
  }
53419
53625
  }
53420
- const idToken = /["']?id-token["']?\s*:\s*["']?write/i.test(str(wf.permissions)) || agentJobs.some((j) => /["']?id-token["']?\s*:\s*["']?write/i.test(str(j.permissions)));
53626
+ const idToken = hasIdTokenWrite(wf, agentJobs);
53421
53627
  const out = [...found].map(([name, kind]) => ({ name, kind }));
53422
53628
  if (idToken) out.push({ name: "id-token (cloud OIDC)", kind: "cloud-oidc" });
53423
53629
  return out;
53424
53630
  }
53425
- function evalAgentJob(job, wf, raw, untrustedTrigger) {
53631
+ function evalAgentJob(job, wf, raw, untrustedTrigger, reusable) {
53426
53632
  const jobSteps = job.steps ?? [];
53427
53633
  const jobAgentSteps = jobSteps.filter(isAgentStep);
53428
53634
  if (jobAgentSteps.length === 0) return null;
@@ -53449,6 +53655,8 @@ function evalAgentJob(job, wf, raw, untrustedTrigger) {
53449
53655
  } else {
53450
53656
  return null;
53451
53657
  }
53658
+ const loadedGun = head === "root" || promptTakesUntrusted(jobAgentSteps);
53659
+ if (reusable && !loadedGun && SEVERITY_RANK2[severity] > SEVERITY_RANK2.medium) severity = "medium";
53452
53660
  return { severity, secrets, injectable, canReadEnv };
53453
53661
  }
53454
53662
  function analyzeWorkflowSecrets(path70, content) {
@@ -53461,12 +53669,10 @@ function analyzeWorkflowSecrets(path70, content) {
53461
53669
  const wf = raw;
53462
53670
  const all = allSteps(wf);
53463
53671
  if (!all.some((s) => isAgentStep(s.step))) return null;
53464
- const triggers = triggerKeys(wf, raw);
53465
- const untrustedTrigger = triggers.some(
53466
- (t) => /pull_request_target|workflow_run|issue|pull_request|workflow_call/i.test(t)
53467
- );
53672
+ const { untrusted: forkInput, reusable } = triggerReach(wf, raw);
53673
+ const untrustedTrigger = forkInput || reusable;
53468
53674
  const agentJobs = [...new Set(all.filter((s) => isAgentStep(s.step)).map((s) => s.job))];
53469
- const worst = agentJobs.map((job) => evalAgentJob(job, wf, raw, untrustedTrigger)).filter((e) => e !== null).sort((a, b) => SEVERITY_RANK2[b.severity] - SEVERITY_RANK2[a.severity])[0];
53675
+ const worst = agentJobs.map((job) => evalAgentJob(job, wf, raw, untrustedTrigger, reusable)).filter((e) => e !== null).sort((a, b) => SEVERITY_RANK2[b.severity] - SEVERITY_RANK2[a.severity])[0];
53470
53676
  if (!worst) return null;
53471
53677
  return {
53472
53678
  check: "CI-4",
@@ -53509,18 +53715,20 @@ function analyzeAgentConfig(path70, content) {
53509
53715
  }
53510
53716
  const findings = [];
53511
53717
  for (const cmd of hookCommands(cfg.hooks)) {
53512
- const remote = /\b(npx|curl|wget|iwr|irm)\b/.test(cmd) || /\|\s*(sh|bash)\b/.test(cmd);
53513
- if (!remote) continue;
53514
- const unpinned = /@latest\b/.test(cmd) || /\bnpx\b/.test(cmd) && !/@\d/.test(cmd);
53718
+ const remoteExec = /\|\s*(sh|bash|zsh)\b/.test(cmd) || /\b(curl|wget|iwr|irm)\b/.test(cmd);
53719
+ const isNpx = /\bnpx\b/.test(cmd);
53720
+ if (!remoteExec && !isNpx) continue;
53721
+ const unpinned = /@latest\b/.test(cmd) || isNpx && !/@\d/.test(cmd);
53722
+ const high = remoteExec || unpinned;
53515
53723
  findings.push({
53516
53724
  check: "CI-1",
53517
53725
  dimension: "toolRules",
53518
- severity: unpinned ? "high" : "medium",
53519
- title: unpinned ? "Agent hook runs UNPINNED third-party code on every action" : "Agent hook runs third-party code in the agent hot path",
53726
+ severity: high ? "high" : "medium",
53727
+ title: high ? "Agent hook runs UNPINNED/remote third-party code on every action" : "Agent hook runs third-party code in the agent hot path",
53520
53728
  file: path70,
53521
53729
  signals: [
53522
53730
  `hook command: \`${cmd.slice(0, 120)}\``,
53523
- unpinned ? "unpinned \u2014 a compromised/yanked package = code execution on every contributor" : "pinned, but still a standing supply-chain dependency in the agent hot path"
53731
+ remoteExec ? "fetch-and-run (curl|wget / pipe-to-shell) \u2014 unpinnable remote code execution on every contributor" : unpinned ? "unpinned \u2014 a compromised/yanked package = code execution on every contributor" : "pinned, but still a standing supply-chain dependency in the agent hot path"
53524
53732
  ],
53525
53733
  fix: "Vendor the command as a committed local script, or pin an exact version and treat updates as security-reviewed."
53526
53734
  });
@@ -53531,17 +53739,18 @@ function analyzeAgentConfig(path70, content) {
53531
53739
  (a) => /^Bash$|^Bash\(\s*\*|^Bash\(git:|^Write\(\s*\*|^Write$|^Edit$/.test(a)
53532
53740
  );
53533
53741
  if (broad.length > 0) {
53742
+ const hasBackstop = deny.some((d) => /Bash|Write|Edit/.test(d));
53534
53743
  findings.push({
53535
53744
  check: "CI-1",
53536
53745
  dimension: "toolRules",
53537
- severity: "medium",
53538
- title: "Committed agent config pre-authorizes broad tools",
53746
+ severity: hasBackstop ? "medium" : "high",
53747
+ title: hasBackstop ? "Committed agent config pre-authorizes broad tools" : "Committed agent config pre-authorizes broad tools with no deny backstop",
53539
53748
  file: path70,
53540
53749
  signals: [
53541
53750
  `broad allow(s): ${broad.slice(0, 5).join(", ")}`,
53542
- ...deny.length === 0 ? ["no `deny` entries to backstop it"] : []
53751
+ hasBackstop ? "a `deny` list backstops the broad allow" : "no `deny` entry covers Bash/Write/Edit \u2014 every contributor is pre-authorized for catastrophic tools"
53543
53752
  ],
53544
- fix: "Scope the allow-list to specific read-only subcommands (e.g. `Bash(gh pr view:*)`); avoid bare `Bash`/`git:`/`Write`."
53753
+ fix: "Scope the allow-list to specific read-only subcommands (e.g. `Bash(gh pr view:*)`); avoid bare `Bash`/`git:`/`Write`, or add a `deny` backstop."
53545
53754
  });
53546
53755
  }
53547
53756
  return findings;
@@ -53556,8 +53765,11 @@ function analyzeMcp(path70, content) {
53556
53765
  } catch {
53557
53766
  return [];
53558
53767
  }
53768
+ return analyzeMcpServers(cfg.mcpServers ?? {}, path70);
53769
+ }
53770
+ function analyzeMcpServers(servers, path70) {
53559
53771
  const findings = [];
53560
- for (const [name, srv] of Object.entries(cfg.mcpServers ?? {})) {
53772
+ for (const [name, srv] of Object.entries(servers ?? {})) {
53561
53773
  if (!srv || srv.disabled) continue;
53562
53774
  const argv = [srv.command, ...Array.isArray(srv.args) ? srv.args.map(String) : []].join(" ");
53563
53775
  if (/\bnpx\b/.test(argv) && (/@latest\b/.test(argv) || !/@\d/.test(argv))) {
@@ -53592,8 +53804,63 @@ function analyzeMcp(path70, content) {
53592
53804
  return findings;
53593
53805
  }
53594
53806
 
53807
+ // src/ci-check/codex.ts
53808
+ import { parse as parseToml5 } from "smol-toml";
53809
+ function analyzeCodexConfig(path70, content) {
53810
+ let cfg;
53811
+ try {
53812
+ cfg = parseToml5(content);
53813
+ } catch {
53814
+ return [];
53815
+ }
53816
+ const findings = [];
53817
+ findings.push(...analyzeMcpServers(cfg.mcp_servers ?? {}, path70));
53818
+ const sandbox = typeof cfg.sandbox_mode === "string" ? cfg.sandbox_mode : "";
53819
+ const approval = typeof cfg.approval_policy === "string" ? cfg.approval_policy : "";
53820
+ const fullAccess = /danger-full-access/i.test(sandbox);
53821
+ const noApproval = /^never$/i.test(approval);
53822
+ if (fullAccess || noApproval) {
53823
+ const signals = [
53824
+ fullAccess ? 'sandbox_mode = "danger-full-access" \u2014 the agent runs arbitrary commands with full disk + network access' : null,
53825
+ noApproval ? 'approval_policy = "never" \u2014 no human approval for agent actions' : null
53826
+ ].filter((s) => s !== null);
53827
+ findings.push({
53828
+ check: "CI-1",
53829
+ dimension: "toolRules",
53830
+ severity: fullAccess ? "high" : "medium",
53831
+ title: fullAccess ? "Codex config grants a full-access sandbox" : "Codex config never requires approval",
53832
+ file: path70,
53833
+ signals,
53834
+ fix: 'Commit a least-privilege Codex config: prefer `sandbox_mode = "read-only"` (or `"workspace-write"`) and `approval_policy = "on-request"`/`"on-failure"`. A repo-committed config applies to every contributor who runs Codex here.'
53835
+ });
53836
+ }
53837
+ return findings;
53838
+ }
53839
+
53595
53840
  // src/ci-check/instructions.ts
53596
- var HIDDEN_CHARS = /[\u200B\u2060\u202A-\u202E\u2066-\u2069]|[\u{E0000}-\u{E007F}]/u;
53841
+ var TAG_CHARS = /[\u{E0000}-\u{E007F}]/u;
53842
+ var BIDI_OVERRIDE = /[‭‮]/;
53843
+ var BIDI_EMBED_ISOLATE = /[‪-‬⁦-⁩]/;
53844
+ function isZwLegitScript(cp) {
53845
+ if (cp === void 0) return false;
53846
+ 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;
53847
+ }
53848
+ var isAsciiWordChar = (ch) => !!ch && /[A-Za-z0-9]/.test(ch);
53849
+ function suspiciousZeroWidth(text) {
53850
+ let n = 0;
53851
+ for (let i = 0; i < text.length; i++) {
53852
+ const c = text.charCodeAt(i);
53853
+ if (c !== 8203 && c !== 8288) continue;
53854
+ if (isZwLegitScript(text.codePointAt(i - 1)) || isZwLegitScript(text.codePointAt(i + 1)))
53855
+ continue;
53856
+ const prev = text[i - 1];
53857
+ const next = text[i + 1];
53858
+ if (!prev || !next || /\s/.test(prev) || /\s/.test(next)) continue;
53859
+ if (isAsciiWordChar(prev) && isAsciiWordChar(next)) n++;
53860
+ }
53861
+ return n;
53862
+ }
53863
+ var stripZeroWidth = (t) => t.replace(/[​⁠]/g, "");
53597
53864
  var OVERRIDE_RE = /ignore\s+(all\s+)?(previous|prior|the\s+above)\s+(instructions|prompts?|rules)|disregard\s+(the\s+|your\s+)?(system\s+)?(prompt|instructions|rules)|forget\s+(everything|all\s+(previous|prior))|you\s+are\s+now\s+(a|an|the)\b|<\/?system>/i;
53598
53865
  var FETCH_OBEY_RE = /\b(curl|wget|iwr|invoke-webrequest)\b[^\n|]*\|\s*(bash|sh|zsh|python3?|node|iex)\b|\b(curl|wget)\b[^\n]*&&[^\n]*\b(bash|sh)\b/i;
53599
53866
  var SECRET_PATH_RE = /~\/\.aws\/credentials|~\/\.ssh\/id_[a-z]+|~\/\.config\/gh\/hosts|read\s+the\s+(token|secret|api[_ ]?key|password)\s+(in|from)\s+[.`'"]?\.?env/i;
@@ -53624,15 +53891,53 @@ function mk(severity, title, signals, fix, path70) {
53624
53891
  function analyzeInstructionFile(path70, content) {
53625
53892
  const findings = [];
53626
53893
  const decoded = decodeSuspiciousBase64(content);
53627
- if (HIDDEN_CHARS.test(content)) {
53894
+ if (TAG_CHARS.test(content))
53895
+ findings.push(
53896
+ mk(
53897
+ "critical",
53898
+ "Unicode tag characters in an agent instruction file",
53899
+ [
53900
+ "contains Unicode tag characters (U+E0000\u2013E007F) \u2014 an invisible instruction-smuggling channel with no legitimate use in text"
53901
+ ],
53902
+ "Remove the tag characters. Instruction files must be plain, reviewable text.",
53903
+ path70
53904
+ )
53905
+ );
53906
+ if (BIDI_OVERRIDE.test(content))
53628
53907
  findings.push(
53629
53908
  mk(
53630
53909
  "critical",
53631
- "Hidden characters in an agent instruction file",
53910
+ "Bidirectional override characters in an agent instruction file",
53911
+ [
53912
+ "contains a bidi override (U+202D/U+202E) \u2014 a Trojan-Source technique that visually reorders text so a human reads something different from what the agent parses"
53913
+ ],
53914
+ "Remove the bidi override characters.",
53915
+ path70
53916
+ )
53917
+ );
53918
+ else if (BIDI_EMBED_ISOLATE.test(content))
53919
+ findings.push(
53920
+ mk(
53921
+ "advisory",
53922
+ "Bidirectional formatting characters in an agent instruction file",
53923
+ [
53924
+ "contains bidi embed/isolate characters (U+202A\u2013202C / U+2066\u20132069) \u2014 legitimate in right-to-left text, but confirm they are not being used to hide or reorder instructions"
53925
+ ],
53926
+ "Confirm the bidi marks are legitimate RTL formatting; remove otherwise.",
53927
+ path70
53928
+ )
53929
+ );
53930
+ const zw = suspiciousZeroWidth(content);
53931
+ if (zw > 0) {
53932
+ const revealed = OVERRIDE_RE.test(stripZeroWidth(content)) && !OVERRIDE_RE.test(content);
53933
+ findings.push(
53934
+ mk(
53935
+ revealed ? "critical" : "medium",
53936
+ "Zero-width characters splitting text in an agent instruction file",
53632
53937
  [
53633
- "contains zero-width / bidi / Unicode-tag characters \u2014 a technique to hide instructions from human review while the agent still reads them"
53938
+ revealed ? "a zero-width character conceals a prompt-override directive that only appears once the hidden characters are stripped" : "a zero-width character splits a visible Latin word \u2014 a concealment technique (hides text from human review while the agent reads it as contiguous)"
53634
53939
  ],
53635
- "Remove the hidden characters. Instruction files must be plain, reviewable text.",
53940
+ "Remove the zero-width characters. Instruction files must be plain, reviewable text.",
53636
53941
  path70
53637
53942
  )
53638
53943
  );
@@ -53716,6 +54021,8 @@ function scanTree(tree) {
53716
54021
  findings.push(...analyzeAgentConfig(file.path, file.content));
53717
54022
  } else if (/\.mcp\.json$|\.cursor\/mcp\.json$/.test(file.path)) {
53718
54023
  findings.push(...analyzeMcp(file.path, file.content));
54024
+ } else if (/(^|\/)\.codex\/config\.toml$/.test(file.path)) {
54025
+ findings.push(...analyzeCodexConfig(file.path, file.content));
53719
54026
  } else if (/(^|\/)(CLAUDE|AGENTS|GEMINI)\.md$|(^|\/)\.cursorrules$|copilot-instructions\.md$|(^|\/)\.(windsurf|cline)rules$/.test(
53720
54027
  file.path
53721
54028
  )) {