fix(guards): read .gitignore like git, allowlist settings.json keys, refuse workspaces and bunfig.toml

Three findings on tools/security_guards.py and tools/install_portal_clis.py.

GHSA-rv99-7xf2-8f4p (Martin Vlach, @Huge): the gitignore guard stripped
both ends of each line, but git drops only unescaped trailing whitespace,
so " salary_data.json" satisfied the required-rule check while git had
stopped ignoring the file. Only the root file was read, though a
.gitignore in a subdirectory outranks it, so a negation there re-included
personal data with CI green. Lines are now read as git reads them, and
every .gitignore below the root (outside .git and node_modules) is checked
for negations; an intentional one is allowlisted as "<dir>/.gitignore:!p".

GHSA-cqcv-crx7-x7q7 (@folovco, @Huge): the guard checked allow, ask, deny
and hooks and ignored every other key, so env, apiKeyHelper, a statusLine
command, enableAllProjectMcpServers, permissions.defaultMode or
additionalDirectories passed CI. Any top-level key outside
ALLOWED_SETTINGS_KEYS and any permissions key outside
ALLOWED_PERMISSIONS_KEYS now fails.

GHSA-wq7j-x5jq-pjrw (@folovco, @Huge), on top of #718's --ignore-scripts
(cherry-picked, rudycelekli): the installer and check 3 now refuse a
manifest that declares workspaces (whose packages bring their own
lifecycle scripts; a workspace postinstall ran under Bun 1.3.10) or that
has a bunfig.toml beside it (which reconfigures the install). The
advisory's workspace proof of concept is now skipped before bun runs.

Twelve new tests, all failing before this change; SECURITY.md, README and
SETUP describe the new checks.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Mads LorentzenandClaude Fable 5.1 authored and Mads Lorentzen committed 2026-10-27 16:14:35 +01:00
1 parent 2b8803f777
commit 0bcfd75e60
8 files changed
+306 -12

No files matched your search

