@agent-beam/beam 0.2.39 → 0.2.41

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 (62) hide show
  1. package/dist/access-risk.d.ts +1 -1
  2. package/dist/access-risk.js +7 -2
  3. package/dist/access-risk.js.map +1 -1
  4. package/dist/action-context.d.ts +5 -0
  5. package/dist/action-context.js +5 -2
  6. package/dist/action-context.js.map +1 -1
  7. package/dist/approvals.d.ts +13 -1
  8. package/dist/approvals.js +14 -3
  9. package/dist/approvals.js.map +1 -1
  10. package/dist/approved-actions.d.ts +11 -0
  11. package/dist/approved-actions.js +53 -0
  12. package/dist/approved-actions.js.map +1 -0
  13. package/dist/cli.js +64 -1
  14. package/dist/cli.js.map +1 -1
  15. package/dist/client.d.ts +17 -0
  16. package/dist/client.js +183 -3
  17. package/dist/client.js.map +1 -1
  18. package/dist/core.d.ts +10 -0
  19. package/dist/core.js +67 -7
  20. package/dist/core.js.map +1 -1
  21. package/dist/credential-egress.d.ts +7 -0
  22. package/dist/credential-egress.js +52 -6
  23. package/dist/credential-egress.js.map +1 -1
  24. package/dist/custom-rules.js +1 -1
  25. package/dist/custom-rules.js.map +1 -1
  26. package/dist/destructive-risk.d.ts +5 -1
  27. package/dist/destructive-risk.js +63 -5
  28. package/dist/destructive-risk.js.map +1 -1
  29. package/dist/flow.d.ts +63 -0
  30. package/dist/flow.js +196 -0
  31. package/dist/flow.js.map +1 -0
  32. package/dist/package-scan.js +1 -1
  33. package/dist/package-scan.js.map +1 -1
  34. package/dist/packages.js +1 -1
  35. package/dist/packages.js.map +1 -1
  36. package/dist/plain-explain.d.ts +38 -0
  37. package/dist/plain-explain.js +197 -0
  38. package/dist/plain-explain.js.map +1 -0
  39. package/dist/policy.d.ts +5 -0
  40. package/dist/policy.js +27 -7
  41. package/dist/policy.js.map +1 -1
  42. package/dist/repo-remote.d.ts +39 -0
  43. package/dist/repo-remote.js +191 -0
  44. package/dist/repo-remote.js.map +1 -0
  45. package/dist/rule-catalog.d.ts +1 -1
  46. package/dist/rule-catalog.js +237 -15
  47. package/dist/rule-catalog.js.map +1 -1
  48. package/dist/secret-use.d.ts +23 -0
  49. package/dist/secret-use.js +142 -0
  50. package/dist/secret-use.js.map +1 -0
  51. package/dist/server.js +27 -1
  52. package/dist/server.js.map +1 -1
  53. package/dist/skill-context.js +5 -2
  54. package/dist/skill-context.js.map +1 -1
  55. package/dist/skill-risk-rules.js +3 -1
  56. package/dist/skill-risk-rules.js.map +1 -1
  57. package/dist/skill-scan.d.ts +4 -0
  58. package/dist/skill-scan.js +32 -9
  59. package/dist/skill-scan.js.map +1 -1
  60. package/dist/studio.js +2 -2
  61. package/package.json +1 -1
  62. package/stress-report.json +46 -46
@@ -0,0 +1,191 @@
1
+ import { closeSync, existsSync, openSync, readSync, statSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ const SAFE_SEGMENT = /^[A-Za-z0-9._~@+-]+$/;
4
+ const cleanPath = (p) => p.replace(/^\/+/, "").replace(/\/+$/, "").replace(/\.git$/i, "");
5
+ const okPath = (p) => p.length > 0 && p.length <= 200 && p.split("/").every(s => SAFE_SEGMENT.test(s) && s !== "." && s !== "..");
6
+ function providerOf(host) {
7
+ const h = host.toLowerCase();
8
+ if (/(^|\.)github\./.test(h) || h === "github.com" || h.endsWith(".github.com"))
9
+ return "github";
10
+ if (/(^|\.)gitlab\./.test(h) || h.includes("gitlab"))
11
+ return "gitlab";
12
+ if (/(^|\.)bitbucket\./.test(h) || h.includes("bitbucket"))
13
+ return "bitbucket";
14
+ if (h === "dev.azure.com" || h === "ssh.dev.azure.com" || h.endsWith(".visualstudio.com"))
15
+ return "azure-devops";
16
+ if (/^git-codecommit(?:-fips)?\.[a-z0-9-]+\.amazonaws\.com(?:\.cn)?$/.test(h))
17
+ return "aws-codecommit";
18
+ if (h === "codeberg.org")
19
+ return "codeberg";
20
+ if (h === "git.sr.ht" || h === "sr.ht")
21
+ return "sourcehut";
22
+ if (h === "huggingface.co" || h.endsWith(".hf.co"))
23
+ return "huggingface";
24
+ if (h.includes("gitea") || h.includes("forgejo"))
25
+ return "gitea";
26
+ return "other";
27
+ }
28
+ function browse(provider, host, path, region) {
29
+ switch (provider) {
30
+ case "azure-devops": {
31
+ const [org, project, repo] = path.split("/");
32
+ return org && project && repo ? `https://dev.azure.com/${org}/${project}/_git/${repo}` : undefined;
33
+ }
34
+ case "aws-codecommit":
35
+ return region ? `https://${region}.console.aws.amazon.com/codesuite/codecommit/repositories/${path}/browse?region=${region}` : undefined;
36
+ case "sourcehut":
37
+ return `https://git.sr.ht/${path}`;
38
+ default:
39
+ return `https://${host}/${path}`;
40
+ }
41
+ }
42
+ /** Parses any git remote URL form. Returns undefined for local paths and anything unrecognisable. */
43
+ export function parseRemoteUrl(input) {
44
+ const raw = input.trim();
45
+ if (!raw || raw.length > 500)
46
+ return undefined;
47
+ // codecommit::us-east-1://my-repo or codecommit://profile@my-repo
48
+ const cc = /^codecommit(?:::([a-z0-9-]+))?:\/\/(?:[^@/]+@)?([A-Za-z0-9._-]+)\/?$/.exec(raw);
49
+ if (cc) {
50
+ const region = cc[1];
51
+ return { provider: "aws-codecommit", host: region ? `git-codecommit.${region}.amazonaws.com` : "git-codecommit.amazonaws.com", path: cc[2], protocol: "codecommit", ...(region ? { region } : {}), ...(region ? { url: browse("aws-codecommit", "", cc[2], region) } : {}) };
52
+ }
53
+ let host = "", path = "", protocol = "https";
54
+ const scheme = /^([a-z][a-z0-9+.-]*):\/\//i.exec(raw);
55
+ if (scheme) {
56
+ let u;
57
+ try {
58
+ u = new URL(raw);
59
+ }
60
+ catch {
61
+ return undefined;
62
+ }
63
+ const proto = u.protocol.replace(":", "").toLowerCase();
64
+ if (proto === "file")
65
+ return undefined;
66
+ protocol = proto === "http" ? "http" : proto === "https" ? "https" : proto === "git" ? "git" : "ssh";
67
+ host = u.hostname.toLowerCase(); // port, user and password are dropped here
68
+ path = decodeURIComponent(u.pathname);
69
+ }
70
+ else {
71
+ // scp-like: git@github.com:owner/repo.git (but not C:\path or ./relative)
72
+ const scp = /^(?:[^@/\s]+@)?([A-Za-z0-9.-]+):(?!\/\/)(.+)$/.exec(raw);
73
+ if (!scp || /^[A-Za-z]$/.test(scp[1]))
74
+ return undefined;
75
+ host = scp[1].toLowerCase();
76
+ path = scp[2];
77
+ protocol = "ssh";
78
+ }
79
+ if (!host || !/^[a-z0-9.-]+$/.test(host))
80
+ return undefined;
81
+ let provider = providerOf(host);
82
+ let region;
83
+ path = cleanPath(path);
84
+ if (provider === "azure-devops") {
85
+ // https://dev.azure.com/org/project/_git/repo | git@ssh.dev.azure.com:v3/org/project/repo
86
+ path = path.replace(/^v3\//, "").replace(/\/_git\//, "/");
87
+ const vs = /^([a-z0-9-]+)\.visualstudio\.com$/.exec(host);
88
+ if (vs)
89
+ path = `${vs[1]}/${path}`;
90
+ }
91
+ else if (provider === "aws-codecommit") {
92
+ region = /^git-codecommit(?:-fips)?\.([a-z0-9-]+)\./.exec(host)?.[1];
93
+ path = path.replace(/^v1\/repos\//, "");
94
+ }
95
+ else if (provider === "sourcehut") {
96
+ path = path.replace(/^~/, "~");
97
+ }
98
+ if (!okPath(path))
99
+ return undefined;
100
+ if (protocol === "http")
101
+ provider = providerOf(host);
102
+ return {
103
+ provider, host, path, protocol,
104
+ ...(region ? { region } : {}),
105
+ ...(protocol !== "http" ? { url: browse(provider, host, path, region) } : {}),
106
+ };
107
+ }
108
+ function readSmall(file, max = 65536) {
109
+ try {
110
+ if (!existsSync(file) || statSync(file).size > max)
111
+ return undefined;
112
+ const fd = openSync(file, "r");
113
+ try {
114
+ const buf = Buffer.alloc(max);
115
+ const n = readSync(fd, buf, 0, max, 0);
116
+ return buf.subarray(0, n).toString("utf8");
117
+ }
118
+ finally {
119
+ closeSync(fd);
120
+ }
121
+ }
122
+ catch {
123
+ return undefined;
124
+ }
125
+ }
126
+ /** The origin remote (or the first remote) from a git directory's config. */
127
+ export function readOriginRemote(gitDir) {
128
+ const config = readSmall(join(gitDir, "config"));
129
+ if (!config)
130
+ return undefined;
131
+ const remotes = [];
132
+ let current;
133
+ for (const line of config.split(/\r?\n/)) {
134
+ const section = /^\s*\[\s*remote\s+"([^"]+)"\s*\]\s*$/.exec(line);
135
+ if (section) {
136
+ current = { name: section[1] };
137
+ remotes.push(current);
138
+ continue;
139
+ }
140
+ if (/^\s*\[/.test(line)) {
141
+ current = undefined;
142
+ continue;
143
+ }
144
+ const url = current && !current.url ? /^\s*url\s*=\s*(.+?)\s*$/.exec(line) : null;
145
+ if (current && url)
146
+ current.url = url[1];
147
+ }
148
+ const pick = remotes.find(r => r.name === "origin" && r.url) ?? remotes.find(r => r.url);
149
+ return pick?.url ? parseRemoteUrl(pick.url) : undefined;
150
+ }
151
+ // ---- container registries ---------------------------------------------------------------------
152
+ const TAG = "(?::([A-Za-z0-9_][A-Za-z0-9_.-]{0,127}))?";
153
+ const REGISTRIES = [
154
+ { provider: "aws-ecr", re: new RegExp(`\\b(\\d{12})\\.dkr\\.ecr(?:-fips)?\\.([a-z0-9-]+)\\.amazonaws\\.com(?:\\.cn)?/([a-z0-9][a-z0-9._/-]*)${TAG}`, "i"),
155
+ build: m => ({ provider: "aws-ecr", host: `${m[1]}.dkr.ecr.${m[2]}.amazonaws.com`, path: m[3], account: m[1], region: m[2], ...(m[4] ? { tag: m[4] } : {}) }) },
156
+ { provider: "aws-ecr-public", re: new RegExp(`\\bpublic\\.ecr\\.aws/([a-z0-9][a-z0-9._-]*/[a-z0-9][a-z0-9._/-]*)${TAG}`, "i"),
157
+ build: m => ({ provider: "aws-ecr-public", host: "public.ecr.aws", path: m[1], ...(m[2] ? { tag: m[2] } : {}) }) },
158
+ { provider: "ghcr", re: new RegExp(`\\bghcr\\.io/([a-z0-9][a-z0-9._-]*/[a-z0-9][a-z0-9._/-]*)${TAG}`, "i"),
159
+ build: m => ({ provider: "ghcr", host: "ghcr.io", path: m[1], ...(m[2] ? { tag: m[2] } : {}) }) },
160
+ { provider: "gcp-artifact-registry", re: new RegExp(`\\b([a-z0-9-]+)-docker\\.pkg\\.dev/([a-z0-9][a-z0-9.:-]*/[a-z0-9._-]+/[a-z0-9][a-z0-9._/-]*)${TAG}`, "i"),
161
+ build: m => ({ provider: "gcp-artifact-registry", host: `${m[1]}-docker.pkg.dev`, path: m[2], region: m[1], ...(m[3] ? { tag: m[3] } : {}) }) },
162
+ { provider: "gcr", re: new RegExp(`\\b((?:[a-z]+\\.)?gcr\\.io)/([a-z0-9][a-z0-9.:-]*/[a-z0-9][a-z0-9._/-]*)${TAG}`, "i"),
163
+ build: m => ({ provider: "gcr", host: m[1].toLowerCase(), path: m[2], ...(m[3] ? { tag: m[3] } : {}) }) },
164
+ { provider: "azure-acr", re: new RegExp(`\\b([a-z0-9]{5,50})\\.azurecr\\.io/([a-z0-9][a-z0-9._/-]*)${TAG}`, "i"),
165
+ build: m => ({ provider: "azure-acr", host: `${m[1]}.azurecr.io`, path: m[2], ...(m[3] ? { tag: m[3] } : {}) }) },
166
+ { provider: "quay", re: new RegExp(`\\bquay\\.io/([a-z0-9][a-z0-9._-]*/[a-z0-9][a-z0-9._/-]*)${TAG}`, "i"),
167
+ build: m => ({ provider: "quay", host: "quay.io", path: m[1], ...(m[2] ? { tag: m[2] } : {}) }) },
168
+ { provider: "gitlab-registry", re: new RegExp(`\\bregistry\\.gitlab\\.com/([a-z0-9][a-z0-9._-]*/[a-z0-9][a-z0-9._/-]*)${TAG}`, "i"),
169
+ build: m => ({ provider: "gitlab-registry", host: "registry.gitlab.com", path: m[1], ...(m[2] ? { tag: m[2] } : {}) }) },
170
+ { provider: "docker-hub", re: new RegExp(`\\b(?:docker\\.io|index\\.docker\\.io|registry-1\\.docker\\.io)/([a-z0-9][a-z0-9._-]*/[a-z0-9][a-z0-9._/-]*)${TAG}`, "i"),
171
+ build: m => ({ provider: "docker-hub", host: "docker.io", path: m[1], ...(m[2] ? { tag: m[2] } : {}) }) },
172
+ ];
173
+ /** Container registries named in a command (docker push/pull/build, aws ecr, ...). At most 4, no duplicates. */
174
+ export function registriesIn(text) {
175
+ const out = [];
176
+ const seen = new Set();
177
+ for (const { re, build } of REGISTRIES) {
178
+ for (const m of text.matchAll(new RegExp(re.source, "gi"))) {
179
+ const ref = build(m);
180
+ const key = `${ref.provider}|${ref.host}|${ref.path}`;
181
+ if (seen.has(key) || !okPath(ref.path.replace(/:/g, "")))
182
+ continue;
183
+ seen.add(key);
184
+ out.push(ref);
185
+ if (out.length >= 4)
186
+ return out;
187
+ }
188
+ }
189
+ return out;
190
+ }
191
+ //# sourceMappingURL=repo-remote.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"repo-remote.js","sourceRoot":"","sources":["../src/repo-remote.ts"],"names":[],"mappings":"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"}
@@ -1,5 +1,5 @@
1
1
  import { type Matcher } from "./rule-matches.js";
2
- export type Severity = "critical" | "high" | "medium" | "info";
2
+ export type Severity = "critical" | "high" | "medium" | "low" | "info";
3
3
  export type RuleCategory = "exec" | "exfil" | "impact" | "integrity" | "persistence" | "privilege" | "recon" | "secrets" | "source_control" | "custom";
4
4
  export type Rule = {
5
5
  id: string;
@@ -2,7 +2,7 @@
2
2
  // the same catalog backs agent hooks (core.ts) and the in-app SDK (sdk.ts), which can run in
3
3
  // edge runtimes.
4
4
  import { promptAttack } from "./prompt-attacks.js";
5
- import { isScopedTemporaryCleanup } from "./destructive-risk.js";
5
+ import { isScopedTemporaryCleanup, isLowRiskCleanup, isProjectScopedCleanup, isSymlinkOnlyDelete } from "./destructive-risk.js";
6
6
  import { patternMatch, anyMatch, allMatch, functionalMatch } from "./rule-matches.js";
7
7
  import { credentialExposureSpans, possibleCredentialExposure } from "./credential-egress.js";
8
8
  const secret = /(?:\.env(?:\b|[./])|\.ssh[\\/]|id_rsa|id_ed25519|\.aws[\\/]credentials|\.pgpass\b|\.docker[\\/]config\.json|\.gnupg[\\/]|credentials\.json|(?:api[_-]?key|access[_-]?token|password|secret)\b)/i;
@@ -11,14 +11,104 @@ const secret = /(?:\.env(?:\b|[./])|\.ssh[\\/]|id_rsa|id_ed25519|\.aws[\\/]crede
11
11
  // rules also run on attacker-written text inside customer apps (App monitoring). Each line's
12
12
  // pipes are split once and scanned in order.
13
13
  const EXEC_AFTER_PIPE = /^\s*(?:sudo\s+)?(?:(?:ba|z|fi)?sh|python3?|node|ruby|perl|pwsh|powershell)\b/i;
14
+ // "curl ... | python3 -c '...'" or "| node script.js" feeds the download to a program you already
15
+ // have as plain input; only an interpreter that reads its PROGRAM from stdin (bash, sh -s,
16
+ // python3 -, node -) runs what was downloaded.
17
+ const wordsOf = (s) => s.match(/"[^"]*"|'[^']*'|\S+/g) ?? [];
18
+ const scriptArg = (t) => /^[A-Za-z0-9_.\/~$][\w.\/~${}@:+-]*$/.test(t);
19
+ function execAfterPipe(segment) {
20
+ const m = EXEC_AFTER_PIPE.exec(segment);
21
+ if (!m)
22
+ return null;
23
+ const name = m[0].trim().split(/\s+/).pop().toLowerCase();
24
+ const rest = wordsOf(segment.slice(m[0].length));
25
+ if (/^(?:pwsh|powershell)$/.test(name))
26
+ return m;
27
+ for (const t of rest) {
28
+ if (/^(?:ba|z|fi)?sh$/.test(name)) {
29
+ if (t === "-s")
30
+ return m;
31
+ if (/^-[a-z]*c[a-z]*$/i.test(t))
32
+ return null;
33
+ if (scriptArg(t) && !t.startsWith("-"))
34
+ return null;
35
+ if (!t.startsWith("-"))
36
+ return m;
37
+ }
38
+ else if (/^python/.test(name)) {
39
+ if (t === "-")
40
+ return m;
41
+ if (/^-[a-z]*[cm][a-z]*$/i.test(t) || (scriptArg(t) && !t.startsWith("-")))
42
+ return null;
43
+ if (!t.startsWith("-"))
44
+ return m;
45
+ }
46
+ else if (name === "node") {
47
+ if (t === "-")
48
+ return m;
49
+ if (/^(?:-e|-p|-pe|--eval|--print)$/.test(t) || (scriptArg(t) && !t.startsWith("-")))
50
+ return null;
51
+ if (!t.startsWith("-"))
52
+ return m;
53
+ }
54
+ else {
55
+ if (/^-[a-z]*[eEr][a-z]*$/.test(t) || (scriptArg(t) && !t.startsWith("-")))
56
+ return null;
57
+ if (!t.startsWith("-"))
58
+ return m;
59
+ }
60
+ }
61
+ return m;
62
+ }
63
+ // Split a line at single pipes (not `||`). A `|` inside quotes still counts -- it is a real pipe
64
+ // when the quoted text is shell code (subprocess.run("curl ... | bash"), bash -c "...", ssh host
65
+ // "...") -- except where the quote holds a regex for grep/sed/awk/etc., where `|` is alternation.
66
+ const REGEX_CONTEXT = /(?:\b(?:e?grep|rg|ag|ack|sed|awk|gawk|pgrep|pkill|jq|rename)\b|\s-[eE]\s*|--regexp(?:=|\s+)|--include=|--exclude=|--glob[= ]|\s-g\s*)[^|]*$/;
67
+ function splitPipes(line) {
68
+ const parts = [];
69
+ let quote = "";
70
+ let from = 0;
71
+ let regexQuote = false;
72
+ for (let i = 0; i < line.length; i++) {
73
+ const c = line[i];
74
+ if (c === "\\" && quote !== "'") {
75
+ i++;
76
+ continue;
77
+ }
78
+ if (quote) {
79
+ if (c === quote) {
80
+ quote = "";
81
+ regexQuote = false;
82
+ continue;
83
+ }
84
+ if (!(c === "|" && !regexQuote))
85
+ continue;
86
+ }
87
+ else if (c === "'" || c === '"') {
88
+ quote = c;
89
+ regexQuote = REGEX_CONTEXT.test(line.slice(from, i));
90
+ continue;
91
+ }
92
+ if (c !== "|")
93
+ continue;
94
+ if (line[i + 1] === "|") {
95
+ i++;
96
+ continue;
97
+ }
98
+ parts.push(line.slice(from, i));
99
+ from = i + 1;
100
+ }
101
+ parts.push(line.slice(from));
102
+ return parts;
103
+ }
14
104
  function pipesIntoShell(s, newArmer) {
15
105
  for (const line of s.split("\n")) {
16
106
  if (!line.includes("|"))
17
107
  continue;
18
108
  const arms = newArmer();
19
109
  let armed = false;
20
- for (const [i, segment] of line.split("|").entries()) {
21
- if (i > 0 && armed && EXEC_AFTER_PIPE.test(segment))
110
+ for (const [i, segment] of splitPipes(line).entries()) {
111
+ if (i > 0 && armed && execAfterPipe(segment))
22
112
  return true;
23
113
  armed = arms(segment) || armed;
24
114
  }
@@ -33,8 +123,8 @@ function pipelineMatcher(newArmer) {
33
123
  const arms = newArmer();
34
124
  let armedAt;
35
125
  let cursor = 0;
36
- for (const segment of line.split("|")) {
37
- const executed = EXEC_AFTER_PIPE.exec(segment);
126
+ for (const segment of splitPipes(line)) {
127
+ const executed = execAfterPipe(segment);
38
128
  if (armedAt !== undefined && executed) {
39
129
  spans.push({ start: offset + armedAt, end: offset + cursor + executed[0].length, label: "Pipeline into an interpreter" });
40
130
  break;
@@ -88,12 +178,105 @@ function recursiveDeleteSpans(s) {
88
178
  function rmRecursiveForce(s) { return recursiveDeleteSpans(s).length > 0; }
89
179
  // Download-then-execute split across a command chain instead of a pipe: `curl -o x.sh ... && chmod
90
180
  // +x x.sh && ./x.sh`. One segment fetches, a later segment (joined by && or ;) runs what it fetched.
91
- const DOWNLOAD_THEN_EXEC = /\b(?:curl|wget)\b[^\n]{0,300}?(?:\s-[oO]\s|--output\b)[^\n]{0,300}?(?:&&|;)[^\n]{0,300}?(?:chmod\s+\+x|\.\/\S|(?:ba|z)?sh\b|python3?\b|node\b)/i;
181
+ // "curl -o f URL && bash f": the file that was downloaded is the file that runs. A -o that belongs
182
+ // to another program (grep -o), a download to /dev/null, or an unrelated later python3/node is not.
183
+ function splitCommands(line) {
184
+ const out = [];
185
+ let from = 0;
186
+ for (let i = 0; i < line.length; i++) {
187
+ const c = line[i];
188
+ if (c === "\\") {
189
+ i++;
190
+ continue;
191
+ }
192
+ const sep = c === ";" ? 1 : c === "&" && line[i + 1] === "&" ? 2 : 0;
193
+ if (!sep)
194
+ continue;
195
+ out.push({ text: line.slice(from, i), start: from });
196
+ i += sep - 1;
197
+ from = i + 1;
198
+ }
199
+ out.push({ text: line.slice(from), start: from });
200
+ return out;
201
+ }
202
+ // `sh -c "curl ... && bash f"` / `ssh host "..."`: look inside the quoted command.
203
+ const unwrap = (segment) => segment.trim()
204
+ .replace(/^(?:sudo\s+)?(?:(?:ba|z|da)?sh\s+-[a-z]*c|ssh\s+(?:-\S+\s+)*\S+)\s+["']/, "")
205
+ .replace(/["')\s]+$/, "");
206
+ const unquote = (t) => t.replace(/^(["'])(.*)\1$/, "$2");
207
+ const baseName = (p) => p.replace(/[?#].*$/, "").split("/").filter(Boolean).pop() ?? "";
208
+ function downloadedFile(segment) {
209
+ if (splitPipes(segment).length > 1)
210
+ return undefined;
211
+ const w = wordsOf(unwrap(segment)).map(unquote);
212
+ let at = 0;
213
+ while (/^\w+=/.test(w[at] ?? "") || w[at] === "sudo")
214
+ at++;
215
+ const tool = w[at]?.split("/").pop();
216
+ if (tool !== "curl" && tool !== "wget")
217
+ return undefined;
218
+ const url = w.find(t => /^https?:\/\//i.test(t));
219
+ for (let i = at + 1; i < w.length; i++) {
220
+ const t = w[i];
221
+ let file;
222
+ if (t === "-o" || t === "--output" || (tool === "curl" && /^-[a-zA-Z]+o$/.test(t)) || (tool === "wget" && /^-[a-zA-Z]*O$/.test(t)))
223
+ file = w[i + 1];
224
+ else if (/^--output=/.test(t))
225
+ file = t.slice(9);
226
+ else if (tool === "curl" && (/^-[a-zA-Z]*O$/.test(t) || t === "--remote-name"))
227
+ file = url ? baseName(url) : undefined;
228
+ if (file !== undefined)
229
+ return file === "-" || file === "/dev/null" ? undefined : file;
230
+ }
231
+ return tool === "wget" && url ? baseName(url) || undefined : undefined;
232
+ }
233
+ function runsFile(segment, files) {
234
+ const w = wordsOf(unwrap(segment)).map(unquote);
235
+ let at = 0;
236
+ while (/^\w+=/.test(w[at] ?? "") || w[at] === "sudo")
237
+ at++;
238
+ const head = w[at];
239
+ if (!head)
240
+ return false;
241
+ const isIt = (t) => !!t && files.some(f => t === f || t.endsWith("/" + f.replace(/^\.?\//, "")) || t === "./" + f);
242
+ if (head === "chmod")
243
+ return w.slice(at + 1).some(isIt);
244
+ if (/^(?:(?:ba|z|fi|da)?sh|python[\d.]*|node|ruby|perl|php|source|\.)$/.test(head.split("/").pop()))
245
+ return isIt(w.slice(at + 1).find(t => !t.startsWith("-")));
246
+ return (head.includes("/") || head.startsWith("./")) && isIt(head);
247
+ }
248
+ function downloadThenExecSpans(text) {
249
+ const spans = [];
250
+ let offset = 0;
251
+ for (const line of text.split("\n")) {
252
+ const files = [];
253
+ let from = -1;
254
+ for (const seg of splitCommands(line)) {
255
+ const file = downloadedFile(seg.text);
256
+ if (file) {
257
+ files.push(file);
258
+ if (from < 0)
259
+ from = seg.start;
260
+ continue;
261
+ }
262
+ if (files.length && runsFile(seg.text, files)) {
263
+ spans.push({ start: offset + from, end: offset + seg.start + seg.text.length, label: "Downloaded file is run" });
264
+ break;
265
+ }
266
+ }
267
+ offset += line.length + 1;
268
+ }
269
+ return spans;
270
+ }
271
+ const DOWNLOAD_THEN_EXEC = functionalMatch(text => downloadThenExecSpans(text).length > 0, downloadThenExecSpans);
92
272
  // PowerShell's `IEX (New-Object Net.WebClient).DownloadString(...)` / `iwr ... | iex`, the
93
273
  // single most common Windows download-and-run one-liner; and a Python process that fetches and
94
274
  // executes in the same call instead of piping to a shell.
95
275
  const POWERSHELL_DOWNLOAD_EXEC = /\bdownloadstring\s*\(|\b(?:iex|invoke-expression)\b[^\n]{0,300}?(?:iwr|invoke-webrequest|downloadstring)\b|\b(?:iwr|invoke-webrequest)\b[^\n]{0,300}?\|\s*(?:iex|invoke-expression)\b/i;
96
- const PYTHON_FETCH_EXEC = /\b(?:exec|eval)\s*\(\s*(?:compile\s*\(\s*)?(?:urllib\.request\.urlopen|urlopen|requests\.get|urlretrieve)\b/i;
276
+ const PYTHON_FETCH_EXEC = /\b(?:exec|eval)\s*\(\s*(?:compile\s*\(\s*)?(?:[\w.]*\.)?(?:urlopen|urlretrieve)\b|\b(?:exec|eval)\s*\(\s*(?:compile\s*\(\s*)?(?:requests|httpx)\.(?:get|post)\b/i;
277
+ // bash <(curl ...), sh -c "$(curl ...)", eval "$(curl ...)", source <(curl ...), . <(curl ...);
278
+ // node fetch(...).then(r => r.text()).then(eval); deno run https://...
279
+ const FETCH_AND_RUN = /(?:\b(?:(?:ba|z|fi|da|k)?sh|source|eval|python[\d.]*|node|ruby|perl|php)\b(?:\s+-[a-z]*c)?|(?:^|[;&|\s])\.)\s+["']?(?:<\(|\$\(|`)\s*(?:sudo\s+)?(?:curl|wget)\b|\bfetch\s*\([^\n]{0,400}?\)\s*\.then\s*\([^\n]{0,200}?text\(\)\s*\)\s*\.then\s*\(\s*(?:eval|new\s+Function)\b|\beval\s*\(\s*await\s+[^\n]{0,80}?fetch\b|\bdeno\s+(?:run|eval)\b[^\n]{0,200}?\bhttps?:\/\//i;
97
280
  // Disk, partition and filesystem-level wipes: destroy an entire volume rather than files inside
98
281
  // it, and are unrecoverable even with the deleted file still referenced elsewhere.
99
282
  const DISK_WIPE = /\bdd\b[^\n]{0,300}?\bof=\/dev\/(?:[sh]d|nvme|disk)\w*|\bmkfs\b|\bmke2fs\b|\bwipefs\b|\bblkdiscard\b|\b(?:fdisk|gdisk|sgdisk|parted)\b|\bdiskutil\s+(?:eraseDisk|eraseVolume|reformat)\b|\bdiskpart\b[\s\S]{0,200}?\bclean\b|\bClear-Disk\b[^\n]{0,300}?-RemoveData|\bRemove-Partition\b|\bcipher\s+\/w:|\bformat\s+[a-z]:\s*\/y\b/i;
@@ -101,7 +284,7 @@ const DISK_WIPE = /\bdd\b[^\n]{0,300}?\bof=\/dev\/(?:[sh]d|nvme|disk)\w*|\bmkfs\
101
284
  const FORK_BOMB = /:\s*\(\s*\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;\s*:/;
102
285
  // Dropping an entire datastore instead of rows in it: Redis/Mongo have no SQL DROP/TRUNCATE syntax.
103
286
  const DATASTORE_WIPE = /\bFLUSHALL\b|\bFLUSHDB\b|db\.dropDatabase\s*\(\)/i;
104
- const otherDestructiveMatcher = anyMatch(patternMatch(/\bfind\b[^\n]{0,300}?-delete\b|\brsync\b[^\n]{0,300}?--delete\b|\bshred\b[^\n]{0,100}?-\w*u|\bshutil\.rmtree\s*\(|(?:\bfs\.)?\brm(?:dir)?Sync\s*\([^\n]{0,200}?recursive\s*:\s*true|\brd\s+\/s\s+\/q\b|\bRemove-Item\b[^\n]{0,1000}?(?:-Recurse|-Force)|\b(?:drop\s+(?:table|database)|truncate\s+(?:table\s+)?\w+)|\bgit\s+(?:reset\s+--hard|clean\s+-[^\n]{0,1000}?f)/i), patternMatch(DATASTORE_WIPE));
287
+ const otherDestructiveMatcher = anyMatch(patternMatch(/\bfind\b[^\n]{0,300}?-delete\b|\brsync\b[^\n]{0,300}?--delete\b|\bshred\b[^\n]{0,100}?-\w*u|\bshutil\.rmtree\s*\(|(?:\bfs\.)?\brm(?:dir)?Sync\s*\([^\n]{0,200}?recursive\s*:\s*true|\brd\s+\/s\s+\/q\b|\bRemove-Item\b[^\n]{0,1000}?(?:-Recurse|-Force)|\b(?:drop\s+(?:table|database)|truncate\s+table\s+\w+)|(?:^|[;&|\n]\s*)(?:sudo\s+)?truncate\s+-s\s*0\b|(?:^|[;\n("'])\s*truncate\s+(?:only\s+)?\w+(?:\.\w+)?\s*(?:;|\bcascade\b|\brestart\b)|\bgit\s+(?:reset\s+--hard|clean\s+-[^\n]{0,1000}?f)/i), patternMatch(DATASTORE_WIPE));
105
288
  function otherDestructiveOperation(s) { return otherDestructiveMatcher(s); }
106
289
  // A vault/secret-manager reference (op://, Azure Key Vault, HashiCorp Vault, Infisical, Doppler,
107
290
  // a GitHub Actions secrets expression, or a generic broker placeholder) typed literally into a
@@ -113,8 +296,12 @@ function otherDestructiveOperation(s) { return otherDestructiveMatcher(s); }
113
296
  // real service (with its own resolver already wired up) is what should happen next, not the agent
114
297
  // improvising. Mirrors the CREDENTIAL_REFERENCE check in data-detectors.ts.
115
298
  const UNRESOLVED_REFERENCE = /\bop:\/\/|@Microsoft\.KeyVault\(|\bvault:[\w/-]|\binfisical:\/\/|\bdoppler:\/\/|\$\{\{\s*secrets\.|__[A-Z][A-Z0-9_]*_REF_[\w-]+__/i;
299
+ // Shared by destructive.production below: split out so the heading-aware matcher can reuse the
300
+ // same two patterns the old allMatch(...) used.
301
+ const PROD_TARGET = /\b(?:production|prod|prd)\b/i;
302
+ const PROD_VERB = /\b(?:deploy|delete|drop|truncate|migrate|apply|destroy|restart|rollback|scale)\b/i;
116
303
  export const builtinRules = [
117
- { id: "destructive.delete", title: "Destructive operation", severity: "high", category: "impact", explanation: "Recursive deletion or a destructive data operation. Scoped temporary test cleanup is MEDIUM; broad or uncertain targets remain HIGH. Review the target before allowing it.", match: anyMatch(functionalMatch(rmRecursiveForce, recursiveDeleteSpans), otherDestructiveMatcher), severityFor: s => !otherDestructiveOperation(s) && isScopedTemporaryCleanup(s) ? "medium" : "high" },
304
+ { id: "destructive.delete", title: "Destructive operation", severity: "high", category: "impact", explanation: "Recursive deletion or a destructive data operation. Scoped temporary test cleanup is MEDIUM; broad or uncertain targets remain HIGH. Review the target before allowing it.", match: anyMatch(functionalMatch(rmRecursiveForce, recursiveDeleteSpans), otherDestructiveMatcher), severityFor: s => isSymlinkOnlyDelete(s) ? "low" : otherDestructiveOperation(s) ? "high" : isLowRiskCleanup(s) ? "low" : isScopedTemporaryCleanup(s) || isProjectScopedCleanup(s) ? "medium" : "high" },
118
305
  { id: "destructive.disk_wipe", title: "Disk or partition wipe", severity: "critical", category: "impact", explanation: "This targets an entire disk, partition or filesystem rather than individual files, and is unrecoverable. Confirm this is an authorized, intentional wipe.", match: patternMatch(DISK_WIPE) },
119
306
  { id: "destructive.fork_bomb", title: "Fork bomb", severity: "critical", category: "impact", explanation: "This pattern spawns processes exponentially and can make the machine unresponsive.", match: patternMatch(FORK_BOMB) },
120
307
  { id: "destructive.audit_trail", title: "Audit trail or recovery point erased", severity: "critical", category: "integrity", explanation: "This clears shadow copies, event logs or shell history. It is also the standard precursor to covering up a destructive or malicious action; confirm the reason before allowing it.", match: patternMatch(/\bvssadmin\b[^\n]{0,100}?delete\s+shadows|\bwevtutil\b[^\n]{0,50}?\bcl\b|\bClear-EventLog\b|\bhistory\s+-c\b|\bunset\s+HISTFILE\b|\bHISTSIZE\s*=\s*0\b|\bbcdedit\b[^\n]{0,50}?\/deletevalue/i) },
@@ -123,7 +310,11 @@ export const builtinRules = [
123
310
  { id: "destructive.account", title: "Deletes a system account", severity: "high", category: "impact", explanation: "This removes a user or group account, which can include its home directory and files. Confirm the account is no longer needed.", match: patternMatch(/\buserdel\s+-r\b|\bgroupdel\b|\bRemove-LocalUser\b|\bnet\s+user\s+\S+\s+\/delete\b|\bdscl\s+\.\s+-delete\s+\/Users\//i) },
124
311
  { id: "integrity.protection_disabled", title: "OS security control disabled", severity: "critical", category: "integrity", explanation: "This turns off a platform security control (System Integrity Protection, Gatekeeper, execution policy, or System Restore), which weakens the machine's security posture beyond this one action.", match: patternMatch(/\bcsrutil\s+disable\b|\bspctl\s+--master-disable\b|\bsecurity\s+delete-keychain\b|\bSet-ExecutionPolicy\s+Unrestricted\b|\bDisable-WindowsOptionalFeature\b|\bDisable-ComputerRestore\b/i) },
125
312
  { id: "network.firewall_disabled", title: "Firewall rules flushed or disabled", severity: "high", category: "integrity", explanation: "This removes or disables firewall protection on the host. Confirm the host doesn't need it, or that replacement rules are applied immediately.", match: patternMatch(/\biptables\s+-F\b|\bufw\s+(?:--force\s+reset|disable)\b|\bnetsh\s+advfirewall\s+set\s+allprofiles\s+state\s+off\b/i) },
126
- { id: "destructive.production", title: "Production change", severity: "high", category: "impact", explanation: "This action targets a production environment. Confirm the scope and rollback plan before proceeding.", match: allMatch(patternMatch(/\b(?:production|prod|prd)\b/i), patternMatch(/\b(?:deploy|delete|drop|truncate|migrate|apply|destroy|restart|rollback|scale)\b/i)) },
313
+ { id: "destructive.production", title: "Production change", severity: "high", category: "impact", explanation: "This action targets a production environment. Confirm the scope and rollback plan before proceeding.",
314
+ // Both words can co-occur in a markdown heading or doc title ("# Deploy production") that is
315
+ // not itself a command. Require the co-occurrence on one line that isn't a heading, so prose
316
+ // describing production deploys doesn't read the same as a command that performs one.
317
+ match: functionalMatch(s => s.split(/\r?\n/).some(line => { const t = line.trim(); return !/^#{1,6}\s/.test(t) && PROD_TARGET.test(t) && PROD_VERB.test(t); }), text => allMatch(patternMatch(PROD_TARGET), patternMatch(PROD_VERB)).spans?.(text) ?? []) },
127
318
  // Dumping the *whole* environment (every var, name and value) is the risky shape: that's what
128
319
  // can hand over credentials the agent was never told about. Referencing one named variable
129
320
  // ($PORT, $NODE_ENV, ${DATABASE_URL}) is how every normal dev workflow reads its own config and
@@ -131,20 +322,51 @@ export const builtinRules = [
131
322
  // called out as noisy. That case now only shows up (at "info", logged but not an approval gate)
132
323
  // in credentials.environment_ref below, and only for the few var-name shapes anyone would
133
324
  // actually call a credential; everyday vars like $PORT or $NODE_ENV don't get flagged anywhere.
134
- { id: "credentials.environment", title: "Environment variable access", severity: "high", category: "secrets", explanation: "The action reads every exported environment variable at once (not one named variable), which can expose credentials the agent was never told about.", match: patternMatch(/\b(?:printenv|env|export\s+-p|process\.env|os\.environ|System\.getenv|declare\s+-x|Get-ChildItem\s+env:)\b|\/proc\/self\/environ\b|(?:^|[;&|]\s*)set(?:\s*$|\s+-o\b)/i) },
325
+ { id: "credentials.environment", title: "Environment variable access", severity: "high", category: "secrets", explanation: "The action reads every exported environment variable at once (not one named variable), which can expose credentials the agent was never told about.",
326
+ // Bare "env" used to be a plain \b...\b alternative, so it matched the standalone token inside
327
+ // "env.FLAGS", "this.env.AI.run(...)", or a TypeScript "Env" binding/generic -- none of which
328
+ // dump the environment. It now needs its own boundaries: not preceded by "." or a word char
329
+ // (so it isn't someone's property/namespace access), and not immediately followed by ".", "(",
330
+ // or "<" (a method call, property access, or generic type argument), so only the bare shell
331
+ // command or standalone identifier still matches.
332
+ // Cloudflare Workers code conventionally declares "interface Env { ... }" and takes a
333
+ // lowercase "env" binding parameter ("fetch(request, env, ctx)", "(req, env) =>",
334
+ // "import { env } from ..."). That is every Workers-based skill's docs, and a boundary-only
335
+ // exclusion list can't keep up with every syntactic shape "env" shows up adjacent to as an
336
+ // identifier (":" for a type, "," in a param list, "}" closing a destructure, "=>", ...).
337
+ // The real "env" command is only ever meaningful as the first word of a shell command, so
338
+ // this now requires exactly that shape -- optionally after a command separator -- instead of
339
+ // trying to list every JS/TS context where "env" is just a parameter name.
340
+ match: anyMatch(
341
+ // process.env.CF_ACCOUNT_ID / os.environ["R2_WAREHOUSE"] read one named property, same as
342
+ // $PORT or ${DATABASE_URL} below -- not the bulk dump this rule means to catch. Only the
343
+ // bare object (passed, spread, or iterated whole) still counts.
344
+ patternMatch(/\b(?:printenv|export\s+-p|process\.env(?![.[])|os\.environ(?![.[])|System\.getenv|declare\s+-x|Get-ChildItem\s+env:)\b|\/proc\/self\/environ\b|(?:^|[;&|]\s*)set(?:\s*$|\s+-o\b)/i), patternMatch(/(?:^|\n|[;&|]\s*)env\b(?![.\w(<])/)) },
135
345
  { id: "credentials.environment_ref", title: "Named credential variable referenced", severity: "info", category: "secrets", explanation: "A single environment variable whose name looks like a credential (KEY/SECRET/TOKEN/PASSWORD/CREDENTIAL) is referenced by name. Logged for visibility; this alone isn't a reason to block the ordinary case of an app reading its own configured secret.", match: patternMatch(/\$\{?[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)*_(?:KEY|SECRET|TOKEN|PASSWORD|CREDENTIAL)S?\}?\b/) },
136
346
  { id: "credentials.access", title: "Sensitive credential reference", severity: "medium", category: "secrets", explanation: "This action or instruction references a credential location. Confirm the agent needs access.", match: patternMatch(secret) },
137
- { id: "source.private_files", title: "Private source or configuration access", severity: "high", category: "secrets", explanation: "This action accesses a private credential or configuration file. Confirm that the agent needs the contents.", match: patternMatch(/(?:\.env(?:\.[\w.-]+)?|\.ssh[\\/]|\.aws[\\/]credentials|id_(?:rsa|ed25519)|\.npmrc|\.netrc|kubeconfig|private.?key|\.pgpass\b|\.docker[\\/]config\.json|\.gnupg[\\/]|credentials\.json)/i) },
347
+ { id: "source.private_files", title: "Private source or configuration access", severity: "high", category: "secrets", explanation: "This action accesses a private credential or configuration file. Confirm that the agent needs the contents.",
348
+ // \.env(?:\.[\w.-]+)? had no boundaries, so it matched the literal substring ".env" inside
349
+ // "this.env.AI.run(...)" (a property access, not a dotenv file) and inside ".environment" (a
350
+ // different word entirely, since nothing required the match to end at "env"). It now needs
351
+ // "not preceded by a word character" (so a leading property-access dot like "this.env" is
352
+ // excluded) and "not followed by a word character unless it's another dotted extension" (so
353
+ // ".environment" doesn't partially match as ".env").
354
+ match: patternMatch(/(?:(?<!\w)\.env(?:\.[\w-]+)?(?!\w)|\.ssh[\\/]|\.aws[\\/]credentials|id_(?:rsa|ed25519)|\.npmrc|\.netrc|kubeconfig|private.?key|\.pgpass\b|\.docker[\\/]config\.json|\.gnupg[\\/]|credentials\.json)/i) },
138
355
  { id: "mcp.write", title: "MCP write operation", severity: "high", category: "impact", explanation: "The MCP tool appears to modify data or send an external side effect. Require approval before allowing it.", match: patternMatch(/\bmcp(?:__|:)[^\s]*(?:create|update|delete|write|send|post|put|patch|execute|run|publish|deploy|remove|save|insert|add|grant|revoke|assign)\b/i) },
139
356
  { id: "credentials.egress", title: "Possible credential exposure", severity: "critical", category: "exfil", explanation: "A transfer command carries a sensitive payload, or sensitive output feeds an outbound command. This is a possible exposure, not proof of delivery; check the destination and authorization.", match: functionalMatch(possibleCredentialExposure, credentialExposureSpans) },
140
357
  { id: "secrets.unresolved_reference_launch", title: "Vault reference used directly instead of resolved", severity: "medium", category: "secrets", explanation: "This command includes a secret-manager reference (op://, Azure Key Vault, Vault, Infisical, Doppler, a GitHub Actions secrets expression, or a broker placeholder) typed in literally rather than resolved by its own CLI or platform. The agent cannot resolve it into the real value -- have a human or CI process start the real service instead, with its existing resolver already wired up.", match: patternMatch(UNRESOLVED_REFERENCE) },
141
- { id: "execution.remote", title: "Remote code execution pattern", severity: "high", category: "exec", explanation: "Downloaded content is fetched and run without review: piped into a shell or interpreter, fetched and executed in one step, or downloaded and run as separate chained commands.", match: anyMatch(anyMatch(anyMatch(pipelineMatcher(downloads), patternMatch(DOWNLOAD_THEN_EXEC)), patternMatch(POWERSHELL_DOWNLOAD_EXEC)), patternMatch(PYTHON_FETCH_EXEC)) },
358
+ { id: "execution.remote", title: "Remote code execution pattern", severity: "high", category: "exec", explanation: "Downloaded content is fetched and run without review: piped into a shell or interpreter, fetched and executed in one step, or downloaded and run as separate chained commands.", match: anyMatch(anyMatch(anyMatch(anyMatch(pipelineMatcher(downloads), DOWNLOAD_THEN_EXEC), patternMatch(POWERSHELL_DOWNLOAD_EXEC)), patternMatch(PYTHON_FETCH_EXEC)), patternMatch(FETCH_AND_RUN)) },
142
359
  { id: "execution.obfuscated", title: "Encoded execution", severity: "high", category: "exec", explanation: "Encoded content is decoded for execution. Review the decoded payload separately; Beam never executes it.", match: anyMatch(pipelineMatcher(decodes), patternMatch(/\b(?:powershell|pwsh)\b[^\n]{0,500}?\s-(?:enc|encodedcommand)\b/i)) },
143
360
  { id: "recon.network_sweep", title: "Network sweep", severity: "medium", category: "recon", explanation: "A scanner targets a network range. Verify scope and authorization before the agent probes other systems (ATT&CK T1046).", match: allMatch(patternMatch(/\b(?:nmap|masscan|rustscan)\b/i), patternMatch(/(?:\d{1,3}\.){3}\d{1,3}\/\d{1,2}|(?:\d{1,3}\.){3}\d{1,3}-\d{1,3}/)) },
144
361
  { id: "privilege.elevation", title: "Elevated privileges", severity: "medium", category: "privilege", explanation: "The action requests elevated privileges or broad write permissions.", match: patternMatch(/\bsudo\s|\bdoas\s|(?:^|[\s;&|`(])su\s+(?:-\s*\w*|root)\b|\bpkexec\b|\brunas\s+\/user:|\bchmod\s+(?:-R\s+)?(?:777\b|[ugoa]*\+s\b|[2467][0-7]{3}\b)/i) },
145
362
  { id: "network.metadata", title: "Cloud metadata access", severity: "high", category: "secrets", explanation: "Cloud metadata endpoints can expose temporary instance credentials.", match: patternMatch(/169\.254\.169\.254|metadata\.google\.internal|100\.100\.100\.200/i) },
146
- { id: "persistence.config", title: "Security-sensitive configuration", severity: "high", category: "persistence", explanation: "A persistence or agent configuration target is referenced. Check the intended change and trust boundary.", match: patternMatch(/authorized_keys|\bcrontab\b|LaunchAgents[\\/]|ANTHROPIC_BASE_URL/i) },
147
- { id: "network.reverse-shell", title: "Reverse shell pattern", severity: "critical", category: "exec", explanation: "This pattern can connect a command shell to another machine.", match: patternMatch(/\/dev\/tcp\/|\bnc\b[^\n]{0,300}?\s-e\s|socket[\s\S]{0,200}?subprocess|\bNew-Object\s+(?:System\.)?Net\.Sockets\.TCPClient\b|\bsocat\b[^\n]{0,300}?\bexec[:=]|\bsocket\([^\n]{0,150}?getprotobyname\(["']tcp["']\)[\s\S]{0,200}?\bconnect\(/i) },
363
+ { id: "persistence.config", title: "Security-sensitive configuration", severity: "high", category: "persistence", explanation: "A persistence or agent configuration target is referenced. Check the intended change and trust boundary.",
364
+ // \bcrontab\b matched the "crontab" in crontab.guru (a validator website, not the command), so
365
+ // the bare word needs a guard against being immediately followed by a domain-style ".word".
366
+ // "[Crontab Guru](https://crontab.guru/)" -- a markdown link to the validator site -- still
367
+ // matched via its link *text* ("Crontab Guru"), not just the URL the first guard covers.
368
+ match: patternMatch(/authorized_keys|\bcrontab\b(?!\.\w)(?!\s+Guru\b)|LaunchAgents[\\/]|ANTHROPIC_BASE_URL/i) },
369
+ { id: "network.reverse-shell", title: "Reverse shell pattern", severity: "critical", category: "exec", explanation: "This pattern can connect a command shell to another machine.", match: patternMatch(/\/dev\/tcp\/|\bnc\b[^\n]{0,300}?\s-e\s|\bsocket\b(?=[\s\S]{0,400}?\.connect\s*\()[\s\S]{0,400}?(?:subprocess|pty\.spawn|dup2|\/bin\/(?:ba)?sh)|\bNew-Object\s+(?:System\.)?Net\.Sockets\.TCPClient\b|\bsocat\b[^\n]{0,300}?\bexec[:=]|\bsocket\([^\n]{0,150}?getprotobyname\(["']tcp["']\)[\s\S]{0,200}?\bconnect\(/i) },
148
370
  { id: "instructions.override", title: "Instruction override attempt", severity: "high", category: "integrity", explanation: "Instructions attempt to override existing safeguards or conceal activity from the user. Checked against the same folded view used for prompt-injection monitoring (Unicode/width normalization and base64 decoding), not a literal-text match, so stylized or encoded phrasing is caught too.", match: functionalMatch(promptAttack.override) },
149
371
  { id: "source_control.history_rewrite", title: "Git history rewrite", severity: "medium", category: "source_control", explanation: "Force-pushing or rewriting shared history can silently discard other people's commits. Confirm the branch isn't shared before proceeding.", match: patternMatch(/\bgit\s+push\s+[^\n]{0,1000}?(?:--force(?:-with-lease)?|-f\b)|\bgit\s+filter-(?:branch|repo)\b/i) },
150
372
  { id: "integrity.tls_bypass", title: "Certificate or signature verification disabled", severity: "medium", category: "integrity", explanation: "Disabling TLS/certificate verification removes protection against a tampered or impersonated destination.", match: patternMatch(/\bcurl\b[^\n]{0,1000}?(?:-k\b|--insecure)|\bwget\b[^\n]{0,1000}?--no-check-certificate|NODE_TLS_REJECT_UNAUTHORIZED\s*=\s*['"]?0|\bgit\b[^\n]{0,1000}?-c\s+http\.sslVerify=false|\bverify\s*=\s*False\b|\brejectUnauthorized\s*:\s*false\b/i) },