failsafe-hook 1.0.0__py3-none-any.whl

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.
failsafe_hook/core.py ADDED
@@ -0,0 +1,1712 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ FailSafe - Claude Code PreToolUse hook
4
+ -----------------------------------------------------------------------
5
+ The zero-config agent seatbelt for Claude Code. Blocks dangerous agent
6
+ actions even in --dangerously-skip-permissions (bypass) mode, where a
7
+ hook deny is the only safety layer that still fires.
8
+
9
+ Current rules:
10
+ 1. Slopsquatting defense - blocks installs of non-existent packages
11
+ (AI hallucinated names attackers pre-register with malware).
12
+ 2. Destructive rm - blocks rm -rf on root, home, and system dirs.
13
+ 3. One-off runner defense - checks npx/npm exec/pnpm dlx/bunx targets
14
+ before the agent executes registry code.
15
+ 4. Manifest install defense - reads package.json/requirements.txt/
16
+ pyproject.toml on bare installs so a hallucinated dep hidden in a
17
+ manifest is caught too.
18
+ 5. Curl-pipe-shell guard - blocks/flags remote scripts piped directly
19
+ into a shell or interpreter (curl ... | bash, wget ... | python).
20
+ 6. Git disaster guard - blocks/flags force pushes to protected branches,
21
+ hard resets, force cleans, and force branch deletions.
22
+ 7. Cloud/infra blast radius - flags terraform destroy, kubectl delete
23
+ namespace, docker system prune -a, aws s3 rm --recursive, etc.
24
+ 8. Secrets exfiltration guard - flags sensitive files (.env, ~/.ssh,
25
+ keys) piped or sent to network commands.
26
+
27
+ Design rules:
28
+ - FAIL OPEN. Any unexpected error -> allow. A guard that breaks your
29
+ workflow gets uninstalled.
30
+ - FAST PATH. Non-relevant commands return instantly, no network.
31
+ - CONSERVATIVE. Only near-certain danger hard-blocks. Fuzzy signals
32
+ only escalate to a prompt.
33
+
34
+ Stdlib only -> zero install. Works wherever Python 3.8+ is present.
35
+ """
36
+
37
+ import json
38
+ import os
39
+ import re
40
+ import shlex
41
+ import sys
42
+ import urllib.request
43
+ import urllib.error
44
+ import urllib.parse
45
+ from concurrent.futures import ThreadPoolExecutor
46
+ from datetime import datetime, timezone
47
+
48
+ REQUEST_TIMEOUT = 3.5
49
+ NEW_PACKAGE_DAYS = 90
50
+ LOW_DOWNLOADS = 100
51
+ MAX_PACKAGES = 25 # beyond this, fail open rather than stall the agent
52
+
53
+
54
+ def _load_config(cwd=None):
55
+ """Load failsafe.toml (project-level first, then ~/.config/failsafe/). Returns {}."""
56
+ search_cwd = cwd or os.getcwd()
57
+ paths = [
58
+ os.path.join(search_cwd, "failsafe.toml"),
59
+ os.path.expanduser(os.path.join("~", ".config", "failsafe", "config.toml")),
60
+ ]
61
+ for p in paths:
62
+ if not os.path.isfile(p):
63
+ continue
64
+ try:
65
+ with open(p, "r", encoding="utf-8", errors="replace") as f:
66
+ text = f.read()
67
+ except Exception:
68
+ continue
69
+ try:
70
+ import tomllib
71
+ return tomllib.loads(text).get("failsafe") or {}
72
+ except Exception:
73
+ return {}
74
+ return {}
75
+
76
+
77
+ _CONFIG = _load_config()
78
+
79
+ POPULAR_NPM = {
80
+ "react", "react-dom", "react-router-dom", "lodash", "express", "axios",
81
+ "chalk", "commander", "next", "vue", "typescript", "webpack", "eslint",
82
+ "prettier", "jest", "vitest", "dotenv", "moment", "dayjs", "uuid",
83
+ "classnames", "zod", "redux", "tailwindcss", "vite", "rollup", "node-fetch",
84
+ "cross-env", "nodemon", "ts-node", "mongoose", "pg", "mysql2", "cors",
85
+ "body-parser", "bcrypt", "jsonwebtoken", "socket.io",
86
+ }
87
+ POPULAR_PYPI = {
88
+ "requests", "numpy", "pandas", "flask", "django", "fastapi", "pytest",
89
+ "pydantic", "sqlalchemy", "boto3", "scipy", "matplotlib", "pillow",
90
+ "urllib3", "certifi", "click", "rich", "tqdm", "beautifulsoup4",
91
+ "scikit-learn", "tensorflow", "torch", "transformers", "openai",
92
+ "anthropic", "aiohttp", "httpx", "uvicorn", "gunicorn", "celery", "redis",
93
+ "psycopg2", "pymongo", "python-dotenv", "setuptools", "wheel", "black",
94
+ "flake8", "mypy", "isort",
95
+ }
96
+ POPULAR_CARGO = {
97
+ "serde", "serde_json", "tokio", "async-std", "reqwest", "hyper",
98
+ "clap", "log", "env_logger", "anyhow", "thiserror", "rand",
99
+ "chrono", "uuid", "regex", "lazy_static", "once_cell", "rayon",
100
+ "itertools", "futures", "async-trait", "tracing", "axum",
101
+ "actix-web", "diesel", "sqlx", "rusqlite", "redis", "bytes",
102
+ "tower", "syn", "quote", "proc-macro2", "nom", "bindgen",
103
+ "wasm-bindgen", "pyo3", "crossbeam", "dashmap", "parking_lot",
104
+ }
105
+ PUBLIC_GO_DOMAINS = frozenset({
106
+ "github.com", "gitlab.com", "bitbucket.org",
107
+ "golang.org", "google.golang.org", "k8s.io", "sigs.k8s.io", "gopkg.in",
108
+ })
109
+ POPULAR_GEMS = {
110
+ "rails", "rake", "bundler", "rspec", "rspec-core", "minitest",
111
+ "nokogiri", "activerecord", "activesupport", "actionpack", "actionview",
112
+ "devise", "sidekiq", "puma", "unicorn", "sinatra", "jekyll",
113
+ "capybara", "factory_bot", "faker", "rubocop", "fastlane", "cocoapods",
114
+ "httparty", "rest-client", "faraday", "redis", "pg", "sqlite3",
115
+ "mongoid", "whenever", "capistrano", "rack", "thor", "zeitwerk",
116
+ "dry-validation", "sorbet", "aws-sdk", "json", "openssl", "net-http",
117
+ "pry", "byebug", "rexml", "csv", "benchmark", "logger",
118
+ }
119
+
120
+ JS_MANAGERS = {
121
+ "npm": {"install", "i", "add"},
122
+ "pnpm": {"add", "install", "i"},
123
+ "yarn": {"add"},
124
+ "bun": {"add", "install", "i"},
125
+ }
126
+ CARGO_VALUE_FLAGS = frozenset({
127
+ "--manifest-path", "--target", "--target-dir", "--features", "-F",
128
+ "--branch", "--tag", "--rev", "--git", "--path", "--registry",
129
+ "--vers", "--version", "--bin", "--example", "--root",
130
+ })
131
+ _CARGO_NON_REGISTRY = frozenset({"--git", "--path"})
132
+ CARGO_NAME_RE = re.compile(r"^[a-zA-Z][a-zA-Z0-9_-]*$")
133
+ GEM_VALUE_FLAGS = frozenset({
134
+ "--version", "-v", "--source", "-s", "--install-dir", "-i",
135
+ "--bindir", "-n", "--document", "--platform", "-P",
136
+ "--proxy", "--build", "--trust-policy",
137
+ })
138
+ GEM_NAME_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_-]*$")
139
+ LOW_GEM_DOWNLOADS = 1000 # total lifetime downloads threshold
140
+
141
+ # Options whose NEXT token is a value, not a package (prevents false positives
142
+ # like `pip install --platform win_amd64 requests` denying "win_amd64").
143
+ PIP_VALUE_FLAGS = {
144
+ "-r", "--requirement", "-c", "--constraint", "-e", "--editable",
145
+ "-i", "--index-url", "--extra-index-url", "-f", "--find-links",
146
+ "--platform", "--python-version", "--implementation", "--abi",
147
+ "-t", "--target", "--prefix", "--root", "--no-binary", "--only-binary",
148
+ "--progress-bar", "--report", "--hash", "--cache-dir", "--log", "--python",
149
+ }
150
+ UV_VALUE_FLAGS = PIP_VALUE_FLAGS | {"--index", "--default-index", "--index-strategy", "-p"}
151
+ POETRY_VALUE_FLAGS = {"--group", "-G", "--source", "--extras", "-E", "--python", "--platform"}
152
+ NPM_VALUE_FLAGS = {
153
+ "--registry", "--prefix", "-C", "--workspace", "-w", "--save-prefix",
154
+ "--omit", "--include", "--tag", "--access", "--otp", "--loglevel", "--cache",
155
+ }
156
+ JS_RUNNER_PACKAGE_FLAGS = {"--package", "-p"}
157
+ JS_RUNNER_VALUE_FLAGS = NPM_VALUE_FLAGS | {
158
+ "--call", "-c", "--shell", "--script-shell", "--userconfig",
159
+ "--node-arg", "-n",
160
+ }
161
+
162
+ SHELLS = {"bash", "sh", "zsh", "dash", "ksh"}
163
+ WRAPPERS = {"env", "sudo", "doas", "command", "time", "nice", "exec", "xargs"}
164
+ ENV_ASSIGN_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=")
165
+ OPERATORS = {"&&", "||", ";", "|", "&", "|&"}
166
+
167
+ WRAPPER_VALUE_FLAGS = {
168
+ "sudo": {
169
+ "-u", "--user", "-g", "--group", "-h", "--host", "-p", "--prompt",
170
+ "-C", "--close-from", "-T", "--command-timeout", "-D", "--chdir",
171
+ },
172
+ "doas": {"-u", "--user"},
173
+ "env": {
174
+ "-u", "--unset", "-C", "--chdir", "-S", "--split-string",
175
+ "--block-signal", "--default-signal", "--ignore-signal",
176
+ },
177
+ "time": {"-o", "--output", "-f", "--format"},
178
+ "nice": {"-n", "--adjustment"},
179
+ }
180
+
181
+ NPM_NAME_RE = re.compile(r"^(@[a-z0-9\-~][a-z0-9\-._~]*/)?[a-z0-9\-~][a-z0-9\-._~]*$", re.I)
182
+ PY_NAME_RE = re.compile(r"^[A-Za-z0-9._-]+$")
183
+
184
+
185
+ # --------------------------------------------------------------- HTTP
186
+ def http_get_json(url):
187
+ req = urllib.request.Request(url, headers={"User-Agent": "failsafe/0.5"})
188
+ try:
189
+ with urllib.request.urlopen(req, timeout=REQUEST_TIMEOUT) as r:
190
+ status = getattr(r, "status", 200)
191
+ if 200 <= status < 300:
192
+ return status, json.loads(r.read().decode("utf-8", "replace"))
193
+ return status, None
194
+ except urllib.error.HTTPError as e:
195
+ return e.code, None # 404 lands here
196
+ except Exception:
197
+ return 0, None # network/timeout -> unknown -> fail open
198
+
199
+
200
+ # ----------------------------------------------------- command parsing
201
+ def strip_npm_version(arg):
202
+ if arg.startswith("@"):
203
+ i = arg.find("@", 1)
204
+ return arg if i == -1 else arg[:i]
205
+ i = arg.find("@")
206
+ return arg if i == -1 else arg[:i]
207
+
208
+
209
+ def strip_py_version(arg):
210
+ return re.split(r"[=<>!~ ;\[]", arg)[0]
211
+
212
+
213
+ def is_js_local_or_url(a):
214
+ if a.startswith("@"):
215
+ return False # scoped package, not a path
216
+ return ("/" in a or "\\" in a or "://" in a or a.startswith("git+")
217
+ or a.startswith(".") or a.endswith(".tgz") or a.endswith(".tar.gz"))
218
+
219
+
220
+ def _consume_wrapper_options(tokens, i, wrapper):
221
+ """Skip common wrapper flags so sudo -n rm ... still exposes rm."""
222
+ value_flags = WRAPPER_VALUE_FLAGS.get(wrapper, set())
223
+ while i < len(tokens):
224
+ t = tokens[i]
225
+ if t == "--":
226
+ return i + 1
227
+ if ENV_ASSIGN_RE.match(t):
228
+ i += 1
229
+ continue
230
+ if not t.startswith("-") or t == "-":
231
+ return i
232
+
233
+ takes_value = t in value_flags
234
+ has_inline_value = any(t.startswith(flag + "=") for flag in value_flags)
235
+ has_short_inline_value = len(t) > 2 and t[:2] in value_flags
236
+
237
+ i += 1
238
+ if takes_value and not has_inline_value and not has_short_inline_value and i < len(tokens):
239
+ i += 1
240
+ return i
241
+
242
+
243
+ def strip_prefix(tokens):
244
+ """Drop leading env assignments and wrappers: env FOO=bar sudo npm ..."""
245
+ i = 0
246
+ while i < len(tokens):
247
+ if ENV_ASSIGN_RE.match(tokens[i]):
248
+ i += 1
249
+ continue
250
+ if tokens[i] in WRAPPERS:
251
+ wrapper = tokens[i]
252
+ i += 1
253
+ i = _consume_wrapper_options(tokens, i, wrapper)
254
+ continue
255
+ break
256
+ return tokens[i:]
257
+
258
+
259
+ def split_segments(toks):
260
+ """Split a token list into segments on shell control operators."""
261
+ segments, cur = [], []
262
+ for t in toks:
263
+ if t in OPERATORS:
264
+ if cur:
265
+ segments.append(cur)
266
+ cur = []
267
+ else:
268
+ cur.append(t)
269
+ if cur:
270
+ segments.append(cur)
271
+ return segments
272
+
273
+
274
+ def add_js_target(arg, targets):
275
+ if not arg or arg.startswith("-") or arg in (".", ".."):
276
+ return False
277
+ if is_js_local_or_url(arg):
278
+ return False
279
+ name = strip_npm_version(arg)
280
+ if name and not name.startswith("-") and NPM_NAME_RE.match(name):
281
+ targets.append(("npm", name))
282
+ return True
283
+ return False
284
+
285
+
286
+ def collect_js(args, targets, value_flags):
287
+ skip_next = False
288
+ for a in args:
289
+ if skip_next:
290
+ skip_next = False
291
+ continue
292
+ if a in value_flags:
293
+ skip_next = True
294
+ continue
295
+ if a.startswith("-") or a in (".", ".."):
296
+ continue
297
+ add_js_target(a, targets)
298
+
299
+
300
+ def _flag_value(token, flags):
301
+ for flag in flags:
302
+ prefix = flag + "="
303
+ if token.startswith(prefix):
304
+ return token[len(prefix):]
305
+ return None
306
+
307
+
308
+ def collect_js_runner(args, targets):
309
+ """Collect packages executed by npx/npm exec/pnpm dlx/bunx style runners."""
310
+ skip_next = False
311
+ explicit_package = False
312
+ after_double_dash = False
313
+
314
+ for i, a in enumerate(args):
315
+ if skip_next:
316
+ skip_next = False
317
+ continue
318
+
319
+ if not after_double_dash:
320
+ if a == "--":
321
+ after_double_dash = True
322
+ continue
323
+
324
+ value = _flag_value(a, JS_RUNNER_PACKAGE_FLAGS)
325
+ if value is not None:
326
+ if add_js_target(value, targets):
327
+ explicit_package = True
328
+ continue
329
+
330
+ if a in JS_RUNNER_PACKAGE_FLAGS:
331
+ if i + 1 < len(args) and add_js_target(args[i + 1], targets):
332
+ explicit_package = True
333
+ skip_next = True
334
+ continue
335
+
336
+ if a in JS_RUNNER_VALUE_FLAGS:
337
+ skip_next = True
338
+ continue
339
+ if _flag_value(a, JS_RUNNER_VALUE_FLAGS) is not None:
340
+ continue
341
+ if a.startswith("-"):
342
+ continue
343
+
344
+ if explicit_package:
345
+ return
346
+ add_js_target(a, targets)
347
+ return
348
+
349
+
350
+ def collect_py(args, targets, value_flags):
351
+ skip_next = False
352
+ for a in args:
353
+ if skip_next:
354
+ skip_next = False
355
+ continue
356
+ if a in value_flags:
357
+ skip_next = True # next token is a value, not a package
358
+ continue
359
+ if a.startswith("-") or a in (".", ".."):
360
+ continue
361
+ if "/" in a or "\\" in a or "://" in a:
362
+ continue
363
+ if "<" in a or ">" in a:
364
+ continue # shell redirect token (e.g. 2>&1 split by shlex)
365
+ if re.search(r"\.(txt|cfg|toml|ini|whl|zip)$", a, re.I) or a.endswith(".tar.gz"):
366
+ continue
367
+ name = strip_py_version(a)
368
+ if name and not name.startswith("-") and PY_NAME_RE.match(name):
369
+ targets.append(("pypi", name))
370
+
371
+
372
+ def collect_cargo(args, targets):
373
+ for a in args:
374
+ if any(a == f or a.startswith(f + "=") for f in _CARGO_NON_REGISTRY):
375
+ return # --git or --path: not a registry install
376
+ skip_next = False
377
+ for a in args:
378
+ if skip_next:
379
+ skip_next = False
380
+ continue
381
+ if a in CARGO_VALUE_FLAGS:
382
+ skip_next = True
383
+ continue
384
+ if any(a.startswith(f + "=") for f in CARGO_VALUE_FLAGS):
385
+ continue
386
+ if a.startswith("-"):
387
+ continue
388
+ name = a.split("@")[0]
389
+ if name and CARGO_NAME_RE.match(name):
390
+ targets.append(("cargo", name))
391
+
392
+
393
+ def collect_go(args, targets):
394
+ for a in args:
395
+ if a.startswith("-"):
396
+ continue
397
+ mod = a.split("@")[0]
398
+ parts = mod.split("/")
399
+ if not parts or parts[0].lower() not in PUBLIC_GO_DOMAINS:
400
+ continue # private/unknown domain -> skip (avoid false positives)
401
+ targets.append(("go", mod))
402
+
403
+
404
+ def collect_gems(args, targets):
405
+ skip_next = False
406
+ for a in args:
407
+ if skip_next:
408
+ skip_next = False
409
+ continue
410
+ if a in GEM_VALUE_FLAGS:
411
+ skip_next = True
412
+ continue
413
+ if any(a.startswith(f + "=") for f in GEM_VALUE_FLAGS):
414
+ continue
415
+ if a.startswith("-"):
416
+ continue
417
+ if "/" in a or a.endswith(".gem"):
418
+ continue # local file path
419
+ if GEM_NAME_RE.match(a):
420
+ targets.append(("rubygems", a))
421
+
422
+
423
+ def tokenize(command):
424
+ try:
425
+ lex = shlex.shlex(command, posix=True, punctuation_chars="|&;")
426
+ lex.whitespace_split = True
427
+ return list(lex)
428
+ except ValueError:
429
+ return command.split()
430
+
431
+
432
+ def parse_install_targets(command, _depth=0):
433
+ toks = tokenize(command)
434
+ targets = []
435
+ for tokens in split_segments(toks):
436
+ tokens = strip_prefix(tokens)
437
+ if len(tokens) < 2:
438
+ continue
439
+ mgr, rest = tokens[0], tokens[1:]
440
+
441
+ # Recurse into shell wrappers: bash -c "npm install x"
442
+ if mgr in SHELLS and _depth < 2:
443
+ for t in rest:
444
+ if " " in t: # a quoted inner command
445
+ targets.extend(parse_install_targets(t, _depth + 1))
446
+ continue
447
+
448
+ if mgr in ("python", "python3") and len(rest) >= 2 and rest[0] == "-m" and rest[1] == "pip":
449
+ mgr, rest = "pip", rest[2:]
450
+
451
+ if mgr == "uv":
452
+ if len(rest) >= 2 and rest[0] == "pip" and rest[1] == "install":
453
+ collect_py(rest[2:], targets, UV_VALUE_FLAGS)
454
+ elif rest and rest[0] == "add":
455
+ collect_py(rest[1:], targets, UV_VALUE_FLAGS)
456
+ continue
457
+ if mgr == "poetry":
458
+ if rest and rest[0] == "add":
459
+ collect_py(rest[1:], targets, POETRY_VALUE_FLAGS)
460
+ continue
461
+ if mgr in ("pip", "pip3"):
462
+ if rest and rest[0] == "install":
463
+ collect_py(rest[1:], targets, PIP_VALUE_FLAGS)
464
+ continue
465
+ if mgr == "npx":
466
+ collect_js_runner(rest, targets)
467
+ continue
468
+ if mgr == "npm":
469
+ if rest and rest[0] in ("exec", "x"):
470
+ collect_js_runner(rest[1:], targets)
471
+ continue
472
+ if mgr == "pnpm" and rest and rest[0] == "dlx":
473
+ collect_js_runner(rest[1:], targets)
474
+ continue
475
+ if mgr == "bunx":
476
+ collect_js_runner(rest, targets)
477
+ continue
478
+ if mgr == "bun" and rest and rest[0] == "x":
479
+ collect_js_runner(rest[1:], targets)
480
+ continue
481
+ if mgr == "yarn" and rest and rest[0] == "dlx":
482
+ collect_js_runner(rest[1:], targets)
483
+ continue
484
+ if mgr == "cargo":
485
+ if rest and rest[0] in ("add", "install"):
486
+ collect_cargo(rest[1:], targets)
487
+ continue
488
+
489
+ if mgr == "go":
490
+ if rest and rest[0] in ("get", "install"):
491
+ collect_go(rest[1:], targets)
492
+ continue
493
+
494
+ if mgr == "gem":
495
+ if rest and rest[0] == "install":
496
+ collect_gems(rest[1:], targets)
497
+ continue
498
+
499
+ if mgr == "bundle":
500
+ if rest and rest[0] == "add":
501
+ collect_gems(rest[1:], targets)
502
+ continue
503
+
504
+ if mgr in JS_MANAGERS:
505
+ if rest and rest[0] in JS_MANAGERS[mgr]:
506
+ collect_js(rest[1:], targets, NPM_VALUE_FLAGS)
507
+ continue
508
+ return targets
509
+
510
+
511
+ # ----------------------------------------- manifest install check
512
+ #
513
+ # Threat model: an agent invents a package name, writes it into a manifest
514
+ # (requirements.txt, package.json, pyproject.toml), then runs a *bare* install
515
+ # such as `npm install` or `pip install -r requirements.txt`. The direct-arg
516
+ # parser above never sees the package because it lives in a file. Here we read
517
+ # the relevant source manifest and feed its declared packages through the same
518
+ # registry checks.
519
+ #
520
+ # Source manifests only -- generated lockfiles (package-lock.json,
521
+ # pnpm-lock.yaml, yarn.lock) are not parsed: an agent is unlikely to hand-edit
522
+ # them, and YAML has no stdlib parser. pyproject.toml needs tomllib (Python
523
+ # 3.11+); on older Python we fail open rather than guess at TOML.
524
+
525
+ MAX_MANIFEST_BYTES = 512 * 1024 # don't slurp a giant generated file
526
+
527
+ # npm bare-install verbs (no positional package => install from package.json)
528
+ JS_INSTALL_VERBS = {
529
+ "npm": {"install", "i"},
530
+ "pnpm": {"install", "i"},
531
+ "yarn": {"install"},
532
+ "bun": {"install", "i"},
533
+ }
534
+
535
+ # package.json spec values that point at something other than a registry name
536
+ _JS_SKIP_SPEC_PREFIXES = (
537
+ "file:", "link:", "portal:", "workspace:", "git+", "git:",
538
+ "github:", "gitlab:", "bitbucket:", "http:", "https:",
539
+ )
540
+
541
+
542
+ def _read_text(path):
543
+ try:
544
+ if os.path.getsize(path) > MAX_MANIFEST_BYTES:
545
+ return None
546
+ with open(path, "r", encoding="utf-8", errors="replace") as f:
547
+ return f.read()
548
+ except Exception:
549
+ return None
550
+
551
+
552
+ def _abs(path, cwd):
553
+ return path if os.path.isabs(path) else os.path.join(cwd, path)
554
+
555
+
556
+ def _parse_package_json(text):
557
+ targets = []
558
+ try:
559
+ data = json.loads(text)
560
+ except Exception:
561
+ return targets
562
+ if not isinstance(data, dict):
563
+ return targets
564
+ for key in ("dependencies", "devDependencies",
565
+ "optionalDependencies", "peerDependencies"):
566
+ section = data.get(key)
567
+ if not isinstance(section, dict):
568
+ continue
569
+ for name, spec in section.items():
570
+ if not isinstance(name, str):
571
+ continue
572
+ if isinstance(spec, str):
573
+ sl = spec.lower()
574
+ if sl.startswith(_JS_SKIP_SPEC_PREFIXES):
575
+ continue # local path, git, url, or workspace alias
576
+ # npm alias: "alias": "npm:real-pkg@version" -- check real pkg
577
+ if sl.startswith("npm:"):
578
+ real = strip_npm_version(spec[4:])
579
+ if real and NPM_NAME_RE.match(real):
580
+ targets.append(("npm", real))
581
+ continue
582
+ if NPM_NAME_RE.match(name):
583
+ targets.append(("npm", name))
584
+ return targets
585
+
586
+
587
+ def _parse_requirements(text):
588
+ targets = []
589
+ for raw in text.splitlines():
590
+ line = raw.strip()
591
+ if not line or line.startswith("#"):
592
+ continue
593
+ if " #" in line: # strip trailing inline comment
594
+ line = line.split(" #", 1)[0].strip()
595
+ if line.startswith("-"):
596
+ continue # -r/-c includes, -e editable, --hash, other options
597
+ if "://" in line or line.startswith("git+") or " @ " in line:
598
+ continue # url / direct reference
599
+ if "/" in line or "\\" in line:
600
+ continue # local path
601
+ name = strip_py_version(line).strip()
602
+ if name and PY_NAME_RE.match(name):
603
+ targets.append(("pypi", name))
604
+ return targets
605
+
606
+
607
+ def _pep508_name(dep):
608
+ if not isinstance(dep, str):
609
+ return None
610
+ name = strip_py_version(dep).strip()
611
+ return name if name and PY_NAME_RE.match(name) else None
612
+
613
+
614
+ def _parse_pyproject(text):
615
+ targets = []
616
+ try:
617
+ import tomllib
618
+ except ImportError:
619
+ return targets # Python < 3.11: fail open
620
+ try:
621
+ data = tomllib.loads(text)
622
+ except Exception:
623
+ return targets
624
+ if not isinstance(data, dict):
625
+ return targets
626
+
627
+ # PEP 621: [project] dependencies + optional-dependencies
628
+ project = data.get("project") or {}
629
+ if isinstance(project, dict):
630
+ for dep in project.get("dependencies") or []:
631
+ name = _pep508_name(dep)
632
+ if name:
633
+ targets.append(("pypi", name))
634
+ opt = project.get("optional-dependencies") or {}
635
+ if isinstance(opt, dict):
636
+ for group in opt.values():
637
+ for dep in group or []:
638
+ name = _pep508_name(dep)
639
+ if name:
640
+ targets.append(("pypi", name))
641
+
642
+ # Poetry: [tool.poetry.dependencies] (table: name = version)
643
+ poetry = ((data.get("tool") or {}).get("poetry") or {})
644
+ if isinstance(poetry, dict):
645
+ tables = [poetry.get("dependencies"), poetry.get("dev-dependencies")]
646
+ groups = poetry.get("group") or {}
647
+ if isinstance(groups, dict):
648
+ for g in groups.values():
649
+ if isinstance(g, dict):
650
+ tables.append(g.get("dependencies"))
651
+ for table in tables:
652
+ if not isinstance(table, dict):
653
+ continue
654
+ for name in table:
655
+ if not isinstance(name, str) or name.lower() == "python":
656
+ continue
657
+ if PY_NAME_RE.match(name):
658
+ targets.append(("pypi", name))
659
+ return targets
660
+
661
+
662
+ def _parse_package_lock(text):
663
+ """Parse npm package-lock.json v2/v3 -> list of (npm, name) targets."""
664
+ targets = []
665
+ try:
666
+ data = json.loads(text)
667
+ except Exception:
668
+ return targets
669
+ packages = data.get("packages")
670
+ if not isinstance(packages, dict):
671
+ return targets
672
+ for key in packages:
673
+ if not key.startswith("node_modules/"):
674
+ continue
675
+ inner = key[len("node_modules/"):]
676
+ if "/node_modules/" in inner:
677
+ continue # nested dep
678
+ if inner.startswith("@"):
679
+ if inner.count("/") != 1:
680
+ continue # not @scope/name
681
+ elif "/" in inner:
682
+ continue # nested path
683
+ if NPM_NAME_RE.match(inner):
684
+ targets.append(("npm", inner))
685
+ return targets
686
+
687
+
688
+ def _parse_yarn_lock(text):
689
+ """Parse yarn.lock v1 -> list of (npm, name) targets."""
690
+ targets, seen = [], set()
691
+ for line in text.splitlines():
692
+ if line.startswith(" ") or line.startswith("\t") or not line.strip():
693
+ continue
694
+ if line.startswith("#"):
695
+ continue
696
+ # Entry header: 'express@^4.0.0:' or '"@scope/pkg@^1.0.0, @scope/pkg@^2.0.0":'
697
+ line = line.rstrip(":").strip().strip('"')
698
+ for entry in line.split(", "):
699
+ entry = entry.strip().strip('"')
700
+ if not entry or "@" not in entry:
701
+ continue
702
+ if entry.startswith("@"):
703
+ at = entry.find("@", 1)
704
+ name = entry[:at] if at > 0 else entry
705
+ else:
706
+ name = entry.split("@")[0]
707
+ name = name.strip()
708
+ if name and name not in seen and NPM_NAME_RE.match(name):
709
+ seen.add(name)
710
+ targets.append(("npm", name))
711
+ return targets
712
+
713
+
714
+ def _parse_poetry_lock(text):
715
+ """Parse poetry.lock (TOML) -> list of (pypi, name) targets."""
716
+ targets = []
717
+ try:
718
+ import tomllib
719
+ except ImportError:
720
+ return targets
721
+ try:
722
+ data = tomllib.loads(text)
723
+ except Exception:
724
+ return targets
725
+ for pkg in data.get("package") or []:
726
+ name = pkg.get("name")
727
+ if name and PY_NAME_RE.match(name):
728
+ targets.append(("pypi", name))
729
+ return targets
730
+
731
+
732
+ def _npm_prefix_dir(args, cwd):
733
+ """Honor --prefix DIR / -C DIR so we read the right package.json."""
734
+ for i, a in enumerate(args):
735
+ if a in ("--prefix", "-C", "--cwd") and i + 1 < len(args):
736
+ return _abs(args[i + 1], cwd)
737
+ for flag in ("--prefix=", "-C=", "--cwd="):
738
+ if a.startswith(flag):
739
+ return _abs(a[len(flag):], cwd)
740
+ return cwd
741
+
742
+
743
+ def _requirement_files(args, cwd):
744
+ files, i = [], 0
745
+ while i < len(args):
746
+ a = args[i]
747
+ if a in ("-r", "--requirement"):
748
+ if i + 1 < len(args):
749
+ files.append(_abs(args[i + 1], cwd))
750
+ i += 2
751
+ continue
752
+ elif a.startswith("--requirement="):
753
+ files.append(_abs(a.split("=", 1)[1], cwd))
754
+ elif a.startswith("-r") and len(a) > 2:
755
+ files.append(_abs(a[2:], cwd))
756
+ i += 1
757
+ return files
758
+
759
+
760
+ def _manifest_file(path, parser):
761
+ text = _read_text(path)
762
+ return parser(text) if text else []
763
+
764
+
765
+ def parse_manifest_targets(command, cwd, _depth=0):
766
+ """Collect packages declared in a manifest that a bare install would pull."""
767
+ if not cwd:
768
+ cwd = os.getcwd()
769
+ toks = tokenize(command)
770
+ out = []
771
+ for tokens in split_segments(toks):
772
+ tokens = strip_prefix(tokens)
773
+ if not tokens:
774
+ continue
775
+ mgr, rest = tokens[0], tokens[1:]
776
+
777
+ if mgr in SHELLS and _depth < 2:
778
+ for t in rest:
779
+ if " " in t:
780
+ out.extend(parse_manifest_targets(t, cwd, _depth + 1))
781
+ continue
782
+
783
+ if mgr in ("python", "python3") and len(rest) >= 2 and rest[0] == "-m" and rest[1] == "pip":
784
+ mgr, rest = "pip", rest[2:]
785
+
786
+ # npm ci reads package-lock.json, not package.json
787
+ if mgr == "npm" and rest and rest[0] == "ci":
788
+ pkg_dir = _npm_prefix_dir(rest[1:], cwd)
789
+ out += _manifest_file(os.path.join(pkg_dir, "package-lock.json"), _parse_package_lock)
790
+ continue
791
+
792
+ # JS bare install -> read package.json (only when no direct package arg)
793
+ if mgr in JS_MANAGERS:
794
+ is_install = (not rest and mgr == "yarn") or (rest and rest[0] in JS_INSTALL_VERBS[mgr])
795
+ if is_install:
796
+ direct = []
797
+ collect_js(rest[1:] if rest else [], direct, NPM_VALUE_FLAGS)
798
+ if not direct: # direct args are handled by parse_install_targets
799
+ pkg_dir = _npm_prefix_dir(rest[1:] if rest else [], cwd)
800
+ out += _manifest_file(os.path.join(pkg_dir, "package.json"), _parse_package_json)
801
+ if mgr == "yarn":
802
+ out += _manifest_file(os.path.join(pkg_dir, "yarn.lock"), _parse_yarn_lock)
803
+ continue
804
+
805
+ if mgr in ("pip", "pip3") and rest and rest[0] == "install":
806
+ for fp in _requirement_files(rest[1:], cwd):
807
+ out += _manifest_file(fp, _parse_requirements)
808
+ continue
809
+
810
+ if mgr == "uv":
811
+ if rest and rest[0] == "sync":
812
+ out += _manifest_file(os.path.join(cwd, "pyproject.toml"), _parse_pyproject)
813
+ elif len(rest) >= 2 and rest[0] == "pip" and rest[1] == "install":
814
+ for fp in _requirement_files(rest[2:], cwd):
815
+ out += _manifest_file(fp, _parse_requirements)
816
+ continue
817
+
818
+ if mgr == "poetry" and rest and rest[0] == "install":
819
+ lock = _manifest_file(os.path.join(cwd, "poetry.lock"), _parse_poetry_lock)
820
+ out += lock if lock else _manifest_file(os.path.join(cwd, "pyproject.toml"), _parse_pyproject)
821
+ continue
822
+
823
+ return out
824
+
825
+
826
+ # ---------------------------------------- destructive command check
827
+
828
+ # Matches any rm flag combination that includes -r or -R
829
+ _RM_RECURSIVE_RE = re.compile(r"^-[a-zA-Z]*[rR][a-zA-Z]*$")
830
+
831
+ # Paths that are dangerous at the top level (rm -rf on these = catastrophe)
832
+ _SYSTEM_ROOTS = frozenset({
833
+ "/usr", "/etc", "/bin", "/sbin", "/lib", "/lib64", "/lib32",
834
+ "/boot", "/sys", "/proc", "/dev", "/opt", "/root", "/var", "/snap",
835
+ "/run", "/srv",
836
+ })
837
+
838
+ # Home-dir aliases that mean "delete my entire home"
839
+ _HOME_ALIASES = frozenset({"~", "$HOME", "${HOME}", "$USERPROFILE", "${USERPROFILE}"})
840
+
841
+ _ROOT_GLOB_SUFFIXES = ("/*", "/**", "/{*,.*}")
842
+
843
+ # Git Bash maps Windows drives to /c/, /d/, etc. (single lowercase letter).
844
+ _WIN_DRIVE_RE = re.compile(r"^/([a-z])(/.*)?$", re.I)
845
+ _WIN_SYSTEM_SUBDIRS = frozenset({
846
+ "windows", "program files", "program files (x86)", "programdata",
847
+ })
848
+ # Windows env vars for system dirs (Git Bash expands these as $VAR)
849
+ _WIN_ENV_SYSTEM = frozenset({
850
+ "$systemroot", "${systemroot}", "$windir", "${windir}",
851
+ "$programfiles", "${programfiles}",
852
+ })
853
+
854
+
855
+ def _path_is_dangerous(path):
856
+ """Return a short description of why this path is dangerous, or None."""
857
+ # Trailing slash does not change what you delete; keep "/" itself intact
858
+ p = path.rstrip("/") or "/"
859
+
860
+ if p == "/":
861
+ return "the filesystem root (/)"
862
+
863
+ if p in _HOME_ALIASES:
864
+ return "your entire home directory (%s)" % p
865
+
866
+ # ~/ with nothing meaningful after it (e.g. "~/" or "~/.")
867
+ if re.match(r"^~/?\.?$", p):
868
+ return "your entire home directory (~)"
869
+
870
+ # Glob at root or home root: /* or ~/* or $HOME/*
871
+ if p in {"/*", "~/*", "$HOME/*", "${HOME}/*"}:
872
+ return "everything under %s" % p.rstrip("*")
873
+ if p in {"~/{*,.*}", "$HOME/{*,.*}", "${HOME}/{*,.*}"}:
874
+ return "everything in your home directory (%s)" % p
875
+
876
+ for suffix in _ROOT_GLOB_SUFFIXES:
877
+ if p.endswith(suffix):
878
+ base = p[:-len(suffix)]
879
+ if base in _SYSTEM_ROOTS:
880
+ return "everything in a critical system directory (%s)" % p
881
+
882
+ # Top-level system directories
883
+ if p in _SYSTEM_ROOTS:
884
+ return "a critical system directory (%s)" % p
885
+
886
+ # /home or /home/<user> (one level: wipes a whole user account)
887
+ if p == "/home":
888
+ return "all user home directories (/home)"
889
+ if re.match(r"^/home/[^/]+$", p):
890
+ return "a user's entire home directory (%s)" % p
891
+
892
+ # /home/<user>/* - glob that empties a home dir
893
+ if re.match(r"^/home/[^/]+/\*$", p):
894
+ return "everything in a home directory (%s)" % p
895
+ if re.match(r"^/home/[^/]+/\{\*,\.\*\}$", p):
896
+ return "everything in a home directory (%s)" % p
897
+
898
+ # Repository metadata - wipes entire git history
899
+ if p == ".git":
900
+ return "the repository's git history (.git)"
901
+
902
+ # Windows env var system dirs (Git Bash: $WINDIR, $SYSTEMROOT, $PROGRAMFILES)
903
+ if p.lower() in _WIN_ENV_SYSTEM:
904
+ return "a critical Windows system directory (%s)" % p
905
+
906
+ # Git Bash Windows drive paths: /c = C:\, /c/Windows = C:\Windows, etc.
907
+ m = _WIN_DRIVE_RE.match(p)
908
+ if m:
909
+ drive = m.group(1).upper()
910
+ rest = (m.group(2) or "").strip("/")
911
+ if not rest:
912
+ return "the Windows %s:\\ drive root" % drive
913
+ parts = [x for x in rest.split("/") if x]
914
+ top = parts[0].lower()
915
+ if top == "users":
916
+ if len(parts) == 1:
917
+ return "all Windows user home directories (%s:\\Users)" % drive
918
+ sub = parts[1]
919
+ if sub == "*":
920
+ return "all Windows user home directories (%s:\\Users\\*)" % drive
921
+ if len(parts) == 2:
922
+ return "a Windows user's entire home directory (%s:\\Users\\%s)" % (drive, sub)
923
+ if top in _WIN_SYSTEM_SUBDIRS:
924
+ return "a critical Windows system directory (%s:\\%s)" % (drive, parts[0])
925
+
926
+ return None
927
+
928
+
929
+ def check_destructive_rm(command, _depth=0):
930
+ """Return (decision, reason) if command contains a dangerous rm, else None."""
931
+ toks = tokenize(command)
932
+
933
+ for tokens in split_segments(toks):
934
+ tokens = strip_prefix(tokens)
935
+ if not tokens:
936
+ continue
937
+
938
+ cmd = tokens[0]
939
+
940
+ # Recurse into bash -c "rm -rf ..."
941
+ if cmd in SHELLS and _depth < 2:
942
+ for t in tokens[1:]:
943
+ if " " in t:
944
+ result = check_destructive_rm(t, _depth + 1)
945
+ if result:
946
+ return result
947
+ continue
948
+
949
+ if cmd != "rm":
950
+ continue
951
+
952
+ # Parse flags and paths from rm arguments
953
+ has_recursive = False
954
+ paths = []
955
+ end_of_flags = False
956
+
957
+ for t in tokens[1:]:
958
+ if end_of_flags:
959
+ paths.append(t)
960
+ continue
961
+ if t == "--":
962
+ end_of_flags = True
963
+ continue
964
+ if t.startswith("-") and len(t) > 1:
965
+ if _RM_RECURSIVE_RE.match(t):
966
+ has_recursive = True
967
+ else:
968
+ paths.append(t)
969
+
970
+ if not has_recursive:
971
+ continue
972
+
973
+ for path in paths:
974
+ why = _path_is_dangerous(path)
975
+ if why:
976
+ return ("deny",
977
+ "FailSafe blocked a destructive command:\n\n"
978
+ " rm -rf %s\n\n"
979
+ "This would permanently delete %s. "
980
+ "If you are certain this is correct, run it yourself outside the agent." % (path, why))
981
+
982
+ return None
983
+
984
+
985
+ # ------------------------------------------ curl-pipe-shell guard
986
+
987
+ _FETCHERS = frozenset({"curl", "wget", "fetch", "aria2c", "http", "lwp-download"})
988
+ _PIPE_EXECUTORS = frozenset({
989
+ "bash", "sh", "zsh", "dash", "ksh",
990
+ "python", "python3", "ruby", "perl", "node", "nodejs",
991
+ })
992
+
993
+
994
+ def _segments_with_ops(toks):
995
+ """Like split_segments but preserves the connecting operator per segment."""
996
+ result, cur, op = [], [], None
997
+ for t in toks:
998
+ if t in OPERATORS:
999
+ if cur:
1000
+ result.append((op, cur))
1001
+ cur, op = [], t
1002
+ else:
1003
+ cur.append(t)
1004
+ if cur:
1005
+ result.append((op, cur))
1006
+ return result
1007
+
1008
+
1009
+ def _fetcher_url(args):
1010
+ for a in args:
1011
+ if a.startswith("http://") or a.startswith("https://"):
1012
+ return a
1013
+ return None
1014
+
1015
+
1016
+ def check_curl_pipe_shell(command, _depth=0):
1017
+ """Return (decision, reason) if command pipes a remote fetch into a shell."""
1018
+ toks = tokenize(command)
1019
+ segs = _segments_with_ops(toks)
1020
+
1021
+ for i, (_, seg) in enumerate(segs):
1022
+ seg_s = strip_prefix(seg)
1023
+ if not seg_s or seg_s[0] not in _FETCHERS:
1024
+ continue
1025
+ if i + 1 >= len(segs):
1026
+ continue
1027
+ next_op, next_seg = segs[i + 1]
1028
+ if next_op != "|":
1029
+ continue
1030
+ next_s = strip_prefix(next_seg)
1031
+ if not next_s or next_s[0] not in _PIPE_EXECUTORS:
1032
+ continue
1033
+
1034
+ fetcher, executor = seg_s[0], next_s[0]
1035
+ url = _fetcher_url(seg_s[1:])
1036
+
1037
+ if url and url.startswith("http://"):
1038
+ return ("deny",
1039
+ "FailSafe blocked a remote script over plain HTTP:\n\n"
1040
+ " %s ... | %s\n\n"
1041
+ "Fetching over unencrypted HTTP lets a network attacker intercept "
1042
+ "and replace the script before it runs. "
1043
+ "If you need this script, switch to HTTPS and inspect it first." % (fetcher, executor))
1044
+
1045
+ return ("ask",
1046
+ "FailSafe flagged a remote script execution:\n\n"
1047
+ " %s ... | %s\n\n"
1048
+ "This downloads and immediately executes remote code without inspection. "
1049
+ "Review the script source before running it." % (fetcher, executor))
1050
+
1051
+ if _depth < 2:
1052
+ for _, seg in segs:
1053
+ seg_s = strip_prefix(seg)
1054
+ if seg_s and seg_s[0] in SHELLS:
1055
+ for t in seg_s[1:]:
1056
+ if " " in t:
1057
+ result = check_curl_pipe_shell(t, _depth + 1)
1058
+ if result:
1059
+ return result
1060
+ return None
1061
+
1062
+
1063
+ # ---------------------------------------------- git disaster guard
1064
+
1065
+ _GIT_PROTECTED_BRANCHES = frozenset({
1066
+ "main", "master", "production", "release", "prod", "staging",
1067
+ })
1068
+ _GIT_FORCE_FLAGS = frozenset({"--force", "-f", "--force-with-lease", "--force-if-includes"})
1069
+ _GIT_SKIP_VALUE_FLAGS = frozenset({
1070
+ "--receive-pack", "--exec", "--repo", "-o", "--push-option",
1071
+ "--recurse-submodules", "--signed",
1072
+ })
1073
+
1074
+
1075
+ def _git_push_branch(subargs):
1076
+ """Return the destination branch from git push args, or None."""
1077
+ skip_next = False
1078
+ non_flag = []
1079
+ for a in subargs:
1080
+ if skip_next:
1081
+ skip_next = False
1082
+ continue
1083
+ if a in _GIT_SKIP_VALUE_FLAGS:
1084
+ skip_next = True
1085
+ continue
1086
+ if a.startswith("-"):
1087
+ continue
1088
+ non_flag.append(a)
1089
+ # non_flag: [remote, refspec, ...] refspec may be src:dst or branch
1090
+ for token in non_flag[1:]:
1091
+ dst = token.split(":")[-1].lstrip("+")
1092
+ if dst.startswith("refs/heads/"):
1093
+ dst = dst[len("refs/heads/"):]
1094
+ if dst:
1095
+ return dst
1096
+ return None
1097
+
1098
+
1099
+ def check_git_disaster(command, _depth=0):
1100
+ """Return (decision, reason) for dangerous git operations, or None."""
1101
+ toks = tokenize(command)
1102
+ for tokens in split_segments(toks):
1103
+ tokens = strip_prefix(tokens)
1104
+ if not tokens:
1105
+ continue
1106
+ cmd = tokens[0]
1107
+ if cmd in SHELLS and _depth < 2:
1108
+ for t in tokens[1:]:
1109
+ if " " in t:
1110
+ result = check_git_disaster(t, _depth + 1)
1111
+ if result:
1112
+ return result
1113
+ continue
1114
+ if cmd != "git":
1115
+ continue
1116
+
1117
+ rest = tokens[1:]
1118
+ # Skip global git options like -C <dir>
1119
+ i = 0
1120
+ while i < len(rest):
1121
+ if rest[i] in ("-C", "--git-dir", "--work-tree", "--namespace") and i + 1 < len(rest):
1122
+ i += 2
1123
+ elif rest[i].startswith("-"):
1124
+ i += 1
1125
+ else:
1126
+ break
1127
+ if i >= len(rest):
1128
+ continue
1129
+ subcmd, subargs = rest[i], rest[i + 1:]
1130
+
1131
+ if subcmd == "reset" and ("--hard" in subargs or "--merge" in subargs):
1132
+ return ("ask",
1133
+ "FailSafe flagged a potentially destructive git operation:\n\n"
1134
+ " git reset %s\n\n"
1135
+ "This discards all uncommitted changes and cannot be undone. "
1136
+ "Confirm this is intentional." % " ".join(subargs))
1137
+
1138
+ if subcmd == "clean":
1139
+ flags_combined = "".join(
1140
+ a[1:] for a in subargs if re.match(r"^-[a-zA-Z]+$", a)
1141
+ )
1142
+ if "f" in flags_combined and "n" not in flags_combined:
1143
+ return ("ask",
1144
+ "FailSafe flagged a potentially destructive git operation:\n\n"
1145
+ " git clean %s\n\n"
1146
+ "This permanently deletes untracked files and cannot be undone. "
1147
+ "Confirm this is intentional." % " ".join(subargs))
1148
+
1149
+ if subcmd == "push":
1150
+ force_flags = [a for a in subargs if a in _GIT_FORCE_FLAGS
1151
+ or a.startswith("--force-with-lease=")]
1152
+ plus_refspecs = [a for a in subargs
1153
+ if not a.startswith("-") and a.startswith("+")]
1154
+ if force_flags or plus_refspecs:
1155
+ cfg_branches = _CONFIG.get("protected_branches")
1156
+ protected = (
1157
+ frozenset(b.lower() for b in cfg_branches if isinstance(b, str))
1158
+ if isinstance(cfg_branches, list)
1159
+ else _GIT_PROTECTED_BRANCHES
1160
+ )
1161
+ branch = _git_push_branch(subargs)
1162
+ if branch and branch.lower() in protected:
1163
+ return ("deny",
1164
+ "FailSafe blocked a force push to a protected branch:\n\n"
1165
+ " git push %s\n\n"
1166
+ "Force pushing to '%s' permanently overwrites remote history "
1167
+ "and affects all collaborators. "
1168
+ "If you are certain, run this yourself outside the agent." % (
1169
+ " ".join(subargs), branch))
1170
+ return ("ask",
1171
+ "FailSafe flagged a force push:\n\n"
1172
+ " git push %s\n\n"
1173
+ "Force pushing rewrites remote history. "
1174
+ "Confirm the target branch and that this is intentional." % " ".join(subargs))
1175
+
1176
+ if subcmd == "branch":
1177
+ flags = [a for a in subargs if a.startswith("-")]
1178
+ branches = [a for a in subargs if not a.startswith("-")]
1179
+ flag_chars = "".join(a[1:] for a in flags if re.match(r"^-[a-zA-Z]+$", a))
1180
+ long_flags = set(flags)
1181
+ is_force_delete = (
1182
+ "D" in flag_chars
1183
+ or ("--delete" in long_flags and ("--force" in long_flags or "f" in flag_chars))
1184
+ or ("-d" in flags and ("--force" in long_flags or "f" in flag_chars))
1185
+ )
1186
+ if is_force_delete and branches:
1187
+ return ("ask",
1188
+ "FailSafe flagged a force branch deletion:\n\n"
1189
+ " git branch -D %s\n\n"
1190
+ "This deletes the branch even if it has unmerged commits. "
1191
+ "Confirm this is intentional." % " ".join(branches))
1192
+
1193
+ return None
1194
+
1195
+
1196
+ # ------------------------------------------ cloud / infra blast-radius guard
1197
+
1198
+ _CLOUD_ASK_TMPL = (
1199
+ "FailSafe flagged a potentially destructive infrastructure command:\n\n"
1200
+ " %s\n\n"
1201
+ "%s\n\n"
1202
+ "Confirm this is intentional before proceeding."
1203
+ )
1204
+
1205
+
1206
+ def check_cloud_infra(command, _depth=0):
1207
+ """Return (decision, reason) for high-blast-radius cloud/infra commands."""
1208
+ toks = tokenize(command)
1209
+ for tokens in split_segments(toks):
1210
+ tokens = strip_prefix(tokens)
1211
+ if not tokens:
1212
+ continue
1213
+ cmd = tokens[0]
1214
+ if cmd in SHELLS and _depth < 2:
1215
+ for t in tokens[1:]:
1216
+ if " " in t:
1217
+ result = check_cloud_infra(t, _depth + 1)
1218
+ if result:
1219
+ return result
1220
+ continue
1221
+ rest = tokens[1:]
1222
+
1223
+ if cmd == "terraform" and rest and rest[0] == "destroy":
1224
+ return ("ask", _CLOUD_ASK_TMPL % (
1225
+ "terraform destroy",
1226
+ "This tears down all infrastructure managed by this Terraform workspace."))
1227
+
1228
+ if cmd == "kubectl" and rest and rest[0] == "delete":
1229
+ args = rest[1:]
1230
+ frag = "kubectl delete " + " ".join(args)
1231
+ if any(a in ("namespace", "ns") for a in args):
1232
+ return ("ask", _CLOUD_ASK_TMPL % (
1233
+ frag, "This deletes an entire Kubernetes namespace and every resource in it."))
1234
+ if "--all" in args or "-A" in args or "--all-namespaces" in args:
1235
+ return ("ask", _CLOUD_ASK_TMPL % (
1236
+ frag, "This deletes all matching Kubernetes resources."))
1237
+
1238
+ if cmd == "docker":
1239
+ if (len(rest) >= 2 and rest[0] == "system" and rest[1] == "prune"
1240
+ and ("--all" in rest or "-a" in rest)):
1241
+ return ("ask", _CLOUD_ASK_TMPL % (
1242
+ "docker system prune -a",
1243
+ "This removes all unused images, containers, networks, and build cache."))
1244
+ if len(rest) >= 2 and rest[0] == "volume" and rest[1] in ("rm", "remove"):
1245
+ return ("ask", _CLOUD_ASK_TMPL % (
1246
+ "docker volume rm " + " ".join(rest[2:]),
1247
+ "This permanently removes Docker volumes and their stored data."))
1248
+
1249
+ if (cmd == "aws" and len(rest) >= 2 and rest[0] == "s3"
1250
+ and rest[1] in ("rm", "sync", "mv")
1251
+ and ("--recursive" in rest or "--delete" in rest)):
1252
+ return ("ask", _CLOUD_ASK_TMPL % (
1253
+ "aws s3 " + " ".join(rest[1:]),
1254
+ "This recursively deletes or overwrites S3 objects and cannot be undone."))
1255
+
1256
+ if cmd == "gcloud" and len(rest) >= 2 and rest[0] == "projects" and rest[1] == "delete":
1257
+ return ("ask", _CLOUD_ASK_TMPL % (
1258
+ "gcloud projects delete " + " ".join(rest[2:]),
1259
+ "This schedules a GCP project for deletion, removing all its resources."))
1260
+
1261
+ if cmd == "az" and len(rest) >= 2 and rest[0] == "group" and rest[1] == "delete":
1262
+ return ("ask", _CLOUD_ASK_TMPL % (
1263
+ "az group delete " + " ".join(rest[2:]),
1264
+ "This deletes an Azure resource group and all resources inside it."))
1265
+
1266
+ return None
1267
+
1268
+
1269
+ # ------------------------------------------ secrets exfiltration guard
1270
+
1271
+ _SENSITIVE_RE = [
1272
+ re.compile(r"(^|[\\/])\.env(\.[a-zA-Z]+)?$"),
1273
+ re.compile(r"(^|[\\/])\.ssh([\\/]|$)"),
1274
+ re.compile(r"\.(pem|key|p12|pfx|crt|cer|jks)$", re.I),
1275
+ re.compile(r"(^|[\\/])(id_rsa|id_ed25519|id_ecdsa|id_dsa)(\.pub)?$"),
1276
+ re.compile(r"(^|[\\/])(\.netrc|\.npmrc|\.pypirc)$"),
1277
+ re.compile(r"(^|[\\/])\.aws[\\/]credentials$"),
1278
+ ]
1279
+
1280
+ _NETWORK_SENDERS = frozenset({
1281
+ "curl", "wget", "scp", "rsync", "nc", "netcat", "ncat", "socat",
1282
+ "ftp", "sftp", "aws", "gcloud", "gsutil",
1283
+ })
1284
+ _FILE_READERS = frozenset({"cat", "head", "tail", "tee"})
1285
+ _CURL_DATA_FLAGS = frozenset({
1286
+ "-d", "--data", "--data-binary", "--data-raw", "--data-ascii",
1287
+ "--data-urlencode", "-F", "--form", "--upload-file", "-T",
1288
+ })
1289
+
1290
+
1291
+ def _is_sensitive(path):
1292
+ for pat in _SENSITIVE_RE:
1293
+ if pat.search(path):
1294
+ return True
1295
+ return False
1296
+
1297
+
1298
+ def _curl_sensitive_file(args):
1299
+ """Return sensitive filename if curl is reading one via @file or -T."""
1300
+ skip_next = False
1301
+ for i, a in enumerate(args):
1302
+ if skip_next:
1303
+ skip_next = False
1304
+ continue
1305
+ if a in _CURL_DATA_FLAGS:
1306
+ skip_next = True
1307
+ if i + 1 < len(args):
1308
+ val = args[i + 1]
1309
+ # -F name=@file or -d @file
1310
+ f = val.split("=@", 1)[1] if "=@" in val else val.lstrip("@")
1311
+ if _is_sensitive(f):
1312
+ return f
1313
+ continue
1314
+ if "=" in a:
1315
+ key, _, val = a.partition("=")
1316
+ if key in _CURL_DATA_FLAGS:
1317
+ val = val.lstrip("@")
1318
+ if _is_sensitive(val):
1319
+ return val
1320
+ return None
1321
+
1322
+
1323
+ def check_secrets_exfil(command, _depth=0):
1324
+ """Return (decision, reason) if command appears to leak sensitive files."""
1325
+ toks = tokenize(command)
1326
+ segs = _segments_with_ops(toks)
1327
+
1328
+ # Pattern 1: cat/head/tail <sensitive> | <network_cmd>
1329
+ for i, (_, seg) in enumerate(segs):
1330
+ seg_s = strip_prefix(seg)
1331
+ if not seg_s or seg_s[0] not in _FILE_READERS:
1332
+ continue
1333
+ sensitive = [a for a in seg_s[1:] if not a.startswith("-") and _is_sensitive(a)]
1334
+ if not sensitive:
1335
+ continue
1336
+ for j in range(i + 1, len(segs)):
1337
+ jop, jseg = segs[j]
1338
+ if jop != "|":
1339
+ break
1340
+ jseg_s = strip_prefix(jseg)
1341
+ if jseg_s and jseg_s[0] in _NETWORK_SENDERS:
1342
+ return ("ask",
1343
+ "FailSafe flagged a possible secrets leak:\n\n"
1344
+ " %s\n\n"
1345
+ "A sensitive file (%s) is being piped into a network command. "
1346
+ "Confirm this is intentional." % (" ".join(toks), sensitive[0]))
1347
+
1348
+ # Pattern 2: curl/wget -d @<sensitive>
1349
+ for _, seg in segs:
1350
+ seg_s = strip_prefix(seg)
1351
+ if not seg_s or seg_s[0] not in {"curl", "wget", "http"}:
1352
+ continue
1353
+ f = _curl_sensitive_file(seg_s[1:])
1354
+ if f:
1355
+ return ("ask",
1356
+ "FailSafe flagged a possible secrets leak:\n\n"
1357
+ " %s\n\n"
1358
+ "A sensitive file (%s) is being sent to a remote server. "
1359
+ "Confirm this is intentional." % (" ".join(toks), f))
1360
+
1361
+ # Pattern 3a: tar <flags> <sensitive> | <network_cmd>
1362
+ for i, (_, seg) in enumerate(segs):
1363
+ seg_s = strip_prefix(seg)
1364
+ if not seg_s or seg_s[0] != "tar":
1365
+ continue
1366
+ sensitive = [a for a in seg_s[1:]
1367
+ if a != "-" and not a.startswith("-") and _is_sensitive(a)]
1368
+ if not sensitive:
1369
+ continue
1370
+ for j in range(i + 1, len(segs)):
1371
+ jop, jseg = segs[j]
1372
+ if jop != "|":
1373
+ break
1374
+ jseg_s = strip_prefix(jseg)
1375
+ if jseg_s and jseg_s[0] in _NETWORK_SENDERS:
1376
+ return ("ask",
1377
+ "FailSafe flagged a possible secrets leak:\n\n"
1378
+ " %s\n\n"
1379
+ "Sensitive files (%s) are being archived and piped to a network command. "
1380
+ "Confirm this is intentional." % (" ".join(toks), sensitive[0]))
1381
+
1382
+ # Pattern 3: scp/rsync <sensitive> <remote:path>
1383
+ for _, seg in segs:
1384
+ seg_s = strip_prefix(seg)
1385
+ if not seg_s or seg_s[0] not in {"scp", "rsync"}:
1386
+ continue
1387
+ non_flags = [a for a in seg_s[1:] if not a.startswith("-")]
1388
+ if len(non_flags) >= 2:
1389
+ src, dst = non_flags[0], non_flags[-1]
1390
+ if ":" in dst and _is_sensitive(src):
1391
+ return ("ask",
1392
+ "FailSafe flagged a possible secrets leak:\n\n"
1393
+ " %s\n\n"
1394
+ "A sensitive file (%s) is being copied to a remote destination. "
1395
+ "Confirm this is intentional." % (" ".join(toks), src))
1396
+
1397
+ # Pattern 4: aws s3 cp/mv <sensitive> s3://...
1398
+ for _, seg in segs:
1399
+ seg_s = strip_prefix(seg)
1400
+ if not seg_s or seg_s[0] != "aws":
1401
+ continue
1402
+ rest = seg_s[1:]
1403
+ if len(rest) < 2 or rest[0] != "s3" or rest[1] not in ("cp", "mv"):
1404
+ continue
1405
+ non_flags = [a for a in rest[2:] if not a.startswith("-")]
1406
+ if len(non_flags) >= 2:
1407
+ src, dst = non_flags[0], non_flags[-1]
1408
+ if dst.startswith("s3://") and _is_sensitive(src):
1409
+ return ("ask",
1410
+ "FailSafe flagged a possible secrets leak:\n\n"
1411
+ " %s\n\n"
1412
+ "A sensitive file (%s) is being uploaded to S3. "
1413
+ "Confirm this is intentional." % (" ".join(toks), src))
1414
+
1415
+ if _depth < 2:
1416
+ for _, seg in segs:
1417
+ seg_s = strip_prefix(seg)
1418
+ if seg_s and seg_s[0] in SHELLS:
1419
+ for t in seg_s[1:]:
1420
+ if " " in t:
1421
+ result = check_secrets_exfil(t, _depth + 1)
1422
+ if result:
1423
+ return result
1424
+
1425
+ return None
1426
+
1427
+
1428
+ # ----------------------------------------------------- registry lookups
1429
+ def parse_dt(s):
1430
+ if not s:
1431
+ return None
1432
+ try:
1433
+ dt = datetime.fromisoformat(s.replace("Z", "+00:00"))
1434
+ return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
1435
+ except Exception:
1436
+ return None
1437
+
1438
+
1439
+ def age_days(dt):
1440
+ if not dt:
1441
+ return None
1442
+ return (datetime.now(timezone.utc) - dt).total_seconds() / 86400.0
1443
+
1444
+
1445
+ def npm_encode(name):
1446
+ return name.replace("/", "%2F") if name.startswith("@") else urllib.parse.quote(name)
1447
+
1448
+
1449
+ def check_npm(name):
1450
+ status, data = http_get_json("https://registry.npmjs.org/" + npm_encode(name))
1451
+ if status == 404:
1452
+ return {"exists": False}
1453
+ if not data:
1454
+ return {"exists": None}
1455
+ created = parse_dt((data.get("time") or {}).get("created"))
1456
+ downloads = None
1457
+ _, dd = http_get_json("https://api.npmjs.org/downloads/point/last-month/" + npm_encode(name))
1458
+ if dd and isinstance(dd.get("downloads"), int):
1459
+ downloads = dd["downloads"]
1460
+ return {"exists": True, "age_days": age_days(created), "downloads": downloads}
1461
+
1462
+
1463
+ def check_pypi(name):
1464
+ status, data = http_get_json("https://pypi.org/pypi/" + urllib.parse.quote(name) + "/json")
1465
+ if status == 404:
1466
+ return {"exists": False}
1467
+ if not data:
1468
+ return {"exists": None}
1469
+ earliest = None
1470
+ for files in (data.get("releases") or {}).values():
1471
+ for f in files or []:
1472
+ dt = parse_dt(f.get("upload_time_iso_8601") or f.get("upload_time"))
1473
+ if dt and (earliest is None or dt < earliest):
1474
+ earliest = dt
1475
+ downloads = None
1476
+ _, dd = http_get_json("https://pypistats.org/api/packages/" + urllib.parse.quote(name.lower()) + "/recent")
1477
+ if dd and isinstance((dd.get("data") or {}).get("last_month"), (int, float)):
1478
+ downloads = int(dd["data"]["last_month"])
1479
+ return {"exists": True, "age_days": age_days(earliest), "downloads": downloads}
1480
+
1481
+
1482
+ LOW_CARGO_DOWNLOADS = 500 # recent_downloads threshold (90-day window from crates.io)
1483
+
1484
+
1485
+ def check_cargo(name):
1486
+ req = urllib.request.Request(
1487
+ "https://crates.io/api/v1/crates/" + urllib.parse.quote(name, safe=""),
1488
+ headers={"User-Agent": "failsafe/0.5 (github.com/MoAz06/FailSafe)"},
1489
+ )
1490
+ try:
1491
+ with urllib.request.urlopen(req, timeout=REQUEST_TIMEOUT) as r:
1492
+ if getattr(r, "status", 200) != 200:
1493
+ return {"exists": None}
1494
+ data = json.loads(r.read().decode("utf-8", "replace"))
1495
+ except urllib.error.HTTPError as e:
1496
+ return {"exists": False} if e.code == 404 else {"exists": None}
1497
+ except Exception:
1498
+ return {"exists": None}
1499
+ krate = data.get("crate") or {}
1500
+ created = parse_dt(krate.get("created_at"))
1501
+ downloads = krate.get("recent_downloads") or krate.get("downloads")
1502
+ return {"exists": True, "age_days": age_days(created), "downloads": downloads}
1503
+
1504
+
1505
+ def check_go(module):
1506
+ # Go proxy encoding: uppercase letter X -> !x
1507
+ encoded = re.sub(r"[A-Z]", lambda m: "!" + m.group().lower(), module)
1508
+ url = "https://proxy.golang.org/" + encoded + "/@v/list"
1509
+ req = urllib.request.Request(url, headers={"User-Agent": "failsafe/0.5"})
1510
+ try:
1511
+ with urllib.request.urlopen(req, timeout=REQUEST_TIMEOUT) as r:
1512
+ return {"exists": getattr(r, "status", 200) == 200}
1513
+ except urllib.error.HTTPError as e:
1514
+ return {"exists": False} if e.code in (404, 410, 451) else {"exists": None}
1515
+ except Exception:
1516
+ return {"exists": None}
1517
+
1518
+
1519
+ def check_rubygems(name):
1520
+ req = urllib.request.Request(
1521
+ "https://rubygems.org/api/v1/gems/" + urllib.parse.quote(name, safe="") + ".json",
1522
+ headers={"User-Agent": "failsafe/0.9"},
1523
+ )
1524
+ try:
1525
+ with urllib.request.urlopen(req, timeout=REQUEST_TIMEOUT) as r:
1526
+ if getattr(r, "status", 200) != 200:
1527
+ return {"exists": None}
1528
+ data = json.loads(r.read().decode("utf-8", "replace"))
1529
+ except urllib.error.HTTPError as e:
1530
+ return {"exists": False} if e.code == 404 else {"exists": None}
1531
+ except Exception:
1532
+ return {"exists": None}
1533
+ downloads = data.get("downloads")
1534
+ created_str = data.get("first_release_created_at") or data.get("version_created_at")
1535
+ return {"exists": True, "age_days": age_days(parse_dt(created_str)), "downloads": downloads}
1536
+
1537
+
1538
+ # --------------------------------------------------- look-alike check
1539
+ def damerau_levenshtein(a, b):
1540
+ """Optimal string alignment: counts an adjacent transposition as 1."""
1541
+ m, n = len(a), len(b)
1542
+ if abs(m - n) > 1:
1543
+ return 2 # we only care about distance <= 1
1544
+ d = [[0] * (n + 1) for _ in range(m + 1)]
1545
+ for i in range(m + 1):
1546
+ d[i][0] = i
1547
+ for j in range(n + 1):
1548
+ d[0][j] = j
1549
+ for i in range(1, m + 1):
1550
+ for j in range(1, n + 1):
1551
+ cost = 0 if a[i - 1] == b[j - 1] else 1
1552
+ d[i][j] = min(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + cost)
1553
+ if i > 1 and j > 1 and a[i - 1] == b[j - 2] and a[i - 2] == b[j - 1]:
1554
+ d[i][j] = min(d[i][j], d[i - 2][j - 2] + 1)
1555
+ return d[m][n]
1556
+
1557
+
1558
+ def nearest_popular(name, popular):
1559
+ if name in popular:
1560
+ return None
1561
+ for p in popular:
1562
+ if damerau_levenshtein(name, p) == 1:
1563
+ return p
1564
+ return None
1565
+
1566
+
1567
+ # ------------------------------------------------------------- evaluate
1568
+ def evaluate(target):
1569
+ ecosystem, name = target
1570
+
1571
+ allowed = _CONFIG.get("allowed_packages")
1572
+ if isinstance(allowed, list) and name.lower() in {a.lower() for a in allowed if isinstance(a, str)}:
1573
+ return ("allow", name, "")
1574
+
1575
+ if ecosystem == "npm":
1576
+ info, registry, popular = check_npm(name), "the npm registry", POPULAR_NPM
1577
+ elif ecosystem == "pypi":
1578
+ info, registry, popular = check_pypi(name), "PyPI", POPULAR_PYPI
1579
+ elif ecosystem == "cargo":
1580
+ info, registry, popular = check_cargo(name), "crates.io", POPULAR_CARGO
1581
+ elif ecosystem == "go":
1582
+ info, registry, popular = check_go(name), "the Go module proxy", set()
1583
+ elif ecosystem == "rubygems":
1584
+ info, registry, popular = check_rubygems(name), "RubyGems", POPULAR_GEMS
1585
+ else:
1586
+ return ("allow", name, "")
1587
+
1588
+ if info["exists"] is False:
1589
+ return ("deny", name, "not found on " + registry)
1590
+ if info["exists"] is not True:
1591
+ return ("allow", name, "")
1592
+
1593
+ reasons = []
1594
+ if popular:
1595
+ look = nearest_popular(name.lower(), popular)
1596
+ if look:
1597
+ reasons.append('one character away from the popular package "%s" (possible look-alike)' % look)
1598
+ dl_threshold = {"cargo": LOW_CARGO_DOWNLOADS, "rubygems": LOW_GEM_DOWNLOADS}.get(ecosystem, LOW_DOWNLOADS)
1599
+ if (info.get("age_days") is not None and info["age_days"] < NEW_PACKAGE_DAYS
1600
+ and info.get("downloads") is not None and info["downloads"] < dl_threshold):
1601
+ reasons.append("first published %d days ago with only ~%d downloads"
1602
+ % (round(info["age_days"]), info["downloads"]))
1603
+ if reasons:
1604
+ return ("ask", name, "; ".join(reasons))
1605
+ return ("allow", name, "")
1606
+
1607
+
1608
+ def _strict(result):
1609
+ """Upgrade 'ask' -> 'deny' when strict mode is enabled in config."""
1610
+ if result and result[0] == "ask" and _CONFIG.get("strict"):
1611
+ return ("deny", result[1])
1612
+ return result
1613
+
1614
+
1615
+ def emit(decision, reason):
1616
+ sys.stdout.write(json.dumps({
1617
+ "hookSpecificOutput": {
1618
+ "hookEventName": "PreToolUse",
1619
+ "permissionDecision": decision,
1620
+ "permissionDecisionReason": reason,
1621
+ }
1622
+ }))
1623
+ sys.exit(0)
1624
+
1625
+
1626
+ def main():
1627
+ try:
1628
+ raw = sys.stdin.read()
1629
+ if not raw:
1630
+ return
1631
+ data = json.loads(raw)
1632
+ if data.get("tool_name") != "Bash":
1633
+ return
1634
+ command = (data.get("tool_input") or {}).get("command")
1635
+ if not command or not isinstance(command, str):
1636
+ return
1637
+
1638
+ # Reload config using the project cwd from the payload.
1639
+ # The module-level _CONFIG was loaded at import using the hook's own
1640
+ # working directory, which may differ from the project being worked on.
1641
+ global _CONFIG
1642
+ cwd = data.get("cwd") or os.getcwd()
1643
+ _CONFIG = _load_config(cwd)
1644
+
1645
+ # Rule 2: destructive rm (instant, no network)
1646
+ rm_result = check_destructive_rm(command)
1647
+ if rm_result:
1648
+ decision, reason = rm_result
1649
+ emit(decision, reason)
1650
+
1651
+ # Rule 6: git disaster (instant, no network)
1652
+ git_result = _strict(check_git_disaster(command))
1653
+ if git_result:
1654
+ decision, reason = git_result
1655
+ emit(decision, reason)
1656
+
1657
+ # Rule 7: cloud/infra blast radius (instant, no network)
1658
+ cloud_result = _strict(check_cloud_infra(command))
1659
+ if cloud_result:
1660
+ decision, reason = cloud_result
1661
+ emit(decision, reason)
1662
+
1663
+ # Rule 5: curl/wget piped into shell (instant, no network)
1664
+ pipe_result = _strict(check_curl_pipe_shell(command))
1665
+ if pipe_result:
1666
+ decision, reason = pipe_result
1667
+ emit(decision, reason)
1668
+
1669
+ # Rule 8: secrets exfiltration (instant, no network)
1670
+ exfil_result = _strict(check_secrets_exfil(command))
1671
+ if exfil_result:
1672
+ decision, reason = exfil_result
1673
+ emit(decision, reason)
1674
+
1675
+ # Rules 1 + 3 + 4: slopsquatting / one-off runners / manifest installs
1676
+ targets = parse_install_targets(command)
1677
+ targets += parse_manifest_targets(command, cwd)
1678
+ seen, uniq = set(), []
1679
+ for t in targets:
1680
+ if t not in seen:
1681
+ seen.add(t)
1682
+ uniq.append(t)
1683
+ if not uniq or len(uniq) > MAX_PACKAGES:
1684
+ return # fast path / too many to check quickly -> fail open
1685
+
1686
+ with ThreadPoolExecutor(max_workers=8) as ex:
1687
+ results = list(ex.map(evaluate, uniq))
1688
+
1689
+ denies = [(n, r) for (lvl, n, r) in results if lvl == "deny"]
1690
+ asks = [(n, r) for (lvl, n, r) in results if lvl == "ask"]
1691
+
1692
+ if denies:
1693
+ listing = "\n".join(" - %s: %s" % (n, r) for n, r in denies)
1694
+ emit("deny",
1695
+ "FailSafe blocked this install:\n%s\n\n"
1696
+ "AI assistants sometimes invent package names that don't exist; attackers "
1697
+ "pre-register those names with malware (\"slopsquatting\"). Verify the correct "
1698
+ "name on the official registry before installing. If you're certain it's "
1699
+ "legitimate, install it yourself outside the agent." % listing)
1700
+ if asks:
1701
+ listing = "\n".join(" - %s: %s" % (n, r) for n, r in asks)
1702
+ pkg_ask = _strict(("ask",
1703
+ "FailSafe flagged a possibly suspicious package:\n%s\n\n"
1704
+ "Review before installing." % listing))
1705
+ emit(*pkg_ask)
1706
+ except Exception:
1707
+ pass # fail open
1708
+ sys.exit(0)
1709
+
1710
+
1711
+ if __name__ == "__main__":
1712
+ main()