Imported from HB0309/toolfence (
AGENTS.md). Install upstream withnpx skills add HB0309/toolfence. Copyright stays with the author.
AGENTS.md
This file provides guidance to agents when working with code in this repository.
Project identity
ToolFence — Python 3.12, pytest, Pydantic v2, no big frameworks, no AI in verdicts or Guard decisions, Windows PowerShell dev environment, no Docker.
Commands
# Install (editable + dev deps)
pip install -e ".[dev]"
# Run all tests
pytest
# Run a single test file
pytest tests/test_guard_core.py
# Run one test by name
pytest tests/test_guard_core.py::test_decision_irreversible_holds
# Collect without running
pytest --collect-only
# Dashboard E2E (optional — needs playwright installed separately)
pip install -e ".[dev-playwright]"
playwright install chromium
pytest tests/test_dashboard.py
# Skip network-dependent tests
pytest --skip-network
Architecture — component boundaries (hard rules)
| Component | Must NEVER import |
|---|---|
toolfence/contracts/ |
nanobot, any LLM client |
toolfence/engine/ |
nanobot internals |
toolfence/guard/ |
nanobot internals, LLM clients, toolfence.engine |
toolfence/integration/ |
nothing blocked — this is where nanobot imports live |
toolfence/cli/ |
nanobot internals |
toolfence/bob/ |
nanobot internals |
Guard boundary between Track B and Track C: guard/ is owned by Track C. integration/ and scripts/launch_*.py are owned by Track B. Neither edits the other's directory. Changes to guard/proxy.py or guard/decision.py require approval from both tracks.
Contracts — shared shapes, never redefined elsewhere
SurfaceEntry/SurfaceFile—toolfence/contracts/surface.py: five fields required;namepattern^[a-z0-9_]+$;description_sha256= 64 lowercase hex.TraceEntry/Trace—toolfence/contracts/trace.py:provenancemaps param name →"user"|"first-party"|"untrusted";guard_decisionis"pass"|"hold".TrialRecord—toolfence/contracts/trial.py: cross-field invariants enforced bymodel_validator;verdict=="skipped"requiresskip_reasonandtrace==None.HeldCallEvent,ProxyStub—toolfence/contracts/guard.py.- JSON Schemas in
toolfence/contracts/schemas/are generated byscripts/generate_schemas.py— never hand-edit them. Thetest_committed_schema_is_freshtest enforces this.
What Track A has completed (already in codebase)
- S-1–S-4 (shared base): pyproject, all contract models + schemas, 10 converted spike fixtures,
ScriptedAdapter,adapter_contract.py. - A-1:
SurfaceScanner(scan_json/openapi/mcp),suggest_action_class,get_action_class,check_description_drift,_sanitise_name. - A-2:
generate_canary,ProbeBuilder,validate_probe_file; probe templates intoolfence/engine/probes/; rule 6 (probe_id required) already added. - A-3:
datamark_result/is_datamarkedintoolfence/guard/datamark.py(Track A wrote this underguard/; Track C owns it from here). - A-4:
assign_verdict,canary_in_args(public helper),aggregate,render_report,ResultSummary(withtimestampfield and defense legend) — fully complete, all tests green. - A-5:
EvaluationRunnerintoolfence/engine/runner.py— drives the full channel × defense matrix (12 attack + 6 normal = 18 records); optionaladapter_factoryparameter applies to both attack and normal trials (when set, it is called once per trial for all trial kinds). - Normal-task success rule:
evaluate_normal_trialintoolfence/engine/normal_success.py— pure-Python, no LLM; success = status ok and at least one goal-tool call that passed Guard (guard_decision=="pass"), had no error, and whose result is not an error payload ("Error calling…"prefix or JSON with a top-level"error"key). - B-4b DecisionGate:
DecisionGateintoolfence/integration/channel_adapter.py— held calls wait for a Telegram inline-keyboard button tap (120 s timeout);store_pendingis called only insideDecisionGate.await_decision;GuardedProxyawaits viaawait_decision. - B-6 Bob MCP server:
toolfence/bob/mcp_server.py— four tools (scan_surface,run_eval,propose_fix,render_report) exposed to Bob over stdio. - Auditor mode and skill:
.bob/custom_modes.yaml(slugauditor) and.bob/skills/toolfence/SKILL.md— read-only audit pipeline, cannot edit code or run shell commands.
Non-obvious project-specific patterns
- A tool absent from
surface.jsondefaults to"state-changing"— the fail-closed BASE-2 rule;get_action_classimplements this. report.pyre-exportsDISCLAIMERfromscoring.py— import from either; the canonical string lives inscoring.DISCLAIMER.- Spike traces in
docs/notes/spike-traces/lackprovenance,guard_decision, andstatusfields — they are pre-schema. Converted versions live infixtures/spike_converted/and carry those fields with safe defaults. ScriptedAdapterignorespromptanddefensearguments entirely — the fixture encodes the outcome.- Datamarking tag strings are copied literals from nanobot — do not import from nanobot; use the constants in
guard/datamark.py. EvaluationRunnerconstructs oneGuardedProxyper trial using two boolean switches (guard_on,datamark_on) —guard_on=Falsemeans the proxy is present but transparent; no separate bypass path.- Tests are flat under
tests/— one directory, no subdirectories. Pytesttestpaths = ["tests"]in pyproject. - No
.envis committed — copy.env.exampleto.env;OPENROUTER_API_KEYis the only required secret and only needed for live eval runs. targets/galaxium/andtargets/nanobot/are git-ignored — fetched byscripts/setup.ps1; our changes to them live only as.patchfiles intargets/galaxium-patches/.- nanobot config is JSON, not YAML; model and provider are under
agents.defaults, notproviders.openrouter(the latter is ignored for model selection). - nanobot WebUI silently drops
OutboundMessage.buttons— confirmed in nanobot-map. Use Telegram for the live Guard confirm card. - Demo identity:
config/nanobot.gateway.jsonpassesX-Traveler-Id: "1"— the gateway agent is traveller 1 (Alice). - Demo irreversible action is
cancel_booking:confirm_holdis not inenabledTools; the only irreversible tool the agent can call ismcp_galaxium_cancel_booking_endpoint_cancel_booking_id_post(action_class"irreversible"in the surface fixture).
