mirror of
https://github.com/MadsLorentzen/ai-job-search.git
synced 2026-11-06 23:17:53 +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
@@ -353,6 +353,40 @@ class SkillGrantGuardTests(GuardRepoFixture):
|
||||
self.assertIn("frontmatter", result.stdout)
|
||||
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):
|
||||
"""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)
|
||||
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):
|
||||
"""A hook in .claude/settings.json runs with no prompt when its event fires.
|
||||
|
||||
Reference in new issue
Block a user