diff --git a/Dockerfile b/Dockerfile index 20cf1f65b..c3c091145 100644 --- a/Dockerfile +++ b/Dockerfile @@ -96,6 +96,7 @@ RUN ln -s ../lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm && \ ln -s ../lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx RUN useradd -m -u 1000 sandbox && \ + useradd -M -u 1001 -g sandbox -d /home/tools -s /usr/sbin/nologin tools && \ mkdir -p /workspace /work /cache /packages /home/sandbox && \ chown sandbox:sandbox /workspace /work /cache /packages /home/sandbox @@ -121,7 +122,7 @@ RUN ln -s ../lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm && \ RUN apt-get update && apt-get install -y --no-install-recommends \ acl git openssh-client xmlsec1 libmagic1 curl ca-certificates jq \ libnl-route-3-200 libprotobuf32 libcap2-bin util-linux \ - passt squashfs-tools \ + passt squashfs-tools uidmap \ && apt-get -y upgrade \ && apt-get clean && rm -rf /var/lib/apt/lists/* @@ -189,6 +190,7 @@ RUN mkdir -p /var/lib/tracecat/sandbox-rootfs/tmp \ # Create apiuser for non-root runtime (required for pasta userspace networking) RUN groupadd -g 1001 apiuser && useradd -m -u 1001 -g apiuser apiuser && \ + usermod --add-subuids 100000-100000 apiuser && \ mkdir -p /home/apiuser/.cache/uv /home/apiuser/.cache/s3 /home/apiuser/.cache/tmp /home/apiuser/.local/bin && \ chown -R apiuser:apiuser /home/apiuser diff --git a/tests/unit/test_agent_nsjail.py b/tests/unit/test_agent_nsjail.py index 6e5c82ef2..51c9a2331 100644 --- a/tests/unit/test_agent_nsjail.py +++ b/tests/unit/test_agent_nsjail.py @@ -1,11 +1,216 @@ from __future__ import annotations +import asyncio +import json +import stat +import sys +import textwrap from pathlib import Path from typing import Any import pytest import tracecat.agent.sandbox.nsjail as nsjail_module +from tracecat.agent.sandbox.config import AGENT_CLAUDE_UID, AGENT_TOOL_UID + +_TOOL_IDENTITY_PROBE = r""" +import errno +import json +import os +import pwd +from pathlib import Path + +def denied(operation): + try: + operation() + except OSError as exc: + return exc.errno in {errno.EACCES, errno.EPERM} + return False + +status = {} +for line in Path('/proc/self/status').read_text().splitlines(): + key, separator, value = line.partition(':') + if separator: + status[key] = value.strip() + +private = Path('/home/agent') +tool_home = Path('/home/tools') +work = Path('/work') +(tool_home / 'tool.txt').write_text('tool-private') +(work / 'from-tool.txt').write_text('tool-shared') +result = { + 'uid': os.getuid(), + 'euid': os.geteuid(), + 'gid': os.getgid(), + 'user': pwd.getpwuid(os.getuid()).pw_name, + 'caps': {name: status[name] for name in ('CapInh', 'CapPrm', 'CapEff', 'CapAmb')}, + 'no_new_privs': status['NoNewPrivs'], + 'home': os.environ['HOME'], + 'tmpdir': os.environ['TMPDIR'], + 'read_work': (work / 'from-claude.txt').read_text(), + 'private_denials': { + 'stat': denied(lambda: (private / 'stat.txt').stat()), + 'read': denied(lambda: (private / 'read.txt').read_text()), + 'write': denied(lambda: (private / 'write.txt').write_text('tampered')), + 'rename': denied(lambda: (private / 'rename.txt').rename(work / 'stolen.txt')), + 'delete': denied(lambda: (private / 'delete.txt').unlink()), + }, +} +print(json.dumps(result)) +""" + + +_CLAUDE_IDENTITY_PROBE = r""" +import base64 +import json +import os +import subprocess +from pathlib import Path + +home = Path('/home/agent') +work = Path('/work') +for name in ('stat.txt', 'read.txt', 'write.txt', 'rename.txt', 'delete.txt'): + (home / name).write_text(name) +(work / 'from-claude.txt').write_text('claude-shared') + +payload = base64.urlsafe_b64encode(json.dumps({ + 'argv': ['/usr/local/bin/python3', '-c', TOOL_SCRIPT], + 'env': {}, +}).encode()).decode() +completed = subprocess.run( + [ + '/usr/local/bin/python3', + '-I', + '/run/tracecat/job/shim_entrypoint.py', + '--tracecat-tool-launch', + payload, + ], + capture_output=True, + text=True, + check=False, +) + +tool_home_denied = False +try: + (Path('/home/tools') / 'tool.txt').read_text() +except PermissionError: + tool_home_denied = True + +status = {} +for line in Path('/proc/self/status').read_text().splitlines(): + key, separator, value = line.partition(':') + if separator: + status[key] = value.strip() + +print(json.dumps({ + 'uid': os.getuid(), + 'euid': os.geteuid(), + 'caps': {name: status[name] for name in ('CapInh', 'CapPrm', 'CapEff', 'CapAmb')}, + 'no_new_privs': status['NoNewPrivs'], + 'tool_returncode': completed.returncode, + 'tool_stderr': completed.stderr, + 'tool': json.loads(completed.stdout) if completed.returncode == 0 else None, + 'tool_home_denied': tool_home_denied, + 'read_work': (work / 'from-tool.txt').read_text() if completed.returncode == 0 else None, + 'private_unchanged': { + name: (home / name).read_text() == name + for name in ('stat.txt', 'read.txt', 'write.txt', 'rename.txt', 'delete.txt') + }, + 'stolen_exists': (work / 'stolen.txt').exists(), +})) +""" + + +def test_runtime_directories_use_private_home_and_shared_setgid_work( + tmp_path: Path, +) -> None: + session_home = tmp_path / "agent-home" + session_work = tmp_path / "agent-work" + + nsjail_module._prepare_runtime_directories( + session_home_dir=session_home, + session_work_dir=session_work, + ) + + assert stat.S_IMODE(session_home.stat().st_mode) == 0o700 + assert stat.S_IMODE(session_work.stat().st_mode) == 0o2770 + for relative_path in (".config", ".cache", ".local/state", "tmp"): + assert stat.S_IMODE((session_home / relative_path).stat().st_mode) == 0o700 + + +@pytest.mark.anyio +async def test_agent_nsjail_separates_claude_and_tool_uids(tmp_path: Path) -> None: + """Exercise real UID maps, capability dropping, and private-home access.""" + nsjail_path = Path(nsjail_module.TRACECAT__SANDBOX_NSJAIL_PATH) + rootfs_path = Path(nsjail_module.TRACECAT__SANDBOX_ROOTFS_PATH) + if sys.platform != "linux" or not nsjail_path.exists() or not rootfs_path.exists(): + pytest.skip("real agent nsjail isolation requires the Linux executor image") + + socket_dir = tmp_path / "sockets" + socket_dir.mkdir() + llm_socket_path = socket_dir / "llm.sock" + mcp_socket_path = socket_dir / "mcp.sock" + llm_socket_path.touch() + mcp_socket_path.touch() + session_home = tmp_path / "agent-home" + session_work = tmp_path / "agent-work" + job_dir = tmp_path / "job" + init_payload_path = tmp_path / "init.json" + claude_script = ( + f"TOOL_SCRIPT = {textwrap.dedent(_TOOL_IDENTITY_PROBE)!r}\n" + f"{textwrap.dedent(_CLAUDE_IDENTITY_PROBE)}" + ) + init_payload_path.write_text( + json.dumps( + { + "command": ["/usr/local/bin/python3", "-c", claude_script], + "env": {}, + "cwd": "/work", + "mcp_bridge_port": 4101, + } + ) + ) + + spawned = await nsjail_module.spawn_jailed_runtime( + socket_dir=socket_dir, + init_payload_path=init_payload_path, + llm_socket_path=llm_socket_path, + mcp_socket_path=mcp_socket_path, + control_socket_required=False, + pipe_stdin=True, + job_dir=job_dir, + session_home_dir=session_home, + session_work_dir=session_work, + ) + stdout_bytes, stderr_bytes = await asyncio.wait_for( + spawned.process.communicate(), timeout=30 + ) + + assert spawned.process.returncode == 0, stderr_bytes.decode(errors="replace") + output_lines = stdout_bytes.decode().splitlines() + assert output_lines + result = json.loads(output_lines[-1]) + expected_claude_caps = f"{1 << 7:016x}" + assert result["uid"] == AGENT_CLAUDE_UID + assert result["euid"] == AGENT_CLAUDE_UID + assert set(result["caps"].values()) == {expected_claude_caps} + assert result["no_new_privs"] == "1" + assert result["tool_returncode"] == 0, result["tool_stderr"] + assert result["tool_home_denied"] is True + assert result["read_work"] == "tool-shared" + assert all(result["private_unchanged"].values()) + assert result["stolen_exists"] is False + + tool_result = result["tool"] + assert tool_result["uid"] == AGENT_TOOL_UID + assert tool_result["euid"] == AGENT_TOOL_UID + assert tool_result["user"] == "tools" + assert set(tool_result["caps"].values()) == {"0000000000000000"} + assert tool_result["no_new_privs"] == "1" + assert tool_result["home"] == "/home/tools" + assert tool_result["tmpdir"] == "/home/tools/tmp" + assert tool_result["read_work"] == "claude-shared" + assert all(tool_result["private_denials"].values()) class _FakeProcess: diff --git a/tests/unit/test_agent_runtime.py b/tests/unit/test_agent_runtime.py index 79043b8a3..b3bf4b407 100644 --- a/tests/unit/test_agent_runtime.py +++ b/tests/unit/test_agent_runtime.py @@ -6,7 +6,9 @@ from __future__ import annotations import asyncio +import base64 import os +import shlex import tempfile import uuid from dataclasses import replace @@ -201,6 +203,14 @@ def get_hook_output(result: SyncHookJSONOutput) -> dict[str, Any]: return cast(dict[str, Any], result.get("hookSpecificOutput", {})) +def decode_tool_wrapper_payload(encoded_payload: str) -> dict[str, Any]: + """Decode a trusted tool-wrapper payload for assertions.""" + return cast( + dict[str, Any], + orjson.loads(base64.urlsafe_b64decode(encoded_payload.encode("ascii"))), + ) + + def test_pre_tool_use_hook_input_declares_subagent_context_fields() -> None: """SDK PreToolUse input must expose subagent attribution fields.""" annotations = set(PreToolUseHookInput.__annotations__) @@ -943,8 +953,8 @@ def _mock_client_ctor(*_args: Any, **kwargs: Any) -> MagicMock: } assert set(options.allowed_tools) == { "mcp__tracecat-registry__core__http_request", - "mcp__local-tools__*", } + assert "mcp__local-tools__*" not in (options.tools or []) @pytest.mark.anyio async def test_root_agent_uses_verified_stdio_tool_inventory( @@ -1013,6 +1023,14 @@ def _mock_client_ctor(*_args: Any, **kwargs: Any) -> MagicMock: "mcp__tracecat-registry__core__http_request", "mcp__sentinel-one__list_alerts", } + assert set(options.tools or []) == { + *runtime_module.BASE_NATIVE_TOOL_INVENTORY, + *runtime_module.INTERNET_TOOLS, + } + assert options.setting_sources == [] + assert options.settings == "{}" + assert options.extra_args == {"strict-mcp-config": None} + assert "FutureMutationTool" not in (options.tools or []) assert "Verified stdio MCP tools configured for this agent" in ( options.system_prompt ) @@ -1021,6 +1039,44 @@ def _mock_client_ctor(*_args: Any, **kwargs: Any) -> MagicMock: assert "delete_alert" not in options.system_prompt assert "legacy_alert" not in options.system_prompt + def test_stdio_mcp_commands_use_the_uid_demoter_in_nsjail( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.setattr(runtime_module, "TRACECAT__DISABLE_NSJAIL", False) + + spec = ClaudeAgentRuntime._stdio_mcp_server_spec( + source_configs=[ + cast( + Any, + { + "type": "stdio", + "name": "local-tools", + "command": "npx", + "args": ["-y", "example-mcp"], + "env": {"API_TOKEN": "configured-token"}, + "tools": [{"name": "lookup"}], + }, + ) + ], + name_prefix="subagent-analyst", + ) + + server = spec.servers["subagent-analyst-local-tools"] + assert server["command"] == runtime_module.JAILED_TOOL_WRAPPER_COMMAND + wrapper_args = server.get("args") + assert wrapper_args is not None + assert wrapper_args[:3] == [ + "-I", + runtime_module.JAILED_TOOL_WRAPPER_SCRIPT, + runtime_module.JAILED_TOOL_WRAPPER_MODE, + ] + assert decode_tool_wrapper_payload(wrapper_args[3]) == { + "argv": ["npx", "-y", "example-mcp"], + "env": {"API_TOKEN": "configured-token"}, + } + assert "env" not in server + @pytest.mark.anyio async def test_root_agent_sanitizes_stdio_tool_inventory_and_approvals( self, @@ -1317,6 +1373,7 @@ async def test_explicit_subagents_get_root_style_agent_definitions( "name": "local-tools", "command": "uvx", "args": ["example-mcp"], + "tools": [{"name": "lookup_local"}], } ], "tool_approvals": {"core.lookup_ip": True}, @@ -1392,7 +1449,7 @@ def _mock_client_ctor(*_args: Any, **kwargs: Any) -> MagicMock: }, ] assert agent_def.tools == [ - "mcp__subagent-analyst-local-tools__*", + "mcp__subagent-analyst-local-tools__lookup_local", "mcp__tracecat-registry-analyst__core__lookup_ip", ] internet_tools = set(runtime_module.INTERNET_TOOLS) @@ -2251,6 +2308,172 @@ async def test_auto_approve_non_registry_mcp_tool_does_not_inject_metadata( "permissionDecision": "allow", } + @pytest.mark.anyio + async def test_unconfigured_future_mcp_tool_is_denied( + self, + mock_socket_writer: MagicMock, + ) -> None: + runtime = ClaudeAgentRuntime( + mock_socket_writer, transport_factory=lambda _: MagicMock() + ) + runtime._available_mcp_tools = {"mcp__approved__lookup"} + + result = await runtime._pre_tool_use_hook( + input_data=make_hook_input( + tool_name="mcp__approved__future_mutation", + tool_input={"path": "/home/agent/session.jsonl"}, + tool_use_id="call-future", + ), + tool_use_id="call-future", + context=make_hook_context(), + ) + + hook_output = get_hook_output(result) + assert hook_output["permissionDecision"] == "deny" + assert ( + "not in the runtime MCP inventory" + in hook_output["permissionDecisionReason"] + ) + + @pytest.mark.anyio + async def test_bash_command_is_wrapped_as_opaque_data_in_nsjail( + self, + monkeypatch: pytest.MonkeyPatch, + mock_socket_writer: MagicMock, + ) -> None: + monkeypatch.setattr(runtime_module, "TRACECAT__DISABLE_NSJAIL", False) + runtime = ClaudeAgentRuntime( + mock_socket_writer, transport_factory=lambda _: MagicMock() + ) + original_command = "printf '%s' \"$HOME\"; touch '/work/a b'" + + result = await runtime._pre_tool_use_hook( + input_data=make_hook_input( + tool_name="Bash", + tool_input={"command": original_command}, + tool_use_id="call-bash", + ), + tool_use_id="call-bash", + context=make_hook_context(), + ) + + hook_output = get_hook_output(result) + wrapped_command = hook_output["updatedInput"]["command"] + wrapper_argv = shlex.split(wrapped_command) + assert wrapper_argv[:4] == [ + runtime_module.JAILED_TOOL_WRAPPER_COMMAND, + "-I", + runtime_module.JAILED_TOOL_WRAPPER_SCRIPT, + runtime_module.JAILED_TOOL_WRAPPER_MODE, + ] + assert decode_tool_wrapper_payload(wrapper_argv[4]) == { + "argv": ["/bin/bash", "-c", original_command], + "env": {}, + } + + @pytest.mark.anyio + @pytest.mark.parametrize("tool_name", ["Write", "Edit"]) + async def test_native_mutation_is_canonicalized_beneath_work( + self, + tool_name: str, + mock_socket_writer: MagicMock, + tmp_path: Path, + ) -> None: + host_work = tmp_path / "work" + (host_work / "nested").mkdir(parents=True) + runtime = ClaudeAgentRuntime( + mock_socket_writer, + transport_factory=lambda _: MagicMock(), + cwd=Path("/work"), + cwd_setup_path=host_work, + ) + + result = await runtime._pre_tool_use_hook( + input_data=make_hook_input( + tool_name=tool_name, + tool_input={"file_path": "nested/output.txt", "content": "safe"}, + tool_use_id=f"call-{tool_name.lower()}", + ), + tool_use_id=f"call-{tool_name.lower()}", + context=make_hook_context(), + ) + + hook_output = get_hook_output(result) + assert hook_output["permissionDecision"] == "allow" + assert hook_output["updatedInput"]["file_path"] == "/work/nested/output.txt" + + @pytest.mark.anyio + @pytest.mark.parametrize( + "raw_path", + [ + "/home/agent/.claude/session.jsonl", + "/work", + "../agent-home/session.jsonl", + "missing-parent/output.txt", + "bad\x00path", + ], + ) + async def test_native_mutation_denies_unsafe_paths( + self, + raw_path: str, + mock_socket_writer: MagicMock, + tmp_path: Path, + ) -> None: + host_work = tmp_path / "work" + host_work.mkdir() + runtime = ClaudeAgentRuntime( + mock_socket_writer, + transport_factory=lambda _: MagicMock(), + cwd=Path("/work"), + cwd_setup_path=host_work, + ) + + result = await runtime._pre_tool_use_hook( + input_data=make_hook_input( + tool_name="Write", + tool_input={"file_path": raw_path, "content": "unsafe"}, + tool_use_id="call-write", + agent_id="child-1", + agent_type="analyst", + ), + tool_use_id="call-write", + context=make_hook_context(), + ) + + hook_output = get_hook_output(result) + assert hook_output["permissionDecision"] == "deny" + + @pytest.mark.anyio + async def test_native_mutation_denies_symlink_escape( + self, + mock_socket_writer: MagicMock, + tmp_path: Path, + ) -> None: + host_work = tmp_path / "work" + outside = tmp_path / "agent-home" + host_work.mkdir() + outside.mkdir() + (host_work / "escape").symlink_to(outside, target_is_directory=True) + runtime = ClaudeAgentRuntime( + mock_socket_writer, + transport_factory=lambda _: MagicMock(), + cwd=Path("/work"), + cwd_setup_path=host_work, + ) + + result = await runtime._pre_tool_use_hook( + input_data=make_hook_input( + tool_name="Edit", + tool_input={"file_path": "/work/escape/session.jsonl"}, + tool_use_id="call-edit", + ), + tool_use_id="call-edit", + context=make_hook_context(), + ) + + hook_output = get_hook_output(result) + assert hook_output["permissionDecision"] == "deny" + @pytest.mark.anyio async def test_tool_requires_approval( self, diff --git a/tests/unit/test_agent_runtime_broker.py b/tests/unit/test_agent_runtime_broker.py index 9db387616..e6bfd3b10 100644 --- a/tests/unit/test_agent_runtime_broker.py +++ b/tests/unit/test_agent_runtime_broker.py @@ -92,6 +92,28 @@ def _stdio_mcp_config(command: str) -> McpStdioServerConfig: return {"type": "stdio", "command": command} +def test_transport_uses_private_claude_home_for_runtime_state(tmp_path: Path) -> None: + transport = _make_transport(tmp_path, use_jailed_paths=True) + + env = transport._build_claude_env_overlay() + + assert ( + env.items() + >= { + "CLAUDE_CODE_ENTRYPOINT": "sdk-py", + "CLAUDE_AGENT_SDK_VERSION": transport_module.__version__, + "HOME": "/home/agent", + "XDG_CONFIG_HOME": "/home/agent/.config", + "XDG_CACHE_HOME": "/home/agent/.cache", + "XDG_STATE_HOME": "/home/agent/.local/state", + "TMPDIR": "/home/agent/tmp", + "TEMP": "/home/agent/tmp", + "TMP": "/home/agent/tmp", + "PWD": "/work", + }.items() + ) + + class _FakeSandboxProcess: stdin = object() stdout = object() diff --git a/tests/unit/test_agent_sandbox_entrypoint.py b/tests/unit/test_agent_sandbox_entrypoint.py index 6c16aba26..c6810108b 100644 --- a/tests/unit/test_agent_sandbox_entrypoint.py +++ b/tests/unit/test_agent_sandbox_entrypoint.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +import base64 import socket from pathlib import Path from typing import Any, cast @@ -14,12 +15,14 @@ INIT_PAYLOAD_ENV_VAR, MCP_SOCKET_ENV_VAR, LLMBridge, + _decode_tool_launch_payload, _pump_stdin_to_process, _read_stdin_chunk, _resolve_init_payload_path, _resolve_llm_socket_path, _resolve_mcp_socket_path, _rewrite_mcp_bridge_command_port, + _tool_environment, _wait_for_process_with_stdin, ) from tracecat.agent.sandbox.shim_entrypoint import ( @@ -202,6 +205,39 @@ def test_rewrite_mcp_bridge_command_port_replaces_dynamic_urls() -> None: ] +def test_tool_launch_payload_preserves_argv_as_data() -> None: + payload = { + "argv": ["/bin/bash", "-c", "touch '/work/a b'; echo $HOME"], + "env": {"TOKEN": "configured"}, + } + encoded = base64.urlsafe_b64encode(orjson.dumps(payload)).decode("ascii") + + assert _decode_tool_launch_payload(encoded) == payload + + +def test_tool_environment_is_private_and_does_not_inherit_claude_secrets( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("ANTHROPIC_AUTH_TOKEN", "must-not-leak") + + env = _tool_environment({"SERVER_TOKEN": "configured"}) + + assert env["HOME"] == "/home/tools" + assert env["TMPDIR"] == "/home/tools/tmp" + assert env["PWD"] == "/work" + assert env["SERVER_TOKEN"] == "configured" + assert "ANTHROPIC_AUTH_TOKEN" not in env + + +@pytest.mark.parametrize( + "key", + ["PATH", "HOME", "XDG_CONFIG_HOME", "TMPDIR", "USER", "PWD"], +) +def test_tool_environment_rejects_private_boundary_overrides(key: str) -> None: + with pytest.raises(RuntimeError, match=rf"cannot override {key}"): + _tool_environment({key: "/home/agent"}) + + class _FakeStreamWriter: def __init__(self, *, fail_after_write: bool = False) -> None: self.fail_after_write = fail_after_write diff --git a/tests/unit/test_nsjail_seccomp.py b/tests/unit/test_nsjail_seccomp.py index bb7ed5ead..86454c5dd 100644 --- a/tests/unit/test_nsjail_seccomp.py +++ b/tests/unit/test_nsjail_seccomp.py @@ -2,9 +2,14 @@ from __future__ import annotations +import os from pathlib import Path from tracecat.agent.sandbox.config import ( + AGENT_CLAUDE_UID, + AGENT_SHARED_GID, + AGENT_TOOL_OUTSIDE_UID, + AGENT_TOOL_UID, AgentResourceLimits, AgentSandboxConfig, build_agent_nsjail_config, @@ -87,6 +92,48 @@ def test_agent_sandbox_config_includes_seccomp_policy(tmp_path: Path): _assert_seccomp_config(config_text) +def test_agent_sandbox_config_separates_claude_and_tool_identities( + tmp_path: Path, +) -> None: + """Claude and model-controlled children must have private homes and one work GID.""" + session_home = tmp_path / "agent-home" + session_work = tmp_path / "agent-work" + config_text = build_agent_nsjail_config( + rootfs=tmp_path / "rootfs", + job_dir=tmp_path / "job", + socket_dir=tmp_path / "socket", + config=AgentSandboxConfig(), + site_packages_dir=tmp_path / "site-packages", + llm_socket_path=tmp_path / "llm.sock", + session_home_dir=session_home, + session_work_dir=session_work, + ) + + assert ( + f'uidmap {{ inside_id: "{AGENT_CLAUDE_UID}" outside_id: ' + f'"{os.getuid()}" count: 1 use_newidmap: true }}' + ) in config_text + assert ( + f'uidmap {{ inside_id: "{AGENT_TOOL_UID}" outside_id: ' + f'"{AGENT_TOOL_OUTSIDE_UID}" count: 1 use_newidmap: true }}' + ) in config_text + assert f'inside_id: "{AGENT_SHARED_GID}"' in config_text + assert 'cap: "CAP_SETUID"' in config_text + assert ( + f'mount {{ src: "{session_home}" dst: "/home/agent" is_bind: true rw: true }}' + in config_text + ) + assert ( + f'mount {{ src: "{session_work}" dst: "/work" is_bind: true rw: true }}' + in config_text + ) + assert ( + 'dst: "/home/tools" fstype: "tmpfs" rw: true ' + f'options: "size=64M,mode=0700,uid={AGENT_TOOL_UID},gid={AGENT_SHARED_GID}"' + in config_text + ) + + def test_worker_pool_config_includes_seccomp_policy(tmp_path: Path): """Warm worker pool configs should emit the shared seccomp policy.""" pool = WorkerPool() diff --git a/tracecat/agent/runtime/claude_code/runtime.py b/tracecat/agent/runtime/claude_code/runtime.py index 8a47c678d..6bdd66895 100644 --- a/tracecat/agent/runtime/claude_code/runtime.py +++ b/tracecat/agent/runtime/claude_code/runtime.py @@ -12,8 +12,10 @@ from __future__ import annotations import asyncio +import base64 import os import re +import shlex import tempfile import uuid from collections.abc import AsyncIterator, Callable, Sequence @@ -182,6 +184,15 @@ class _StdioMCPServerSpec: blocked_approval_tools: set[str] +@dataclass(frozen=True, slots=True) +class _ToolCommandSpec: + """Executable configuration for a model-controlled child process.""" + + command: str + args: list[str] + env: dict[str, str] + + def _configure_claude_sdk_process_env() -> None: """Prime process-level SDK env before ClaudeSDKClient.connect(). @@ -234,6 +245,23 @@ def _configure_claude_sdk_process_env() -> None: "WebFetch", ] +NATIVE_MUTATION_TOOLS = frozenset({"Write", "Edit"}) +BASE_NATIVE_TOOL_INVENTORY = frozenset( + { + "Bash", + "Read", + "Write", + "Edit", + "Glob", + "Grep", + "Skill", + "ToolSearch", + } +) +JAILED_TOOL_WRAPPER_COMMAND = "/usr/local/bin/python3" +JAILED_TOOL_WRAPPER_SCRIPT = "/run/tracecat/job/shim_entrypoint.py" +JAILED_TOOL_WRAPPER_MODE = "--tracecat-tool-launch" + COMMAND_LINE_TOOLS_PROMPT = ( "\n" "- `duckdb`: The runtime shell includes the DuckDB CLI. Use it for local " @@ -301,6 +329,7 @@ def __init__( self.registry_tools: dict[str, MCPToolDefinition] | None = None self.tool_approvals: dict[str, bool] | None = None self._stdio_approval_blocked_tools: set[str] = set() + self._available_mcp_tools: set[str] | None = None self._runtime_internet_access_enabled: bool = False self._explicit_subagent_aliases: set[str] = set() self._registry_mcp_server_names: set[str] = {REGISTRY_MCP_SERVER_NAME} @@ -375,6 +404,58 @@ def _trusted_mcp_server_config(auth_token: str) -> McpHttpServerConfig: }, } + @staticmethod + def _tool_command_spec( + *, + command: str, + args: Sequence[str] = (), + env: dict[str, str] | None = None, + ) -> _ToolCommandSpec: + """Wrap a model-controlled command with the trusted UID demoter.""" + configured_env = dict(env or {}) + if TRACECAT__DISABLE_NSJAIL: + return _ToolCommandSpec( + command=command, + args=list(args), + env=configured_env, + ) + + payload = base64.urlsafe_b64encode( + orjson.dumps( + { + "argv": [command, *args], + "env": configured_env, + } + ) + ).decode("ascii") + return _ToolCommandSpec( + command=JAILED_TOOL_WRAPPER_COMMAND, + args=[ + "-I", + JAILED_TOOL_WRAPPER_SCRIPT, + JAILED_TOOL_WRAPPER_MODE, + payload, + ], + env={}, + ) + + @classmethod + def _wrapped_bash_input(cls, tool_input: dict[str, Any]) -> dict[str, Any]: + """Return a Bash input whose command is passed as opaque wrapper data.""" + if TRACECAT__DISABLE_NSJAIL: + return tool_input + original_command = tool_input.get("command") + if not isinstance(original_command, str) or not original_command: + raise AgentSandboxValidationError("Bash command must be a non-empty string") + spec = cls._tool_command_spec( + command="/bin/bash", + args=("-c", original_command), + ) + return { + **tool_input, + "command": shlex.join([spec.command, *spec.args]), + } + @staticmethod def _mcp_tool_name_for_action(server_name: str, action_name: str) -> str: if action_name.startswith("mcp__"): @@ -383,10 +464,6 @@ def _mcp_tool_name_for_action(server_name: str, action_name: str) -> str: concrete_name = action_name_to_mcp_tool_name(action_name) return f"mcp__{server_name}__{concrete_name}" - @staticmethod - def _mcp_tool_wildcard_for_server(server_name: str) -> str: - return f"mcp__{server_name}__*" - @staticmethod def _mcp_tool_name_for_user_mcp_tool(server_name: str, tool_name: str) -> str: return f"mcp__{server_name}__{tool_name}" @@ -414,7 +491,10 @@ def _mcp_tool_names_for_stdio_servers( tool_names: set[str] = set() for server_name in stdio_server_names: if server_name not in stdio_tools_by_server: - tool_names.add(cls._mcp_tool_wildcard_for_server(server_name)) + logger.warning( + "Excluding stdio MCP server without a verified tool inventory", + server_name=server_name, + ) continue tool_names.update( @@ -521,14 +601,19 @@ def _stdio_mcp_server_spec( suffix += 1 used_names.add(server_name) + wrapped_command = cls._tool_command_spec( + command=stdio_config["command"], + args=stdio_config.get("args", ()), + env=stdio_config.get("env"), + ) server_config: dict[str, Any] = { "type": "stdio", - "command": stdio_config["command"], + "command": wrapped_command.command, } - if args := stdio_config.get("args"): - server_config["args"] = args - if env := stdio_config.get("env"): - server_config["env"] = env + if wrapped_command.args: + server_config["args"] = wrapped_command.args + if wrapped_command.env: + server_config["env"] = wrapped_command.env if (timeout := stdio_config.get("timeout")) is not None: server_config["timeout"] = timeout servers[server_name] = cast(McpStdioServerConfig, server_config) @@ -1139,6 +1224,56 @@ async def _send_pending_interrupt(self) -> None: self._interrupt_sent = True await self.client.interrupt() + def _canonical_native_mutation_path(self, raw_path: object) -> str: + """Validate and canonicalize a native Write/Edit path beneath /work.""" + if not isinstance(raw_path, str) or not raw_path or "\x00" in raw_path: + raise AgentSandboxValidationError( + "Native mutation requires a non-empty file_path without NUL bytes" + ) + if self._cwd is None: + raise AgentSandboxValidationError("Runtime work directory is unavailable") + + logical_root = self._cwd + host_root_input = self._cwd_setup_path or logical_root + requested_path = Path(raw_path) + if ".." in requested_path.parts: + raise AgentSandboxValidationError( + "Native mutation paths cannot contain traversal components" + ) + if requested_path.is_absolute(): + try: + relative_path = requested_path.relative_to(logical_root) + except ValueError as exc: + raise AgentSandboxValidationError( + "Native mutation is restricted to the runtime work directory" + ) from exc + else: + relative_path = requested_path + if relative_path == Path(".") or not relative_path.parts: + raise AgentSandboxValidationError( + "Native mutation cannot target the work directory itself" + ) + + try: + host_root = host_root_input.resolve(strict=True) + candidate = host_root / relative_path + resolved_parent = candidate.parent.resolve(strict=True) + resolved_parent.relative_to(host_root) + canonical_target = resolved_parent / candidate.name + if candidate.is_symlink(): + canonical_target = candidate.resolve(strict=True) + canonical_relative = canonical_target.relative_to(host_root) + except (OSError, RuntimeError, ValueError) as exc: + raise AgentSandboxValidationError( + "Native mutation path could not be safely resolved beneath the work directory" + ) from exc + + if canonical_relative == Path(".") or not canonical_relative.parts: + raise AgentSandboxValidationError( + "Native mutation cannot target the work directory itself" + ) + return str(logical_root / canonical_relative) + async def _pre_tool_use_hook( self, input_data: HookInput, @@ -1158,6 +1293,48 @@ async def _pre_tool_use_hook( tool_name: str = input_data.get("tool_name", "") tool_input: dict[str, Any] = input_data.get("tool_input", {}) + if ( + self._available_mcp_tools is not None + and tool_name.startswith("mcp__") + and tool_name not in self._available_mcp_tools + ): + return { + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "deny", + "permissionDecisionReason": ( + f"Tool '{tool_name}' is not in the runtime MCP inventory." + ), + } + } + + updated_tool_input = tool_input + if tool_name in NATIVE_MUTATION_TOOLS: + try: + canonical_path = self._canonical_native_mutation_path( + tool_input.get("file_path") + ) + except AgentSandboxValidationError as exc: + return { + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "deny", + "permissionDecisionReason": str(exc), + } + } + updated_tool_input = {**tool_input, "file_path": canonical_path} + elif tool_name == "Bash": + try: + updated_tool_input = self._wrapped_bash_input(tool_input) + except AgentSandboxValidationError as exc: + return { + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "deny", + "permissionDecisionReason": str(exc), + } + } + action_name = normalize_mcp_tool_name(tool_name) if denial_reason := self._validate_agent_tool_call( input_data, @@ -1229,7 +1406,9 @@ async def _pre_tool_use_hook( "hookEventName": "PreToolUse", "permissionDecision": "allow", } - if tool_name in AGENT_TOOL_NAMES: + if updated_tool_input is not tool_input: + hook_output["updatedInput"] = updated_tool_input + elif tool_name in AGENT_TOOL_NAMES: sanitized_input = sanitize_agent_tool_input(tool_name, tool_input) if sanitized_input != tool_input: hook_output["updatedInput"] = sanitized_input @@ -1391,6 +1570,7 @@ def _configure_runtime_state(self, payload: RuntimeInitPayload) -> None: self.registry_tools = payload.allowed_actions self.tool_approvals = payload.config.tool_approvals self._stdio_approval_blocked_tools = set() + self._available_mcp_tools = None self._agents_enabled = payload.config.agents.enabled self._explicit_subagent_aliases = { subagent.alias for subagent in payload.subagents @@ -1447,6 +1627,15 @@ def _root_allowed_tools( allowed_tools.extend(sorted(AGENT_TOOL_NAMES)) return allowed_tools + def _root_builtin_tool_inventory(self) -> list[str]: + """Return the fail-closed Claude built-in tool availability boundary.""" + inventory = set(BASE_NATIVE_TOOL_INVENTORY) + if self._runtime_internet_access_enabled: + inventory.update(INTERNET_TOOLS) + if self._agents_enabled: + inventory.update(AGENT_TOOL_NAMES) + return sorted(inventory) + @staticmethod def _stdio_tools_system_prompt( tools_by_server: dict[str, list[MCPServerToolSummary]], @@ -1533,7 +1722,10 @@ def _build_options( if payload.config.enable_thinking else {"type": "disabled"} ), - setting_sources=["user"], + tools=self._root_builtin_tool_inventory(), + setting_sources=[], + settings="{}", + extra_args={"strict-mcp-config": None}, env=self._sdk_env(payload), model=get_litellm_route_model( model_provider=payload.config.model_provider, @@ -1677,6 +1869,22 @@ def log_benchmark_phase(phase: str, **extra: object) -> None: stdio_mcp_servers = stdio_mcp_spec.servers mcp_servers.update(stdio_mcp_servers) agent_definitions = self._build_agent_definitions(payload=payload) + available_mcp_tools = { + tool_name + for tool_name in self._root_allowed_tools( + actions=payload.allowed_actions, + stdio_server_names=list(stdio_mcp_servers), + stdio_tools_by_server=stdio_mcp_spec.tools_by_server, + ) + if tool_name.startswith("mcp__") + } + for definition in (agent_definitions or {}).values(): + available_mcp_tools.update( + tool_name + for tool_name in (definition.tools or []) + if tool_name.startswith("mcp__") + ) + self._available_mcp_tools = available_mcp_tools def handle_claude_stderr(line: str) -> None: """Forward Claude CLI stderr to loopback via queue.""" diff --git a/tracecat/agent/runtime/claude_code/transport.py b/tracecat/agent/runtime/claude_code/transport.py index 4449a1d5b..2c284c039 100644 --- a/tracecat/agent/runtime/claude_code/transport.py +++ b/tracecat/agent/runtime/claude_code/transport.py @@ -476,10 +476,17 @@ def _rewrite_command_for_jail(cls, command: list[str]) -> list[str]: def _build_claude_env_overlay(self) -> dict[str, str]: """Build the Claude child env overlay applied inside the shim.""" + runtime_home = self._path_mapping.runtime_home_dir env = { "CLAUDE_CODE_ENTRYPOINT": "sdk-py", "CLAUDE_AGENT_SDK_VERSION": __version__, - "HOME": str(self._path_mapping.runtime_home_dir), + "HOME": str(runtime_home), + "XDG_CONFIG_HOME": str(runtime_home / ".config"), + "XDG_CACHE_HOME": str(runtime_home / ".cache"), + "XDG_STATE_HOME": str(runtime_home / ".local/state"), + "TMPDIR": str(runtime_home / "tmp"), + "TEMP": str(runtime_home / "tmp"), + "TMP": str(runtime_home / "tmp"), "PWD": str(self._path_mapping.runtime_work_dir), **self._options.env, } diff --git a/tracecat/agent/runtime/session_paths.py b/tracecat/agent/runtime/session_paths.py index 16f70e943..775011d11 100644 --- a/tracecat/agent/runtime/session_paths.py +++ b/tracecat/agent/runtime/session_paths.py @@ -7,6 +7,7 @@ from pathlib import Path JAILED_AGENT_HOME_DIR = Path("/home/agent") +JAILED_TOOL_HOME_DIR = Path("/home/tools") JAILED_AGENT_JOB_DIR = Path("/run/tracecat/job") JAILED_AGENT_WORK_DIR = Path("/work") @@ -30,8 +31,10 @@ def build_agent_sandbox_path_mapping( session_root = Path(tempfile.gettempdir()) / f"tracecat-agent-{session_id}" host_home_dir = session_root / "agent-home" host_work_dir = session_root / "agent-work-dir" - host_home_dir.mkdir(parents=True, exist_ok=True) - host_work_dir.mkdir(parents=True, exist_ok=True) + host_home_dir.mkdir(parents=True, exist_ok=True, mode=0o700) + host_home_dir.chmod(0o700) + host_work_dir.mkdir(parents=True, exist_ok=True, mode=0o2770) + host_work_dir.chmod(0o2770) if disable_nsjail: runtime_home_dir = host_home_dir diff --git a/tracecat/agent/sandbox/config.py b/tracecat/agent/sandbox/config.py index 5add0513a..85a455d41 100644 --- a/tracecat/agent/sandbox/config.py +++ b/tracecat/agent/sandbox/config.py @@ -40,11 +40,21 @@ JAILED_AGENT_HOME_DIR, JAILED_AGENT_JOB_DIR, JAILED_AGENT_WORK_DIR, + JAILED_TOOL_HOME_DIR, ) # Valid environment variable name pattern (POSIX compliant) _ENV_VAR_KEY_PATTERN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") +# The Claude process owns its private runtime state as UID 1000. Model-controlled +# Bash commands and stdio MCP servers are irreversibly demoted to UID 1001. Both +# identities use the same mapped group so they can collaborate only through +# /work. +AGENT_CLAUDE_UID = 1000 +AGENT_TOOL_UID = 1001 +AGENT_SHARED_GID = 1000 +AGENT_TOOL_OUTSIDE_UID = 100000 + def _contains_dangerous_chars(value: str) -> tuple[bool, str | None]: """Check for null bytes and control characters that could affect sandbox behavior. @@ -119,7 +129,17 @@ class AgentSandboxConfig: AGENT_SANDBOX_BASE_ENV = { "PATH": "/usr/local/bin:/usr/bin:/bin", "HOME": "/home/agent", + "XDG_CONFIG_HOME": "/home/agent/.config", + "XDG_CACHE_HOME": "/home/agent/.cache", + "XDG_STATE_HOME": "/home/agent/.local/state", + "TMPDIR": "/home/agent/tmp", + "TEMP": "/home/agent/tmp", + "TMP": "/home/agent/tmp", "USER": "agent", + "LOGNAME": "agent", + "TRACECAT__AGENT_CLAUDE_UID": str(AGENT_CLAUDE_UID), + "TRACECAT__AGENT_TOOL_UID": str(AGENT_TOOL_UID), + "TRACECAT__AGENT_SHARED_GID": str(AGENT_SHARED_GID), "TRACECAT__DISABLE_NSJAIL": "false", "PYTHONDONTWRITEBYTECODE": "1", "PYTHONUNBUFFERED": "1", @@ -309,9 +329,11 @@ def build_agent_nsjail_config( "clone_newipc: true", "clone_newuts: true", "", - "# UID/GID mapping - map container user to sandbox user", - f'uidmap {{ inside_id: "1000" outside_id: "{os.getuid()}" count: 1 }}', - f'gidmap {{ inside_id: "1000" outside_id: "{os.getgid()}" count: 1 }}', + "# UID/GID mapping - Claude plus a subordinate tool identity", + f'uidmap {{ inside_id: "{AGENT_CLAUDE_UID}" outside_id: "{os.getuid()}" count: 1 use_newidmap: true }}', + f'uidmap {{ inside_id: "{AGENT_TOOL_UID}" outside_id: "{AGENT_TOOL_OUTSIDE_UID}" count: 1 use_newidmap: true }}', + f'gidmap {{ inside_id: "{AGENT_SHARED_GID}" outside_id: "{os.getgid()}" count: 1 }}', + 'cap: "CAP_SETUID"', "", "# Syscall filtering", f'seccomp_string: "{build_untrusted_seccomp_policy()}"', @@ -359,7 +381,7 @@ def build_agent_nsjail_config( 'mount { src: "/dev/zero" dst: "/dev/zero" is_bind: true rw: false }', "", "# Temporary filesystems", - 'mount { dst: "/tmp" fstype: "tmpfs" rw: true options: "size=256M" }', + 'mount { dst: "/tmp" fstype: "tmpfs" rw: true options: "size=256M,mode=1777" }', "", "# Tracecat job mountpoint namespace", "# The tmpfs only backs files placed directly under this directory;", @@ -419,7 +441,7 @@ def build_agent_nsjail_config( [ "", "# Ephemeral agent work dir", - f'mount {{ dst: "{JAILED_AGENT_WORK_DIR}" fstype: "tmpfs" rw: true options: "size=256M" }}', + f'mount {{ dst: "{JAILED_AGENT_WORK_DIR}" fstype: "tmpfs" rw: true options: "size=256M,mode=2770,uid={AGENT_CLAUDE_UID},gid={AGENT_SHARED_GID}" }}', ] ) @@ -436,10 +458,18 @@ def build_agent_nsjail_config( [ "", "# Ephemeral agent home", - f'mount {{ dst: "{JAILED_AGENT_HOME_DIR}" fstype: "tmpfs" rw: true options: "size=64M" }}', + f'mount {{ dst: "{JAILED_AGENT_HOME_DIR}" fstype: "tmpfs" rw: true options: "size=64M,mode=0700,uid={AGENT_CLAUDE_UID},gid={AGENT_SHARED_GID}" }}', ] ) + lines.extend( + [ + "", + "# Ephemeral private home for model-controlled tool processes", + f'mount {{ dst: "{JAILED_TOOL_HOME_DIR}" fstype: "tmpfs" rw: true options: "size=64M,mode=0700,uid={AGENT_TOOL_UID},gid={AGENT_SHARED_GID}" }}', + ] + ) + if skills_dir is not None: if session_home_dir is not None: lines.extend( diff --git a/tracecat/agent/sandbox/nsjail.py b/tracecat/agent/sandbox/nsjail.py index 814fa9d11..489c6a338 100644 --- a/tracecat/agent/sandbox/nsjail.py +++ b/tracecat/agent/sandbox/nsjail.py @@ -25,7 +25,9 @@ import asyncio import os +import pwd import shutil +import stat import sys import tempfile import uuid @@ -47,6 +49,7 @@ JAILED_AGENT_WORK_DIR, ) from tracecat.agent.sandbox.config import ( + AGENT_TOOL_OUTSIDE_UID, JAILED_SHIM_ENTRYPOINT_PATH, AgentSandboxConfig, build_agent_env_map, @@ -63,6 +66,8 @@ SESSION_HOME_ENV_VAR = "TRACECAT__AGENT_SESSION_HOME_DIR" SESSION_WORK_DIR_ENV_VAR = "TRACECAT__AGENT_SESSION_WORK_DIR" CLAUDE_SHIM_STDIO_LIMIT_BYTES = 5 * 1024 * 1024 +NEWUIDMAP_PATH = Path("/usr/bin/newuidmap") +SUBUID_PATH = Path("/etc/subuid") @dataclass(frozen=True) @@ -125,6 +130,85 @@ def _get_tracecat_pkg_dir() -> Path: return Path(tracecat.__file__).parent +def _subuid_file_allows(*, owner_names: set[str], outside_uid: int) -> bool: + """Return whether /etc/subuid assigns the requested identity to this user.""" + try: + lines = SUBUID_PATH.read_text().splitlines() + except OSError as exc: + raise AgentSandboxExecutionError( + f"Could not read subordinate UID configuration: {SUBUID_PATH}" + ) from exc + + for line in lines: + try: + owner, start_text, count_text = line.split(":", maxsplit=2) + start = int(start_text) + count = int(count_text) + except ValueError: + continue + if owner in owner_names and start <= outside_uid < start + count: + return True + return False + + +def _validate_tool_uid_mapping() -> None: + """Fail closed unless the executor can create the subordinate tool UID map.""" + try: + helper_stat = NEWUIDMAP_PATH.stat() + except OSError as exc: + raise AgentSandboxExecutionError( + f"Required UID mapping helper is unavailable: {NEWUIDMAP_PATH}" + ) from exc + + if ( + helper_stat.st_uid != 0 + or not stat.S_ISREG(helper_stat.st_mode) + or not helper_stat.st_mode & stat.S_ISUID + or not os.access(NEWUIDMAP_PATH, os.X_OK) + ): + raise AgentSandboxExecutionError( + f"UID mapping helper must be a root-owned setuid executable: {NEWUIDMAP_PATH}" + ) + + try: + username = pwd.getpwuid(os.getuid()).pw_name + except KeyError as exc: + raise AgentSandboxExecutionError( + "Executor UID has no passwd entry for subordinate UID validation" + ) from exc + owner_names = {username, str(os.getuid())} + if not _subuid_file_allows( + owner_names=owner_names, + outside_uid=AGENT_TOOL_OUTSIDE_UID, + ): + raise AgentSandboxExecutionError( + "Executor subordinate UID range does not contain the configured tool UID" + ) + + +def _prepare_runtime_directories( + *, + session_home_dir: Path | None, + session_work_dir: Path | None, +) -> None: + """Create the private Claude home and shared setgid work directory.""" + if session_home_dir is not None: + session_home_dir.mkdir(parents=True, exist_ok=True) + session_home_dir.chmod(0o700) + for relative_path in ( + Path(".config"), + Path(".cache"), + Path(".local/state"), + Path("tmp"), + ): + private_dir = session_home_dir / relative_path + private_dir.mkdir(parents=True, exist_ok=True) + private_dir.chmod(0o700) + if session_work_dir is not None: + session_work_dir.mkdir(parents=True, exist_ok=True) + session_work_dir.chmod(0o2770) + + async def spawn_jailed_runtime( socket_dir: Path, init_payload_path: Path, @@ -290,6 +374,12 @@ async def _spawn_direct_runtime( JAILED_CONTROL_SOCKET_PATH, ) + logger.warning("Agent runtime is using direct mode without UID or mount isolation") + _prepare_runtime_directories( + session_home_dir=session_home_dir, + session_work_dir=session_work_dir, + ) + control_socket_path = socket_dir / CONTROL_SOCKET_NAME shim_script_path = ( _get_tracecat_pkg_dir() / "agent" / "sandbox" / BROKER_SHIM_SCRIPT_NAME @@ -328,11 +418,15 @@ async def _spawn_direct_runtime( if value := os.environ.get(key): env[key] = value if session_home_dir is not None: - session_home_dir.mkdir(parents=True, exist_ok=True) env[SESSION_HOME_ENV_VAR] = str(session_home_dir) env["HOME"] = str(session_home_dir) + env["XDG_CONFIG_HOME"] = str(session_home_dir / ".config") + env["XDG_CACHE_HOME"] = str(session_home_dir / ".cache") + env["XDG_STATE_HOME"] = str(session_home_dir / ".local/state") + env["TMPDIR"] = str(session_home_dir / "tmp") + env["TEMP"] = str(session_home_dir / "tmp") + env["TMP"] = str(session_home_dir / "tmp") if session_work_dir is not None: - session_work_dir.mkdir(parents=True, exist_ok=True) env[SESSION_WORK_DIR_ENV_VAR] = str(session_work_dir) await _sync_direct_skills_dir( skills_dir=skills_dir, @@ -382,6 +476,7 @@ async def _spawn_nsjail_runtime( raise AgentSandboxExecutionError(f"Rootfs not found: {rootfs}") if not nsjail.exists(): raise AgentSandboxExecutionError(f"nsjail binary not found: {nsjail}") + _validate_tool_uid_mapping() if llm_socket_path is not None and not llm_socket_path.exists(): raise AgentSandboxExecutionError(f"LLM socket not found: {llm_socket_path}") if mcp_socket_path is None: @@ -403,10 +498,10 @@ async def _spawn_nsjail_runtime( jailed_init_payload_path = job_dir / "init.json" try: - if session_home_dir is not None: - session_home_dir.mkdir(parents=True, exist_ok=True) - if session_work_dir is not None: - session_work_dir.mkdir(parents=True, exist_ok=True) + _prepare_runtime_directories( + session_home_dir=session_home_dir, + session_work_dir=session_work_dir, + ) if skills_dir is not None: skills_dir.mkdir(parents=True, exist_ok=True) if session_home_dir is not None: diff --git a/tracecat/agent/sandbox/shim_entrypoint.py b/tracecat/agent/sandbox/shim_entrypoint.py index 62e209583..d2b08cfc7 100644 --- a/tracecat/agent/sandbox/shim_entrypoint.py +++ b/tracecat/agent/sandbox/shim_entrypoint.py @@ -8,7 +8,10 @@ from __future__ import annotations import asyncio +import base64 +import binascii import contextlib +import ctypes import json import logging import os @@ -28,6 +31,45 @@ LLM_BRIDGE_HOST = "127.0.0.1" TRUSTED_MCP_BRIDGE_PATH = "/mcp" MAX_BODY_SIZE = 10 * 1024 * 1024 +TOOL_LAUNCH_MODE = "--tracecat-tool-launch" +CAP_SETUID = 7 +LINUX_CAPABILITY_VERSION_3 = 0x20080522 +PR_CAP_AMBIENT = 47 +PR_CAP_AMBIENT_CLEAR_ALL = 4 +TOOL_ENVIRONMENT_BOUNDARY_KEYS = frozenset( + { + "PATH", + "HOME", + "XDG_CONFIG_HOME", + "XDG_CACHE_HOME", + "XDG_STATE_HOME", + "TMPDIR", + "TEMP", + "TMP", + "USER", + "LOGNAME", + "PWD", + } +) + + +class _CapabilityHeader(ctypes.Structure): + _fields_ = [("version", ctypes.c_uint32), ("pid", ctypes.c_int)] + + +class _CapabilityData(ctypes.Structure): + _fields_ = [ + ("effective", ctypes.c_uint32), + ("permitted", ctypes.c_uint32), + ("inheritable", ctypes.c_uint32), + ] + + +class ToolLaunchPayload(TypedDict): + """Trusted wrapper payload for one demoted tool process.""" + + argv: list[str] + env: dict[str, str] class ClaudeShimInitPayload(TypedDict): @@ -40,6 +82,170 @@ class ClaudeShimInitPayload(TypedDict): mcp_bridge_fd: NotRequired[int | None] +def _required_identity_env(name: str) -> int: + value = os.environ.get(name) + if value is None: + raise RuntimeError(f"{name} is not set") + try: + parsed = int(value) + except ValueError as exc: + raise RuntimeError(f"{name} must be an integer") from exc + if parsed < 0: + raise RuntimeError(f"{name} must be non-negative") + return parsed + + +def _process_status_fields() -> dict[str, str]: + fields: dict[str, str] = {} + for line in Path("/proc/self/status").read_text().splitlines(): + key, separator, value = line.partition(":") + if separator: + fields[key] = value.strip() + return fields + + +def _capability_sets() -> dict[str, int]: + status = _process_status_fields() + return { + field: int(status.get(field, "0"), 16) + for field in ("CapInh", "CapPrm", "CapEff", "CapAmb") + } + + +def _verify_claude_identity() -> None: + """Verify nsjail retained only the capability needed by the trusted shim.""" + if os.environ.get("TRACECAT__DISABLE_NSJAIL") == "true": + return + + claude_uid = _required_identity_env("TRACECAT__AGENT_CLAUDE_UID") + shared_gid = _required_identity_env("TRACECAT__AGENT_SHARED_GID") + if (os.getuid(), os.geteuid()) != (claude_uid, claude_uid): + raise RuntimeError("Claude shim did not start as the configured Claude UID") + if (os.getgid(), os.getegid()) != (shared_gid, shared_gid): + raise RuntimeError("Claude shim did not start with the shared work GID") + + expected = 1 << CAP_SETUID + capability_sets = _capability_sets() + if any(value != expected for value in capability_sets.values()): + raise RuntimeError( + "Claude shim must start with only CAP_SETUID in every capability set" + ) + if _process_status_fields().get("NoNewPrivs") != "1": + raise RuntimeError("Claude shim must start with no_new_privs enabled") + + +def _clear_capability_sets() -> None: + """Drop effective, permitted, inheritable, and ambient capabilities.""" + libc = ctypes.CDLL(None, use_errno=True) + data = (_CapabilityData * 2)() + header = _CapabilityHeader(version=LINUX_CAPABILITY_VERSION_3, pid=0) + if libc.capset(ctypes.byref(header), ctypes.byref(data)) != 0: + error_number = ctypes.get_errno() + raise OSError(error_number, os.strerror(error_number)) + if libc.prctl(PR_CAP_AMBIENT, PR_CAP_AMBIENT_CLEAR_ALL, 0, 0, 0) != 0: + error_number = ctypes.get_errno() + raise OSError(error_number, os.strerror(error_number)) + + +def _tool_environment(configured_env: dict[str, str]) -> dict[str, str]: + tool_home = Path("/home/tools") + env = { + "PATH": "/usr/local/bin:/usr/bin:/bin", + "HOME": str(tool_home), + "XDG_CONFIG_HOME": str(tool_home / ".config"), + "XDG_CACHE_HOME": str(tool_home / ".cache"), + "XDG_STATE_HOME": str(tool_home / ".local/state"), + "TMPDIR": str(tool_home / "tmp"), + "TEMP": str(tool_home / "tmp"), + "TMP": str(tool_home / "tmp"), + "USER": "tools", + "LOGNAME": "tools", + "PWD": "/work", + "LANG": "C.UTF-8", + "LC_ALL": "C.UTF-8", + "PYTHONDONTWRITEBYTECODE": "1", + "PYTHONUNBUFFERED": "1", + } + for key, value in configured_env.items(): + if not key or "=" in key or "\x00" in key or "\x00" in value: + raise RuntimeError("Tool environment contains an invalid entry") + if key in TOOL_ENVIRONMENT_BOUNDARY_KEYS: + raise RuntimeError(f"Tool environment cannot override {key}") + env[key] = value + return env + + +def _decode_tool_launch_payload(encoded_payload: str) -> ToolLaunchPayload: + try: + decoded = base64.urlsafe_b64decode(encoded_payload.encode("ascii")) + raw_payload = json.loads(decoded) + except (binascii.Error, ValueError, UnicodeError, json.JSONDecodeError) as exc: + raise RuntimeError("Tool launch payload is malformed") from exc + if not isinstance(raw_payload, dict): + raise RuntimeError("Tool launch payload must be an object") + argv = raw_payload.get("argv") + env = raw_payload.get("env") + if ( + not isinstance(argv, list) + or not argv + or not all(isinstance(value, str) and value for value in argv) + ): + raise RuntimeError("Tool launch argv must be a non-empty list[str]") + if not isinstance(env, dict) or not all( + isinstance(key, str) and isinstance(value, str) for key, value in env.items() + ): + raise RuntimeError("Tool launch env must be a dict[str, str]") + return {"argv": argv, "env": env} + + +def _run_demoted_tool(encoded_payload: str) -> None: + """Irreversibly demote and replace this process with a configured tool.""" + if os.environ.get("TRACECAT__DISABLE_NSJAIL") == "true": + raise RuntimeError("UID-demoted tool launching is unavailable in direct mode") + _verify_claude_identity() + payload = _decode_tool_launch_payload(encoded_payload) + tool_uid = _required_identity_env("TRACECAT__AGENT_TOOL_UID") + shared_gid = _required_identity_env("TRACECAT__AGENT_SHARED_GID") + + os.setresuid(tool_uid, tool_uid, tool_uid) + _clear_capability_sets() + if (os.getuid(), os.geteuid(), os.getresuid()[2]) != ( + tool_uid, + tool_uid, + tool_uid, + ): + raise RuntimeError("Tool process did not irreversibly enter the tool UID") + if (os.getgid(), os.getegid()) != (shared_gid, shared_gid): + raise RuntimeError("Tool process lost the shared work GID") + if any(_capability_sets().values()): + raise RuntimeError("Tool process retained Linux capabilities") + if _process_status_fields().get("NoNewPrivs") != "1": + raise RuntimeError("Tool process must retain no_new_privs") + + tool_home = Path("/home/tools") + for path in ( + tool_home / ".config", + tool_home / ".cache", + tool_home / ".local/state", + tool_home / "tmp", + ): + path.mkdir(parents=True, exist_ok=True, mode=0o700) + os.chdir("/work") + os.umask(0o002) + os.execvpe(payload["argv"][0], payload["argv"], _tool_environment(payload["env"])) + + +def _prepare_claude_runtime_home() -> None: + home = Path(os.environ["HOME"]) + for env_name in ("XDG_CONFIG_HOME", "XDG_CACHE_HOME", "XDG_STATE_HOME", "TMPDIR"): + Path(os.environ.get(env_name, str(home))).mkdir( + parents=True, + exist_ok=True, + mode=0o700, + ) + os.umask(0o002) + + class LLMBridge: """HTTP bridge that forwards localhost traffic to a Unix socket.""" @@ -254,6 +460,8 @@ def _rewrite_mcp_bridge_command_port( async def run_sandboxed_claude_shim() -> None: """Read shim config, start the LLM bridge, and proxy Claude stdio.""" + _verify_claude_identity() + _prepare_claude_runtime_home() llm_bridge: LLMBridge | None = None mcp_bridge: LLMBridge | None = None process: asyncio.subprocess.Process | None = None @@ -468,6 +676,11 @@ def main() -> None: level=os.environ.get("LOG_LEVEL", "INFO").upper(), format="%(asctime)s %(levelname)s %(name)s %(message)s", ) + if len(sys.argv) == 3 and sys.argv[1] == TOOL_LAUNCH_MODE: + _run_demoted_tool(sys.argv[2]) + return + if len(sys.argv) != 1: + raise RuntimeError("Unsupported sandbox shim invocation") asyncio.run(run_sandboxed_claude_shim())