mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-09 00:12:21 +02:00
fix(runtime): resolve Wave 3-S audit findings (P2-1, P2-2, P2-3)
- P2-1: reject non-process PID values (None, 0, negative integers) in pid_alive without invoking underlying process probe. - P2-2: truthfully represent production external bridge executions as uncontained, external, non-authoritative grants carrying sanitized endpoint metadata. - P2-3: reject writable_extra overlay bindings over protected system roots and their descendants while preserving legitimate scratch destinations.
This commit is contained in:
@@ -653,6 +653,16 @@ class HostShellTool:
|
||||
requested_timeout = 30
|
||||
timeout = max(1, min(requested_timeout, 120))
|
||||
|
||||
from src import containment
|
||||
from src.tool_execution import agent_cwd
|
||||
|
||||
sanitized_endpoint = f"{parsed.scheme}://{parsed.netloc}{parsed.path}" if parsed.scheme and parsed.netloc else "host_shell_bridge"
|
||||
owner = str(ctx.get("session_id") or ctx.get("owner") or "host_shell")
|
||||
spec = _owned_spec(agent_cwd(), ctx.get("subproc_env"), timeout)
|
||||
grant = containment.declare_external_bridge(spec, owner=owner, endpoint=sanitized_endpoint)
|
||||
boundary = grant.to_dict()
|
||||
boundary["executed"] = False
|
||||
|
||||
request_body: dict[str, object] = {"timeout": timeout}
|
||||
request_id = ""
|
||||
if job_id:
|
||||
@@ -676,6 +686,8 @@ class HostShellTool:
|
||||
return {
|
||||
"error": f"host_shell: bridge returned HTTP {resp.status_code}",
|
||||
"exit_code": 1,
|
||||
"host_bridge": "tui",
|
||||
"containment": boundary,
|
||||
}
|
||||
data = resp.json()
|
||||
|
||||
@@ -702,6 +714,8 @@ class HostShellTool:
|
||||
return {
|
||||
"error": f"host_shell: bridge returned HTTP {poll.status_code}",
|
||||
"exit_code": 1,
|
||||
"host_bridge": "tui",
|
||||
"containment": boundary,
|
||||
}
|
||||
data = poll.json()
|
||||
if not isinstance(data, dict):
|
||||
@@ -732,15 +746,16 @@ class HostShellTool:
|
||||
task.add_done_callback(_HOST_SHELL_CANCEL_TASKS.discard)
|
||||
raise
|
||||
except Exception as e:
|
||||
return {"error": f"host_shell: bridge call failed: {e}", "exit_code": 1}
|
||||
return {"error": f"host_shell: bridge call failed: {e}", "exit_code": 1, "containment": boundary}
|
||||
|
||||
if not isinstance(data, dict):
|
||||
return {"error": "host_shell: bridge returned invalid payload", "exit_code": 1}
|
||||
return {"error": "host_shell: bridge returned invalid payload", "exit_code": 1, "containment": boundary}
|
||||
if data.get("error"):
|
||||
return {
|
||||
"error": _truncate(str(data["error"]), MAX_OUTPUT_CHARS),
|
||||
"exit_code": 1,
|
||||
"host_bridge": "tui",
|
||||
"containment": boundary,
|
||||
}
|
||||
stdout = str(data.get("stdout") or data.get("output") or "")
|
||||
stderr = str(data.get("stderr") or "")
|
||||
@@ -754,15 +769,18 @@ class HostShellTool:
|
||||
"error": "host_shell: bridge returned an invalid exit_code",
|
||||
"exit_code": 1,
|
||||
"host_bridge": "tui",
|
||||
"containment": boundary,
|
||||
}
|
||||
exit_code = raw_exit_code
|
||||
output = stdout.rstrip()
|
||||
if stderr.strip():
|
||||
output = (output + "\nSTDERR: " + stderr.strip()).strip() if output else "STDERR: " + stderr.strip()
|
||||
boundary["executed"] = True
|
||||
result = {
|
||||
"output": _truncate(output, MAX_OUTPUT_CHARS) or "(no output)",
|
||||
"exit_code": exit_code,
|
||||
"host_bridge": "tui",
|
||||
"containment": boundary,
|
||||
}
|
||||
for key in ("detached", "job_id", "status", "running", "finished", "cwd"):
|
||||
if key in data:
|
||||
|
||||
+37
-2
@@ -121,9 +121,35 @@ _DEATH_POLL_S = 0.05
|
||||
# private /tmp or the workspace itself with a host directory would undo the
|
||||
# namespace from inside the argv that builds it.
|
||||
_RESERVED_BIND_DESTS = frozenset({
|
||||
"/", "/tmp", "/proc", "/dev", "/sys", WORKSPACE_MOUNT,
|
||||
"/", "/tmp", "/home", "/proc", "/dev", "/sys", WORKSPACE_MOUNT,
|
||||
})
|
||||
|
||||
# System hierarchies where a writable overlay would invalidate the boundary
|
||||
# established by bubblewrap. Reject both exact roots and all descendants.
|
||||
_PROTECTED_WRITABLE_HIERARCHIES = frozenset({
|
||||
"/etc",
|
||||
"/usr",
|
||||
"/bin",
|
||||
"/sbin",
|
||||
"/lib",
|
||||
"/lib64",
|
||||
"/proc",
|
||||
"/dev",
|
||||
"/sys",
|
||||
"/root",
|
||||
WORKSPACE_MOUNT,
|
||||
})
|
||||
|
||||
|
||||
def _is_protected_writable_destination(path: str) -> bool:
|
||||
normalized = os.path.abspath(path)
|
||||
if normalized in _RESERVED_BIND_DESTS:
|
||||
return True
|
||||
for root in _PROTECTED_WRITABLE_HIERARCHIES:
|
||||
if normalized == root or normalized.startswith(root.rstrip(os.sep) + os.sep):
|
||||
return True
|
||||
return False
|
||||
|
||||
# WORKSPACE_MOUNT is re-exported from src.constants: where the workspace is
|
||||
# mounted inside a namespace is a property of the tool contract, not of this
|
||||
# module, and two definitions of it would be two contracts.
|
||||
@@ -217,6 +243,7 @@ class ContainmentGrant:
|
||||
pgid: Optional[int] = None
|
||||
namespace_pid: Optional[int] = None
|
||||
namespace_start_token: Optional[str] = None
|
||||
endpoint: Optional[str] = None
|
||||
|
||||
@property
|
||||
def contained(self) -> bool:
|
||||
@@ -229,7 +256,7 @@ class ContainmentGrant:
|
||||
Deliberately omits ``env``: it is part of the boundary but it is also
|
||||
where credentials live, and a tool result is model-visible.
|
||||
"""
|
||||
return {
|
||||
data = {
|
||||
"id": self.id,
|
||||
"mechanism": self.mechanism,
|
||||
"mode": self.mode,
|
||||
@@ -242,6 +269,9 @@ class ContainmentGrant:
|
||||
"requested": sorted(self.spec.requested),
|
||||
"network": self.spec.network,
|
||||
}
|
||||
if self.endpoint:
|
||||
data["endpoint"] = self.endpoint
|
||||
return data
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -480,6 +510,7 @@ def _write_record(grant: ContainmentGrant) -> None:
|
||||
"pgid": grant.pgid,
|
||||
"namespace_pid": grant.namespace_pid,
|
||||
"namespace_start_token": grant.namespace_start_token,
|
||||
"endpoint": grant.endpoint,
|
||||
"acquired_at": time.time(),
|
||||
"released_at": None,
|
||||
"release": None,
|
||||
@@ -581,6 +612,9 @@ def _validate_spec(spec: ContainmentSpec) -> ContainmentSpec:
|
||||
for path in writable + readonly:
|
||||
if path in _RESERVED_BIND_DESTS:
|
||||
raise ValueError(f"containment: refusing to bind over reserved path {path}")
|
||||
for path in writable:
|
||||
if _is_protected_writable_destination(path):
|
||||
raise ValueError(f"containment: refusing to bind over reserved path {path}")
|
||||
return replace(spec, workspace=workspace, writable_extra=writable, readonly_extra=readonly)
|
||||
|
||||
|
||||
@@ -770,6 +804,7 @@ def declare_external_bridge(
|
||||
mode=CONTAINMENT_MODE,
|
||||
spec=spec,
|
||||
external=True,
|
||||
endpoint=endpoint,
|
||||
)
|
||||
logger.info(
|
||||
"containment: grant %s is external (%s); nothing local contains it",
|
||||
|
||||
+25
-1
@@ -348,9 +348,24 @@ def _text_write_to_binary_artifact_result(content: str) -> tuple[str, Dict] | No
|
||||
|
||||
async def _route_tool_via_bridge(tool: str, content: str, session_id: Optional[str], client_runtime_context: Optional[Dict]):
|
||||
import base64
|
||||
from urllib.parse import urlparse
|
||||
bridge = _client_bridge(client_runtime_context)
|
||||
if bridge is None:
|
||||
return tool, {"error": f"{tool}: TUI host bridge is not available", "exit_code": 1}
|
||||
|
||||
url = str(bridge.get("url") or "").strip()
|
||||
parsed = urlparse(url)
|
||||
sanitized_endpoint = f"{parsed.scheme}://{parsed.netloc}{parsed.path}" if parsed.scheme and parsed.netloc else "tui_bridge"
|
||||
from src import containment
|
||||
spec = containment.agent_spec(agent_cwd(), {}, int(_BRIDGE_TOOL_TIMEOUT_S))
|
||||
grant = containment.declare_external_bridge(
|
||||
spec,
|
||||
owner=str(session_id or "tui_bridge"),
|
||||
endpoint=sanitized_endpoint,
|
||||
)
|
||||
boundary = grant.to_dict()
|
||||
boundary["executed"] = False
|
||||
|
||||
if tool == "bash":
|
||||
from src.agent_tools.subprocess_tools import _host_shell_requires_detach, _host_shell_should_auto_poll
|
||||
|
||||
@@ -401,9 +416,13 @@ async def _route_tool_via_bridge(tool: str, content: str, session_id: Optional[s
|
||||
"output": "host job still running; poll the returned job_id",
|
||||
"exit_code": 0,
|
||||
}
|
||||
if isinstance(result, dict):
|
||||
b = dict(boundary)
|
||||
b["executed"] = (result.get("exit_code") == 0 or (isinstance(result.get("exit_code"), int) and not result.get("error")))
|
||||
result["containment"] = b
|
||||
return desc, result
|
||||
if tool == "python":
|
||||
return "python: (client)", await _bridge_post(
|
||||
py_res = await _bridge_post(
|
||||
bridge,
|
||||
"/run",
|
||||
{
|
||||
@@ -414,6 +433,11 @@ async def _route_tool_via_bridge(tool: str, content: str, session_id: Optional[s
|
||||
timeout_s=_BRIDGE_TOOL_TIMEOUT_S,
|
||||
err_prefix="python",
|
||||
)
|
||||
if isinstance(py_res, dict):
|
||||
b = dict(boundary)
|
||||
b["executed"] = (py_res.get("exit_code") == 0 or (isinstance(py_res.get("exit_code"), int) and not py_res.get("error")))
|
||||
py_res["containment"] = b
|
||||
return "python: (client)", py_res
|
||||
if tool == "grep":
|
||||
stripped = content.strip()
|
||||
try:
|
||||
|
||||
Reference in New Issue
Block a user