+34
View File
@@ -497,6 +497,40 @@ per-file diff commands.
`gate()` now fails closed with `UNCONFIRMED` on any scheme but http(s) and on a URL without a
host, before curl runs, and curl itself gets `--proto =http,https --proto-redir =http,https`.
Reported privately by @Huge (Martin Vlach).
- **`security_guards.py` reads `.gitignore` the way git does and checks every `.gitignore` in the
tree** (`tools/security_guards.py` check 2, `tests/test_security_guards.py`) - the guard stripped
both ends of each line, but git drops only unescaped trailing whitespace: ` salary_data.json`
satisfied the required-rule check while git had stopped ignoring the file (the pattern now named
a file beginning with a space). And only the root file was read, though a `.gitignore` in a
subdirectory outranks it, so `documents/cv/.gitignore` holding `!personal_cv.pdf` re-included
personal data with CI green. Leading whitespace is now part of the pattern, and every
`.gitignore` below the root (outside `.git` and `node_modules`) is checked for negations; an
intentional one is allowlisted as `<dir>/.gitignore:!pattern`. Both need a change to a tracked
`.gitignore` that a reviewer would see, which is why the guard exists. Reported privately by
@Huge (Martin Vlach).
- **`security_guards.py` allows only the `settings.json` keys it checks** (`tools/security_guards.py`
check 1, `tests/test_security_guards.py`, `SECURITY.md`) - the guard verified `permissions.allow`,
`ask`, `deny` and `hooks` and ignored every other key, so a `settings.json` carrying `env`
(which reaches every pre-approved `python tools/*.py` run; none uses `-I`), `apiKeyHelper`, a
`statusLine` command, `enableAllProjectMcpServers`, `permissions.defaultMode: bypassPermissions`
or `permissions.additionalDirectories` passed CI with the lists intact and the prompts gone.
Any top-level key outside `ALLOWED_SETTINGS_KEYS` (`permissions`, `hooks`) and any
`permissions` key outside `ALLOWED_PERMISSIONS_KEYS` (`allow`, `ask`, `deny`) now fails; a new
key is added to the allowlist in the same PR, with its own check. Reported privately by
@folovco and @Huge (Martin Vlach).
- **Portal CLI installation never runs a lifecycle script, and refuses `workspaces` and
`bunfig.toml`** (#718 by @rudycelekli, `tools/install_portal_clis.py`, `tools/security_guards.py`
check 3, `tests/test_install_portal_hooks.py`, `tests/test_install_portal_clis.py`) - the
installer refused a manifest's own lifecycle scripts and `trustedDependencies`, then ran plain
`bun install`, which on Bun 1.3.x still runs the hooks of default-trusted dependencies (esbuild's
`postinstall` ran in the reproduction) and of workspace packages: a `workspaces` entry pulled in
sub-packages whose scripts the check never read, and a `bunfig.toml` beside the manifest
reconfigured the install itself (registry, scopes, trusted packages). Every install now passes
`--ignore-scripts` (verified against an offline registry serving a hooked package), the
installer skips a manifest that declares `workspaces` or has a `bunfig.toml` beside it, and the
in-tree guard forbids both. Reported privately by @folovco and @Huge (Martin Vlach).
- **The ask list now covers the files the allow list runs, and the guard reads load-time commands**
+1 -1
View File
@@ -120,7 +120,7 @@ python3 tools/install_portal_clis.py # Windows: python tools\install_portal_cl
<details>
<summary>What the installer does</summary>
It runs `bun install` in every portal CLI under `.agents/skills/*/cli/` - discovered, not listed, so a portal you add with `/add-portal` or copy from another fork is included on a fresh clone. A CLI whose `package.json` declares an install-time lifecycle script is skipped and reported rather than run. `--dry-run` shows what it would install.
It runs `bun install` in every portal CLI under `.agents/skills/*/cli/` - discovered, not listed, so a portal you add with `/add-portal` or copy from another fork is included on a fresh clone. Every install runs with `--ignore-scripts`, and a CLI whose `package.json` declares an install-time lifecycle script, `trustedDependencies` or `workspaces`, or that has a `bunfig.toml` beside it, is skipped and reported rather than run. `--dry-run` shows what it would install.
For `linkedin-search` and `freehire-search` the install is optional: both have zero runtime dependencies and run with plain `bun`; `bun install` only pulls TypeScript dev types.
+3 -1
View File
@@ -29,8 +29,10 @@ Every grant the template ships, where it lives, how long it lasts, and what chec
| Subagents (`.claude/agents/*.md`) | `posting-fetcher` (`/apply` Step 0), `posting-scorer` (`/rank` Step 2) and `application-reviewer` (`/apply` Step 3): Read, Glob/Grep where needed, WebFetch, WebSearch, Bash - never Edit, Write or Agent. Bash inside them is scoped by the allow list above, because an agent's `tools:` list is tool-name granularity. `/scrape`'s portal-search agents still run as `general-purpose`. | While the agent runs | Check 5: every agent must declare `tools:` drawn from `ALLOWED_AGENT_TOOLS`; a missing key (= every tool) or a `Bash(pattern)` entry fails CI, as do a `permissionMode` that removes prompts and agent `hooks` or `mcpServers` |
| The posting integrity gate (`tools/posting_guard.py`) | Runs on every posting before `/apply` Step 1 (URL or pasted), inside each `/rank` scorer, and on the archived posting in `/interview`: removes hidden HTML and invisible characters, quotes instruction-shaped lines and hidden URLs, lists mixed-script words, prints a one-line verdict. Advises; the user decides. The archive keeps counts, never the removed excerpts. | - | Pinned by `tests/test_posting_guard.py` and `tests/test_posting_gate_wiring.py` |
| Hooks (`.claude/settings.json` `hooks`, skill and command frontmatter `hooks`, !`command` lines) | None. The template ships no hooks: a hook runs with no prompt the moment its event fires, which is the vector the Shai-Hulud worm used in August 2026. A !`command` line in a skill or command runs when it loads, the same way. | - | Any of them fails CI |
| Every other `settings.json` key | None. `permissions` (with `allow`, `ask`, `deny`) and `hooks` are the whole file: `env` would reach every pre-approved `python tools/*.py` run, `apiKeyHelper` and a `statusLine` command run a program with no prompt, and `permissions.defaultMode` or `additionalDirectories` would widen what the lists above scope. | - | Any other top-level or `permissions` key fails CI (`ALLOWED_SETTINGS_KEYS`, `ALLOWED_PERMISSIONS_KEYS`) |
| Compiling (`lualatex`, `xelatex`, a custom template's command) | Not pre-approved: a `lualatex:*` grant would also approve `-shell-escape`. In default mode each compile prompts. Answering "don't ask again" would persist exactly that `lualatex` rule to your `settings.local.json`, so answer per call. | - | - |
| Portal CLI manifests (`.agents/**/package.json`) | No lifecycle scripts (`preinstall`, `postinstall`, ...) and no `trustedDependencies`, so `bun install` runs no code | - | Fails CI; `tools/install_portal_clis.py` also skips a manifest that carries one |
| Portal CLI manifests (`.agents/**/package.json`) | No lifecycle scripts (`preinstall`, `postinstall`, ...), no `trustedDependencies`, no `workspaces` (whose packages bring their own scripts) and no `bunfig.toml` beside the manifest (which reconfigures the install), so `bun install` runs no code; the installer passes `--ignore-scripts` as well | - | Fails CI; `tools/install_portal_clis.py` also skips a manifest that carries one |
| Personal-data ignore rules (`.gitignore`) | Every rule in `REQUIRED_IGNORE_RULES` present, read as git reads them (leading whitespace is part of the pattern); no negation outside `ALLOWED_IGNORE_NEGATIONS`, in the root file or in any subdirectory `.gitignore`, which outranks it | - | Fails CI |
| The browser-header retry (`tools/fetch_page.py`) | A GET with a fixed argv, http(s) only, 45 s / 5 redirects / 10 MB, `robots.txt` enforced before each redirect target with no bypass flag, output refused inside the repo | - | Pinned by `tests/test_fetch_page.py`; the grant names the script, not `curl` |
### What this does not do
+1 -1
View File
@@ -196,7 +196,7 @@ Run these from the repository root.
python3 tools/install_portal_clis.py # Windows: python tools\install_portal_clis.py
```
It runs `bun install` in every portal CLI under `.agents/skills/*/cli/` - discovered, not listed, so a portal you add with `/add-portal` or copy from another fork is included on a fresh clone. A CLI whose `package.json` declares an install-time lifecycle script is skipped and reported rather than run. `--dry-run` shows what it would install.
It runs `bun install` in every portal CLI under `.agents/skills/*/cli/` - discovered, not listed, so a portal you add with `/add-portal` or copy from another fork is included on a fresh clone. Every install runs with `--ignore-scripts`, and a CLI whose `package.json` declares an install-time lifecycle script, `trustedDependencies` or `workspaces`, or that has a `bunfig.toml` beside it, is skipped and reported rather than run. `--dry-run` shows what it would install.
For `linkedin-search` and `freehire-search` the install is optional: both have zero runtime dependencies and run with plain `bun`; `bun install` only pulls TypeScript dev types.
+21
View File
@@ -33,6 +33,7 @@ class FakeRun:
def __call__(self, argv, cwd):
self.calls.append(Path(cwd).parent.name)
self.argvs = getattr(self, "argvs", []) + [list(argv)]
return subprocess.CompletedProcess(argv, 1 if Path(cwd).parent.name in self.fail else 0)
@@ -70,6 +71,26 @@ class InstallPortalClis(unittest.TestCase):
def test_trusted_dependencies_is_unsafe(self):
self.assertIn("trustedDependencies", unsafe_reason(self.add_cli("x", {"trustedDependencies": ["y"]})))
# GHSA-wq7j-x5jq-pjrw: the gate read only the top-level manifest, but a
# `workspaces` entry pulls in sub-packages with their own lifecycle scripts
# (a workspace postinstall ran under Bun 1.3.10), and a bunfig.toml beside
# the manifest reconfigures the install itself (registry, scopes, cache).
def test_workspaces_is_unsafe(self):
self.assertIn("workspaces", unsafe_reason(self.add_cli("x", {"workspaces": ["packages/*"]})))
def test_bunfig_is_unsafe(self):
cli = self.add_cli("x")
(cli / "bunfig.toml").write_text('[install]\nregistry = "https://registry.example"\n', encoding="utf-8")
self.assertIn("bunfig.toml", unsafe_reason(cli))
def test_bun_install_never_runs_lifecycle_scripts(self):
# #718: --ignore-scripts on every install, so a dependency's hook stays
# off whatever the Bun release's default trust list says.
self.add_cli("a-search")
run = FakeRun()
self.assertEqual(install(discover(self.root), "bun", False, self.root, run), 0)
self.assertEqual(run.argvs, [["bun", "install", "--ignore-scripts"]])
def test_one_failure_does_not_stop_the_rest(self):
for name in ("a-search", "b-search", "c-search"):
self.add_cli(name)
+127
View File
@@ -724,6 +724,133 @@ class GitignoreNegationTests(GuardRepoFixture):
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
class GitignoreWhitespaceTests(GuardRepoFixture):
"""GHSA-rv99-7xf2-8f4p: git keeps leading whitespace in a pattern and drops
only unescaped trailing whitespace. The guard stripped both ends, so a
required rule prefixed with a space passed the guard while git had stopped
ignoring the file."""
def test_rule_with_leading_whitespace_fails(self):
rules = list(security_guards.REQUIRED_IGNORE_RULES)
rules[rules.index("salary_data.json")] = " salary_data.json"
self.write_gitignore(rules)
result = run_guards(self.root)
self.assertEqual(result.returncode, 1)
self.assertIn("required personal-data rule missing: 'salary_data.json'", result.stdout)
def test_rule_with_a_leading_tab_fails(self):
rules = list(security_guards.REQUIRED_IGNORE_RULES)
rules[rules.index("job_search_tracker.csv")] = "\tjob_search_tracker.csv"
self.write_gitignore(rules)
result = run_guards(self.root)
self.assertEqual(result.returncode, 1)
self.assertIn("required personal-data rule missing: 'job_search_tracker.csv'", result.stdout)
def test_trailing_whitespace_is_ignored_like_git_ignores_it(self):
rules = list(security_guards.REQUIRED_IGNORE_RULES)
rules[rules.index("salary_data.json")] = "salary_data.json "
self.write_gitignore(rules)
result = run_guards(self.root)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
def test_negation_with_leading_whitespace_is_still_a_negation(self):
# " !salary_data.json" is no negation for git either (it names a file
# beginning with a space), so it must not pass as an ordinary rule.
self.write_gitignore(list(security_guards.REQUIRED_IGNORE_RULES) + ["!salary_data.json "])
result = run_guards(self.root)
self.assertEqual(result.returncode, 1)
self.assertIn("negation rule not in the reviewed allowlist", result.stdout)
class SubdirectoryGitignoreTests(GuardRepoFixture):
"""GHSA-rv99-7xf2-8f4p: a .gitignore in a subdirectory takes precedence
over the root file, so a negation there re-includes personal data the
guard, which read only the root file, never saw."""
def test_subdirectory_gitignore_negation_fails(self):
sub = self.root / "documents" / "cv"
sub.mkdir(parents=True)
(sub / ".gitignore").write_text("!personal_cv.pdf\n", encoding="utf-8")
result = run_guards(self.root)
self.assertEqual(result.returncode, 1)
self.assertIn("negation rule not in the reviewed allowlist", result.stdout)
self.assertIn("!personal_cv.pdf", result.stdout)
self.assertIn("documents/cv/.gitignore", result.stdout.replace("\\", "/"))
def test_subdirectory_gitignore_without_negation_passes(self):
sub = self.root / "documents"
sub.mkdir(parents=True)
(sub / ".gitignore").write_text("*.tmp\n# a comment\n\n", encoding="utf-8")
result = run_guards(self.root)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
def test_root_allowlist_does_not_cover_a_subdirectory_negation(self):
# The allowlist is written for the root file; the same text in a
# subdirectory file is a different rule (relative to that directory).
sub = self.root / "documents"
sub.mkdir(parents=True)
(sub / ".gitignore").write_text("!cv/main_example.tex\n", encoding="utf-8")
result = run_guards(self.root)
self.assertEqual(result.returncode, 1)
self.assertIn("documents/.gitignore", result.stdout.replace("\\", "/"))
def test_gitignore_inside_node_modules_or_git_is_not_read(self):
for parts in (("node_modules", "pkg"), (".git", "info")):
sub = self.root.joinpath(*parts)
sub.mkdir(parents=True, exist_ok=True)
(sub / ".gitignore").write_text("!anything\n", encoding="utf-8")
result = run_guards(self.root)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
class SettingsKeyAllowlistTests(GuardRepoFixture):
"""GHSA-cqcv-crx7-x7q7: the guard checked allow, ask, deny and hooks and
nothing else, so a settings.json could add keys that run commands or turn
prompts off (env, apiKeyHelper, statusLine, permissions.defaultMode) and
still pass."""
def _settings_with(self, top=None, permissions=None):
data = json.loads(self.settings.read_text())
data.update(top or {})
data["permissions"].update(permissions or {})
self.settings.write_text(json.dumps(data))
def test_unrecognised_top_level_setting_fails(self):
for key, value in (
("env", {"PYTHONPATH": "/tmp/evil"}),
("apiKeyHelper", "/tmp/evil.sh"),
("statusLine", {"type": "command", "command": "/tmp/evil.sh"}),
("enableAllProjectMcpServers", True),
):
with self.subTest(key=key):
self.write_settings(sorted(security_guards.ALLOWED_PERMISSIONS))
self._settings_with(top={key: value})
result = run_guards(self.root)
self.assertEqual(result.returncode, 1, result.stdout)
self.assertIn("top-level setting not in the reviewed allowlist", result.stdout)
self.assertIn(key, result.stdout)
def test_unrecognised_permissions_setting_fails(self):
for key, value in (
("defaultMode", "bypassPermissions"),
("additionalDirectories", ["~"]),
):
with self.subTest(key=key):
self.write_settings(sorted(security_guards.ALLOWED_PERMISSIONS))
self._settings_with(permissions={key: value})
result = run_guards(self.root)
self.assertEqual(result.returncode, 1, result.stdout)
self.assertIn("permissions setting not in the reviewed allowlist", result.stdout)
self.assertIn(key, result.stdout)
def test_shipped_keys_pass(self):
# permissions with allow/ask/deny, and an empty hooks object, is the
# whole reviewed shape.
self._settings_with(top={"hooks": {}})
result = run_guards(self.root)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
class ManifestGuardTests(GuardRepoFixture):
def test_each_lifecycle_script_fails(self):
for script in sorted(security_guards.FORBIDDEN_SCRIPTS):
+13 -4
View File
@@ -13,10 +13,12 @@ One command for every shell, since Python is already a prerequisite - the
README previously carried a PowerShell and a Bash loop, each with its own
hand-copied list of portal names.
A manifest that declares an npm/bun lifecycle script or trustedDependencies is
skipped and reported instead of installed: those are the hooks that run code
during `bun install`, and tools/security_guards.py forbids them in-tree. A
borrowed portal that carries one gets read by a human before it runs.
A manifest that declares an npm/bun lifecycle script, trustedDependencies or
workspaces, or that has a bunfig.toml beside it, is skipped and reported
instead of installed: those are the doors that run code or reconfigure the
install during `bun install`, and tools/security_guards.py forbids them
in-tree. A borrowed portal that carries one gets read by a human before it
runs.
Every install also passes --ignore-scripts, including on older Bun releases
that run a built-in list of dependency hooks without trustedDependencies.
@@ -58,6 +60,13 @@ def unsafe_reason(cli_dir: Path) -> str | None:
return f"declares lifecycle script(s) {bad}"
if "trustedDependencies" in data:
return "declares trustedDependencies"
if "workspaces" in data:
# Workspace packages carry their own lifecycle scripts, which the check
# above never reads; a workspace postinstall ran under Bun 1.3.10.
return "declares workspaces"
if (cli_dir / "bunfig.toml").exists():
# Reconfigures the install itself: registry, scopes, trusted packages.
return "carries a bunfig.toml"
return None
+106 -5
View File
@@ -15,14 +15,20 @@ Checks:
which would auto-approve commands on every fork. The same file's `hooks`
key is held to an allowlist too: a hook runs automatically when its event
fires, with no prompt, so it is strictly more dangerous than a pre-approved
permission.
permission. No other top-level key, and no `permissions` key beyond allow,
ask and deny, may appear: `env`, `apiKeyHelper`, a `statusLine` command or
`permissions.defaultMode` would run code or switch prompts off behind the
lists this guard reads.
2. .gitignore — the personal-data ignore rules must all still be present,
and no un-allowlisted negation (!pattern) may re-include them. Catches
weakening that would make future users silently commit their tracker,
profile exports, or application archives.
profile exports, or application archives. Lines are read as git reads
them (leading whitespace is part of the pattern), and every .gitignore in
a subdirectory, which outranks the root file, is checked for negations.
3. .agents/**/package.json — no npm/bun lifecycle scripts (preinstall,
install, postinstall, prepare, prepack) and no trustedDependencies.
Catches code execution smuggled into `bun install`.
install, postinstall, prepare, prepack), no trustedDependencies, no
workspaces, and no bunfig.toml beside the manifest. Catches code
execution smuggled into `bun install`.
4. Skill and command frontmatter — every `allowed-tools` entry in
.claude/skills/*/SKILL.md, .agents/skills/*/SKILL.md and
.claude/commands/*.md must be in ALLOWED_SKILL_TOOLS (a portal skill may
@@ -233,7 +239,25 @@ ALLOWED_IGNORE_NEGATIONS = {
# https://research.jfrog.com/post/shai-hulud-is-back-august/
ALLOWED_HOOKS: set[str] = set()
# The only keys .claude/settings.json may carry, at the top level and inside
# `permissions`. Everything the guard does not read is a way around it: `env`
# reaches every allowlisted `python tools/*.py` run (PYTHONPATH, none of them
# runs with -I), `apiKeyHelper` and a `statusLine` command run a program with
# no prompt, `enableAllProjectMcpServers` auto-approves servers, and
# `permissions.defaultMode: bypassPermissions` or `additionalDirectories`
# widen what the allow/ask/deny lists scope. A new key goes here in the same
# PR, exactly as with ALLOWED_PERMISSIONS, so the widening is reviewable.
ALLOWED_SETTINGS_KEYS = {"permissions", "hooks"}
ALLOWED_PERMISSIONS_KEYS = {"allow", "ask", "deny"}
FORBIDDEN_SCRIPTS = {"preinstall", "install", "postinstall", "prepare", "prepack"}
# Manifest keys that pull code into `bun install` by another door: `workspaces`
# installs sub-packages with their own lifecycle scripts (a workspace
# postinstall ran under Bun 1.3.10, scripts the top-level check never saw), and
# `trustedDependencies` re-enables dependency scripts. A bunfig.toml beside the
# manifest reconfigures the install itself (registry, scopes, cache) and is
# refused for the same reason. tools/install_portal_clis.py skips all of these.
FORBIDDEN_MANIFEST_KEYS = {"trustedDependencies", "workspaces"}
# The exact `allowed-tools` entries a skill or command frontmatter may carry.
# Claude Code pre-approves these for the turn that invokes the skill, with no
@@ -454,6 +478,18 @@ def check_permissions() -> None:
errors.append(".claude/settings.json: top-level JSON value must be an object")
return
# Checked first: an unknown key is a setting the rest of this guard does
# not read, and some of them run commands or switch prompting off.
for key in sorted(set(data) - ALLOWED_SETTINGS_KEYS):
errors.append(
f".claude/settings.json: top-level setting not in the reviewed allowlist: {key!r}. "
"Only `permissions` and `hooks` are checked here; any other key (env, apiKeyHelper, "
"statusLine, enableAllProjectMcpServers, ...) can run a program or change what every "
"pre-approved tool sees, with no prompt, on every fork. If the key is intentional, "
"add it to ALLOWED_SETTINGS_KEYS in tools/security_guards.py in the same PR and "
"extend the guard to check its value."
)
# Checked before the permissions shape guards below, so a file that pairs a
# malformed permissions block with a hook cannot return early and skip this.
hooks = data.get("hooks", {})
@@ -477,6 +513,14 @@ def check_permissions() -> None:
if not isinstance(permissions, dict):
errors.append(".claude/settings.json: permissions must be an object")
return
for key in sorted(set(permissions) - ALLOWED_PERMISSIONS_KEYS):
errors.append(
f".claude/settings.json: permissions setting not in the reviewed allowlist: {key!r}. "
"`defaultMode: bypassPermissions` turns every prompt off and `additionalDirectories` "
"widens what the rules scope, so the allow, ask and deny lists are only as strong as "
"the keys beside them. If the key is intentional, add it to ALLOWED_PERMISSIONS_KEYS "
"in tools/security_guards.py in the same PR and extend the guard to check its value."
)
allow = permissions.get("allow", [])
if not isinstance(allow, list) or not all(isinstance(entry, str) for entry in allow):
errors.append(".claude/settings.json: permissions.allow must be a list of strings")
@@ -514,10 +558,34 @@ def check_permissions() -> None:
)
def _gitignore_lines(path: Path) -> list[str]:
"""The patterns in a .gitignore, read the way git reads them.
git drops only unescaped trailing whitespace; leading whitespace is part of
the pattern. The guard used to strip both ends, so ` salary_data.json`
satisfied the required-rule check while git had stopped ignoring the file
(it now matched only a name beginning with a space).
"""
return [
re.sub(r"(?<!\\)\s+$", "", line)
for line in path.read_text(encoding="utf-8").splitlines()
]
def _other_gitignores():
"""Every .gitignore below the root, skipping .git and installed dependencies."""
import os
for dirpath, dirnames, filenames in os.walk(ROOT):
dirnames[:] = sorted(d for d in dirnames if d not in (".git", "node_modules"))
if ".gitignore" in filenames and Path(dirpath) != ROOT:
yield Path(dirpath) / ".gitignore"
def check_gitignore() -> None:
path = ROOT / ".gitignore"
try:
lines = [line.strip() for line in path.read_text(encoding="utf-8").splitlines()]
lines = _gitignore_lines(path)
except OSError as exc:
errors.append(f".gitignore: unreadable: {exc}")
return
@@ -539,6 +607,27 @@ def check_gitignore() -> None:
"taking effect). If this negation is intentional, add it to "
"ALLOWED_IGNORE_NEGATIONS in tools/security_guards.py in the same PR."
)
# A .gitignore in a subdirectory outranks the root file for paths under it,
# so a negation there re-includes personal data the root rules excluded.
# The allowlist above is written for the root file (a pattern is relative
# to its own file), so an intentional negation in a subdirectory is listed
# as "<dir>/.gitignore:!pattern". None is shipped.
for sub in _other_gitignores():
rel = sub.relative_to(ROOT).as_posix()
try:
sub_lines = _gitignore_lines(sub)
except OSError as exc:
errors.append(f"{rel}: unreadable: {exc}")
continue
for line in sub_lines:
if line.startswith("!") and f"{rel}:{line}" not in ALLOWED_IGNORE_NEGATIONS:
errors.append(
f"{rel}: negation rule not in the reviewed allowlist: {line!r}. "
"A subdirectory .gitignore takes precedence over the root file, so this "
"re-includes paths the root rules exclude. If it is intentional, add "
f"{rel + ':' + line!r} to ALLOWED_IGNORE_NEGATIONS in "
"tools/security_guards.py in the same PR."
)
def check_package_manifests() -> None:
@@ -572,6 +661,18 @@ def check_package_manifests() -> None:
f"{relpath}: trustedDependencies is forbidden - it re-enables dependency "
"lifecycle scripts that bun blocks by default."
)
if "workspaces" in data:
errors.append(
f"{relpath}: workspaces is forbidden - each workspace package brings its own "
"lifecycle scripts, which this check never reads, into `bun install`."
)
bunfig = manifest.parent / "bunfig.toml"
if bunfig.exists():
errors.append(
f"{bunfig.relative_to(ROOT)}: a bunfig.toml beside a portal manifest is forbidden - "
"it reconfigures `bun install` itself (registry, scopes, trusted packages) for "
"every fork user."
)
def check_tracked_node_modules() -> None: