Why the asymmetry mattered: notTool can only forbid the calls the test
author thought of, and the set of undeclared tools is unbounded — so "this
skill stayed inside the tools it declares" was not expressible, which is
exactly the claim a SKILL.md frontmatter makes in prose. See
skillContract, which builds this check from that declaration.
Fails closed on an empty trace. A Trace records tool calls only when the
run captured the stream (transcript: true on the harness tier; always on the
eval tier), so an uncaptured run is indistinguishable from a tool-free one —
and passing it would assert nothing. Same discipline as assertWroteOnly
refusing a result that never recorded writes: "we didn't look" is not "it was
clean".
🔴 A Tool(restriction) DECLARATION IS MATCHED BY ITS BASE NAME. allowed-tools: Bash(git:*) is the ordinary way to write a narrow grant, and skillContract
hands that literal string here — while a trace only ever carries the base name
Bash, because that is the tool the harness reports. Set membership therefore
missed, and every legitimate Bash call was reported as outside the declared
surface, against a declaration that literally lists Bash (measured 2026-08-11:
agent used tool(s) outside its declared set: Bash (declared: Bash(git:*), Read, Skill)). A check that fires on its own happy path gets deleted, not
debugged — and it would fire on exactly the authors who narrowed their grant.
⚠️ AND THE RESTRICTION ITSELF IS NOT VERIFIED HERE, deliberately. A trace names
the tool, not the grant it was matched against, so Bash(git:*) can be held to
"no tool outside the declared set" and no further; whether the command really
was a git one is a claim this layer has no evidence for. Narrowing beyond the
base name is disallowed-tools: + lethal-trifecta's job (see
skill-contract.ts), which reads the grant instead of the run. The same
split("(") normalization the rest of the codebase already applies to tool
declarations — core/tool-contract.ts, core/lethal-trifecta.ts,
core/delegation-trifecta.ts.
⚠️ SPANS THE WHOLE TRACE, deliberately. It answers "did this RUN stay inside
these tools?", which is the right question for a harness that drives one thing.
It is the wrong question when the trace has setup calls before the surface
under test activated, drives several skills, or dispatches a subagent whose
nested calls the parser folds into the same flat list — and reporting those as
a violation is an accusation about the wrong code.
skillContract therefore does NOT use this directly: it wraps it in a window
bounded by its own Skill activation (see duringSkill in
src/skill-contract.ts). If you are checking one skill, prefer the contract;
reach for this when the claim really is about the whole run.
The agent used only tools drawn from
allowed— the white-list, and the missing half of a symmetry the file surface already has:assertNoWriteassertWroteOnlyonlyTools(this)Why the asymmetry mattered:
notToolcan only forbid the calls the test author thought of, and the set of undeclared tools is unbounded — so "this skill stayed inside the tools it declares" was not expressible, which is exactly the claim aSKILL.mdfrontmatter makes in prose. SeeskillContract, which builds this check from that declaration.Fails closed on an empty trace. A
Tracerecords tool calls only when the run captured the stream (transcript: trueon the harness tier; always on the eval tier), so an uncaptured run is indistinguishable from a tool-free one — and passing it would assert nothing. Same discipline asassertWroteOnlyrefusing a result that never recorded writes: "we didn't look" is not "it was clean".🔴 A
Tool(restriction)DECLARATION IS MATCHED BY ITS BASE NAME.allowed-tools: Bash(git:*)is the ordinary way to write a narrow grant, andskillContracthands that literal string here — while a trace only ever carries the base nameBash, because that is the tool the harness reports. Set membership therefore missed, and every legitimateBashcall was reported as outside the declared surface, against a declaration that literally lists Bash (measured 2026-08-11:agent used tool(s) outside its declared set: Bash (declared: Bash(git:*), Read, Skill)). A check that fires on its own happy path gets deleted, not debugged — and it would fire on exactly the authors who narrowed their grant.⚠️ AND THE RESTRICTION ITSELF IS NOT VERIFIED HERE, deliberately. A trace names the tool, not the grant it was matched against, so
Bash(git:*)can be held to "no tool outside the declared set" and no further; whether the command really was agitone is a claim this layer has no evidence for. Narrowing beyond the base name isdisallowed-tools:+lethal-trifecta's job (seeskill-contract.ts), which reads the grant instead of the run. The samesplit("(")normalization the rest of the codebase already applies to tool declarations —core/tool-contract.ts,core/lethal-trifecta.ts,core/delegation-trifecta.ts.⚠️ SPANS THE WHOLE TRACE, deliberately. It answers "did this RUN stay inside these tools?", which is the right question for a harness that drives one thing. It is the wrong question when the trace has setup calls before the surface under test activated, drives several skills, or dispatches a subagent whose nested calls the parser folds into the same flat list — and reporting those as a violation is an accusation about the wrong code.
skillContracttherefore does NOT use this directly: it wraps it in a window bounded by its ownSkillactivation (seeduringSkillinsrc/skill-contract.ts). If you are checking one skill, prefer the contract; reach for this when the claim really is about the whole run.