@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.js CHANGED
@@ -52985,6 +52985,28 @@ var SURFACE_FILES = [
52985
52985
  ".clinerules"
52986
52986
  ];
52987
52987
  var WORKFLOW_DIR = ".github/workflows";
52988
+ 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$/;
52989
+ var IGNORE_HARD = /(^|\/)(node_modules|vendor|\.git|\.next|\.venv|site-packages)\//;
52990
+ var IGNORE_SOFT = /(^|\/)(dist|build|out|target)\//;
52991
+ var isIgnoredDir = (relSlash) => IGNORE_HARD.test(relSlash) || IGNORE_SOFT.test(relSlash);
52992
+ var MAX_SURFACE_FILES = 200;
52993
+ function pickSurfacePaths(paths, truncated, notes) {
52994
+ const surface = paths.filter((p) => SURFACE_BASENAME.test(p) && !IGNORE_HARD.test(p));
52995
+ const matched = surface.filter((p) => !IGNORE_SOFT.test(p));
52996
+ const softSkipped = surface.filter((p) => IGNORE_SOFT.test(p));
52997
+ const capped = matched.slice(0, MAX_SURFACE_FILES);
52998
+ if (truncated || matched.length > MAX_SURFACE_FILES) {
52999
+ notes.push(
53000
+ `repo tree is large/truncated \u2014 some agent-surface files may be INCOMPLETE (scanned ${capped.length} of ${matched.length}${truncated ? "+" : ""}).`
53001
+ );
53002
+ }
53003
+ if (softSkipped.length) {
53004
+ notes.push(
53005
+ `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.`
53006
+ );
53007
+ }
53008
+ return capped;
53009
+ }
52988
53010
  async function pooled(items, limit, fn) {
52989
53011
  const out = new Array(items.length);
52990
53012
  let next = 0;
@@ -53076,13 +53098,37 @@ async function listWorkflowPaths(owner, repo, notes) {
53076
53098
  if (status !== 200 || !Array.isArray(json)) return [];
53077
53099
  return json.filter((e) => e.type === "file" && /\.ya?ml$/.test(e.name)).map((e) => e.path);
53078
53100
  }
53101
+ var ROOT_WORKFLOW_RE = /^\.github\/workflows\/[^/]+\.ya?ml$/;
53102
+ async function listSurfaceTree(owner, repo, notes) {
53103
+ const url = `https://api.github.com/repos/${owner}/${repo}/git/trees/HEAD?recursive=1`;
53104
+ const { status, json } = await ghGet(url);
53105
+ if (status === 403 || status === 429) {
53106
+ if (!notes.includes(RATE_LIMIT_NOTE)) notes.push(RATE_LIMIT_NOTE);
53107
+ return null;
53108
+ }
53109
+ if (status === 0) {
53110
+ if (!notes.includes(NETWORK_NOTE)) notes.push(NETWORK_NOTE);
53111
+ return null;
53112
+ }
53113
+ if (status !== 200 || !json || typeof json !== "object") return null;
53114
+ const tree = json;
53115
+ if (!Array.isArray(tree.tree)) return null;
53116
+ const blobs = tree.tree.filter((e) => e.type === "blob" && typeof e.path === "string").map((e) => e.path);
53117
+ return {
53118
+ surface: pickSurfacePaths(blobs, !!tree.truncated, notes),
53119
+ workflows: blobs.filter((p) => ROOT_WORKFLOW_RE.test(p))
53120
+ };
53121
+ }
53079
53122
  var FETCH_CONCURRENCY = 8;
53080
53123
  async function fetchGitHubTree(owner, repo, onProgress) {
53081
53124
  const notes = [];
53082
53125
  try {
53083
- onProgress?.({ phase: "listing workflows", done: 0, total: 1 });
53084
- const workflowPaths = await listWorkflowPaths(owner, repo, notes);
53085
- const allPaths = [...SURFACE_FILES, ...workflowPaths];
53126
+ onProgress?.({ phase: "discovering agent surface", done: 0, total: 1 });
53127
+ const discovered = await listSurfaceTree(owner, repo, notes);
53128
+ const workflowPaths = discovered ? discovered.workflows : await listWorkflowPaths(owner, repo, notes);
53129
+ const allPaths = [
53130
+ .../* @__PURE__ */ new Set([...SURFACE_FILES, ...discovered?.surface ?? [], ...workflowPaths])
53131
+ ];
53086
53132
  let done = 0;
53087
53133
  const fetched = await pooled(allPaths, FETCH_CONCURRENCY, async (p) => {
53088
53134
  const f = await fetchOne(owner, repo, p, notes);
@@ -53091,7 +53137,9 @@ async function fetchGitHubTree(owner, repo, onProgress) {
53091
53137
  });
53092
53138
  return { source: `${owner}/${repo}`, files: fetched.filter((f) => !!f), notes };
53093
53139
  } catch (err2) {
53094
- notes.push(`fetch degraded: ${err2?.message ?? "network error"}`);
53140
+ notes.push(
53141
+ `fetch degraded: ${err2?.message ?? "network error"} \u2014 results may be INCOMPLETE (the repo could not be fetched).`
53142
+ );
53095
53143
  return { source: `${owner}/${repo}`, files: [], notes };
53096
53144
  }
53097
53145
  }
@@ -53108,7 +53156,42 @@ function readLocalTree(dir) {
53108
53156
  } catch {
53109
53157
  }
53110
53158
  };
53111
- for (const p of SURFACE_FILES) add(p);
53159
+ const seen = /* @__PURE__ */ new Set();
53160
+ const collect = (rel) => {
53161
+ if (seen.has(rel)) return;
53162
+ seen.add(rel);
53163
+ add(rel);
53164
+ };
53165
+ for (const p of SURFACE_FILES) collect(p);
53166
+ const matches = [];
53167
+ const MAX_DIRS = 5e3;
53168
+ let dirsVisited = 0;
53169
+ const walk = (relDir) => {
53170
+ if (matches.length >= MAX_SURFACE_FILES || dirsVisited >= MAX_DIRS) return;
53171
+ dirsVisited++;
53172
+ let entries;
53173
+ try {
53174
+ entries = import_fs59.default.readdirSync(import_path56.default.join(root, relDir), { withFileTypes: true });
53175
+ } catch {
53176
+ return;
53177
+ }
53178
+ for (const e of entries) {
53179
+ if (matches.length >= MAX_SURFACE_FILES || dirsVisited >= MAX_DIRS) return;
53180
+ const rel = relDir ? `${relDir}/${e.name}` : e.name;
53181
+ if (e.isDirectory()) {
53182
+ if (isIgnoredDir(`${rel}/`)) continue;
53183
+ walk(rel);
53184
+ } else if (e.isFile() && SURFACE_BASENAME.test(rel)) {
53185
+ matches.push(rel);
53186
+ }
53187
+ }
53188
+ };
53189
+ walk("");
53190
+ if (matches.length >= MAX_SURFACE_FILES || dirsVisited >= MAX_DIRS)
53191
+ notes.push(
53192
+ `repo is large \u2014 some agent-surface files may be INCOMPLETE (capped at ${MAX_SURFACE_FILES} files / ${MAX_DIRS} dirs).`
53193
+ );
53194
+ for (const rel of matches) collect(rel);
53112
53195
  const wfDir = import_path56.default.join(root, WORKFLOW_DIR);
53113
53196
  try {
53114
53197
  if (import_fs59.default.existsSync(wfDir)) {
@@ -53149,6 +53232,18 @@ var AGENT_ACTION_RE = /(anthropics\/claude-code(-base)?-action|anthropics\/claud
53149
53232
  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;
53150
53233
  var EXFIL_RCE_RE = /(^|["\s,])Bash(\s|,|"|$)|Bash\(\s*\*|Bash\((curl|wget|sh[\s):]|eval|rm[\s):]|git push)/i;
53151
53234
  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;
53235
+ 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;
53236
+ var MEMBERSHIP_CHECK_RE = /orgs\/[^/'"\s]+\/memberships\/|teams\/[^/'"\s]+\/memberships\/|getCollaboratorPermissionLevel|collaborators\/[^/'"\s]+\/permission|checkMembershipForUser|getMembershipForUser/i;
53237
+ var STRANGER_ISSUE_TYPES = /* @__PURE__ */ new Set(["opened", "edited", "reopened", "closed"]);
53238
+ var STRANGER_PR_TYPES = /* @__PURE__ */ new Set([
53239
+ "opened",
53240
+ "edited",
53241
+ "reopened",
53242
+ "closed",
53243
+ "synchronize",
53244
+ "ready_for_review",
53245
+ "converted_to_draft"
53246
+ ]);
53152
53247
  function str(v) {
53153
53248
  return typeof v === "string" ? v : v == null ? "" : JSON.stringify(v);
53154
53249
  }
@@ -53159,9 +53254,57 @@ function triggerKeys(wf, raw) {
53159
53254
  if (on && typeof on === "object") return Object.keys(on);
53160
53255
  return [];
53161
53256
  }
53257
+ function onObject(wf, raw) {
53258
+ return wf.on ?? raw["on"] ?? raw[true];
53259
+ }
53260
+ function activityTypes(node) {
53261
+ return node && Array.isArray(node.types) ? node.types.map(String) : [];
53262
+ }
53263
+ function keyStrangerFirable(on, key) {
53264
+ switch (key) {
53265
+ case "issue_comment":
53266
+ case "pull_request_review":
53267
+ case "pull_request_review_comment":
53268
+ case "workflow_run":
53269
+ case "discussion":
53270
+ case "discussion_comment":
53271
+ case "fork":
53272
+ case "watch":
53273
+ case "public":
53274
+ return true;
53275
+ case "pull_request":
53276
+ case "pull_request_target": {
53277
+ const ts = activityTypes(on[key]);
53278
+ return ts.length ? ts.some((t) => STRANGER_PR_TYPES.has(t)) : true;
53279
+ }
53280
+ case "issues": {
53281
+ const ts = activityTypes(on["issues"]);
53282
+ return ts.length ? ts.some((t) => STRANGER_ISSUE_TYPES.has(t)) : true;
53283
+ }
53284
+ // workflow_call / workflow_dispatch / schedule / push / create / delete / …
53285
+ // are NOT stranger-firable (require write access, or run in a trusted context).
53286
+ default:
53287
+ return false;
53288
+ }
53289
+ }
53290
+ function triggerReach(wf, raw) {
53291
+ const on = onObject(wf, raw) ?? {};
53292
+ const keys = triggerKeys(wf, raw);
53293
+ const firable = keys.filter((k) => keyStrangerFirable(on, k));
53294
+ const untrusted = firable.length > 0;
53295
+ const reusable = !untrusted && keys.some((k) => /^workflow_call$/i.test(k));
53296
+ const secretExposed = firable.some((t) => /pull_request_target|workflow_run/i.test(t));
53297
+ const privileged = untrusted && firable.some(
53298
+ (t) => /pull_request_target|pull_request_review|workflow_run|issue|discussion/i.test(t)
53299
+ );
53300
+ return { keys, untrusted, reusable, secretExposed, privileged };
53301
+ }
53302
+ function jobList(wf) {
53303
+ return Object.values(wf.jobs ?? {}).filter((j) => j != null);
53304
+ }
53162
53305
  function allSteps(wf) {
53163
53306
  const out = [];
53164
- for (const job of Object.values(wf.jobs ?? {})) {
53307
+ for (const job of jobList(wf)) {
53165
53308
  for (const step of job.steps ?? []) out.push({ job, step });
53166
53309
  }
53167
53310
  return out;
@@ -53216,63 +53359,100 @@ function promptTakesUntrusted(steps) {
53216
53359
  }
53217
53360
  return false;
53218
53361
  }
53219
- 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;
53362
+ var NONCONTAINS_GATE_RE = new RegExp(
53363
+ [
53364
+ String.raw`==\s*['"]?(OWNER|MEMBER|COLLABORATOR)\b`,
53365
+ String.raw`==\s*['"](write|admin|maintain)`,
53366
+ String.raw`github\.actor\s*==`,
53367
+ String.raw`user\.login\s*==`,
53368
+ String.raw`head\.repo\.full_name\s*==\s*github\.repository`
53369
+ ].join("|"),
53370
+ "i"
53371
+ );
53372
+ 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;
53373
+ var CONTAINS_GATE_RE = /contains\((?:[^()]|\([^()]*\))*(login|actor|association|OWNER|MEMBER|COLLABORATOR)/i;
53374
+ var NEGATED_CONTAINS_RE = /!\s*\(?\s*contains\((?:[^()]|\([^()]*\))*(login|actor|association|OWNER|MEMBER|COLLABORATOR)/i;
53220
53375
  function labelTypeConfigured(wf, raw) {
53221
53376
  const on = wf.on ?? raw["on"] ?? raw[true];
53222
53377
  const prTypes = (t) => t && Array.isArray(t.types) ? t.types.map(String) : [];
53223
53378
  return prTypes(on?.["pull_request_target"]).includes("labeled") || prTypes(on?.["pull_request"]).includes("labeled");
53224
53379
  }
53225
53380
  function ifsAreGated(ifs, labelConfigured) {
53226
- const gated = ACTOR_GATE_RE.test(ifs);
53381
+ const containsGate = CONTAINS_GATE_RE.test(ifs) && !NEGATED_CONTAINS_RE.test(ifs);
53382
+ const gated = NONCONTAINS_GATE_RE.test(ifs) || containsGate;
53227
53383
  const labelGated = labelConfigured && /event\.label|label\.name/i.test(ifs);
53228
53384
  return gated || labelGated;
53229
53385
  }
53230
53386
  function hasActorGate(wf, raw) {
53231
- const ifs = [
53232
- wf.jobs ? Object.values(wf.jobs).map((j) => j.if) : [],
53233
- allSteps(wf).map((s) => s.step.if)
53234
- ].flat().map(str).join(" ");
53387
+ const ifs = [jobList(wf).map((j) => j.if), allSteps(wf).map((s) => s.step.if)].flat().map(str).join(" ");
53235
53388
  return ifsAreGated(ifs, labelTypeConfigured(wf, raw));
53236
53389
  }
53390
+ function hasStepMembershipGate(job) {
53391
+ const steps = job.steps ?? [];
53392
+ const gateIds = steps.filter((s) => s.id && MEMBERSHIP_CHECK_RE.test(str(s.run))).map((s) => s.id);
53393
+ if (!gateIds.length) return false;
53394
+ return steps.some(
53395
+ (s) => isAgentStep(s) && gateIds.some(
53396
+ (id) => new RegExp(`steps\\.${id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\.outputs\\.`).test(
53397
+ str(s.if)
53398
+ )
53399
+ )
53400
+ );
53401
+ }
53237
53402
  function jobActorGate(job, wf, raw) {
53238
53403
  const ifs = [job.if, ...(job.steps ?? []).map((s) => s.if)].map(str).join(" ");
53239
- return ifsAreGated(ifs, labelTypeConfigured(wf, raw)) || /assignee\.login\s*==|event\.assignee\b/i.test(ifs);
53404
+ 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
53405
+ hasStepMembershipGate(job);
53240
53406
  }
53241
53407
  function hasImplicitActorGate(agentSteps, bypassActive) {
53242
53408
  if (bypassActive) return false;
53243
53409
  return agentSteps.some((s) => /anthropics\/claude-code-action@/i.test(s.uses ?? ""));
53244
53410
  }
53245
- function injectableAgentSteps(wf, raw, untrustedTrigger) {
53411
+ function injectableJobs(wf, raw, untrustedTrigger) {
53246
53412
  const out = [];
53247
- for (const job of Object.values(wf.jobs ?? {})) {
53413
+ for (const job of jobList(wf)) {
53248
53414
  const a = (job.steps ?? []).filter(isAgentStep);
53249
53415
  if (!a.length) continue;
53250
53416
  const jobStar = str(a.map((s) => s.with?.["allowed_non_write_users"]).find(Boolean)) === "*";
53251
- const jobBypass = jobStar && untrustedTrigger;
53252
- if (jobActorGate(job, wf, raw) || hasImplicitActorGate(a, jobBypass)) continue;
53253
- out.push(...a);
53417
+ if (jobActorGate(job, wf, raw) || hasImplicitActorGate(a, jobStar && untrustedTrigger))
53418
+ continue;
53419
+ out.push(job);
53254
53420
  }
53255
53421
  return out;
53256
53422
  }
53257
- function permsElevated(wf, agentJobs) {
53258
- const check = (p) => {
53259
- const s = str(p);
53260
- return /["']?(contents|id-token|packages)["']?\s*:\s*["']?write/i.test(s);
53261
- };
53262
- return check(wf.permissions) || agentJobs.some((j) => check(j.permissions));
53423
+ function usesPatIn(jobs) {
53424
+ for (const j of jobs)
53425
+ for (const step of j.steps ?? []) {
53426
+ const gt = str(step.with?.["github_token"]);
53427
+ if (/secrets\./i.test(gt) && !/secrets\.GITHUB_TOKEN/i.test(gt)) return true;
53428
+ }
53429
+ return false;
53263
53430
  }
53264
- function hasGithubWritePerm(wf, agentJobs) {
53265
- const check = (p) => /["']?(contents|pull-requests|issues|packages|actions|deployments)["']?\s*:\s*["']?write/i.test(
53266
- str(p)
53431
+ function jobPerms(wf, job) {
53432
+ return job.permissions != null ? str(job.permissions) : str(wf.permissions);
53433
+ }
53434
+ function jobsHaveWrite(wf, jobs, rx) {
53435
+ return jobs.some((j) => {
53436
+ const p = jobPerms(wf, j);
53437
+ return /\bwrite-all\b/i.test(p) || rx.test(p);
53438
+ });
53439
+ }
53440
+ function hasIdTokenWrite(wf, jobs) {
53441
+ const rx = /["']?id-token["']?\s*:\s*["']?write/i;
53442
+ return jobs.some((j) => rx.test(jobPerms(wf, j)));
53443
+ }
53444
+ function hasPrWritePerm(wf, jobs) {
53445
+ return jobsHaveWrite(wf, jobs, /["']?pull-requests["']?\s*:\s*["']?write/i);
53446
+ }
53447
+ function hasCodeWritePerm(wf, agentJobs) {
53448
+ return jobsHaveWrite(
53449
+ wf,
53450
+ agentJobs,
53451
+ /["']?(contents|packages|actions|deployments)["']?\s*:\s*["']?write/i
53267
53452
  );
53268
- return check(wf.permissions) || agentJobs.some((j) => check(j.permissions));
53269
53453
  }
53270
- function usesPat(wf) {
53271
- for (const { step } of allSteps(wf)) {
53272
- const gt = str(step.with?.["github_token"]);
53273
- if (/secrets\./i.test(gt) && !/secrets\.GITHUB_TOKEN/i.test(gt)) return true;
53274
- }
53275
- return false;
53454
+ function hasMetaWritePerm(wf, agentJobs) {
53455
+ return jobsHaveWrite(wf, agentJobs, /["']?(pull-requests|issues)["']?\s*:\s*["']?write/i);
53276
53456
  }
53277
53457
  function hasEnvDeny(steps) {
53278
53458
  return steps.some((s) => /"?mode"?\s*:\s*"?deny/i.test(str(s.with?.["settings"])));
@@ -53305,33 +53485,38 @@ function analyzeWorkflow(path70, content) {
53305
53485
  )
53306
53486
  ];
53307
53487
  if (agentSteps.length === 0) return null;
53308
- const triggers = triggerKeys(wf, raw);
53309
- const secretExposed = triggers.some((t) => /pull_request_target|workflow_run/i.test(t));
53310
- const forkInput = triggers.some((t) => /issue|pull_request|workflow_call/i.test(t));
53311
- const privileged = triggers.some(
53312
- (t) => /pull_request_target|pull_request_review|workflow_run|workflow_call|issue|discussion/i.test(t)
53313
- );
53488
+ const {
53489
+ keys: triggers,
53490
+ untrusted: forkInput,
53491
+ secretExposed,
53492
+ reusable,
53493
+ privileged
53494
+ } = triggerReach(wf, raw);
53314
53495
  const nonWrite = str(agentSteps.map((s) => s.with?.["allowed_non_write_users"]).find(Boolean));
53315
53496
  const nonWriteStar = nonWrite === "*";
53316
53497
  const nonWriteList = !!nonWrite && nonWrite !== "*";
53317
- const untrustedTrigger = secretExposed || forkInput;
53498
+ const untrustedTrigger = forkInput || reusable;
53318
53499
  const bypassActive = nonWriteStar && untrustedTrigger;
53319
53500
  const head = untrustedHeadCheckout(steps);
53320
53501
  const promptUntrusted = promptTakesUntrusted(agentSteps);
53321
- const reach = untrustedTrigger ? Math.max(
53502
+ const injJobs = injectableJobs(wf, raw, untrustedTrigger);
53503
+ const powerJobs = injJobs.length ? injJobs : agentJobs;
53504
+ const powerSteps = injJobs.flatMap((j) => (j.steps ?? []).filter(isAgentStep));
53505
+ const scopedSteps = powerSteps.length ? powerSteps : agentSteps;
53506
+ const toolsBlob = collectTools(scopedSteps);
53507
+ const reach = untrustedTrigger && injJobs.length ? Math.max(
53322
53508
  head === "root" ? 3 : head === "subdir" ? 1 : 0,
53323
53509
  promptUntrusted ? 2 : 0,
53324
53510
  bypassActive ? 2 : 0
53325
53511
  ) : 0;
53326
- const powerSteps = injectableAgentSteps(wf, raw, untrustedTrigger);
53327
- const scopedSteps = powerSteps.length ? powerSteps : agentSteps;
53328
- const toolsBlob = collectTools(scopedSteps);
53329
53512
  const broadTools = BROAD_TOOL_RE.test(toolsBlob);
53330
- const elevated = permsElevated(wf, agentJobs);
53331
- const pat = usesPat(wf);
53513
+ const egressTool = EGRESS_TOOL_RE.test(toolsBlob);
53514
+ const elevated = hasCodeWritePerm(wf, powerJobs) || hasIdTokenWrite(wf, powerJobs) && egressTool;
53515
+ const pat = usesPatIn(powerJobs);
53332
53516
  const power = (broadTools ? 2 : 0) + (bypassActive ? 1 : 0) + (elevated ? 1 : 0) + (pat ? 1 : 0);
53333
53517
  const explicitGate = hasActorGate(wf, raw);
53334
53518
  const implicitGate = hasImplicitActorGate(agentSteps, bypassActive);
53519
+ const membershipGated = injJobs.length === 0 && jobList(wf).some((j) => (j.steps ?? []).some(isAgentStep) && hasStepMembershipGate(j));
53335
53520
  const gate = explicitGate || implicitGate;
53336
53521
  const envDeny = hasEnvDeny(agentSteps);
53337
53522
  const pinned = agentActionsPinned(agentSteps);
@@ -53340,7 +53525,7 @@ function analyzeWorkflow(path70, content) {
53340
53525
  if (envDeny) score -= 1;
53341
53526
  if (pinned) score -= 1;
53342
53527
  score = Math.max(0, score);
53343
- if (score === 0 && !secretExposed) return null;
53528
+ if (score === 0 && !secretExposed && !reusable) return null;
53344
53529
  let severity = severityFromScore(score);
53345
53530
  if (gate && severity && severity !== "advisory") severity = "advisory";
53346
53531
  if (reach === 0 && severity && severity !== "advisory") severity = "advisory";
@@ -53348,9 +53533,20 @@ function analyzeWorkflow(path70, content) {
53348
53533
  severity = "medium";
53349
53534
  const exfilOrRce = EXFIL_RCE_RE.test(toolsBlob);
53350
53535
  const githubWriteTool = GH_WRITE_TOOL_RE.test(toolsBlob);
53351
- const canDamage = exfilOrRce || (hasGithubWritePerm(wf, agentJobs) || pat) && githubWriteTool;
53536
+ const rce = exfilOrRce;
53537
+ const codeWrite = pat || hasCodeWritePerm(wf, powerJobs) && githubWriteTool;
53538
+ const metaWrite = hasMetaWritePerm(wf, powerJobs) && githubWriteTool;
53539
+ const canDamage = rce || codeWrite || metaWrite;
53352
53540
  if (!canDamage && severity && SEVERITY_RANK2[severity] > SEVERITY_RANK2.medium) severity = "medium";
53353
- if (untrustedTrigger && !privileged && severity && severity !== "advisory") severity = "advisory";
53541
+ if (canDamage && !rce && !codeWrite && severity === "critical") severity = "high";
53542
+ const issueOnlyWrite = metaWrite && !hasPrWritePerm(wf, powerJobs) && !hasCodeWritePerm(wf, powerJobs);
53543
+ if (issueOnlyWrite && !rce && severity && SEVERITY_RANK2[severity] > SEVERITY_RANK2.medium)
53544
+ severity = "medium";
53545
+ if (untrustedTrigger && !privileged && !reusable && severity && severity !== "advisory")
53546
+ severity = "advisory";
53547
+ const reusableLoadedGun = reusable && (head === "root" || promptUntrusted);
53548
+ if (reusable && !reusableLoadedGun && severity && SEVERITY_RANK2[severity] > SEVERITY_RANK2.medium)
53549
+ severity = "medium";
53354
53550
  if (!severity) severity = "advisory";
53355
53551
  const signals = [];
53356
53552
  if (secretExposed)
@@ -53358,7 +53554,11 @@ function analyzeWorkflow(path70, content) {
53358
53554
  `runs with base-repo secrets (${triggers.filter((t) => /target|workflow_run/i.test(t)).join(", ")})`
53359
53555
  );
53360
53556
  else if (forkInput) signals.push(`triggered by untrusted input (${triggers.join(", ")})`);
53361
- if (untrustedTrigger && !privileged)
53557
+ else if (reusable)
53558
+ signals.push(
53559
+ 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`
53560
+ );
53561
+ if (forkInput && !privileged && !reusable)
53362
53562
  signals.push(
53363
53563
  "triggered by `pull_request` \u2014 fork PRs run with a read-only token (lower risk than pull_request_target)"
53364
53564
  );
@@ -53370,8 +53570,14 @@ function analyzeWorkflow(path70, content) {
53370
53570
  if (elevated) signals.push("elevated permissions (contents/id-token: write)");
53371
53571
  if (pat) signals.push("a static PAT is exposed to the agent (recoverable via injection)");
53372
53572
  if (!gate && reach > 0) signals.push("no effective actor gate");
53573
+ const noExplicitPerms = wf.permissions == null && jobList(wf).every((j) => j.permissions == null);
53574
+ if (noExplicitPerms && reach > 0 && (broadTools || githubWriteTool))
53575
+ signals.push(
53576
+ "no explicit `permissions:` \u2014 the token defaults to the repo/org setting, which may grant write; set it explicitly to read-only"
53577
+ );
53373
53578
  const mitigations = [];
53374
- if (explicitGate) mitigations.push("actor-gated (maintainer/label/write-user required)");
53579
+ if (explicitGate || membershipGated)
53580
+ mitigations.push("actor-gated (maintainer/label/write-user required)");
53375
53581
  else if (implicitGate)
53376
53582
  mitigations.push("claude-code-action gates the agent to write-access users by default");
53377
53583
  if (head === "subdir") mitigations.push("untrusted head isolated in a subdir, not root");
@@ -53424,12 +53630,12 @@ function agentReachableSecrets(wf, agentSteps, agentJobs) {
53424
53630
  found.set(name, classifySecret(name));
53425
53631
  }
53426
53632
  }
53427
- 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)));
53633
+ const idToken = hasIdTokenWrite(wf, agentJobs);
53428
53634
  const out = [...found].map(([name, kind]) => ({ name, kind }));
53429
53635
  if (idToken) out.push({ name: "id-token (cloud OIDC)", kind: "cloud-oidc" });
53430
53636
  return out;
53431
53637
  }
53432
- function evalAgentJob(job, wf, raw, untrustedTrigger) {
53638
+ function evalAgentJob(job, wf, raw, untrustedTrigger, reusable) {
53433
53639
  const jobSteps = job.steps ?? [];
53434
53640
  const jobAgentSteps = jobSteps.filter(isAgentStep);
53435
53641
  if (jobAgentSteps.length === 0) return null;
@@ -53456,6 +53662,8 @@ function evalAgentJob(job, wf, raw, untrustedTrigger) {
53456
53662
  } else {
53457
53663
  return null;
53458
53664
  }
53665
+ const loadedGun = head === "root" || promptTakesUntrusted(jobAgentSteps);
53666
+ if (reusable && !loadedGun && SEVERITY_RANK2[severity] > SEVERITY_RANK2.medium) severity = "medium";
53459
53667
  return { severity, secrets, injectable, canReadEnv };
53460
53668
  }
53461
53669
  function analyzeWorkflowSecrets(path70, content) {
@@ -53468,12 +53676,10 @@ function analyzeWorkflowSecrets(path70, content) {
53468
53676
  const wf = raw;
53469
53677
  const all = allSteps(wf);
53470
53678
  if (!all.some((s) => isAgentStep(s.step))) return null;
53471
- const triggers = triggerKeys(wf, raw);
53472
- const untrustedTrigger = triggers.some(
53473
- (t) => /pull_request_target|workflow_run|issue|pull_request|workflow_call/i.test(t)
53474
- );
53679
+ const { untrusted: forkInput, reusable } = triggerReach(wf, raw);
53680
+ const untrustedTrigger = forkInput || reusable;
53475
53681
  const agentJobs = [...new Set(all.filter((s) => isAgentStep(s.step)).map((s) => s.job))];
53476
- 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];
53682
+ 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];
53477
53683
  if (!worst) return null;
53478
53684
  return {
53479
53685
  check: "CI-4",
@@ -53516,18 +53722,20 @@ function analyzeAgentConfig(path70, content) {
53516
53722
  }
53517
53723
  const findings = [];
53518
53724
  for (const cmd of hookCommands(cfg.hooks)) {
53519
- const remote = /\b(npx|curl|wget|iwr|irm)\b/.test(cmd) || /\|\s*(sh|bash)\b/.test(cmd);
53520
- if (!remote) continue;
53521
- const unpinned = /@latest\b/.test(cmd) || /\bnpx\b/.test(cmd) && !/@\d/.test(cmd);
53725
+ const remoteExec = /\|\s*(sh|bash|zsh)\b/.test(cmd) || /\b(curl|wget|iwr|irm)\b/.test(cmd);
53726
+ const isNpx = /\bnpx\b/.test(cmd);
53727
+ if (!remoteExec && !isNpx) continue;
53728
+ const unpinned = /@latest\b/.test(cmd) || isNpx && !/@\d/.test(cmd);
53729
+ const high = remoteExec || unpinned;
53522
53730
  findings.push({
53523
53731
  check: "CI-1",
53524
53732
  dimension: "toolRules",
53525
- severity: unpinned ? "high" : "medium",
53526
- title: unpinned ? "Agent hook runs UNPINNED third-party code on every action" : "Agent hook runs third-party code in the agent hot path",
53733
+ severity: high ? "high" : "medium",
53734
+ title: high ? "Agent hook runs UNPINNED/remote third-party code on every action" : "Agent hook runs third-party code in the agent hot path",
53527
53735
  file: path70,
53528
53736
  signals: [
53529
53737
  `hook command: \`${cmd.slice(0, 120)}\``,
53530
- 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"
53738
+ 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"
53531
53739
  ],
53532
53740
  fix: "Vendor the command as a committed local script, or pin an exact version and treat updates as security-reviewed."
53533
53741
  });
@@ -53538,17 +53746,18 @@ function analyzeAgentConfig(path70, content) {
53538
53746
  (a) => /^Bash$|^Bash\(\s*\*|^Bash\(git:|^Write\(\s*\*|^Write$|^Edit$/.test(a)
53539
53747
  );
53540
53748
  if (broad.length > 0) {
53749
+ const hasBackstop = deny.some((d) => /Bash|Write|Edit/.test(d));
53541
53750
  findings.push({
53542
53751
  check: "CI-1",
53543
53752
  dimension: "toolRules",
53544
- severity: "medium",
53545
- title: "Committed agent config pre-authorizes broad tools",
53753
+ severity: hasBackstop ? "medium" : "high",
53754
+ title: hasBackstop ? "Committed agent config pre-authorizes broad tools" : "Committed agent config pre-authorizes broad tools with no deny backstop",
53546
53755
  file: path70,
53547
53756
  signals: [
53548
53757
  `broad allow(s): ${broad.slice(0, 5).join(", ")}`,
53549
- ...deny.length === 0 ? ["no `deny` entries to backstop it"] : []
53758
+ hasBackstop ? "a `deny` list backstops the broad allow" : "no `deny` entry covers Bash/Write/Edit \u2014 every contributor is pre-authorized for catastrophic tools"
53550
53759
  ],
53551
- fix: "Scope the allow-list to specific read-only subcommands (e.g. `Bash(gh pr view:*)`); avoid bare `Bash`/`git:`/`Write`."
53760
+ 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."
53552
53761
  });
53553
53762
  }
53554
53763
  return findings;
@@ -53563,8 +53772,11 @@ function analyzeMcp(path70, content) {
53563
53772
  } catch {
53564
53773
  return [];
53565
53774
  }
53775
+ return analyzeMcpServers(cfg.mcpServers ?? {}, path70);
53776
+ }
53777
+ function analyzeMcpServers(servers, path70) {
53566
53778
  const findings = [];
53567
- for (const [name, srv] of Object.entries(cfg.mcpServers ?? {})) {
53779
+ for (const [name, srv] of Object.entries(servers ?? {})) {
53568
53780
  if (!srv || srv.disabled) continue;
53569
53781
  const argv = [srv.command, ...Array.isArray(srv.args) ? srv.args.map(String) : []].join(" ");
53570
53782
  if (/\bnpx\b/.test(argv) && (/@latest\b/.test(argv) || !/@\d/.test(argv))) {
@@ -53599,8 +53811,63 @@ function analyzeMcp(path70, content) {
53599
53811
  return findings;
53600
53812
  }
53601
53813
 
53814
+ // src/ci-check/codex.ts
53815
+ var import_smol_toml5 = require("smol-toml");
53816
+ function analyzeCodexConfig(path70, content) {
53817
+ let cfg;
53818
+ try {
53819
+ cfg = (0, import_smol_toml5.parse)(content);
53820
+ } catch {
53821
+ return [];
53822
+ }
53823
+ const findings = [];
53824
+ findings.push(...analyzeMcpServers(cfg.mcp_servers ?? {}, path70));
53825
+ const sandbox = typeof cfg.sandbox_mode === "string" ? cfg.sandbox_mode : "";
53826
+ const approval = typeof cfg.approval_policy === "string" ? cfg.approval_policy : "";
53827
+ const fullAccess = /danger-full-access/i.test(sandbox);
53828
+ const noApproval = /^never$/i.test(approval);
53829
+ if (fullAccess || noApproval) {
53830
+ const signals = [
53831
+ fullAccess ? 'sandbox_mode = "danger-full-access" \u2014 the agent runs arbitrary commands with full disk + network access' : null,
53832
+ noApproval ? 'approval_policy = "never" \u2014 no human approval for agent actions' : null
53833
+ ].filter((s) => s !== null);
53834
+ findings.push({
53835
+ check: "CI-1",
53836
+ dimension: "toolRules",
53837
+ severity: fullAccess ? "high" : "medium",
53838
+ title: fullAccess ? "Codex config grants a full-access sandbox" : "Codex config never requires approval",
53839
+ file: path70,
53840
+ signals,
53841
+ 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.'
53842
+ });
53843
+ }
53844
+ return findings;
53845
+ }
53846
+
53602
53847
  // src/ci-check/instructions.ts
53603
- var HIDDEN_CHARS = /[\u200B\u2060\u202A-\u202E\u2066-\u2069]|[\u{E0000}-\u{E007F}]/u;
53848
+ var TAG_CHARS = /[\u{E0000}-\u{E007F}]/u;
53849
+ var BIDI_OVERRIDE = /[‭‮]/;
53850
+ var BIDI_EMBED_ISOLATE = /[‪-‬⁦-⁩]/;
53851
+ function isZwLegitScript(cp) {
53852
+ if (cp === void 0) return false;
53853
+ 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;
53854
+ }
53855
+ var isAsciiWordChar = (ch) => !!ch && /[A-Za-z0-9]/.test(ch);
53856
+ function suspiciousZeroWidth(text) {
53857
+ let n = 0;
53858
+ for (let i = 0; i < text.length; i++) {
53859
+ const c = text.charCodeAt(i);
53860
+ if (c !== 8203 && c !== 8288) continue;
53861
+ if (isZwLegitScript(text.codePointAt(i - 1)) || isZwLegitScript(text.codePointAt(i + 1)))
53862
+ continue;
53863
+ const prev = text[i - 1];
53864
+ const next = text[i + 1];
53865
+ if (!prev || !next || /\s/.test(prev) || /\s/.test(next)) continue;
53866
+ if (isAsciiWordChar(prev) && isAsciiWordChar(next)) n++;
53867
+ }
53868
+ return n;
53869
+ }
53870
+ var stripZeroWidth = (t) => t.replace(/[​⁠]/g, "");
53604
53871
  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;
53605
53872
  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;
53606
53873
  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;
@@ -53631,15 +53898,53 @@ function mk(severity, title, signals, fix, path70) {
53631
53898
  function analyzeInstructionFile(path70, content) {
53632
53899
  const findings = [];
53633
53900
  const decoded = decodeSuspiciousBase64(content);
53634
- if (HIDDEN_CHARS.test(content)) {
53901
+ if (TAG_CHARS.test(content))
53902
+ findings.push(
53903
+ mk(
53904
+ "critical",
53905
+ "Unicode tag characters in an agent instruction file",
53906
+ [
53907
+ "contains Unicode tag characters (U+E0000\u2013E007F) \u2014 an invisible instruction-smuggling channel with no legitimate use in text"
53908
+ ],
53909
+ "Remove the tag characters. Instruction files must be plain, reviewable text.",
53910
+ path70
53911
+ )
53912
+ );
53913
+ if (BIDI_OVERRIDE.test(content))
53635
53914
  findings.push(
53636
53915
  mk(
53637
53916
  "critical",
53638
- "Hidden characters in an agent instruction file",
53917
+ "Bidirectional override characters in an agent instruction file",
53918
+ [
53919
+ "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"
53920
+ ],
53921
+ "Remove the bidi override characters.",
53922
+ path70
53923
+ )
53924
+ );
53925
+ else if (BIDI_EMBED_ISOLATE.test(content))
53926
+ findings.push(
53927
+ mk(
53928
+ "advisory",
53929
+ "Bidirectional formatting characters in an agent instruction file",
53930
+ [
53931
+ "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"
53932
+ ],
53933
+ "Confirm the bidi marks are legitimate RTL formatting; remove otherwise.",
53934
+ path70
53935
+ )
53936
+ );
53937
+ const zw = suspiciousZeroWidth(content);
53938
+ if (zw > 0) {
53939
+ const revealed = OVERRIDE_RE.test(stripZeroWidth(content)) && !OVERRIDE_RE.test(content);
53940
+ findings.push(
53941
+ mk(
53942
+ revealed ? "critical" : "medium",
53943
+ "Zero-width characters splitting text in an agent instruction file",
53639
53944
  [
53640
- "contains zero-width / bidi / Unicode-tag characters \u2014 a technique to hide instructions from human review while the agent still reads them"
53945
+ 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)"
53641
53946
  ],
53642
- "Remove the hidden characters. Instruction files must be plain, reviewable text.",
53947
+ "Remove the zero-width characters. Instruction files must be plain, reviewable text.",
53643
53948
  path70
53644
53949
  )
53645
53950
  );
@@ -53723,6 +54028,8 @@ function scanTree(tree) {
53723
54028
  findings.push(...analyzeAgentConfig(file.path, file.content));
53724
54029
  } else if (/\.mcp\.json$|\.cursor\/mcp\.json$/.test(file.path)) {
53725
54030
  findings.push(...analyzeMcp(file.path, file.content));
54031
+ } else if (/(^|\/)\.codex\/config\.toml$/.test(file.path)) {
54032
+ findings.push(...analyzeCodexConfig(file.path, file.content));
53726
54033
  } else if (/(^|\/)(CLAUDE|AGENTS|GEMINI)\.md$|(^|\/)\.cursorrules$|copilot-instructions\.md$|(^|\/)\.(windsurf|cline)rules$/.test(
53727
54034
  file.path
53728
54035
  )) {