Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"$schema": "https://anthropic.com/claude-code/marketplace.schema.json",
"name": "agentfile",
"description": "Static analysis for the AI agent configuration a repository already has.",
"owner": {
"name": "Dennis Havermans",
"url": "https://github.com/dennishavermans"
},
"plugins": [
{
"name": "agentfile",
"description": "Catches agent configuration that grants more than it appears to: permission rules whose wildcards reach further than they read, broken skill references, and MCP servers that will silently fail to load. Reports after an edit; never blocks one.",
"category": "productivity",
"source": "./plugin",
"homepage": "https://github.com/dennishavermans/agentfile"
}
]
}
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -402,6 +402,35 @@ the first one's alerts.

---

## Claude Code plugin

The configuration agentfile reads is configuration someone is editing in Claude
Code, so the tool can speak up at the moment of the edit rather than waiting to
be remembered:

```bash
claude plugin marketplace add dennishavermans/agentfile
claude plugin install agentfile@agentfile
```

It installs two things.

A **skill** covering the permission-matching behaviour that makes a rule grant
more than it reads, so an agent writing `Bash(git * main)` knows what it is
approving before the rule exists.

A **hook** on `PostToolUse` that runs the analysis after an edit to
`CLAUDE.md`, `AGENTS.md`, `.mcp.json`, `.claude/` or `.cursor/`, and reports
only the findings in the file that changed. It informs and never blocks: a hook
that refuses a write on a warning gets uninstalled the same day, and a finding
is advice rather than a verdict. Every failure path exits 0, so a missing
binary cannot wedge an edit.

The hook prefers an `agentfile` on `PATH` and falls back to `npx`, so it works
offline once the CLI is installed.

---

## pre-commit

For the repositories that already run [pre-commit](https://pre-commit.com):
Expand Down
19 changes: 19 additions & 0 deletions packages/cli/__tests__/pre-commit-hooks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ const root = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
const version = JSON.parse(readFileSync(join(root, "packages", "cli", "package.json"), "utf-8")).version;
const hooks = readFileSync(join(root, ".pre-commit-hooks.yaml"), "utf-8");
const readme = readFileSync(join(root, "README.md"), "utf-8");
const pluginHook = readFileSync(join(root, "plugin", "hooks", "after_config_edit.py"), "utf-8");
const pluginManifest = JSON.parse(readFileSync(join(root, "plugin", ".claude-plugin", "plugin.json"), "utf-8"));

describe(".pre-commit-hooks.yaml", () => {
it("pins every hook to the current CLI version", () => {
Expand All @@ -31,3 +33,20 @@ describe(".pre-commit-hooks.yaml", () => {
expect(hooks).toContain("--strict");
});
});

/**
* The Claude Code plugin carries the same two drift points as the pre-commit
* hooks: a pinned CLI version and a version of its own. Same failure, same
* guard.
*/
describe("plugin/", () => {
it("pins the hook to the current CLI version", () => {
const pinned = [...pluginHook.matchAll(/@agentfile\/cli@(\S+?)"/g)].map((match) => match[1]);
expect(pinned.length, "no pinned entry found in the plugin hook").toBeGreaterThan(0);
for (const pin of pinned) expect(pin, "plugin hook pin is behind packages/cli").toBe(version);
});

it("keeps the plugin manifest version in step", () => {
expect(pluginManifest.version, "plugin.json is behind packages/cli").toBe(version);
});
});
10 changes: 10 additions & 0 deletions plugin/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"name": "agentfile",
"description": "Catches agent configuration that grants more than it appears to: permission rules whose wildcards reach further than they read, broken skill references, and MCP servers that will silently fail to load.",
"version": "2.7.0",
"author": {
"name": "Dennis Havermans"
},
"homepage": "https://github.com/dennishavermans/agentfile",
"license": "MIT"
}
108 changes: 108 additions & 0 deletions plugin/hooks/after_config_edit.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
#!/usr/bin/env python3
"""Report agentfile findings for a file the agent just edited.

Informs, never blocks. A hook that refuses a write on a warning gets
uninstalled the same day, and a finding is advice rather than a verdict:
static analysis cannot see intent. Every failure path exits 0 silently, so a
missing binary or an unreadable payload can never wedge an edit.
"""

from __future__ import annotations

import json
import os
import shutil
import subprocess
import sys

# Agent configuration only. Everything else is none of this hook's business.
SUFFIXES = ("CLAUDE.md", "AGENTS.md", ".mcp.json", "copilot-instructions.md")
DIRECTORIES = ("/.claude/", "/.cursor/")
LISTED = 5
PINNED = "@agentfile/cli@2.7.0"


def edited_path(payload: dict) -> str:
tool_input = payload.get("tool_input") or {}
return tool_input.get("file_path") or ""


def is_agent_configuration(path: str) -> bool:
if any(path.endswith(suffix) for suffix in SUFFIXES):
return True
return any(directory in path for directory in DIRECTORIES)


def command_for(root: str) -> list[str] | None:
"""An installed binary beats a package fetch: faster, and works offline."""
override = os.environ.get("AGENTFILE_BIN")
if override:
return [*override.split(), "doctor", "--root", root, "--format", "json"]
if shutil.which("agentfile"):
return ["agentfile", "doctor", "--root", root, "--format", "json"]
if shutil.which("npx"):
return ["npx", "--yes", PINNED, "doctor", "--root", root, "--format", "json"]
return None


def findings_for(report: dict, relative: str) -> list[dict]:
diagnostics = (report.get("report") or {}).get("diagnostics") or []
return [d for d in diagnostics if (d.get("location") or {}).get("file") == relative]


def render(findings: list[dict], relative: str) -> str:
lines = [f"agentfile on {relative}:"]
for finding in findings[:LISTED]:
line = (finding.get("location") or {}).get("line")
where = f":{line}" if line else ""
severity = finding.get("severity", "warning")
lines.append(f" {severity} {finding.get('code', '')}{where} {finding.get('message', '')}")
if len(findings) > LISTED:
lines.append(f" and {len(findings) - LISTED} more")
lines.append(f" Detail: agentfile explain {findings[0].get('code', '')}")
return "\n".join(lines)


def main() -> int:
try:
payload = json.load(sys.stdin)
except Exception:
return 0

edited = edited_path(payload)
if not edited or not is_agent_configuration(edited):
return 0

root = os.environ.get("CLAUDE_PROJECT_DIR") or os.getcwd()
command = command_for(root)
if command is None:
return 0

try:
completed = subprocess.run(command, capture_output=True, text=True, timeout=15)
report = json.loads(completed.stdout)
except Exception:
return 0

prefix = root.rstrip("/") + "/"
relative = edited[len(prefix):] if edited.startswith(prefix) else edited

findings = findings_for(report, relative)
if not findings:
return 0

print(
json.dumps(
{
"hookSpecificOutput": {
"hookEventName": "PostToolUse",
"additionalContext": render(findings, relative),
}
}
)
)
return 0


if __name__ == "__main__":
sys.exit(main())
17 changes: 17 additions & 0 deletions plugin/hooks/hooks.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"description": "Runs agentfile after an edit to agent configuration, and reports what it finds.",
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write|MultiEdit",
"hooks": [
{
"type": "command",
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/after_config_edit.py\"",
"timeout": 20
}
]
}
]
}
}
85 changes: 85 additions & 0 deletions plugin/skills/agent-config/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
---
name: agent-config
description: Use when writing or editing agent configuration, especially permission rules in settings.json, skills, hooks, or MCP servers. Covers the permission-matching behaviour that makes a rule grant more than it reads, and how to check configuration with agentfile.
---

# Writing agent configuration that means what it says

Permission rules are the part people get wrong, because a rule that looks
narrow can be wide. The behaviour below was measured on Claude Code 2.1.238
with a write probe, a command that creates a file, so the read-only classifier
could not approve it on its own.

## The one thing to know

**Everything before the first `*` is matched as written, and that prefix is the
only thing limiting the rule. The wildcard spans spaces.**

Nearly every dangerous rule follows from that sentence.

## Rule shapes that grant more than they read

**A wildcard where the subcommand goes leaves only the program.**

`Bash(git * main)` reads as "git something main". It approves every git
subcommand and every option before it. Measured with that rule as the only rule
present: `git branch -D main` deleted the branch, and
`git -c core.fsmonitor=<script> diff main` ran the named script. `-c
core.fsmonitor=` makes git run a program you name, so this rule is arbitrary
command execution.

**A leading wildcard leaves nothing at all.**

`Bash(* --version)` has no prefix, so nothing limits it. Measured: `bash -c
'touch <marker>' --version` ran and the marker appeared. The tail still has to
match, which is exactly what makes the rule read narrower than it is.

**A runner passes the wildcard to a shell.**

`Bash(npx *)`, `Bash(uvx *)` and friends approve any command, because the
runner executes its arguments. The rule limits the runner, not what runs.

**`gh api` writes look like reads.**

A rule shaped for fetching also approves mutation, because the method is a
flag. `-X`, `--method`, `-f`, `-F`, `--field`, `--raw-field` and `--input` all
turn a GET into a write, and short flags cluster, so `-fkey=value` is `-f`.

**Some rules approve nothing at all.**

`:*` is only recognised at the end of a pattern. A shell separator like `&&`
splits a command before rules are matched, so a rule containing one never
fires. These are the harmless half of the same misunderstanding, and they are
worth fixing because they give false confidence.

## When writing a rule

Name the program and the subcommand, and put the wildcard last where you can:
`Bash(git diff *)` rather than `Bash(git * main)`. One rule per thing you mean
to approve. Deny beats allow, so a deny rule is the reliable half of a pair.

## Checking configuration

Run the full analysis over the repository:

```bash
npx @agentfile/cli doctor
```

`doctor` runs every layer. The narrower verbs are subsets of it: `check` for
the fast structural pass suitable for CI, `lint` for quality, `audit` for the
security layer alone. To understand one finding:

```bash
npx @agentfile/cli explain AGF506
```

Run it after editing `settings.json`, a skill, a hook, or an MCP server, and
before committing agent configuration.

## Reading the result

A clean run means no pattern in agentfile's set matched the configuration it
could read. It is not a statement that the configuration is safe: static
analysis cannot see intent, cannot follow a variable, and cannot read a binary.
Nothing is executed to produce the result.
Loading