test(lease): enforce read-only tool invariant
This commit is contained in:
@@ -25,7 +25,7 @@
|
||||
"lint": "eslint src",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run --passWithNoTests && pnpm run test:framework-shell",
|
||||
"test:framework-shell": "bash framework/tools/quality/scripts/check-test-enumeration.sh && bash framework/tools/quality/scripts/test-check-test-enumeration.sh && python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/promotion_binding_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 src/mutator-gate/version_coupling_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-ci-queue-wait-tristate.sh && bash framework/tools/git/test-ci-queue-wait-github-checks.sh && bash framework/tools/git/test-pr-merge-queue-branch.sh && bash framework/tools/git/test-pr-merge-head-pin.sh && bash framework/tools/git/test-pr-merge-message-field.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/woodpecker/test-terminal-green-contract.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh && bash framework/tools/tmux/agent-send.test.sh && bash framework/tools/wake/test-wake-store-ack.sh && bash framework/tools/wake/test-wake-store-enqueue-race.sh && bash framework/tools/wake/test-wake-digest-hmac.sh && bash framework/tools/wake/test-wake-digest-quarantine.sh && bash framework/tools/wake/test-wake-detector.sh && bash framework/tools/wake/test-wake-fn-oracle.sh && bash framework/tools/wake/test-wake-reconcile.sh && bash framework/tools/wake/test-wake-beacon.sh && bash framework/tools/wake/test-wake-preimage.sh && bash framework/tools/wake/test-wake-install.sh"
|
||||
"test:framework-shell": "bash framework/tools/quality/scripts/check-test-enumeration.sh && bash framework/tools/quality/scripts/test-check-test-enumeration.sh && python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/promotion_binding_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/invariant_r_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 src/mutator-gate/version_coupling_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-ci-queue-wait-tristate.sh && bash framework/tools/git/test-ci-queue-wait-github-checks.sh && bash framework/tools/git/test-pr-merge-queue-branch.sh && bash framework/tools/git/test-pr-merge-head-pin.sh && bash framework/tools/git/test-pr-merge-message-field.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/woodpecker/test-terminal-green-contract.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh && bash framework/tools/tmux/agent-send.test.sh && bash framework/tools/wake/test-wake-store-ack.sh && bash framework/tools/wake/test-wake-store-enqueue-race.sh && bash framework/tools/wake/test-wake-digest-hmac.sh && bash framework/tools/wake/test-wake-digest-quarantine.sh && bash framework/tools/wake/test-wake-detector.sh && bash framework/tools/wake/test-wake-fn-oracle.sh && bash framework/tools/wake/test-wake-reconcile.sh && bash framework/tools/wake/test-wake-beacon.sh && bash framework/tools/wake/test-wake-preimage.sh && bash framework/tools/wake/test-wake-install.sh"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mosaicstack/brain": "workspace:*",
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Invariant R: a read-only carve-out can neither disappear nor be shadowed.
|
||||
|
||||
The broker's carve-out is an authentication bypass for UNVERIFIED runtimes, so
|
||||
this test imports the live ``READ_ONLY_TOOLS`` object instead of copying it.
|
||||
Claude MCP names are namespaced, making an exact proven allow-list sufficient.
|
||||
Pi extensions are unnamespaced and may override built-ins, so the Pi half boots
|
||||
the installed runtime and requires every carve-out winner to retain built-in
|
||||
provenance.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from typing import Final
|
||||
|
||||
|
||||
PACKAGE_ROOT = Path(__file__).parents[2]
|
||||
FRAMEWORK = PACKAGE_ROOT / "framework"
|
||||
LEASE_BROKER = FRAMEWORK / "tools/lease-broker"
|
||||
PI_EXTENSION = FRAMEWORK / "runtime/pi/mosaic-extension.ts"
|
||||
sys.path.insert(0, str(LEASE_BROKER))
|
||||
daemon = importlib.import_module("daemon")
|
||||
READ_ONLY_TOOLS = daemon.READ_ONLY_TOOLS
|
||||
|
||||
# Claude Code's measured, bare built-ins that are both registered and incapable
|
||||
# of filesystem mutation or subprocess execution. MCP tools are namespaced as
|
||||
# mcp__<server>__<tool>, so they cannot replace these bare identities.
|
||||
CLAUDE_PROVEN_READ_ONLY_TOOLS: Final = frozenset({"Read", "Grep", "Glob"})
|
||||
|
||||
# W-C owns removal of these known-dead names from daemon.py. This pin is
|
||||
# deliberately two-sided: until W-C lands, each name must remain in the live
|
||||
# carve-out and absent from the installed/proven set. W-C must delete the pin in
|
||||
# the same commit that removes the daemon entries, or this suite turns red.
|
||||
KNOWN_DEAD_CLAUDE: Final = frozenset({"Ls", "Find"})
|
||||
|
||||
# W-B measured Pi 0.84.1 through getAllTools(), observed every tool_call name,
|
||||
# and cross-checked dist/core/tools/index.js:17. Keep the mutating names here so
|
||||
# a runtime registry change forces the security classification to be revisited.
|
||||
PI_VERSION: Final = "0.84.1"
|
||||
PI_PROVEN_READ_ONLY_TOOLS: Final = frozenset({"read", "grep", "find", "ls"})
|
||||
PI_MUTATING_TOOLS: Final = frozenset({"bash", "edit", "write"})
|
||||
PI_MEASURED_BUILTINS: Final = PI_PROVEN_READ_ONLY_TOOLS | PI_MUTATING_TOOLS
|
||||
|
||||
# Falsifier-only inputs. They are intentionally undocumented outside this test:
|
||||
# normal CI leaves them unset; the W-A evidence run uses them to prove that the
|
||||
# suite turns red for a nonexistent Claude carve-out or a Pi built-in override.
|
||||
CLAUDE_EXTRA_TOOL_ENV: Final = "MOSAIC_INVARIANT_R_CLAUDE_EXTRA_TOOL"
|
||||
PI_EXTRA_EXTENSION_ENV: Final = "MOSAIC_INVARIANT_R_PI_EXTRA_EXTENSION"
|
||||
|
||||
|
||||
def probe_pi_registry() -> list[dict[str, object]]:
|
||||
"""Boot Pi's real registry and return the final winning tool definitions."""
|
||||
|
||||
pi = shutil.which("pi")
|
||||
if pi is None:
|
||||
raise AssertionError("installed Pi runtime is required for Invariant R")
|
||||
|
||||
version = subprocess.run(
|
||||
[pi, "--version"],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
if version.returncode != 0:
|
||||
raise AssertionError(f"Pi version probe failed: {version.stderr.strip()}")
|
||||
if version.stdout.strip() != PI_VERSION:
|
||||
raise AssertionError(
|
||||
f"Pi runtime changed from measured {PI_VERSION} to {version.stdout.strip()!r}; "
|
||||
"remeasure its registry before updating Invariant R"
|
||||
)
|
||||
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
output = root / "registry.json"
|
||||
observer = root / "registry-observer.ts"
|
||||
observer.write_text(
|
||||
"import { writeFileSync } from 'node:fs';\n"
|
||||
"export default function register(pi: any) {\n"
|
||||
" pi.on('session_start', () => {\n"
|
||||
f" writeFileSync({json.dumps(str(output))}, JSON.stringify(pi.getAllTools()));\n"
|
||||
" process.exit(0);\n"
|
||||
" });\n"
|
||||
"}\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
command = [
|
||||
pi,
|
||||
"--mode",
|
||||
"text",
|
||||
"--no-session",
|
||||
"--no-approve",
|
||||
"--no-context-files",
|
||||
"--no-skills",
|
||||
"--no-prompt-templates",
|
||||
"--no-extensions",
|
||||
"-e",
|
||||
str(observer),
|
||||
"-e",
|
||||
str(PI_EXTENSION),
|
||||
]
|
||||
extra_extension = os.environ.get(PI_EXTRA_EXTENSION_ENV)
|
||||
if extra_extension:
|
||||
command.extend(("-e", extra_extension))
|
||||
command.append("Invariant R registry probe")
|
||||
|
||||
completed = subprocess.run(
|
||||
command,
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env={**os.environ, "PI_OFFLINE": "1"},
|
||||
timeout=20,
|
||||
)
|
||||
if completed.returncode != 0 or not output.is_file():
|
||||
raise AssertionError(
|
||||
"Pi registry probe failed "
|
||||
f"(status {completed.returncode}): {completed.stderr.strip()}"
|
||||
)
|
||||
value = json.loads(output.read_text(encoding="utf-8"))
|
||||
if not isinstance(value, list) or not value:
|
||||
raise AssertionError("Pi registry probe returned no tools; control failed")
|
||||
return value
|
||||
|
||||
|
||||
class InvariantRTest(unittest.TestCase):
|
||||
def test_live_carve_out_has_only_supported_runtimes(self) -> None:
|
||||
self.assertEqual(set(READ_ONLY_TOOLS), {"claude", "pi"})
|
||||
|
||||
def test_claude_carve_out_is_registered_proven_and_pinned_for_w_c(self) -> None:
|
||||
carve_out = set(READ_ONLY_TOOLS["claude"])
|
||||
falsifier = os.environ.get(CLAUDE_EXTRA_TOOL_ENV)
|
||||
if falsifier:
|
||||
carve_out.add(falsifier)
|
||||
|
||||
self.assertLessEqual(
|
||||
KNOWN_DEAD_CLAUDE,
|
||||
carve_out,
|
||||
"W-C changed the live Claude carve-out but left its known-defect pin stale",
|
||||
)
|
||||
self.assertTrue(
|
||||
KNOWN_DEAD_CLAUDE.isdisjoint(CLAUDE_PROVEN_READ_ONLY_TOOLS),
|
||||
"a W-C dead-name pin unexpectedly exists in Claude's installed/proven tool set",
|
||||
)
|
||||
self.assertEqual(
|
||||
carve_out - KNOWN_DEAD_CLAUDE,
|
||||
set(CLAUDE_PROVEN_READ_ONLY_TOOLS),
|
||||
"every unpinned Claude carve-out must exist and be in the exact proven read-only allow-list",
|
||||
)
|
||||
|
||||
def test_pi_carve_out_resolves_to_real_unshadowed_builtins(self) -> None:
|
||||
carve_out = set(READ_ONLY_TOOLS["pi"])
|
||||
self.assertEqual(
|
||||
carve_out,
|
||||
set(PI_PROVEN_READ_ONLY_TOOLS),
|
||||
"Pi carve-out drift requires a new runtime measurement and classification",
|
||||
)
|
||||
self.assertTrue(carve_out.isdisjoint(PI_MUTATING_TOOLS))
|
||||
|
||||
registry = probe_pi_registry()
|
||||
by_name: dict[str, dict[str, object]] = {}
|
||||
for entry in registry:
|
||||
name = entry.get("name")
|
||||
if not isinstance(name, str):
|
||||
self.fail(f"Pi registry entry has no string name: {entry!r}")
|
||||
by_name[name] = entry
|
||||
|
||||
builtin_names = {
|
||||
name
|
||||
for name, entry in by_name.items()
|
||||
if isinstance(entry.get("sourceInfo"), dict)
|
||||
and entry["sourceInfo"].get("source") == "builtin"
|
||||
}
|
||||
self.assertEqual(
|
||||
builtin_names,
|
||||
set(PI_MEASURED_BUILTINS),
|
||||
"Pi's real built-in registry drifted from the positive-control W-B measurement",
|
||||
)
|
||||
|
||||
for name in sorted(carve_out):
|
||||
with self.subTest(tool=name):
|
||||
self.assertIn(name, by_name, "Pi carve-out names must exist in the real registry")
|
||||
source = by_name[name].get("sourceInfo")
|
||||
self.assertIsInstance(source, dict)
|
||||
if isinstance(source, dict):
|
||||
self.assertEqual(
|
||||
source.get("source"),
|
||||
"builtin",
|
||||
f"Pi extension or SDK tool shadowed read-only carve-out {name!r}",
|
||||
)
|
||||
self.assertEqual(source.get("path"), f"<builtin:{name}>")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user