Skip to content

Fail closed on contracts and secure MCP domains - #108

Merged
aswhitehouse merged 2 commits into
mainfrom
agent/fix-contracts-sandbox
Aug 31, 2026
Merged

Fail closed on contracts and secure MCP domains#108
aswhitehouse merged 2 commits into
mainfrom
agent/fix-contracts-sandbox

Conversation

@aswhitehouse

Copy link
Copy Markdown
Collaborator

What changed

What changed

Makes behavioural contracts fail closed when enforcement is unavailable, raising CONTRACTS_UNAVAILABLE before any model call. It also extends sandbox domain enforcement
to support host:port restrictions and validates MCP endpoints before discovery or network access.

Why

Closes #103 and #104.

Declared behavioural contracts were previously skipped when the optional enforcement dependency was unavailable, allowing execution without a promised constraint. Sandbox
domain rules also could not restrict ports and did not apply to MCP endpoints.

How tested

  • Added unit tests for unavailable contract enforcement on direct and dependency tasks.

  • Added domain-matching tests covering bare hosts, pinned ports, effective default ports and case-insensitive matching.

  • Added MCP endpoint preflight and integration tests confirming violations occur before discovery or model execution.

  • Ran python3 -m pytest tests/ -q: 529 passed, 3 skipped.

  • Ran ruff check . --exclude test_output/.

  • Ran ruff format --check . --exclude test_output/.

  • Ran the npm build and test suite: 7 passed.

  • Ran the cross-runtime conformance matrix:

    • Python: 33/33 passed.
    • npm: 31/31 supported cases passed; 2 correctly reported unsupported.
  • All existing tests pass (pytest tests/)

  • New tests added (if applicable)

Type of change

  • Bug fix
  • New feature
  • Breaking change
  • Documentation
  • Refactoring / CI / tooling

Breaking changes

Specs declaring behavioural_contract no longer continue with a warning when the behavioural-contracts dependency is unavailable. They now fail before model execution with
CONTRACTS_UNAVAILABLE.

Install contract enforcement support with:

pip install 'open-agent-spec[contracts]'

Bare allow_domains hostnames retain their existing any-port behaviour. Port restrictions are opt-in using host:port.

MCP endpoints are now subject to the effective sandbox.http.allow_domains policy. Existing specs with MCP tools and restrictive domain allowlists must add the MCP
endpoint host—or host:port—to that allowlist.

Checklist

  • Code follows project style (ruff check . && ruff format --check .)
  • Self-review completed
  • Version bumped if needed (pyproject.toml, templates, docs)

@vercel

vercel Bot commented Aug 28, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
open-agent-spec Ready Ready Preview Aug 31, 2026 1:28am

Request Review

@sgriffiths

Copy link
Copy Markdown
Contributor

Reviewed against f36b8e9. Security direction is right and the domain matcher is solid — parsed.hostname over netloc.split(":")[0] fixes a latent userinfo bug, bare-host back-compat is intact, and fail-closed with no opt-out flag was the right call on #103.

Three things I'd want resolved before merge.

1. Delegated sub-spec contracts fail closed after a model call

_preflight_contract_support only walks the coordinator spec, so a contract on a delegated task is caught at the _run_single_task boundary — after an upstream dependency has already been billed:

# main.yaml — run: depends_on: [first, delegated]
# delegated → sub.yaml#work, contract on `work`
raised: CONTRACTS_UNAVAILABLE task=work
model calls before error: ['FIRST']

That contradicts the MUST this PR adds to §13.2 ("before any model call is made"). Either extend preflight to local spec: refs, or scope the sentence. Note the three statements currently disagree — §13.2 says "any model call", REFERENCE:458 says "a model call", REFERENCE:536 says "the first affected model call". The last is the accurate one.

2. MCP domain check is per-task, not chain-preflight

Contracts are preflighted before _resolve_chain; _check_mcp_endpoints only runs inside _run_single_task. Correct relative to discovery, late relative to the chain:

# run: depends_on: [first, mcptask], mcptask holds a blocked endpoint
raised: SANDBOX_DOMAIN_VIOLATION task=mcptask
model calls before error: ['first']

test_mcp_endpoint_violation_precedes_discovery_and_model_call uses a single-task spec, so it can't catch this. Mirror _preflight_contract_support — walk chosen task + direct depends_on before the chain starts.

3. No conformance cases for the new MUST rules

AGENTS.md: normative behaviour needs a case under spec/conformance/cases/ "so no runtime can regress silently". This adds three — CONTRACTS_UNAVAILABLE, host:port, MCP-before-discovery — and no cases, and there's no sandbox directory to extend. The 33/33 + 31/31 matrix can't regress what it doesn't encode.

CONTRACTS_UNAVAILABLE is the awkward one: the Python adapter only claims contracts when the extra is installed, and the harness has no "requires capability absent" concept. Probably wants a contracts-honesty case expecting CONTRACTS_UNAVAILABLE when the adapter doesn't claim contracts — npm already refuses the field, so it reports UNSUPPORTED rather than skipping silently.


