diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5aea42c2..6cd91fd0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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 `
/.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**
diff --git a/README.md b/README.md
index fe17123d..24cd8f03 100644
--- a/README.md
+++ b/README.md
@@ -120,7 +120,7 @@ python3 tools/install_portal_clis.py # Windows: python tools\install_portal_cl
What the installer does
-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.
diff --git a/SECURITY.md b/SECURITY.md
index c7acd5ec..3a3684fd 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -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
diff --git a/SETUP.md b/SETUP.md
index 6aa053f6..f09bae3b 100644
--- a/SETUP.md
+++ b/SETUP.md
@@ -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.
diff --git a/tests/test_install_portal_clis.py b/tests/test_install_portal_clis.py
index 8da5f837..436665b2 100644
--- a/tests/test_install_portal_clis.py
+++ b/tests/test_install_portal_clis.py
@@ -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)
diff --git a/tests/test_security_guards.py b/tests/test_security_guards.py
index 0616f722..faa39f7a 100644
--- a/tests/test_security_guards.py
+++ b/tests/test_security_guards.py
@@ -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):
diff --git a/tools/install_portal_clis.py b/tools/install_portal_clis.py
index 732b989c..35edf99b 100644
--- a/tools/install_portal_clis.py
+++ b/tools/install_portal_clis.py
@@ -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
diff --git a/tools/security_guards.py b/tools/security_guards.py
index d9635bef..759149cf 100644
--- a/tools/security_guards.py
+++ b/tools/security_guards.py
@@ -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"(? 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 "/.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: