@node9/proxy 1.58.5 โ†’ 1.59.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 (4) hide show
  1. package/README.md +47 -0
  2. package/dist/cli.js +298 -17
  3. package/dist/cli.mjs +298 -17
  4. package/package.json +1 -1
package/README.md CHANGED
@@ -68,6 +68,53 @@ Findings are grouped by **who can fix them**: ๐Ÿ”’ the ones node9 reduces (just
68
68
  Track this across your fleet & keep it green โ†’ node9.ai
69
69
  ```
70
70
 
71
+ ## Scan a repo โ€” agent-CI security
72
+
73
+ `node9 scan-repo` checks any repo (or a local folder) for ways an AI agent wired into GitHub Actions could be **hijacked by an outsider** โ€” injectable workflows, agent-reachable secrets, unpinned MCP servers, over-broad agent config, and poisoned instruction files. Static and parse-only: it reads only committed config, never executes repo code. No install or token needed for public repos.
74
+
75
+ ```bash
76
+ npx node9-ai scan-repo <owner/repo> # any public repo, no install
77
+ node9 scan-repo . # a local checkout โ€” no network
78
+ node9 scan-repo <owner/repo> --json # machine-readable
79
+ ```
80
+
81
+ ```text
82
+ ๐Ÿ›ก๏ธ node9 scan-repo ยท node9-ai/agent-security-demo ยท โš ๏ธ agent-security risk found
83
+ inspected 2 config file(s), 2 finding(s)
84
+
85
+ ๐Ÿ”ด CRITICAL Injectable agent workflow โ€” untrusted input reaches a tool-using agent with secrets
86
+ .github/workflows/vulnerable-example.yml ยท CI-2
87
+ โ€ข runs with base-repo secrets (pull_request_target)
88
+ โ€ข checks out the untrusted PR head into the workspace root
89
+ โ€ข allowed_non_write_users: "*" โ€” any user can trigger the agent
90
+ โ€ข no effective actor gate
91
+
92
+ ๐Ÿ”ด CRITICAL Exfiltratable secrets reachable by an injectable agent
93
+ .github/workflows/vulnerable-example.yml ยท CI-4
94
+ โ€ข agent has arbitrary shell (bare Bash) โ†’ can read env and exfiltrate
95
+ ```
96
+
97
+ What it checks:
98
+
99
+ | Check | Flags |
100
+ | -------- | ---------------------------------------------------------------------------- |
101
+ | **CI-1** | committed agent config that pre-authorizes broad tools or runs remote hooks |
102
+ | **CI-2** | injectable agent workflows โ€” an outsider can trigger the agent and hijack it |
103
+ | **CI-3** | unpinned / `@latest` MCP servers or inline credentials (supply chain) |
104
+ | **CI-4** | secrets an injected agent could exfiltrate |
105
+ | **CI-6** | poisoned or dangerous instructions in `CLAUDE.md` / `AGENTS.md` / skills |
106
+
107
+ **Gate every PR** โ€” the same engine as a GitHub Action, so a hijackable config can't get merged:
108
+
109
+ ```yaml
110
+ # .github/workflows/agent-security.yml
111
+ - uses: node9-ai/agent-security-action@v1
112
+ with:
113
+ fail-on: high # or 'never' to just comment
114
+ ```
115
+
116
+ Marketplace: **[node9 Agent Security Check](https://github.com/marketplace/actions/node9-agent-security-check)**
117
+
71
118
  ## Live monitoring
72
119
 
73
120
  <p align="center">
package/dist/cli.js CHANGED
@@ -52974,7 +52974,15 @@ var SURFACE_FILES = [
52974
52974
  ".claude/settings.local.json",
52975
52975
  ".mcp.json",
52976
52976
  ".cursor/mcp.json",
52977
- ".codex/config.toml"
52977
+ ".codex/config.toml",
52978
+ // CI-6: agent instruction files (auto-loaded into the agent's system prompt).
52979
+ "CLAUDE.md",
52980
+ "AGENTS.md",
52981
+ "GEMINI.md",
52982
+ ".cursorrules",
52983
+ ".github/copilot-instructions.md",
52984
+ ".windsurfrules",
52985
+ ".clinerules"
52978
52986
  ];
52979
52987
  var WORKFLOW_DIR = ".github/workflows";
52980
52988
  async function pooled(items, limit, fn) {
@@ -53164,18 +53172,32 @@ function isAgentStep(step) {
53164
53172
  return true;
53165
53173
  return false;
53166
53174
  }
53175
+ function allowedToolsFromArgs(claudeArgs) {
53176
+ let out = "";
53177
+ for (const m of claudeArgs.matchAll(/--allowed[-_]?tools[=\s]+("[^"]*"|'[^']*'|\S+)/gi))
53178
+ out += " " + m[1];
53179
+ return out;
53180
+ }
53181
+ function allowedToolsFromSettings(settings) {
53182
+ let out = "";
53183
+ for (const key of ["allowedTools", "allow"]) {
53184
+ for (const m of settings.matchAll(new RegExp(`"${key}"\\s*:\\s*(\\[[^\\]]*\\])`, "gi")))
53185
+ out += " " + m[1];
53186
+ }
53187
+ return out;
53188
+ }
53167
53189
  function collectTools(steps) {
53168
- const stripDeny = (x) => x.replace(/--disallowed[-_]?tools\s+("[^"]*"|'[^']*'|\S+)/gi, " ").replace(/["']disallowed[_]?[tT]ools["']\s*:\s*\[[^\]]*\]/g, " ");
53169
53190
  let s = "";
53170
53191
  for (const st of steps) {
53171
53192
  const w = st.with ?? {};
53172
- s += " " + stripDeny(str(w["claude_args"])) + " " + str(w["allowed_tools"]) + " " + str(w["allowedTools"]);
53173
- s += " " + stripDeny(str(w["settings"]));
53193
+ s += " " + allowedToolsFromArgs(str(w["claude_args"]));
53194
+ s += " " + str(w["allowed_tools"]) + " " + str(w["allowedTools"]);
53195
+ s += " " + allowedToolsFromSettings(str(w["settings"]));
53174
53196
  }
53175
53197
  return s;
53176
53198
  }
53177
- function untrustedHeadCheckout(wf) {
53178
- for (const { step } of allSteps(wf)) {
53199
+ function untrustedHeadCheckout(steps) {
53200
+ for (const step of steps) {
53179
53201
  if (!step.uses || !/actions\/checkout/.test(step.uses)) continue;
53180
53202
  const ref = str(step.with?.["ref"]);
53181
53203
  if (/pull_request\.head|head[._]sha|head_ref|expected_head|workflow_run\.head|inputs\.[\w]*head/i.test(
@@ -53194,24 +53216,44 @@ function promptTakesUntrusted(steps) {
53194
53216
  }
53195
53217
  return false;
53196
53218
  }
53197
- function hasActorGate(wf, raw) {
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;
53220
+ function labelTypeConfigured(wf, raw) {
53198
53221
  const on = wf.on ?? raw["on"] ?? raw[true];
53199
53222
  const prTypes = (t) => t && Array.isArray(t.types) ? t.types.map(String) : [];
53200
- const labeled = prTypes(on?.["pull_request_target"]).includes("labeled") || prTypes(on?.["pull_request"]).includes("labeled");
53223
+ return prTypes(on?.["pull_request_target"]).includes("labeled") || prTypes(on?.["pull_request"]).includes("labeled");
53224
+ }
53225
+ function ifsAreGated(ifs, labelConfigured) {
53226
+ const gated = ACTOR_GATE_RE.test(ifs);
53227
+ const labelGated = labelConfigured && /event\.label|label\.name/i.test(ifs);
53228
+ return gated || labelGated;
53229
+ }
53230
+ function hasActorGate(wf, raw) {
53201
53231
  const ifs = [
53202
53232
  wf.jobs ? Object.values(wf.jobs).map((j) => j.if) : [],
53203
53233
  allSteps(wf).map((s) => s.step.if)
53204
53234
  ].flat().map(str).join(" ");
53205
- const gated = /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.test(
53206
- ifs
53207
- );
53208
- const labelGated = !!labeled && /event\.label|label\.name/i.test(ifs);
53209
- return gated || labelGated;
53235
+ return ifsAreGated(ifs, labelTypeConfigured(wf, raw));
53236
+ }
53237
+ function jobActorGate(job, wf, raw) {
53238
+ 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);
53210
53240
  }
53211
53241
  function hasImplicitActorGate(agentSteps, bypassActive) {
53212
53242
  if (bypassActive) return false;
53213
53243
  return agentSteps.some((s) => /anthropics\/claude-code-action@/i.test(s.uses ?? ""));
53214
53244
  }
53245
+ function injectableAgentSteps(wf, raw, untrustedTrigger) {
53246
+ const out = [];
53247
+ for (const job of Object.values(wf.jobs ?? {})) {
53248
+ const a = (job.steps ?? []).filter(isAgentStep);
53249
+ if (!a.length) continue;
53250
+ 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);
53254
+ }
53255
+ return out;
53256
+ }
53215
53257
  function permsElevated(wf, agentJobs) {
53216
53258
  const check = (p) => {
53217
53259
  const s = str(p);
@@ -53274,14 +53316,16 @@ function analyzeWorkflow(path70, content) {
53274
53316
  const nonWriteList = !!nonWrite && nonWrite !== "*";
53275
53317
  const untrustedTrigger = secretExposed || forkInput;
53276
53318
  const bypassActive = nonWriteStar && untrustedTrigger;
53277
- const head = untrustedHeadCheckout(wf);
53319
+ const head = untrustedHeadCheckout(steps);
53278
53320
  const promptUntrusted = promptTakesUntrusted(agentSteps);
53279
- const reach = Math.max(
53321
+ const reach = untrustedTrigger ? Math.max(
53280
53322
  head === "root" ? 3 : head === "subdir" ? 1 : 0,
53281
53323
  promptUntrusted ? 2 : 0,
53282
53324
  bypassActive ? 2 : 0
53283
- );
53284
- const toolsBlob = collectTools(agentSteps);
53325
+ ) : 0;
53326
+ const powerSteps = injectableAgentSteps(wf, raw, untrustedTrigger);
53327
+ const scopedSteps = powerSteps.length ? powerSteps : agentSteps;
53328
+ const toolsBlob = collectTools(scopedSteps);
53285
53329
  const broadTools = BROAD_TOOL_RE.test(toolsBlob);
53286
53330
  const elevated = permsElevated(wf, agentJobs);
53287
53331
  const pat = usesPat(wf);
@@ -53346,6 +53390,105 @@ function analyzeWorkflow(path70, content) {
53346
53390
  fix: head === "root" && privileged ? "Do not check out the untrusted PR head into the workspace root under a privileged trigger (pull_request_target/workflow_run) \u2014 check out the base ref, or isolate the head in a subdir (--add-dir). Add an actor gate and scope the agent tools." : head === "root" ? "This runs under `pull_request` (fork PRs get a read-only token), so the head checkout is low-risk today \u2014 keep it on `pull_request` (not `pull_request_target`) and keep the actor gate + scoped tools." : "Add/verify an actor gate, scope the agent tools to read-only, and env-deny secrets. See Anthropic\u2019s claude-code-action security doc."
53347
53391
  };
53348
53392
  }
53393
+ 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;
53394
+ 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;
53395
+ function fuelSecretNames(agentSteps) {
53396
+ const out = /* @__PURE__ */ new Set();
53397
+ const add = (v) => {
53398
+ for (const m of str(v).matchAll(/secrets\.([A-Za-z_][A-Za-z0-9_]*)/gi)) out.add(m[1]);
53399
+ };
53400
+ for (const st of agentSteps) {
53401
+ for (const [k, v] of Object.entries(st.with ?? {})) if (FUEL_INPUT_KEYS.test(k)) add(v);
53402
+ for (const [k, v] of Object.entries(st.env ?? {})) if (AGENT_FUEL_RE.test(k)) add(v);
53403
+ }
53404
+ return out;
53405
+ }
53406
+ function classifySecret(name) {
53407
+ if (/AWS_|AZURE_|GCP_|GOOGLE_APPLICATION|GCLOUD/i.test(name)) return "cloud";
53408
+ if (/_PAT\b|PAT$|_TOKEN$|GH_TOKEN/i.test(name)) return "pat";
53409
+ if (/DATABASE|_DB_|POSTGRES|MYSQL|REDIS|MONGO|CONNECTION_STRING/i.test(name)) return "db";
53410
+ if (/API_KEY|_KEY$|SECRET|PASSWORD|PASSWD/i.test(name)) return "api-key";
53411
+ return "generic";
53412
+ }
53413
+ function agentReachableSecrets(wf, agentSteps, agentJobs) {
53414
+ const blobs = [];
53415
+ for (const st of agentSteps) blobs.push(str(st.env), str(st.with));
53416
+ for (const j of agentJobs) blobs.push(str(j.env));
53417
+ blobs.push(str(wf.env));
53418
+ const fuel = fuelSecretNames(agentSteps);
53419
+ const found = /* @__PURE__ */ new Map();
53420
+ for (const b of blobs) {
53421
+ for (const m of b.matchAll(/secrets\.([A-Za-z_][A-Za-z0-9_]*)/gi)) {
53422
+ const name = m[1];
53423
+ if (AGENT_FUEL_RE.test(name) || fuel.has(name)) continue;
53424
+ found.set(name, classifySecret(name));
53425
+ }
53426
+ }
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)));
53428
+ const out = [...found].map(([name, kind]) => ({ name, kind }));
53429
+ if (idToken) out.push({ name: "id-token (cloud OIDC)", kind: "cloud-oidc" });
53430
+ return out;
53431
+ }
53432
+ function evalAgentJob(job, wf, raw, untrustedTrigger) {
53433
+ const jobSteps = job.steps ?? [];
53434
+ const jobAgentSteps = jobSteps.filter(isAgentStep);
53435
+ if (jobAgentSteps.length === 0) return null;
53436
+ const secrets = agentReachableSecrets(wf, jobAgentSteps, [job]);
53437
+ if (secrets.length === 0) return null;
53438
+ const nonWriteStar = str(jobAgentSteps.map((s) => s.with?.["allowed_non_write_users"]).find(Boolean)) === "*";
53439
+ const bypassActive = nonWriteStar && untrustedTrigger;
53440
+ const head = untrustedHeadCheckout(jobSteps);
53441
+ const reach = Math.max(
53442
+ head === "root" ? 3 : head === "subdir" ? 1 : 0,
53443
+ promptTakesUntrusted(jobAgentSteps) ? 2 : 0,
53444
+ bypassActive ? 2 : 0
53445
+ );
53446
+ const gate = jobActorGate(job, wf, raw) || hasImplicitActorGate(jobAgentSteps, bypassActive);
53447
+ const injectable = untrustedTrigger && !gate && reach > 0;
53448
+ const canReadEnv = EXFIL_RCE_RE.test(collectTools(jobAgentSteps));
53449
+ const realSecrets = secrets.filter((s) => s.kind !== "cloud-oidc");
53450
+ const hasOidc = secrets.some((s) => s.kind === "cloud-oidc");
53451
+ let severity;
53452
+ if (injectable && canReadEnv) {
53453
+ severity = hasOidc || realSecrets.some((s) => ["cloud", "pat", "db"].includes(s.kind)) ? "critical" : "high";
53454
+ } else if (realSecrets.length > 0) {
53455
+ severity = "advisory";
53456
+ } else {
53457
+ return null;
53458
+ }
53459
+ return { severity, secrets, injectable, canReadEnv };
53460
+ }
53461
+ function analyzeWorkflowSecrets(path70, content) {
53462
+ let raw;
53463
+ try {
53464
+ raw = (0, import_yaml.parse)(content) ?? {};
53465
+ } catch {
53466
+ return null;
53467
+ }
53468
+ const wf = raw;
53469
+ const all = allSteps(wf);
53470
+ 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
+ );
53475
+ 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];
53477
+ if (!worst) return null;
53478
+ return {
53479
+ check: "CI-4",
53480
+ dimension: "data",
53481
+ severity: worst.severity,
53482
+ title: worst.severity === "advisory" ? "Secrets reachable by the agent \u2014 hardening" : "Exfiltratable secrets reachable by an injectable agent",
53483
+ file: path70,
53484
+ signals: [
53485
+ `agent can reach: ${worst.secrets.map((s) => s.name).join(", ")}`,
53486
+ worst.injectable ? "the agent is externally triggerable (untrusted trigger, no gate)" : "gated / not externally triggerable \u2014 latent risk only",
53487
+ worst.canReadEnv ? "agent has arbitrary shell (bare Bash) \u2192 can read env and exfiltrate" : "no arbitrary-shell tool \u2014 not exfiltratable today, but one tool-add away"
53488
+ ],
53489
+ fix: "Move extra secrets to a separate trusted job the agent cannot reach; drop id-token:write if unused; scope the agent tools to read-only and gate the trigger."
53490
+ };
53491
+ }
53349
53492
 
53350
53493
  // src/ci-check/agent-config.ts
53351
53494
  function asStrings(v) {
@@ -53456,6 +53599,106 @@ function analyzeMcp(path70, content) {
53456
53599
  return findings;
53457
53600
  }
53458
53601
 
53602
+ // src/ci-check/instructions.ts
53603
+ var HIDDEN_CHARS = /[\u200B\u2060\u202A-\u202E\u2066-\u2069]|[\u{E0000}-\u{E007F}]/u;
53604
+ 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
+ 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
+ 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;
53607
+ var EXFIL_RE = /\b(post|send|upload|exfiltrate|forward)\b[^\n]{0,40}\b(to|at)\b[^\n]{0,50}(https?:\/\/|webhook|hook\.[a-z])/i;
53608
+ var HUMAN_SECTION_RE = /^#+\s*(install|installation|setup|set ?up|getting started|quick ?start|contributing|contribution|development|dev setup|build|prerequisites|requirements|usage)\b/i;
53609
+ var NEGATION_RE = /\b(never|do not|don'?t|avoid|must not|should not|no need to|refuse to)\b/i;
53610
+ function isNegated(text, idx) {
53611
+ return NEGATION_RE.test(text.slice(Math.max(0, idx - 40), idx));
53612
+ }
53613
+ function inHumanSection(text, idx) {
53614
+ const heading = text.slice(0, idx).split("\n").reverse().find((l) => /^#+\s/.test(l));
53615
+ return !!heading && HUMAN_SECTION_RE.test(heading);
53616
+ }
53617
+ function decodeSuspiciousBase64(text) {
53618
+ let out = "";
53619
+ for (const m of text.matchAll(/[A-Za-z0-9+/]{40,}={0,2}/g)) {
53620
+ try {
53621
+ const d = Buffer.from(m[0], "base64").toString("utf8");
53622
+ if (/[\x20-\x7E]{16,}/.test(d) && /[a-z]{4,}/i.test(d)) out += " " + d;
53623
+ } catch {
53624
+ }
53625
+ }
53626
+ return out;
53627
+ }
53628
+ function mk(severity, title, signals, fix, path70) {
53629
+ return { check: "CI-6", dimension: "instructions", severity, title, file: path70, signals, fix };
53630
+ }
53631
+ function analyzeInstructionFile(path70, content) {
53632
+ const findings = [];
53633
+ const decoded = decodeSuspiciousBase64(content);
53634
+ if (HIDDEN_CHARS.test(content)) {
53635
+ findings.push(
53636
+ mk(
53637
+ "critical",
53638
+ "Hidden characters in an agent instruction file",
53639
+ [
53640
+ "contains zero-width / bidi / Unicode-tag characters \u2014 a technique to hide instructions from human review while the agent still reads them"
53641
+ ],
53642
+ "Remove the hidden characters. Instruction files must be plain, reviewable text.",
53643
+ path70
53644
+ )
53645
+ );
53646
+ }
53647
+ const ov = OVERRIDE_RE.exec(content);
53648
+ const ovEnc = !ov ? OVERRIDE_RE.exec(decoded) : null;
53649
+ if (ov || ovEnc) {
53650
+ const m = ov || ovEnc;
53651
+ findings.push(
53652
+ mk(
53653
+ ovEnc ? "critical" : "high",
53654
+ "Prompt-override directive in an agent instruction file",
53655
+ [
53656
+ `contains a prompt-override / role-impersonation directive (\`${m[0].slice(0, 60).trim()}\`)${ovEnc ? " \u2014 concealed in a base64 blob" : ""}`
53657
+ ],
53658
+ "Remove the override text. An instruction file should not tell the agent to ignore its own rules.",
53659
+ path70
53660
+ )
53661
+ );
53662
+ }
53663
+ const fo = FETCH_OBEY_RE.exec(content);
53664
+ if (fo && !inHumanSection(content, fo.index) && !isNegated(content, fo.index)) {
53665
+ findings.push(
53666
+ mk(
53667
+ "medium",
53668
+ "Instruction directs the agent to fetch and run remote code",
53669
+ [`\`${fo[0].slice(0, 70).trim()}\` \u2014 fetch-and-obey, outside an install/setup section`],
53670
+ "Do not instruct the agent to pipe remote content into a shell; pin and vendor scripts instead.",
53671
+ path70
53672
+ )
53673
+ );
53674
+ }
53675
+ const sp = SECRET_PATH_RE.exec(content);
53676
+ if (sp && !isNegated(content, sp.index)) {
53677
+ findings.push(
53678
+ mk(
53679
+ "medium",
53680
+ "Instruction points the agent at credential material",
53681
+ [`references \`${sp[0].slice(0, 50).trim()}\` \u2014 directs the agent toward secrets`],
53682
+ "Do not reference credential files or paths in agent instructions.",
53683
+ path70
53684
+ )
53685
+ );
53686
+ }
53687
+ const ex = EXFIL_RE.exec(content);
53688
+ if (ex && !inHumanSection(content, ex.index) && !isNegated(content, ex.index)) {
53689
+ findings.push(
53690
+ mk(
53691
+ "medium",
53692
+ "Instruction directs the agent to send data to an external endpoint",
53693
+ [`\`${ex[0].slice(0, 70).trim()}\` \u2014 possible exfiltration directive`],
53694
+ "Remove external post/upload directives from agent instructions.",
53695
+ path70
53696
+ )
53697
+ );
53698
+ }
53699
+ return findings;
53700
+ }
53701
+
53459
53702
  // src/ci-check/index.ts