Non-blocking, but worth picking up in the same pass:

  • §13.2's SANDBOX_DOMAIN_VIOLATION row still reads "HTTP request host", and both oas-schema copies still scope allow_domains to http.get/http.post — the behavioural_contract description in the same files was updated.
  • Version bump is still unticked. Fail-closed is defensible in place (1.6 already carries the honesty rule) and host:port is additive, but applying allow_domains to MCP is a real break — suggest 1.6.1 with that under Breaking Changes, and bump both manifests.
  • Landing page still says contracts "degrade gracefully when not installed".
  • Malformed allow_domains entries (localhost:abc, https://api.example.com) parse to an empty host and go silently inert. Fail-closed, but confusing mixed in with valid rules — worth catching at oa validate.

Full write-up with the reproductions and the rest of the detail to follow.

@sgriffiths

Copy link
Copy Markdown
Contributor

@sgriffiths sgriffiths left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed cfcf5d4 against my earlier comment. Checked out the branch, re-ran both original reproductions and the full suites. All three blocking points are resolved — approving.

Blocking points cleared

1. Delegated sub-spec contracts. _preflight_contract_support_preflight_runtime_guards, now recursing into local spec: refs with a _visited_specs guard for delegation cycles. My original reproduction is clean:

raised: CONTRACTS_UNAVAILABLE task=work stage=contract
model calls before error: []      # was ['FIRST']

test_local_delegated_contract_is_preflighted_before_chain asserts calls == [], which is the right assertion — it pins the actual regression rather than just the error code. I also probed whether transitive dependencies (run → mid → deep) leave a hole; they don't, because §7.3 makes execution direct-only, so preflight scope exactly matches execution scope. Leaving remote refs to the post-fetch boundary is the correct call.

2. MCP chain preflight. _check_mcp_endpoints moved into the same preflight walk, covering chosen task + direct depends_on before the chain starts. sandbox/mcp-domain-preflight.yaml uses the multi-task shape the old single-task test couldn't catch.

3. Conformance coverage. Four new cases plus the requires_absent: capability mechanism in the harness and PROTOCOL.md — that's a clean answer to the CONTRACTS_UNAVAILABLE awkwardness. Verified locally: Python 36 passed / 1 unsupported, node 33 passed / 4 unsupported, 537 pytest passed, npm 10 passed, ruff check + format clean.

One caveat on the coverage, not blocking: CI installs .[dev], which pins behavioural-contracts, so the Python adapter always declares contracts and errors/contracts-unavailable is permanently UNSUPPORTED on the Python side. Only npm actually exercises that case in CI. The Python fail-closed path is covered by unit tests, so this is fine — just worth knowing the conformance matrix isn't what's guarding it.

Also picked up every non-blocking item from last round: the §13.2 error row, both oas-schema copies, the landing page, 1.6.1 in pyproject.toml + npm/package.json, and malformed allow_domains now caught at oa validate. The three-way doc disagreement resolved in favour of the accurate wording, including splitting CONTRACTS_UNAVAILABLE out of the "before any model call" MUST list — good.

Follow-ups (not blocking this merge)

A. Sandbox doesn't propagate across delegation. Verified: a parent with allow_domains: [safe.example] delegating to a child whose MCP endpoint is blocked.example runs with no SANDBOX_DOMAIN_VIOLATION — only the child's own sandbox applies. Add a sandbox block to the child and it blocks correctly, so the mechanism works; it's inheritance that's absent. Pre-existing (main behaves identically) and genuinely a spec-level gap, since §11.1 only defines root+task resolution within a single document. Worth naming because it softens this PR's headline claim: moving a tool into a delegated sub-spec still escapes the declared network boundary. Filing separately.

B. Normative document edited in place while stamped 1.6.0. spec/open-agent-spec-1.6.md still reads "Version: 1.6.0" but this PR rewrites two MUSTs and redefines SANDBOX_DOMAIN_VIOLATION — a runtime built against published 1.6.0 now fails conformance. Both schema description strings still say "OA 1.6.0" too. Related: §799's "every valid 1.5.x document is a valid 1.6.0 document" is weaker now that allow_domains entries which used to validate no longer do. Filing separately.

C. Schema regex is looser than the Python validator. Three inputs disagree — localhost:0, localhost:99999, and user@host.com all pass the JSON-Schema pattern but are rejected by _validate_allow_domain. npm's separate port check covers the first two but not user@host.com, which is the exact userinfo-confusion shape the parsed.hostname fix guards against. Low practical impact since npm refuses sandbox: specs anyway, but the schema is the shared cross-runtime contract, so I'd tighten the pattern when convenient.

D. CHANGELOG mis-files the npm fix. [Unreleased] sits above [1.6.1] and still holds the #100 bare-spec fix — but that fix is already on main and will ship inside the 1.6.1 npm artifact. It should move down into the 1.6.1 section.

C and D are small enough to fold into this PR if you'd rather not carry them; either way, not holding the merge. Minor: the PR body's "What changed" still uses the old "before any model call" wording, and the version-bump checkbox is unticked though the work is done.


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Runner: contract validation fails open when the contracts extra is not installed

2 participants