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
47 changes: 31 additions & 16 deletions agent/tool_arg_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,15 @@
letting an under-specified call reach the tool at all.

Design mirrors :mod:`agent.verify_policy` / :mod:`agent.policy_interceptors`
intentionally: frozen dataclasses, a pure check function, opt-in via an
env var / config flag (default **OFF** — see :func:`tool_arg_contract_enabled`),
fail-open whenever the tool has no schema or the schema is malformed. Only
``required`` presence, ``enum`` membership, and basic ``type`` matching
(string, integer, number, boolean, array, object) are checked; this is
deliberately narrower than full JSON Schema validation (no format/min-max/
pattern checks). The type check mirrors :func:`tools.tool_search._check_type`
so native tools get the same guard already available to discovered tools.
intentionally: frozen dataclasses, a pure check function, opt-out via an
env var / config flag (default **ON** since #1530 — see
:func:`tool_arg_contract_enabled`), fail-open whenever the tool has no schema
or the schema is malformed. Only ``required`` presence, ``enum`` membership,
and basic ``type`` matching (string, integer, number, boolean, array, object)
are checked; this is deliberately narrower than full JSON Schema validation (no
format/min-max/pattern checks). The type check mirrors
:func:`tools.tool_search._check_type` so native tools get the same guard
already available to discovered tools.
"""

from __future__ import annotations
Expand Down Expand Up @@ -243,12 +244,26 @@ def check_tool_args_contract(
def tool_arg_contract_enabled() -> bool:
"""Whether deterministic tool-argument contract enforcement is active.

Default **OFF**. Enabled by setting ``HERMES_TOOL_ARG_CONTRACT`` to a
truthy value (``1``/``true``/``yes``/``on``), or via the
``tool_arg_contract.enabled`` key in ``config.yaml``. Reads the env var
first so a session can flip it without editing config. Any failure
resolving config -> OFF (safe default). Mirrors
:func:`agent.verify_policy.verify_policy_enabled` exactly.
Default **ON** (issue #1530). The #1528 audit confirmed every native tool
ships a safe schema (``required`` arrays + ``type`` declarations), and the
check itself is fail-open on any schema without a structured contract
(no ``parameters``/``properties``/``required``), so flipping the default ON
only ever blocks calls that genuinely violate the schema the model was
given — it cannot invent a stricter contract than the tool declared.

Escape hatches (in priority order):

* ``HERMES_TOOL_ARG_CONTRACT`` env var — ``0``/``false``/``no``/``off``
disables; any truthy value forces ON. Read first so a session can flip
the gate without editing config.
* ``tool_arg_contract.enabled`` in ``config.yaml`` — set ``false`` to
disable persistently.

Any failure resolving config -> ON (the new safe default — the check is
fail-open, so leaving it ON is the lower-risk choice). This differs from
the historical default-OFF convention (and from its sibling
:func:`agent.verify_policy.verify_policy_enabled`, which stays OFF) because
#1528/#1529/#1530 made the check cheap, safe, and schema-gated.
"""
env = os.environ.get(_TOOL_ARG_CONTRACT_ENV)
if env is not None:
Expand All @@ -258,11 +273,11 @@ def tool_arg_contract_enabled() -> bool:

cfg = _load_config() or {}
except Exception:
return False
return True
section = cfg.get("tool_arg_contract") if isinstance(cfg, dict) else None
if isinstance(section, dict) and "enabled" in section:
return bool(section.get("enabled"))
return False
return True


__all__ = [
Expand Down
8 changes: 5 additions & 3 deletions model_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -1272,9 +1272,11 @@ def handle_function_call(
# Deterministic tool-argument contract check (issue #904). Re-checks
# the final, post-coercion/post-middleware arguments against the
# tool's own registered schema (``required`` fields, ``enum``
# values) right at the composition boundary, before dispatch()
# invokes the handler. Opt-in and fail-open — see
# agent.tool_arg_contract for rationale and default-OFF gating.
# values, basic ``type`` matching) right at the composition boundary,
# before dispatch() invokes the handler. Default ON since #1530 (the
# #1528 audit confirmed every native tool ships a safe schema, and
# the check is fail-open on any schema without a structured contract),
# with env/config escape hatches — see agent.tool_arg_contract.
try:
from agent.tool_arg_contract import (
check_tool_args_contract,
Expand Down
41 changes: 37 additions & 4 deletions tests/agent/test_tool_arg_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -217,15 +217,18 @@ def test_missing_required_and_invalid_enum_both_reported():
assert kinds == {"missing_required", "invalid_enum"}


# ── enable gate (opt-in, default OFF) ────────────────────────────────────────
# ── enable gate (default ON since #1530; env/config escape hatches) ──────────


def test_tool_arg_contract_disabled_by_default(monkeypatch):
def test_tool_arg_contract_enabled_by_default(monkeypatch):
"""#1530: the contract check is ON by default for every native tool whose
schema was confirmed safe by the #1528 audit. A bare config (no
tool_arg_contract section) and no env var → ON."""
monkeypatch.delenv("HERMES_TOOL_ARG_CONTRACT", raising=False)
monkeypatch.setattr(
"hermes_cli.config.load_config", lambda *a, **k: {}, raising=False
)
assert tool_arg_contract_enabled() is False
assert tool_arg_contract_enabled() is True


@pytest.mark.parametrize("val", ["1", "true", "TRUE", "yes", "on"])
Expand All @@ -236,11 +239,28 @@ def test_tool_arg_contract_env_enables(monkeypatch, val):

@pytest.mark.parametrize("val", ["0", "false", "no", "off", ""])
def test_tool_arg_contract_env_disables(monkeypatch, val):
"""The env var is the session-level escape hatch: ``0``/``false``/``no``/
``off`` forces the check OFF even though the default flipped to ON in
#1530."""
monkeypatch.setenv("HERMES_TOOL_ARG_CONTRACT", val)
assert tool_arg_contract_enabled() is False