53460
53703
  function worstOf(findings) {
53461
53704
  let worst = null;
@@ -53474,10 +53717,16 @@ function scanTree(tree) {
53474
53717
  if (/\.github\/workflows\/.+\.ya?ml$/.test(file.path)) {
53475
53718
  const f = analyzeWorkflow(file.path, file.content);
53476
53719
  if (f) findings.push(f);
53720
+ const s = analyzeWorkflowSecrets(file.path, file.content);
53721
+ if (s) findings.push(s);
53477
53722
  } else if (/\.claude\/settings(\.local)?\.json$/.test(file.path)) {
53478
53723
  findings.push(...analyzeAgentConfig(file.path, file.content));
53479
53724
  } else if (/\.mcp\.json$|\.cursor\/mcp\.json$/.test(file.path)) {
53480
53725
  findings.push(...analyzeMcp(file.path, file.content));
53726
+ } else if (/(^|\/)(CLAUDE|AGENTS|GEMINI)\.md$|(^|\/)\.cursorrules$|copilot-instructions\.md$|(^|\/)\.(windsurf|cline)rules$/.test(
53727
+ file.path
53728
+ )) {
53729
+ findings.push(...analyzeInstructionFile(file.path, file.content));
53481
53730
  }
53482
53731
  } catch (err2) {
53483
53732
  notes.push(`checker degraded on ${file.path}: ${err2?.message ?? "error"}`);
@@ -53508,6 +53757,36 @@ var COLOR = {
53508
53757
  medium: import_chalk29.default.yellow,
53509
53758
  advisory: import_chalk29.default.gray
53510
53759
  };
53760
+ var ACTION_URL = "https://github.com/marketplace/actions/node9-agent-security-check?ref=cli_scan_repo";
53761
+ function renderCta(res) {
53762
+ const L = [];
53763
+ L.push(import_chalk29.default.dim(" " + "\u2500".repeat(63)));
53764
+ if (res.worst === "critical" || res.worst === "high") {
53765
+ const n = res.findings.filter((f) => f.severity === "critical" || f.severity === "high").length;
53766
+ L.push(
53767
+ " " + import_chalk29.default.red.bold(
53768
+ `\u{1F534} ${n} ${n === 1 ? "issue" : "issues"} to fix \u2014 then stop the next at the PR.`
53769
+ )
53770
+ );
53771
+ L.push("");
53772
+ L.push(" " + import_chalk29.default.bold("Catch this class of issue on every PR, automatically:"));
53773
+ } else if (res.worst) {
53774
+ L.push(" " + import_chalk29.default.yellow("\u{1F7E1} Review the findings above, then keep it covered:"));
53775
+ L.push("");
53776
+ L.push(" " + import_chalk29.default.bold("Check every PR for agent-CI risk:"));
53777
+ } else if (res.incomplete) {
53778
+ L.push(" " + import_chalk29.default.yellow.bold("\u26A0\uFE0F Incomplete \u2014 not a clean bill of health."));
53779
+ L.push("");
53780
+ L.push(" " + import_chalk29.default.bold("Get a complete check on every PR (CI reads the tree directly):"));
53781
+ } else {
53782
+ L.push(" " + import_chalk29.default.green("\u2705 Agent CI is well-configured \u2014 0 unmitigated issues."));
53783
+ L.push("");
53784
+ L.push(" " + import_chalk29.default.bold("Keep it green as you add agent workflows \u2014 check every PR:"));
53785
+ }
53786
+ L.push(" " + import_chalk29.default.dim("\u2192 ") + import_chalk29.default.cyan.underline(ACTION_URL));
53787
+ L.push(" " + import_chalk29.default.gray(" zero setup \xB7 no token \xB7 runs in your CI"));
53788
+ return L;
53789
+ }
53511
53790
  function ownedHint(source) {
53512
53791
  return source.startsWith("/") || source.startsWith(".") || source.startsWith("~");
53513
53792
  }
@@ -53552,6 +53831,8 @@ function renderScan(res) {
53552
53831
  )
53553
53832
  );
53554
53833
  }
