fix(guards): ask before editing the files the allow list runs; refuse load-time commands

settings.json pre-approves tools/*.py, every portal CLI and
salary_lookup.py by path, and the skills keep Edit/Write granted. In the
turn that reads a posting, an agent could rewrite one of those files and
then run it under its existing allow rule. SECURITY.md's guarantee that
an agent "cannot silently turn that into a wider grant" covered the files
that define permissions, not the files the permissions name.
Edit(tools/**), Edit(.agents/**) and Edit(salary_lookup.py) join the
required ask rules. CV, cover-letter and tracker writes are unaffected.

Check 4 now also refuses skill and command frontmatter `hooks` and
!`command` dynamic-context lines, both of which run at load with no
prompt; only settings.json hooks were checked. Check 5 refuses an agent
permissionMode that removes prompts, and agent hooks and mcpServers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
MaverickHQandClaude Opus 5.5 authored and Mads Lorentzen committed 2026-10-27 16:07:33 +01:00
1 parent eb0fcd71fb
commit 9ca456318c
5 files changed
+145 -9

No files matched your search

+3
View File
@@ -36,6 +36,9 @@
"Edit(.claude/agents/**)", "Edit(.claude/agents/**)",
"Edit(.github/**)", "Edit(.github/**)",
"Edit(tools/security_guards.py)", "Edit(tools/security_guards.py)",
"Edit(tools/**)",
"Edit(.agents/**)",
"Edit(salary_lookup.py)",
"Bash(git push:*)" "Bash(git push:*)"
], ],
"deny": [ "deny": [
+13
View File
@@ -480,6 +480,19 @@ per-file diff commands.
automated updates would freeze both legs. The test reads the workflow text with the stdlib, so automated updates would freeze both legs. The test reads the workflow text with the stdlib, so
it needs neither zizmor nor PyYAML. it needs neither zizmor nor PyYAML.
- **The ask list now covers the files the allow list runs, and the guard reads load-time commands**
(`.claude/settings.json`, `tools/security_guards.py` checks 4 and 5, `SECURITY.md`,
`tests/test_security_guards.py`) - `allow` pre-approves `tools/*.py`, every portal CLI and
`salary_lookup.py` by path, and the skills keep `Edit`/`Write` granted, so in the turn that
reads a posting an agent could rewrite one of those files and run it under its existing allow
rule: the "cannot silently turn that into a wider grant" guarantee covered the files that
define permissions but not the ones the permissions name. `Edit(tools/**)`, `Edit(.agents/**)`
and `Edit(salary_lookup.py)` join the required ask rules; CV, cover-letter and tracker writes
are unaffected. Check 4 now also refuses frontmatter `hooks` and !`command` dynamic-context
lines in skills and commands, which run at load with no prompt (SECURITY.md already said the
template ships no hooks; only settings.json hooks were checked). Check 5 refuses an agent
`permissionMode` that removes prompts and agent `hooks` or `mcpServers`.
## [1.7.3] - 2026-10-19 ## [1.7.3] - 2026-10-19
### Added ### Added
+4 -4
View File
@@ -21,14 +21,14 @@ Every grant the template ships, where it lives, how long it lasts, and what chec
| Surface | What is granted | Lifetime | Guard | | Surface | What is granted | Lifetime | Guard |
|---|---|---|---| |---|---|---|---|
| `.claude/settings.json` `permissions.allow` | Each shipped portal CLI by exact path, `salary_lookup.py`, the `tools/` scripts (`rank_state`, `job_key`, `verify_pdf`, `verify_layout`, `robots_check`, `fetch_page`), `pdftotext`, and `Skill(job-application-assistant)` | Whole session | Exact allowlist; any addition fails CI unless added to `ALLOWED_PERMISSIONS` in the same PR | | `.claude/settings.json` `permissions.allow` | Each shipped portal CLI by exact path, `salary_lookup.py`, the `tools/` scripts (`rank_state`, `job_key`, `verify_pdf`, `verify_layout`, `robots_check`, `fetch_page`), `pdftotext`, and `Skill(job-application-assistant)` | Whole session | Exact allowlist; any addition fails CI unless added to `ALLOWED_PERMISSIONS` in the same PR |
| `.claude/settings.json` `permissions.ask` | Always prompt, even over an allow rule or a skill grant: edits to `settings.json`, `settings.local.json`, any `.claude/skills/*/SKILL.md`, any `.claude/commands/*.md`, `.claude/agents/**`, `.github/**`, `tools/security_guards.py`; and `git push` | Whole session | Each rule is required; removal fails CI | | `.claude/settings.json` `permissions.ask` | Always prompt, even over an allow rule or a skill grant: edits to `settings.json`, `settings.local.json`, any `.claude/skills/*/SKILL.md`, any `.claude/commands/*.md`, `.claude/agents/**`, `.github/**`, `tools/security_guards.py`; edits to the files the allow list runs by path (`tools/**`, `.agents/**`, `salary_lookup.py`); and `git push` | Whole session | Each rule is required; removal fails CI |
| `.claude/settings.json` `permissions.deny` | Blocked in every mode, `bypassPermissions` included: `curl`/`wget` with data, form or upload flags, `scp`, `sftp`, `ssh`, `nc`; reads of `.env`, `.env.*`, `~/.ssh/**`, `~/.aws/**`, `~/.gnupg/**` (Read denies also cover Bash `cat`/`head`/`tail`/`sed`) | Whole session | Each rule is required; removal fails CI | | `.claude/settings.json` `permissions.deny` | Blocked in every mode, `bypassPermissions` included: `curl`/`wget` with data, form or upload flags, `scp`, `sftp`, `ssh`, `nc`; reads of `.env`, `.env.*`, `~/.ssh/**`, `~/.aws/**`, `~/.gnupg/**` (Read denies also cover Bash `cat`/`head`/`tail`/`sed`) | Whole session | Each rule is required; removal fails CI |
| Skill `allowed-tools` (`.claude/skills/*/SKILL.md`) | `job-application-assistant`: Read, Glob, Grep, WebFetch, WebSearch, Edit, Write, AskUserQuestion. `scrape`: the same plus Agent, `bun --version`, `tools/job_key.py`. `upskill`: Read, Write, Glob, Grep, WebFetch, WebSearch. No bare `Bash` anywhere. | The turn that invokes the skill; clears on your next message. Not gated by workspace trust. | Exact allowlist (`ALLOWED_SKILL_TOOLS`); bare tools and wildcards fail CI | | Skill `allowed-tools` (`.claude/skills/*/SKILL.md`) | `job-application-assistant`: Read, Glob, Grep, WebFetch, WebSearch, Edit, Write, AskUserQuestion. `scrape`: the same plus Agent, `bun --version`, `tools/job_key.py`. `upskill`: Read, Write, Glob, Grep, WebFetch, WebSearch. No bare `Bash` anywhere. | The turn that invokes the skill; clears on your next message. Not gated by workspace trust. | Exact allowlist (`ALLOWED_SKILL_TOOLS`); bare tools and wildcards fail CI |
| Portal skill `allowed-tools` (`.agents/skills/<name>/SKILL.md`) | Exactly that portal's own `cli/src/cli.ts` and nothing else | Invoking turn | Checked by path, so a skill that grants another portal's CLI, or a glob, fails CI | | Portal skill `allowed-tools` (`.agents/skills/<name>/SKILL.md`) | Exactly that portal's own `cli/src/cli.ts` and nothing else | Invoking turn | Checked by path, so a skill that grants another portal's CLI, or a glob, fails CI |
| Slash commands (`.claude/commands/*.md`) | No `allowed-tools` frontmatter; they run under the session's rules above | - | Same frontmatter check as skills | | Slash commands (`.claude/commands/*.md`) | No `allowed-tools` frontmatter; they run under the session's rules above | - | Same frontmatter check as skills |
| 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 | | 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` | | 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`) | 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. | - | Any hook fails CI | | 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 |
| 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. | - | - | | 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`, ...) and no `trustedDependencies`, so `bun install` runs no code | - | Fails CI; `tools/install_portal_clis.py` also skips a manifest that carries one |
| 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` | | 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` |
@@ -40,7 +40,7 @@ Every grant the template ships, where it lives, how long it lasts, and what chec
- **WebFetch is unrestricted by domain.** Company research needs it, and a GET to an arbitrary host can carry data in the URL. The instruction-level rule (never fetch URLs from the posting body) is what stands between a hidden link and a fetch; the permission layer does not. - **WebFetch is unrestricted by domain.** Company research needs it, and a GET to an arbitrary host can carry data in the URL. The instruction-level rule (never fetch URLs from the posting body) is what stands between a hidden link and a fetch; the permission layer does not.
- **`/rank` scorers still hold the rubric and the posting in one context.** Each scoring agent fetches and scores a batch, so the candidate rubric and the untrusted posting share a context that also has WebFetch. What the agent definition removes is Edit, Write and unscoped shell. Fully separating fetch from scoring would route every posting through the orchestrator, which `/rank` is built to avoid. - **`/rank` scorers still hold the rubric and the posting in one context.** Each scoring agent fetches and scores a batch, so the candidate rubric and the untrusted posting share a context that also has WebFetch. What the agent definition removes is Edit, Write and unscoped shell. Fully separating fetch from scoring would route every posting through the orchestrator, which `/rank` is built to avoid.
- **The gate is deterministic, not an oracle.** `posting_guard.py` catches the payload classes that leave a trace: hidden markup, invisible characters, agent-addressed phrasing, hidden links, look-alike letters. A manipulation written to read naturally to a human and an agent alike leaves none, and the instruction-level rules remain the only defence for that. - **The gate is deterministic, not an oracle.** `posting_guard.py` catches the payload classes that leave a trace: hidden markup, invisible characters, agent-addressed phrasing, hidden links, look-alike letters. A manipulation written to read naturally to a human and an agent alike leaves none, and the instruction-level rules remain the only defence for that.
- **Edit and Write are what the skill does.** Tailoring a CV means editing files, so those tools stay granted. What the `ask` list guarantees is that an agent cannot silently turn that into a wider grant: the files that define permissions always prompt. - **Edit and Write are what the skill does.** Tailoring a CV means editing files, so those tools stay granted. What the `ask` list guarantees is that an agent cannot silently turn that into a wider grant: the files that define permissions always prompt, and so do the files the allow list pre-approves to run, since rewriting one would turn its allow rule into whatever was written.
Instruction-level defenses raise the bar; permission-level defenses bound the damage. Neither is a guarantee. If you run this workflow against job boards you do not trust at all, review what the agent fetched and wrote before sending anything out. Instruction-level defenses raise the bar; permission-level defenses bound the damage. Neither is a guarantee. If you run this workflow against job boards you do not trust at all, review what the agent fetched and wrote before sending anything out.
+66
View File
@@ -353,6 +353,40 @@ class SkillGrantGuardTests(GuardRepoFixture):
self.assertIn("frontmatter", result.stdout) self.assertIn("frontmatter", result.stdout)
self.assertNotIn("Traceback", result.stderr) self.assertNotIn("Traceback", result.stderr)
def test_skill_hooks_frontmatter_fails(self):
# A skill-scoped hook runs while the skill is active, with no prompt.
self.write_skill(
self.core_skill,
"Read",
extra_frontmatter="hooks:\n PreToolUse:\n - hooks:\n - type: command\n command: ./x.sh\n",
)
result = run_guards(self.root)
self.assertEqual(result.returncode, 1)
self.assertIn("frontmatter `hooks`", result.stdout)
self.assertIn(".claude/skills/example/SKILL.md", result.stdout.replace("\\", "/"))
def test_dynamic_context_line_fails_in_skills_and_commands(self):
# !`command` runs at load and splices its output in, with no permission check.
line = "Today's tracker: !`cat job_scraper/seen_jobs.json`\n"
for path in (self.core_skill, self.command):
with self.subTest(path=path.name):
original = path.read_text(encoding="utf-8")
path.write_text(original + line, encoding="utf-8")
result = run_guards(self.root)
path.write_text(original, encoding="utf-8")
self.assertEqual(result.returncode, 1)
self.assertIn("dynamic-context line", result.stdout)
self.assertIn("!`cat job_scraper/seen_jobs.json`", result.stdout)
def test_inline_code_and_exclamation_marks_pass(self):
self.core_skill.write_text(
self.core_skill.read_text(encoding="utf-8")
+ "Run `python3 tools/job_key.py` first! Then `!important` is CSS, not a command.\n",
encoding="utf-8",
)
result = run_guards(self.root)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
class AgentGrantGuardTests(GuardRepoFixture): class AgentGrantGuardTests(GuardRepoFixture):
"""A .claude/agents/*.md definition's `tools:` list is an allowlist at tool-name """A .claude/agents/*.md definition's `tools:` list is an allowlist at tool-name
@@ -403,6 +437,38 @@ class AgentGrantGuardTests(GuardRepoFixture):
result = run_guards(self.root) result = run_guards(self.root)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr) self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
def write_agent_with(self, extra):
self.agent.write_text(
"---\nname: example\ndescription: An example agent that reads postings.\n"
f"tools: Read, Grep, WebFetch\n{extra}---\n\nYou read postings and report.\n",
encoding="utf-8",
)
def test_agent_permission_mode_that_removes_prompts_fails(self):
for mode in ("bypassPermissions", "acceptEdits", "dontAsk", "auto"):
for spelling in ("permissionMode", "permission-mode"):
with self.subTest(mode=mode, key=spelling):
self.write_agent_with(f"{spelling}: {mode}\n")
result = run_guards(self.root)
self.assertEqual(result.returncode, 1)
self.assertIn("permissionMode", result.stdout)
self.assertIn(mode, result.stdout)
def test_agent_permission_mode_that_still_prompts_passes(self):
for mode in ("default", "plan", "'default'"):
with self.subTest(mode=mode):
self.write_agent_with(f"permissionMode: {mode}\n")
result = run_guards(self.root)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
def test_agent_hooks_and_mcp_servers_fail(self):
for key in ("hooks", "mcpServers"):
with self.subTest(key=key):
self.write_agent_with(f"{key}:\n example: {{}}\n")
result = run_guards(self.root)
self.assertEqual(result.returncode, 1)
self.assertIn(f"`{key.lower()}`", result.stdout)
class HookGuardTests(GuardRepoFixture): class HookGuardTests(GuardRepoFixture):
"""A hook in .claude/settings.json runs with no prompt when its event fires. """A hook in .claude/settings.json runs with no prompt when its event fires.
+59 -5
View File
@@ -29,14 +29,19 @@ Checks:
instead grant exactly its own CLI). Claude Code pre-approves these for the instead grant exactly its own CLI). Claude Code pre-approves these for the
turn that invokes the skill, with no prompt, regardless of workspace trust, turn that invokes the skill, with no prompt, regardless of workspace trust,
so the frontmatter is a second permission allowlist. Catches bare Bash and so the frontmatter is a second permission allowlist. Catches bare Bash and
glob grants. The settings.json `ask` and `deny` lists are also required to glob grants. The same files may carry no frontmatter `hooks` and no
keep every rule in REQUIRED_ASK_RULES / REQUIRED_DENY_RULES. !`command` dynamic-context line: both run a command when the skill or
command loads, before any prompt. The settings.json `ask` and `deny` lists
are also required to keep every rule in REQUIRED_ASK_RULES /
REQUIRED_DENY_RULES.
5. Agent definitions — every .claude/agents/*.md must declare `tools:` (a 5. Agent definitions — every .claude/agents/*.md must declare `tools:` (a
missing key means every tool) and each entry must be in missing key means every tool) and each entry must be in
ALLOWED_AGENT_TOOLS, which has no Edit, Write, NotebookEdit or Agent: the ALLOWED_AGENT_TOOLS, which has no Edit, Write, NotebookEdit or Agent: the
shipped agents read untrusted postings. `tools:` is tool-name granularity shipped agents read untrusted postings. `tools:` is tool-name granularity
(a `Bash(pattern)` entry resolves to plain Bash), so a patterned entry is (a `Bash(pattern)` entry resolves to plain Bash), so a patterned entry is
rejected as misleading; settings.json's allow list is what scopes Bash. rejected as misleading; settings.json's allow list is what scopes Bash. A
`permissionMode` must be one that still prompts (ALLOWED_AGENT_PERMISSION_MODES),
and `hooks` and `mcpServers` are refused.
Stdlib only. Exit 0 on success, 1 with a failure list otherwise. Stdlib only. Exit 0 on success, 1 with a failure list otherwise.
""" """
@@ -106,6 +111,12 @@ REQUIRED_ASK_RULES = [
"Edit(.claude/agents/**)", "Edit(.claude/agents/**)",
"Edit(.github/**)", "Edit(.github/**)",
"Edit(tools/security_guards.py)", "Edit(tools/security_guards.py)",
# The files `allow` pre-approves to run, by path: editing one silently
# turns its allow rule into "run whatever is written here", which is the
# same widening. Every portal CLI lives under .agents/.
"Edit(tools/**)",
"Edit(.agents/**)",
"Edit(salary_lookup.py)",
"Bash(git push:*)", "Bash(git push:*)",
] ]
@@ -255,6 +266,20 @@ ALLOWED_SKILL_TOOLS = {
# `Bash(echo *)` entry gives the agent plain Bash. # `Bash(echo *)` entry gives the agent plain Bash.
ALLOWED_AGENT_TOOLS = {"Read", "Glob", "Grep", "WebFetch", "WebSearch", "Bash"} ALLOWED_AGENT_TOOLS = {"Read", "Glob", "Grep", "WebFetch", "WebSearch", "Bash"}
# An agent's permissionMode applies to everything it does, so only modes that
# still prompt: acceptEdits, auto, dontAsk and bypassPermissions each remove
# the prompts this file's allowlists assume.
ALLOWED_AGENT_PERMISSION_MODES = {"default", "manual", "plan"}
# A skill or command line of the form !`command` runs the command when the
# skill loads and splices its output into the prompt, with no permission check.
DYNAMIC_CONTEXT = re.compile(r"!`[^`\n]+`")
def _key(name: str) -> str:
"""Frontmatter key spelling-insensitive: permissionMode, permission-mode, ..."""
return name.lower().replace("-", "").replace("_", "")
_FRONTMATTER_KEY = re.compile(r"^([A-Za-z0-9_-]+):\s*(.*)$") _FRONTMATTER_KEY = re.compile(r"^([A-Za-z0-9_-]+):\s*(.*)$")
@@ -343,10 +368,24 @@ def check_skill_tool_grants() -> None:
for path in skills + commands: for path in skills + commands:
rel = path.relative_to(ROOT).as_posix() rel = path.relative_to(ROOT).as_posix()
try: try:
data = _frontmatter(path.read_text(encoding="utf-8")) text = path.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError) as exc: except (OSError, UnicodeDecodeError) as exc:
errors.append(f"{rel}: unreadable (must be UTF-8): {exc}") errors.append(f"{rel}: unreadable (must be UTF-8): {exc}")
continue continue
for match in DYNAMIC_CONTEXT.finditer(text):
errors.append(
f"{rel}: dynamic-context line {match.group(0)!r}. Claude Code runs a !`command` "
"when the skill or command loads and splices its output into the prompt, with no "
"permission check, so this is a pre-approved shell command. Move it into a step "
"the model runs, where settings.json decides."
)
data = _frontmatter(text)
if data is not None and "hooks" in {_key(k) for k in data}:
errors.append(
f"{rel}: frontmatter `hooks`. A skill or command hook runs its command with no "
"prompt whenever its event fires while the skill is active; the template ships no "
"hooks (SECURITY.md), and settings.json hooks are held to ALLOWED_HOOKS."
)
if data is None: if data is None:
if path in commands: if path in commands:
continue # commands may omit frontmatter entirely continue # commands may omit frontmatter entirely
@@ -573,6 +612,20 @@ def check_agent_tool_grants() -> None:
if data is None: if data is None:
errors.append(f"{rel}: missing or unterminated frontmatter (expected --- ... ---)") errors.append(f"{rel}: missing or unterminated frontmatter (expected --- ... ---)")
continue continue
keyed = {_key(k): v for k, v in data.items()}
mode = keyed.get("permissionmode")
if mode is not None and str(mode).strip().strip("'\"") not in ALLOWED_AGENT_PERMISSION_MODES:
errors.append(
f"{rel}: permissionMode {mode!r}. An agent's mode applies to every call it makes, "
"and this one removes prompts the allowlists here assume; use one of "
f"{sorted(ALLOWED_AGENT_PERMISSION_MODES)} or omit the key."
)
for key, why in (
("hooks", "runs its command with no prompt whenever its event fires inside the agent"),
("mcpservers", "connects a server whose tools no allowlist here has reviewed"),
):
if key in keyed:
errors.append(f"{rel}: frontmatter `{key}`: an agent-scoped entry {why}.")
if "tools" not in data or not _tool_entries(data.get("tools")): if "tools" not in data or not _tool_entries(data.get("tools")):
errors.append( errors.append(
f"{rel}: no `tools:` key - an agent without one gets every tool, including " f"{rel}: no `tools:` key - an agent without one gets every tool, including "
@@ -613,7 +666,8 @@ def main() -> int:
return 1 return 1
print( print(
"security_guards: OK (permissions allowlist, hooks allowlist, gitignore rules, " "security_guards: OK (permissions allowlist, hooks allowlist, gitignore rules, "
"package manifests, no tracked node_modules, skill/command allowed-tools, agent tools)" "package manifests, no tracked node_modules, skill/command allowed-tools, "
"load-time commands, agent tools and modes)"
) )
return 0 return 0