A Typo in agent.md Quietly Widened My Permissions — Writing a Strict Frontmatter Lint
Misspelled keys in agent.md frontmatter do not raise errors. They fall back to defaults, and for permission fields that fallback points the wrong way. Here is the failure I hit, the lint I wrote to catch it, and what the measurements showed.
A subagent whose only job was assembling release notes somehow had the full MCP tool list available.
This is a setup I run as an indie developer, and I try to keep subagent permissions as narrow as they can be. I had never intended to give that one MCP access. I was fairly sure I had written it down. Opening the file confirmed it — the line was right there.
inheritMCP: false
MCP instead of Mcp. A single character of casing.
That one character had inverted my intent completely.
Nothing fails, so nothing tells you
The awkward part is that the config file loaded fine. No startup warning, no degraded mode. The agent came up and behaved normally.
The reason is simple: the frontmatter is perfectly valid YAML. I confirmed it directly.
To the parser, inheritMCP is just a valid key. The false value was read correctly. It is simply that nothing ever goes looking for that name.
The consumer looks up inheritMcp, finds nothing, and applies the default. My false sits in the file, syntactically perfect, never once consulted.
The fallback pointed the wrong way
This is where reality diverged from what I had assumed.
I had a vague belief that configuration mistakes fail safe — that an unreadable setting would resolve toward the stricter option. In practice, an unspecified key resolves to whatever default is most convenient. And for permission settings, convenient almost always means permissive.
Key
What I intended
What the typo actually applied
Direction
inheritMcp
false (no MCP inheritance)
Default (inherits)
Permissions widen
commandExecutionPolicy
Something like deny
Default (ask)
Execution path remains
hidden
true (keep it out of listings)
Default (visible)
Exposure increases
So the lines that restrict are precisely the ones a typo silently cancels. Lines that loosen point the same way as the defaults, which means a typo there produces no symptom at all — and no opportunity to notice.
Syntax errors get reported. Semantic mistakes do not. That is true of configuration in general, but when the subject is permissions the cost of the asymmetry changes.
One caveat worth stating plainly: the specific default values can shift between versions. The table above reflects what I observed in my own environment, so verify against your version before relying on it. The lint below keeps those defaults as a clearly marked constant for exactly this reason.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦Why a misspelled key becomes a silent default fallback rather than an error, and why that direction favors wider permissions
✦A ~120-line lint (PyYAML + difflib) that escalates only permission-relevant typos to ERROR and leaves the rest as WARN
✦Measured results: 6 errors across 7 real files, ~100 ms for 200 files, and the thresholds I settled on after weeks of use
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
I wrote a lint. It reads each agent.md, parses the frontmatter, and checks it against a known schema.
The principle I cared most about: do not treat every unknown key with equal weight.
People put notes in frontmatter. People write keys ahead of a version that supports them. Flagging all of that as an error trains developers to ignore the lint, and an ignored check is no check at all.
So the lint only treats an unknown key as a suspected typo when it is close to a known key, and only escalates to ERROR when that known key governs permissions.
#!/usr/bin/env python3"""Strict validation for agent.md frontmatter.Unknown keys are silently ignored, so a misspelled permission keypasses through as a default fallback rather than an error.This catches that in CI."""import sys, os, glob, difflib, timeimport yamlKNOWN_KEYS = { "mainAgent": bool, "subagent": bool, "hidden": bool, "inheritMcp": bool, "commandExecutionPolicy": str, "model": str,}# Values actually applied when a key is missing or misspelled.# These shift between versions — update to match your environment.DEFAULTS = { "mainAgent": False, "subagent": False, "hidden": False, "inheritMcp": True, "commandExecutionPolicy": "ask", "model": "inherit",}POLICY_VALUES = {"ask", "allow", "deny"}SECURITY_KEYS = {"inheritMcp", "commandExecutionPolicy", "hidden"}RISK = {"allow": 2, "ask": 1, "deny": 0}def split_frontmatter(text): if not text.startswith("---"): return None, text, "no frontmatter" end = text.find("\n---", 3) if end == -1: return None, text, "unterminated frontmatter" try: data = yaml.safe_load(text[3:end]) except yaml.YAMLError as e: return None, text[end + 4:], f"YAML parse error: {e}" if data is None: data = {} if not isinstance(data, dict): return None, text[end + 4:], "frontmatter is not a map" return data, text[end + 4:], None
The per-file check is where the actual logic lives.
def lint_file(path): findings = [] with open(path, encoding="utf-8") as f: text = f.read() data, body, err = split_frontmatter(text) if err: return [("ERROR", err)], {} for key, value in data.items(): if key in KNOWN_KEYS: expected = KNOWN_KEYS[key] if not isinstance(value, expected): findings.append( ("ERROR", f"{key}: expected {expected.__name__}, got {value!r}")) continue # Infer typos by edit distance against known keys. # cutoff=0.6 catches inherit_mcp and inheritMCP without # matching unrelated keys like memo or owner. near = difflib.get_close_matches(key, KNOWN_KEYS, n=1, cutoff=0.6) if near: suggestion = near[0] level = "ERROR" if suggestion in SECURITY_KEYS else "WARN" findings.append((level, f"unknown key {key!r} -> likely typo of {suggestion!r}; " f"currently applying {suggestion}={DEFAULTS[suggestion]!r}")) else: findings.append(("WARN", f"unknown key {key!r} (ignored)")) policy = data.get("commandExecutionPolicy") if policy is not None and policy not in POLICY_VALUES: findings.append(("ERROR", f"commandExecutionPolicy: unknown value {policy!r}; " f"allowed {sorted(POLICY_VALUES)}; falling back to 'ask'")) policy = None if "# " not in body: findings.append(("WARN", "no H1 heading; check the system prompt delimiter")) # Rebuild the effective config. Wrongly typed keys are discarded. effective = dict(DEFAULTS) for k, v in data.items(): if k in KNOWN_KEYS and isinstance(v, KNOWN_KEYS[k]): effective[k] = v if policy is None: effective["commandExecutionPolicy"] = DEFAULTS["commandExecutionPolicy"] if effective["commandExecutionPolicy"] == "allow" and effective["inheritMcp"]: findings.append(("ERROR", "commandExecutionPolicy=allow together with inheritMcp=true: " "inherited MCP tools become callable without approval")) return findings, effective
That last combination check deserves a word of explanation.
Both allow and inheritMcp are legitimate on their own. Prompting for approval on every step of a benchmark run is impractical, and inheriting MCP is an ordinary setup.
The problem is the product of the two: an agent that runs commands without approval also reaches the entire inherited MCP toolset. Looking at either field alone, nothing seems wrong. You only see it after reconstructing the effective config.
Printing the effective config changed how I used it
The check itself mattered less than I expected. The output format mattered more.
While the lint only printed violations, I could tell what to fix but not what the overall configuration currently permitted. So I made it always print every file's effective config, sorted by risk.
def main(paths): t0 = time.perf_counter() errors = warns = 0 inventory = [] for path in sorted(paths): findings, effective = lint_file(path) name = os.path.basename(path) for level, msg in findings: print(f"{level:5} {name}: {msg}") if level == "ERROR": errors += 1 else: warns += 1 if effective: inventory.append((name, effective)) elapsed = (time.perf_counter() - t0) * 1000 print("\n--- permission surface (highest risk first) ---") inventory.sort(key=lambda r: -RISK.get(r[1]["commandExecutionPolicy"], 1)) print(f"{'file':20} {'policy':8} {'inheritMcp':11} {'hidden':7} model") for name, e in inventory: print(f"{name:20} {e['commandExecutionPolicy']:8} " f"{str(e['inheritMcp']):11} {str(e['hidden']):7} {e['model']}") print(f"\n{len(paths)} files / ERROR {errors} / WARN {warns} / {elapsed:.1f} ms") return 1 if errors else 0if __name__ == "__main__": sys.exit(main(sys.argv[1:] or sorted(glob.glob("agents/*.md"))))
Running it against seven real files
Here is the output against my own agents/ directory.
WARN bench.md: no H1 heading; check the system prompt delimiter
ERROR bench.md: commandExecutionPolicy=allow together with inheritMcp=true: inherited MCP tools become callable without approval
ERROR migration.md: commandExecutionPolicy: unknown value 'allways'; allowed ['allow', 'ask', 'deny']; falling back to 'ask'
ERROR release-notes.md: unknown key 'inheritMCP' -> likely typo of 'inheritMcp'; currently applying inheritMcp=True
ERROR release-notes.md: commandExecutionPolicy=allow together with inheritMcp=true: inherited MCP tools become callable without approval
ERROR triage.md: unknown key 'inherit_mcp' -> likely typo of 'inheritMcp'; currently applying inheritMcp=True
ERROR triage.md: unknown key 'commandexecutionpolicy' -> likely typo of 'commandExecutionPolicy'; currently applying commandExecutionPolicy='ask'
--- permission surface (highest risk first) ---
file policy inheritMcp hidden model
bench.md allow True False flash
release-notes.md allow True False flash
doc-sync.md ask False True flash
migration.md ask True False inherit
orchestrator.md ask True False pro
reviewer.md ask True False pro
triage.md ask True False flash
7 files / ERROR 6 / WARN 1 / 3.9 ms
Six errors across seven files. As the person who wrote all seven, that was a humbling number.
Three distinct failure modes are mixed together in there.
inheritMCP and inherit_mcp are casing and snake_case slips. Reaching for inherit_mcp out of YAML habit is not, I suspect, a mistake unique to me. The all-lowercase commandexecutionpolicy is the same category.
allways is a misspelled value. The key name is correct, so any check that only inspects key names walks right past it. You need the allowed-value set to catch it.
Then the two combination violations. bench.md was a deliberate allow; release-notes.md arrived at allow plus inheritance through a typo. Identical message, entirely different cause.
That last distinction only became visible once I printed effective configs. Reading the violation list alone, both looked like the same problem.
Scaling to 200 files
Since this was headed for CI, I wanted timing data. I duplicated the seven files up to 200 and ran it three times.
Corpus
Files
ERROR
WARN
Elapsed
Real agents/
7
6
1
3.9 ms
Synthetic, run 1
200
171
28
103.7 ms
Synthetic, run 2
200
171
28
99.1 ms
Synthetic, run 3
200
171
28
102.9 ms
Roughly 0.5 ms per file. Two hundred files land near a tenth of a second, which is invisible inside a pre-commit hook.
I had expected difflib.get_close_matches to dominate, since it does repeated string comparison. It did not register — the candidate vocabulary is only six keys, and file I/O plus YAML parsing account for most of the time.
Worth stating: the synthetic corpus is duplicated from the same seven files, so the 171 errors carry no meaning. Only the timing curve does. When you measure on synthetic data, write that caveat down somewhere durable, or the number escapes into contexts it does not belong in.
The threshold was far less sensitive than expected
I had set cutoff to 0.6 on instinct, picturing a delicate dial: nudge it down and false positives creep in, nudge it up and real typos slip through.
To check, I assembled 16 typos I had actually made or could plausibly make, plus 14 unrelated keys that might reasonably share a frontmatter block, and swept the threshold.
cutoff
Typo detection
False positives
Missed
0.4
100.0% (16/16)
42.9% (6/14)
—
0.5
100.0% (16/16)
0.0% (0/14)
—
0.6
100.0% (16/16)
0.0% (0/14)
—
0.7
100.0% (16/16)
0.0% (0/14)
—
0.8
100.0% (16/16)
0.0% (0/14)
—
0.85
75.0% (12/16)
0.0% (0/14)
inheritMCP, Hidden, Model, main_agent
0.9
50.0% (8/16)
0.0% (0/14)
the above plus inherit_mcp, subAgent, and others
From 0.5 through 0.8, neither number moves at all. Not a dial — a plateau.
That contradicted what I believed. I had assumed 0.8 would drop inherit_mcp; it catches everything. Degradation starts at 0.85, and even at 0.9 the false positive count stays at zero.
The identity of the first casualties surprised me too. inheritMCP, Hidden, and Model differ from their targets only in capitalization. difflib.SequenceMatcher is case-sensitive, so a casing flip counts as an ordinary character mismatch rather than a near-match — and on short keys, one character moves the ratio a long way.
Here is what the 0.4 false positives actually were.
Unrelated key
Wrongly suggested
Ratio
memo
model
0.444
note
model
0.444
owner
model
0.400
source
subagent
0.429
license
hidden
0.462
priority
inheritMcp
0.444
Every false positive sits in a narrow 0.40–0.47 band. There is a clear valley between them and genuine typos.
So in this case any value from 0.5 to 0.8 behaves identically. Choosing 0.6 means nothing more than sitting in the middle of the plateau. The six-key vocabulary is doing most of the work here, so this needs re-measuring the moment the schema grows.
The useful part was not that the guess held up. It was learning that my reasoning for it had been wrong — had I kept believing it was a delicate dial, I would never have thought to re-measure after adding keys.
Where I ended up drawing lines
After several weeks of running this, a few settings changed.
cutoff stayed at 0.6, for the reason described in the previous section — measuring it showed the safe range is far wider than I assumed.
WARN does not fail CI. Most unknown keys are just forward-looking. Failing on them is how --no-verify becomes muscle memory. Only permission-key typos, invalid values, and dangerous combinations break the build.
DEFAULTS stayed a constant in the source. I tried moving it to a config file and reversed course. That table records observed behavior for a specific version, and changing it should require confirming the behavior first. As a config file it becomes something you edit without checking. As a constant at the top of the module, changes go through review — which suits what the value actually represents.
I tend to prefer the option that is harder to change, when the thing being changed should not be routine.
An unexpected second use
The lint also surfaced a setting that was spelled correctly but no longer made sense.
doc-sync.md had hidden: true. I had set it to keep the agent out of listings. Seeing it in the effective-config table, it was the only row with anything in the hidden column — and once it stood out, the reasoning fell apart. I was hiding it because I did not use it, which argues for deleting the definition or reworking the subagent layout instead.
Lining configurations up side by side exposes inconsistencies that per-file reading never surfaces. The lint exists to catch violations, but forcing it to always print effective values turned it into an inventory tool as well.
If you want to chase the same problem, start by opening every agent.md you have and eyeballing the spelling of the permission keys alone. Before writing any tooling, that alone may turn something up.
For me it was two files out of seven. Eyeballing works while the count is small, and stops working as it grows. What you miss is not an error — it is a quiet expansion of what your agents are allowed to do.
Syntax correctness comes for free. Semantic correctness is something you have to check yourself. When configuration is what decides permissions, that check is not the part to skip.
Share
Thank You for Reading
Antigravity Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.