53834
+ L.push("");
53835
+ L.push(...renderCta(res));
53555
53836
  return L.join("\n");
53556
53837
  }
53557
53838
  function renderScanMarkdown(res) {
package/dist/cli.mjs CHANGED
@@ -52967,7 +52967,15 @@ var SURFACE_FILES = [
52967
52967
  ".claude/settings.local.json",
52968
52968
  ".mcp.json",
52969
52969
  ".cursor/mcp.json",
52970
- ".codex/config.toml"
52970
+ ".codex/config.toml",
52971
+ // CI-6: agent instruction files (auto-loaded into the agent's system prompt).
52972
+ "CLAUDE.md",
52973
+ "AGENTS.md",
52974
+ "GEMINI.md",
52975
+ ".cursorrules",
52976
+ ".github/copilot-instructions.md",
52977
+ ".windsurfrules",
52978
+ ".clinerules"
52971
52979
  ];
52972
52980
  var WORKFLOW_DIR = ".github/workflows";
52973
52981
  async function pooled(items, limit, fn) {
@@ -53157,18 +53165,32 @@ function isAgentStep(step) {
53157
53165
  return true;
53158
53166
  return false;
53159
53167
  }
53168
+ function allowedToolsFromArgs(claudeArgs) {
53169
+ let out = "";
53170
+ for (const m of claudeArgs.matchAll(/--allowed[-_]?tools[=\s]+("[^"]*"|'[^']*'|\S+)/gi))
53171
+ out += " " + m[1];
53172
+ return out;
53173
+ }
53174
+ function allowedToolsFromSettings(settings) {
53175
+ let out = "";
53176
+ for (const key of ["allowedTools", "allow"]) {
53177
+ for (const m of settings.matchAll(new RegExp(`"${key}"\\s*:\\s*(\\[[^\\]]*\\])`, "gi")))
53178
+ out += " " + m[1];
53179
+ }
53180
+ return out;
53181
+ }
53160
53182
  function collectTools(steps) {
53161
- const stripDeny = (x) => x.replace(/--disallowed[-_]?tools\s+("[^"]*"|'[^']*'|\S+)/gi, " ").replace(/["']disallowed[_]?[tT]ools["']\s*:\s*\[[^\]]*\]/g, " ");
53162
53183
  let s = "";
53163
53184
  for (const st of steps) {
53164
53185
  const w = st.with ?? {};
53165
- s += " " + stripDeny(str(w["claude_args"])) + " " + str(w["allowed_tools"]) + " " + str(w["allowedTools"]);
53166
- s += " " + stripDeny(str(w["settings"]));
53186
+ s += " " + allowedToolsFromArgs(str(w["claude_args"]));
53187
+ s += " " + str(w["allowed_tools"]) + " " + str(w["allowedTools"]);
53188
+ s += " " + allowedToolsFromSettings(str(w["settings"]));
53167
53189
  }
53168
53190
  return s;
53169
53191
  }
53170
- function untrustedHeadCheckout(wf) {
53171
- for (const { step } of allSteps(wf)) {
53192
+ function untrustedHeadCheckout(steps) {
53193
+ for (const step of steps) {
53172
53194
  if (!step.uses || !/actions\/checkout/.test(step.uses)) continue;
53173
53195
  const ref = str(step.with?.["ref"]);
53174
53196
  if (/pull_request\.head|head[._]sha|head_ref|expected_head|workflow_run\.head|inputs\.[\w]*head/i.test(
@@ -53187,24 +53209,44 @@ function promptTakesUntrusted(steps) {
53187
53209
  }
53188
53210
  return false;
53189
53211
  }
53190
- function hasActorGate(wf, raw) {
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;
53213
+ function labelTypeConfigured(wf, raw) {
53191
53214
  const on = wf.on ?? raw["on"] ?? raw[true];
53192
53215
  const prTypes = (t) => t && Array.isArray(t.types) ? t.types.map(String) : [];
53193
- const labeled = prTypes(on?.["pull_request_target"]).includes("labeled") || prTypes(on?.["pull_request"]).includes("labeled");
53216
+ return prTypes(on?.["pull_request_target"]).includes("labeled") || prTypes(on?.["pull_request"]).includes("labeled");
53217
+ }
53218
+ function ifsAreGated(ifs, labelConfigured) {
53219
+ const gated = ACTOR_GATE_RE.test(ifs);
53220
+ const labelGated = labelConfigured && /event\.label|label\.name/i.test(ifs);
53221
+ return gated || labelGated;
53222
+ }
53223
+ function hasActorGate(wf, raw) {
53194
53224
  const ifs = [
53195
53225
  wf.jobs ? Object.values(wf.jobs).map((j) => j.if) : [],
53196
53226
  allSteps(wf).map((s) => s.step.if)
53197
53227
  ].flat().map(str).join(" ");
53198
- const gated = /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.test(
53199
- ifs
53200
- );
53201
- const labelGated = !!labeled && /event\.label|label\.name/i.test(ifs);
53202
- return gated || labelGated;
53228
+ return ifsAreGated(ifs, labelTypeConfigured(wf, raw));
53229
+ }
53230
+ function jobActorGate(job, wf, raw) {
53231
+ 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);
53203
53233
  }
53204
53234
  function hasImplicitActorGate(agentSteps, bypassActive) {
53205
53235
  if (bypassActive) return false;
53206
53236
  return agentSteps.some((s) => /anthropics\/claude-code-action@/i.test(s.uses ?? ""));
53207
53237
  }
53238
+ function injectableAgentSteps(wf, raw, untrustedTrigger) {
53239
+ const out = [];
53240
+ for (const job of Object.values(wf.jobs ?? {})) {
53241
+ const a = (job.steps ?? []).filter(isAgentStep);
53242
+ if (!a.length) continue;
53243
+ 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);
53247
+ }
53248
+ return out;
53249
+ }
53208
53250
  function permsElevated(wf, agentJobs) {
53209
53251
  const check = (p) => {
53210
53252
  const s = str(p);
@@ -53267,14 +53309,16 @@ function analyzeWorkflow(path70, content) {
53267
53309
  const nonWriteList = !!nonWrite && nonWrite !== "*";
53268
53310
  const untrustedTrigger = secretExposed || forkInput;
53269
53311
  const bypassActive = nonWriteStar && untrustedTrigger;
53270
- const head = untrustedHeadCheckout(wf);
53312
+ const head = untrustedHeadCheckout(steps);
53271
53313
  const promptUntrusted = promptTakesUntrusted(agentSteps);
53272
- const reach = Math.max(
53314
+ const reach = untrustedTrigger ? Math.max(
53273
53315
  head === "root" ? 3 : head === "subdir" ? 1 : 0,
53274
53316
  promptUntrusted ? 2 : 0,
53275
53317
  bypassActive ? 2 : 0
53276
- );
53277
- const toolsBlob = collectTools(agentSteps);
53318
+ ) : 0;
53319
+ const powerSteps = injectableAgentSteps(wf, raw, untrustedTrigger);
53320
+ const scopedSteps = powerSteps.length ? powerSteps : agentSteps;
53321
+ const toolsBlob = collectTools(scopedSteps);
53278
53322
  const broadTools = BROAD_TOOL_RE.test(toolsBlob);
53279
53323
  const elevated = permsElevated(wf, agentJobs);
53280
53324
  const pat = usesPat(wf);
@@ -53339,6 +53383,105 @@ function analyzeWorkflow(path70, content) {
53339
53383
  fix: head === "root" && privileged ? "Do not check out the untrusted PR head into the workspace root under a privileged trigger (pull_request_target/workflow_run) \u2014 check out the base ref, or isolate the head in a subdir (--add-dir). Add an actor gate and scope the agent tools." : head === "root" ? "This runs under `pull_request` (fork PRs get a read-only token), so the head checkout is low-risk today \u2014 keep it on `pull_request` (not `pull_request_target`) and keep the actor gate + scoped tools." : "Add/verify an actor gate, scope the agent tools to read-only, and env-deny secrets. See Anthropic\u2019s claude-code-action security doc."
53340
53384
  };
53341
53385
  }
53386
+ 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;
53387
+ 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;
53388
+ function fuelSecretNames(agentSteps) {
53389
+ const out = /* @__PURE__ */ new Set();
53390
+ const add = (v) => {
53391
+ for (const m of str(v).matchAll(/secrets\.([A-Za-z_][A-Za-z0-9_]*)/gi)) out.add(m[1]);
53392
+ };
53393
+ for (const st of agentSteps) {
53394
+ for (const [k, v] of Object.entries(st.with ?? {})) if (FUEL_INPUT_KEYS.test(k)) add(v);
53395
+ for (const [k, v] of Object.entries(st.env ?? {})) if (AGENT_FUEL_RE.test(k)) add(v);
53396
+ }
53397
+ return out;
53398
+ }
53399
+ function classifySecret(name) {
53400
+ if (/AWS_|AZURE_|GCP_|GOOGLE_APPLICATION|GCLOUD/i.test(name)) return "cloud";
53401
+ if (/_PAT\b|PAT$|_TOKEN$|GH_TOKEN/i.test(name)) return "pat";
53402
+ if (/DATABASE|_DB_|POSTGRES|MYSQL|REDIS|MONGO|CONNECTION_STRING/i.test(name)) return "db";
53403
+ if (/API_KEY|_KEY$|SECRET|PASSWORD|PASSWD/i.test(name)) return "api-key";
53404
+ return "generic";
53405
+ }
53406
+ function agentReachableSecrets(wf, agentSteps, agentJobs) {
53407
+ const blobs = [];
53408
+ for (const st of agentSteps) blobs.push(str(st.env), str(st.with));
53409
+ for (const j of agentJobs) blobs.push(str(j.env));
53410
+ blobs.push(str(wf.env));
53411
+ const fuel = fuelSecretNames(agentSteps);
53412
+ const found = /* @__PURE__ */ new Map();
53413
+ for (const b of blobs) {
53414
+ for (const m of b.matchAll(/secrets\.([A-Za-z_][A-Za-z0-9_]*)/gi)) {
53415
+ const name = m[1];
53416
+ if (AGENT_FUEL_RE.test(name) || fuel.has(name)) continue;
53417
+ found.set(name, classifySecret(name));
53418
+ }
53419
+ }
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)));
53421
+ const out = [...found].map(([name, kind]) => ({ name, kind }));
53422
+ if (idToken) out.push({ name: "id-token (cloud OIDC)", kind: "cloud-oidc" });
53423
+ return out;
53424
+ }
53425
+ function evalAgentJob(job, wf, raw, untrustedTrigger) {
53426
+ const jobSteps = job.steps ?? [];
53427
+ const jobAgentSteps = jobSteps.filter(isAgentStep);
53428
+ if (jobAgentSteps.length === 0) return null;
53429
+ const secrets = agentReachableSecrets(wf, jobAgentSteps, [job]);
53430
+ if (secrets.length === 0) return null;
53431
+ const nonWriteStar = str(jobAgentSteps.map((s) => s.with?.["allowed_non_write_users"]).find(Boolean)) === "*";
53432
+ const bypassActive = nonWriteStar && untrustedTrigger;
53433
+ const head = untrustedHeadCheckout(jobSteps);
53434
+ const reach = Math.max(
53435
+ head === "root" ? 3 : head === "subdir" ? 1 : 0,
53436
+ promptTakesUntrusted(jobAgentSteps) ? 2 : 0,
53437
+ bypassActive ? 2 : 0
53438
+ );
53439
+ const gate = jobActorGate(job, wf, raw) || hasImplicitActorGate(jobAgentSteps, bypassActive);
53440
+ const injectable = untrustedTrigger && !gate && reach > 0;
53441
+ const canReadEnv = EXFIL_RCE_RE.test(collectTools(jobAgentSteps));
53442
+ const realSecrets = secrets.filter((s) => s.kind !== "cloud-oidc");
53443
+ const hasOidc = secrets.some((s) => s.kind === "cloud-oidc");
53444
+ let severity;
53445
+ if (injectable && canReadEnv) {
53446
+ severity = hasOidc || realSecrets.some((s) => ["cloud", "pat", "db"].includes(s.kind)) ? "critical" : "high";
53447
+ } else if (realSecrets.length > 0) {
53448
+ severity = "advisory";
53449
+ } else {
53450
+ return null;
53451
+ }
53452
+ return { severity, secrets, injectable, canReadEnv };
53453
+ }
53454
+ function analyzeWorkflowSecrets(path70, content) {
53455
+ let raw;
53456
+ try {
53457
+ raw = parseYaml(content) ?? {};
53458
+ } catch {
53459
+ return null;
53460
+ }
53461
+ const wf = raw;
53462
+ const all = allSteps(wf);
53463
+ 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
+ );
53468
+ 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];
53470
+ if (!worst) return null;
53471
+ return {
53472
+ check: "CI-4",
53473
+ dimension: "data",
53474
+ severity: worst.severity,
53475
+ title: worst.severity === "advisory" ? "Secrets reachable by the agent \u2014 hardening" : "Exfiltratable secrets reachable by an injectable agent",
53476
+ file: path70,
53477
+ signals: [
53478
+ `agent can reach: ${worst.secrets.map((s) => s.name).join(", ")}`,
53479
+ worst.injectable ? "the agent is externally triggerable (untrusted trigger, no gate)" : "gated / not externally triggerable \u2014 latent risk only",
53480
+ worst.canReadEnv ? "agent has arbitrary shell (bare Bash) \u2192 can read env and exfiltrate" : "no arbitrary-shell tool \u2014 not exfiltratable today, but one tool-add away"
53481
+ ],
53482
+ fix: "Move extra secrets to a separate trusted job the agent cannot reach; drop id-token:write if unused; scope the agent tools to read-only and gate the trigger."
53483
+ };
53484
+ }
53342
53485
 
53343
53486
  // src/ci-check/agent-config.ts
53344
53487
  function asStrings(v) {
@@ -53449,6 +53592,106 @@ function analyzeMcp(path70, content) {
53449
53592
  return findings;
53450
53593
  }
53451
53594
 
53595
+ // src/ci-check/instructions.ts
53596
+ var HIDDEN_CHARS = /[\u200B\u2060\u202A-\u202E\u2066-\u2069]|[\u{E0000}-\u{E007F}]/u;
53597
+ 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
+ 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
+ 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;
53600
+ var EXFIL_RE = /\b(post|send|upload|exfiltrate|forward)\b[^\n]{0,40}\b(to|at)\b[^\n]{0,50}(https?:\/\/|webhook|hook\.[a-z])/i;
53601
+ var HUMAN_SECTION_RE = /^#+\s*(install|installation|setup|set ?up|getting started|quick ?start|contributing|contribution|development|dev setup|build|prerequisites|requirements|usage)\b/i;
53602
+ var NEGATION_RE = /\b(never|do not|don'?t|avoid|must not|should not|no need to|refuse to)\b/i;
53603
+ function isNegated(text, idx) {
53604
+ return NEGATION_RE.test(text.slice(Math.max(0, idx - 40), idx));
53605
+ }
53606
+ function inHumanSection(text, idx) {
53607
+ const heading = text.slice(0, idx).split("\n").reverse().find((l) => /^#+\s/.test(l));
53608
+ return !!heading && HUMAN_SECTION_RE.test(heading);
53609
+ }
53610
+ function decodeSuspiciousBase64(text) {
53611
+ let out = "";
53612
+ for (const m of text.matchAll(/[A-Za-z0-9+/]{40,}={0,2}/g)) {
53613
+ try {
53614
+ const d = Buffer.from(m[0], "base64").toString("utf8");
53615
+ if (/[\x20-\x7E]{16,}/.test(d) && /[a-z]{4,}/i.test(d)) out += " " + d;
53616
+ } catch {
53617
+ }
53618
+ }
53619
+ return out;
53620
+ }
53621
+ function mk(severity, title, signals, fix, path70) {
53622
+ return { check: "CI-6", dimension: "instructions", severity, title, file: path70, signals, fix };
53623
+ }
53624
+ function analyzeInstructionFile(path70, content) {
53625
+ const findings = [];
53626
+ const decoded = decodeSuspiciousBase64(content);
53627
+ if (HIDDEN_CHARS.test(content)) {
53628
+ findings.push(
53629
+ mk(
53630
+ "critical",
53631
+ "Hidden characters in an agent instruction file",
53632
+ [
53633
+ "contains zero-width / bidi / Unicode-tag characters \u2014 a technique to hide instructions from human review while the agent still reads them"
53634
+ ],
53635
+ "Remove the hidden characters. Instruction files must be plain, reviewable text.",
53636
+ path70
53637
+ )
53638
+ );
53639
+ }
53640
+ const ov = OVERRIDE_RE.exec(content);
53641
+ const ovEnc = !ov ? OVERRIDE_RE.exec(decoded) : null;
53642
+ if (ov || ovEnc) {
53643
+ const m = ov || ovEnc;
53644
+ findings.push(
53645
+ mk(
53646
+ ovEnc ? "critical" : "high",
53647
+ "Prompt-override directive in an agent instruction file",
53648
+ [
53649
+ `contains a prompt-override / role-impersonation directive (\`${m[0].slice(0, 60).trim()}\`)${ovEnc ? " \u2014 concealed in a base64 blob" : ""}`
53650
+ ],
53651
+ "Remove the override text. An instruction file should not tell the agent to ignore its own rules.",
53652
+ path70
53653
+ )
53654
+ );
53655
+ }
53656
+ const fo = FETCH_OBEY_RE.exec(content);
53657
+ if (fo && !inHumanSection(content, fo.index) && !isNegated(content, fo.index)) {
53658
+ findings.push(
53659
+ mk(
53660
+ "medium",
53661
+ "Instruction directs the agent to fetch and run remote code",
53662
+ [`\`${fo[0].slice(0, 70).trim()}\` \u2014 fetch-and-obey, outside an install/setup section`],
53663
+ "Do not instruct the agent to pipe remote content into a shell; pin and vendor scripts instead.",
53664
+ path70
53665
+ )
53666
+ );
53667
+ }
53668
+ const sp = SECRET_PATH_RE.exec(content);
53669
+ if (sp && !isNegated(content, sp.index)) {
53670
+ findings.push(
53671
+ mk(
53672
+ "medium",
53673
+ "Instruction points the agent at credential material",
53674
+ [`references \`${sp[0].slice(0, 50).trim()}\` \u2014 directs the agent toward secrets`],
53675
+ "Do not reference credential files or paths in agent instructions.",
53676
+ path70
53677
+ )
53678
+ );
53679
+ }
53680
+ const ex = EXFIL_RE.exec(content);
53681
+ if (ex && !inHumanSection(content, ex.index) && !isNegated(content, ex.index)) {
53682
+ findings.push(
53683
+ mk(
53684
+ "medium",
53685
+ "Instruction directs the agent to send data to an external endpoint",
53686
+ [`\`${ex[0].slice(0, 70).trim()}\` \u2014 possible exfiltration directive`],
53687
+ "Remove external post/upload directives from agent instructions.",
53688
+ path70
53689
+ )
53690
+ );
53691
+ }
53692
+ return findings;
53693
+ }
53694
+
53452
53695
  // src/ci-check/index.ts
53453
53696
  function worstOf(findings) {
53454
53697
  let worst = null;
@@ -53467,10 +53710,16 @@ function scanTree(tree) {
53467
53710
  if (/\.github\/workflows\/.+\.ya?ml$/.test(file.path)) {
53468
53711
  const f = analyzeWorkflow(file.path, file.content);
53469
53712
  if (f) findings.push(f);
53713
+ const s = analyzeWorkflowSecrets(file.path, file.content);
53714
+ if (s) findings.push(s);
53470
53715
  } else if (/\.claude\/settings(\.local)?\.json$/.test(file.path)) {
53471
53716
  findings.push(...analyzeAgentConfig(file.path, file.content));
53472
53717
  } else if (/\.mcp\.json$|\.cursor\/mcp\.json$/.test(file.path)) {
53473
53718
  findings.push(...analyzeMcp(file.path, file.content));
53719
+ } else if (/(^|\/)(CLAUDE|AGENTS|GEMINI)\.md$|(^|\/)\.cursorrules$|copilot-instructions\.md$|(^|\/)\.(windsurf|cline)rules$/.test(
53720
+ file.path
53721
+ )) {
53722
+ findings.push(...analyzeInstructionFile(file.path, file.content));
53474
53723
  }
53475
53724
  } catch (err2) {
53476
53725
  notes.push(`checker degraded on ${file.path}: ${err2?.message ?? "error"}`);
@@ -53501,6 +53750,36 @@ var COLOR = {
53501
53750
  medium: chalk29.yellow,
53502
53751
  advisory: chalk29.gray
53503
53752
  };
53753
+ var ACTION_URL = "https://github.com/marketplace/actions/node9-agent-security-check?ref=cli_scan_repo";
53754
+ function renderCta(res) {
53755
+ const L = [];
53756
+ L.push(chalk29.dim(" " + "\u2500".repeat(63)));
53757
+ if (res.worst === "critical" || res.worst === "high") {
53758
+ const n = res.findings.filter((f) => f.severity === "critical" || f.severity === "high").length;
53759
+ L.push(
53760
+ " " + chalk29.red.bold(
53761
+ `\u{1F534} ${n} ${n === 1 ? "issue" : "issues"} to fix \u2014 then stop the next at the PR.`
53762
+ )
53763
+ );
53764
+ L.push("");
53765
+ L.push(" " + chalk29.bold("Catch this class of issue on every PR, automatically:"));
53766
+ } else if (res.worst) {
53767
+ L.push(" " + chalk29.yellow("\u{1F7E1} Review the findings above, then keep it covered:"));
53768
+ L.push("");
53769
+ L.push(" " + chalk29.bold("Check every PR for agent-CI risk:"));
53770
+ } else if (res.incomplete) {
53771
+ L.push(" " + chalk29.yellow.bold("\u26A0\uFE0F Incomplete \u2014 not a clean bill of health."));
53772
+ L.push("");
53773
+ L.push(" " + chalk29.bold("Get a complete check on every PR (CI reads the tree directly):"));
53774
+ } else {
53775
+ L.push(" " + chalk29.green("\u2705 Agent CI is well-configured \u2014 0 unmitigated issues."));
53776
+ L.push("");
53777
+ L.push(" " + chalk29.bold("Keep it green as you add agent workflows \u2014 check every PR:"));
53778
+ }
53779
+ L.push(" " + chalk29.dim("\u2192 ") + chalk29.cyan.underline(ACTION_URL));
53780
+ L.push(" " + chalk29.gray(" zero setup \xB7 no token \xB7 runs in your CI"));
53781
+ return L;
53782
+ }
53504
53783
  function ownedHint(source) {
53505
53784
  return source.startsWith("/") || source.startsWith(".") || source.startsWith("~");
53506
53785
  }
@@ -53545,6 +53824,8 @@ function renderScan(res) {
53545
53824
  )
53546
53825
  );
53547
53826
  }
53827
+ L.push("");
53828
+ L.push(...renderCta(res));
53548
53829
  return L.join("\n");
53549
53830
  }
53550
53831
  function renderScanMarkdown(res) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@node9/proxy",
3
- "version": "1.58.5",
3
+ "version": "1.59.0",
4
4
  "description": "The Sudo Command for AI Agents. Execution Security for Claude Code, Codex, Gemini, Cursor, Opencode, Pi, and any MCP server.",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.mjs",