def test_tool_arg_contract_config_enables_when_env_absent(monkeypatch):
def test_tool_arg_contract_config_disables_when_env_absent(monkeypatch):
"""The config key is the persistent escape hatch: ``enabled: false`` in
config.yaml disables the check even with no env var set."""
monkeypatch.delenv("HERMES_TOOL_ARG_CONTRACT", raising=False)
monkeypatch.setattr(
"hermes_cli.config.load_config",
lambda *a, **k: {"tool_arg_contract": {"enabled": False}},
raising=False,
)
assert tool_arg_contract_enabled() is False


def test_tool_arg_contract_config_enables_explicit(monkeypatch):
"""An explicit ``enabled: true`` config is honored and matches the new
default (ON) — no behavior change, but documents the affirmative path."""
monkeypatch.delenv("HERMES_TOOL_ARG_CONTRACT", raising=False)
monkeypatch.setattr(
"hermes_cli.config.load_config",
Expand All @@ -250,6 +270,19 @@ def test_tool_arg_contract_config_enables_when_env_absent(monkeypatch):
assert tool_arg_contract_enabled() is True


def test_tool_arg_contract_config_load_error_defaults_on(monkeypatch):
"""#1530: if config can't be resolved (import error, corrupt yaml, etc.),
the gate falls back to ON rather than OFF — the check is fail-open, so
leaving it ON is the lower-risk choice."""
monkeypatch.delenv("HERMES_TOOL_ARG_CONTRACT", raising=False)

def _boom(*a, **k):
raise RuntimeError("config load failed")

monkeypatch.setattr("hermes_cli.config.load_config", _boom, raising=False)
assert tool_arg_contract_enabled() is True


# ── type checking (issue #1529) ──────────────────────────────────────────────

TYPE_SCHEMA = {
Expand Down
69 changes: 61 additions & 8 deletions tests/test_model_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
import json
from unittest.mock import ANY, call, patch

import pytest


from model_tools import (
handle_function_call,
Expand All @@ -14,6 +16,20 @@
)


# This module exercises the dispatch / hook / middleware plumbing around
# handle_function_call, not the tool_arg_contract gate itself (that has its
# own suite in tests/agent/test_tool_arg_contract.py). Many cases here reuse
# the real ``web_search`` schema with a mocked ``registry.dispatch`` and pass
# ``{"q": "test"}`` — a value that pre-dates the schema's actual required
# ``query`` field. Before #1530 the default-OFF gate tolerated that mismatch;
# now that the gate is ON by default it would intercept these calls before
# dispatch, derailing the hook-order assertions under test. Disable the gate
# for this whole module so the dispatch-adjacent behavior stays the subject.
@pytest.fixture(autouse=True)
def _disable_arg_contract_for_dispatch_tests(monkeypatch):
monkeypatch.setenv("HERMES_TOOL_ARG_CONTRACT", "0")


# =========================================================================
# handle_function_call
# =========================================================================
Expand All @@ -37,7 +53,17 @@ def test_exception_returns_json_error(self):
assert isinstance(parsed, dict)
assert "error" in parsed
assert len(parsed["error"]) > 0
assert "error" in parsed["error"].lower() or "failed" in parsed["error"].lower()
# The error is either a dispatch failure ("...failed...") or, since
# #1530 turned the tool_arg_contract check ON by default, a clean
# contract violation ("Invalid arguments for 'web_search': missing
# required parameter 'query'."). Both are valid JSON error envelopes.
msg = parsed["error"].lower()
assert (
"error" in msg
or "failed" in msg
or "invalid arguments" in msg
or "missing required" in msg
)

def test_tool_hooks_receive_session_and_tool_call_ids(self):
with (
Expand Down Expand Up @@ -374,16 +400,18 @@ def fake_invoke_hook(hook_name, **kwargs):

class TestArgContractEnforcement:
"""handle_function_call() re-checks args against the tool's own schema
at the composition boundary, right before dispatch — but only when
agent.tool_arg_contract.tool_arg_contract_enabled() opts in (default OFF,
matching agent.verify_policy's gating convention)."""
at the composition boundary, right before dispatch. Default ON since
#1530 (the #1528 audit confirmed every native tool ships a safe schema),
with env/config escape hatches to force it OFF."""

def test_disabled_by_default_lets_invalid_call_through_to_dispatch(
def test_enabled_by_default_blocks_missing_required_before_dispatch(
self, monkeypatch
):
"""With the feature off (the default), a call missing a required
arg still reaches dispatch unchanged — no new blocking behavior
for anyone who hasn't opted in."""
"""#1530: with the feature ON by default, a call missing a required
arg is blocked at the composition boundary — it never reaches
dispatch. This is the behavior flip from the historical opt-in
default; the gate now protects every safe-schema tool out of the
box."""
monkeypatch.delenv("HERMES_TOOL_ARG_CONTRACT", raising=False)
monkeypatch.setattr(
"hermes_cli.config.load_config", lambda *a, **k: {}, raising=False
Expand All @@ -397,6 +425,31 @@ def test_disabled_by_default_lets_invalid_call_through_to_dispatch(
}
},
)

def fake_dispatch(*args, **kwargs):
raise AssertionError("dispatch should not run when contract is violated")

monkeypatch.setattr("model_tools.registry.dispatch", fake_dispatch)

result = json.loads(handle_function_call("read_file", {}, task_id="t1"))
assert "error" in result
assert "path" in result["error"]

def test_env_disabled_lets_invalid_call_through_to_dispatch(self, monkeypatch):
"""The HERMES_TOOL_ARG_CONTRACT env var is the session-level escape
hatch: setting it to a falsy value forces the check OFF so a call
missing a required arg reaches dispatch unchanged — matching the
pre-#1530 behavior for anyone who needs to opt out."""
monkeypatch.setenv("HERMES_TOOL_ARG_CONTRACT", "0")
monkeypatch.setattr(
"model_tools.registry.get_schema",
lambda name: {
"parameters": {
"properties": {"path": {"type": "string"}},
"required": ["path"],
}
},
)
monkeypatch.setattr(
"model_tools.registry.dispatch", lambda *a, **kw: json.dumps({"ok": True})
)
Expand Down
Loading