mirror of
https://github.com/MadsLorentzen/ai-job-search.git
synced 2026-11-02 13:12:50 +00:00
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:
1 parent
eb0fcd71fb
commit
9ca456318c
5 files changed
+145
-9
No files matched your search
@@ -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": [
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user