diff --git a/README.md b/README.md index 4065c7d..c20302a 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ It should contain: - guardrail/eval/evidence helpers; - agent sandbox/run helpers; - Agent Machine local mount and secure host-interface helpers; -- Office Plane dry-run, inspection, and evidence helpers; +- Office Plane dry-run, guarded execution, inspection, and evidence helpers; - fingerprint and proof bundle tools; - local-to-mesh registration helpers; - release/operator install scripts. @@ -38,7 +38,7 @@ It should not contain: ## sourceosctl CLI -`sourceosctl` is the read-only/dry-run CLI surface for SourceOS developer and AI operator workflows. +`sourceosctl` is the guarded CLI surface for SourceOS developer and AI operator workflows. Commands are read-only or dry-run by default. Narrow local mutations require explicit `--execute --policy-ok` and emit evidence. ### Usage @@ -62,12 +62,15 @@ sourceosctl [--version] [] [options] | `sourceosctl agents sandbox plan --dry-run` | Print agent sandbox plan (dry-run only) | | `sourceosctl agent-machine mounts plan` | Render Agent Machine local mount plan for dev/docs/downloads roots (dry-run) | | `sourceosctl agent-machine mounts init --dry-run` | Render mount initialization plan; no directories or mounts are created | +| `sourceosctl agent-machine mounts init --execute --policy-ok` | Create only scoped local output/download directories and emit AgentMachineMountEvidence | | `sourceosctl agent-machine mounts inspect [--include-downloads]` | Inspect default/local Agent Machine mount posture | | `sourceosctl agent-machine mounts evidence inspect ` | Inspect Agent Machine mount evidence JSON (read-only) | | `sourceosctl office doctor` | Inspect local Office Plane backend availability, including LibreOffice detection | | `sourceosctl office plan` | Render an OfficeArtifact-compatible workroom artifact plan | | `sourceosctl office generate --dry-run` | Render an Office generation plan without writing files | +| `sourceosctl office generate --execute --policy-ok --format md|txt|json` | Write a guarded text/Markdown/JSON artifact and emit OfficeArtifactEvidence | | `sourceosctl office convert --to --dry-run` | Render a LibreOffice-style conversion plan without writing files | +| `sourceosctl office convert --to --execute --policy-ok` | Run guarded local LibreOffice conversion and emit OfficeArtifactEvidence | | `sourceosctl office inspect ` | Inspect a local office artifact file and hash it | | `sourceosctl office evidence inspect ` | Inspect Office Plane evidence JSON (read-only) | @@ -87,16 +90,19 @@ python3 bin/sourceosctl ai labs list python3 bin/sourceosctl agents sandbox plan --dry-run python3 bin/sourceosctl agent-machine mounts plan python3 bin/sourceosctl agent-machine mounts init --dry-run +python3 bin/sourceosctl agent-machine mounts init --execute --policy-ok --evidence-out ./mount-evidence.json python3 bin/sourceosctl agent-machine mounts inspect --include-downloads python3 bin/sourceosctl office doctor python3 bin/sourceosctl office plan --artifact-type slide-deck --format pptx --title "Demo Deck" python3 bin/sourceosctl office generate --dry-run --artifact-type document --format docx --title "Demo Report" +python3 bin/sourceosctl office generate --execute --policy-ok --artifact-type document --format md --title "Demo Report" --evidence-out ./office-evidence.json python3 bin/sourceosctl office convert ./example.docx --to pdf --dry-run +python3 bin/sourceosctl office convert ./example.docx --to pdf --execute --policy-ok --evidence-out ./office-convert-evidence.json ``` ### Agent Machine local mount defaults -The first Agent Machine mount slice is contract-first and dry-run only. It aligns with the SourceOS contracts in `SourceOS-Linux/sourceos-spec`: +The first Agent Machine mount slice aligns with the SourceOS contracts in `SourceOS-Linux/sourceos-spec`: - `AgentMachineLocalDataPlane` - `AgentMachineMountPolicy` @@ -107,16 +113,18 @@ Default host roots: | Purpose | Host path | Agent path | Posture | | --- | --- | --- | --- | | Code / repositories | `~/dev` | `/workspace/dev` | read/write; explicit workspace root | -| Generated documents / reports | `~/Documents/SourceOS/agent-output` | `/workspace/output` | read/write; created only by future explicit mutation | +| Generated documents / reports | `~/Documents/SourceOS/agent-output` | `/workspace/output` | read/write; created only by explicit guarded materialization | | Browser downloads | `~/Downloads/SourceOS/agent-downloads` | `/workspace/downloads` | browser read/write; agent read-only | The CLI does **not** mount `$HOME` wholesale and does **not** expose `.ssh`, `.gnupg`, browser profiles, keychains, cloud credential directories, token stores, or password stores by default. +Guarded materialization creates only the declared `createIfMissing` folders. It does not create Podman machines, Podman bind mounts, containers, or background services. + TopoLVM is treated as a Linux cluster-local backend profile for the same logical mount contract. It is not used for macOS/APFS local mode and it is not represented as cross-node shared storage. ### Office Plane local defaults -The first Office Plane slice is dry-run/read-only. It aligns with `SocioProphet/prophet-workspace`: +The Office Plane aligns with `SocioProphet/prophet-workspace`: - `ProfessionalWorkroom` - `OfficeArtifact` @@ -137,11 +145,17 @@ Backends are modeled as an abstraction: - Microsoft Graph / Office 365 and Google Workspace: compatibility adapters, not core authority. - SourceOS-native: future native document surfaces. -The CLI does not create, convert, or modify files yet. It renders plans and inspects artifacts/evidence. Email sending and external publishing remain policy-gated side effects and are not enabled here. +Guarded Office execution is intentionally narrow: + +- `office generate --execute --policy-ok` currently writes only `txt`, `md`, or `json` artifacts. +- Office binary generation (`docx`, `xlsx`, `pptx`, `odt`, `ods`, `odp`) remains disabled until template/render backends are hardened. +- `office convert --execute --policy-ok` uses local LibreOffice/`soffice` when available. +- All guarded Office execution emits or writes `OfficeArtifactEvidence`. +- Email sending, external publishing, and calendar modification remain policy-gated side effects and are not enabled here. ### Design constraints -All commands in the current surface are **read-only or dry-run**. No mutating command is implemented. Commands that would mutate host state are explicitly rejected at runtime. +All mutating commands require `--execute --policy-ok`. Commands that would mutate host state without both flags are rejected at runtime. ## First milestone diff --git a/sourceosctl/cli.py b/sourceosctl/cli.py index c082227..b346517 100644 --- a/sourceosctl/cli.py +++ b/sourceosctl/cli.py @@ -163,7 +163,9 @@ def add_mount_common(p): add_mount_common(mounts_plan_p) mounts_plan_p.set_defaults(func=agent_machine.mounts_plan) - mounts_init_p = mounts_sub.add_parser("init", help="Render mount initialization plan (dry-run only)") + mounts_init_p = mounts_sub.add_parser( + "init", help="Render or execute guarded local directory materialization" + ) add_mount_common(mounts_init_p) mounts_init_p.add_argument( "--dry-run", @@ -172,6 +174,23 @@ def add_mount_common(p): dest="dry_run", help="Print plan without creating directories or mounts (default: True)", ) + mounts_init_p.add_argument( + "--execute", + action="store_true", + default=False, + help="Create only explicitly-scoped output/download directories; does not create Podman mounts", + ) + mounts_init_p.add_argument( + "--policy-ok", + action="store_true", + default=False, + help="Confirm Policy Fabric/operator approval for guarded local materialization", + ) + mounts_init_p.add_argument( + "--evidence-out", + default=None, + help="Optional path to write AgentMachineMountEvidence JSON", + ) mounts_init_p.set_defaults(func=agent_machine.mounts_init) mounts_inspect_p = mounts_sub.add_parser("inspect", help="Inspect default/local mount posture") @@ -236,7 +255,9 @@ def add_office_common(p): add_office_common(office_plan_p) office_plan_p.set_defaults(func=office.plan) - office_generate_p = office_sub.add_parser("generate", help="Render Office generation plan (dry-run only)") + office_generate_p = office_sub.add_parser( + "generate", help="Render or execute guarded Office text/Markdown/JSON generation" + ) add_office_common(office_generate_p) office_generate_p.add_argument("--template", default=None, help="Optional template reference") office_generate_p.add_argument("--prompt-ref", default=None, help="Optional prompt/context reference") @@ -248,9 +269,28 @@ def add_office_common(p): dest="dry_run", help="Print generation plan without writing files (default: True)", ) + office_generate_p.add_argument( + "--execute", + action="store_true", + default=False, + help="Write txt/md/json artifacts only; Office binary generation remains disabled", + ) + office_generate_p.add_argument( + "--policy-ok", + action="store_true", + default=False, + help="Confirm Policy Fabric/operator approval for guarded Office generation", + ) + office_generate_p.add_argument( + "--evidence-out", + default=None, + help="Optional path to write OfficeArtifactEvidence JSON", + ) office_generate_p.set_defaults(func=office.generate) - office_convert_p = office_sub.add_parser("convert", help="Render Office conversion plan (dry-run only)") + office_convert_p = office_sub.add_parser( + "convert", help="Render or execute guarded LibreOffice conversion" + ) office_convert_p.add_argument("input", help="Input Office artifact path") office_convert_p.add_argument("--to", required=True, help="Target format, e.g. pdf, docx, pptx") add_office_common(office_convert_p) @@ -261,6 +301,23 @@ def add_office_common(p): dest="dry_run", help="Print conversion plan without writing files (default: True)", ) + office_convert_p.add_argument( + "--execute", + action="store_true", + default=False, + help="Run LibreOffice conversion under guarded local execution", + ) + office_convert_p.add_argument( + "--policy-ok", + action="store_true", + default=False, + help="Confirm Policy Fabric/operator approval for guarded Office conversion", + ) + office_convert_p.add_argument( + "--evidence-out", + default=None, + help="Optional path to write OfficeArtifactEvidence JSON", + ) office_convert_p.set_defaults(func=office.convert) office_inspect_p = office_sub.add_parser("inspect", help="Inspect an Office artifact file") diff --git a/sourceosctl/commands/agent_machine.py b/sourceosctl/commands/agent_machine.py index 5890c8a..d51b34d 100644 --- a/sourceosctl/commands/agent_machine.py +++ b/sourceosctl/commands/agent_machine.py @@ -1,19 +1,21 @@ """agent-machine command helpers. -This module implements the first dry-run/read-only slice of the SourceOS -Agent Machine local mount surface. It does not create Podman machines, create -containers, or mutate host mounts. It renders and inspects the mount contract -that later commands will apply under policy. +This module implements SourceOS Agent Machine local mount planning and the first +small guarded materialization slice. It does not create Podman machines, +containers, or bind mounts. It may create explicitly-scoped local output +folders only when called with --execute --policy-ok. """ from __future__ import annotations +import datetime as _dt +import hashlib import json import os import platform import sys from pathlib import Path -from typing import Any, Dict, Iterable, List +from typing import Any, Dict, Iterable DEFAULT_DEV_ROOT = "~/dev" @@ -24,6 +26,10 @@ DEFAULT_DOCS_AGENT_PATH = "/workspace/output" DEFAULT_DOWNLOADS_AGENT_PATH = "/workspace/downloads" +LOCAL_DATA_PLANE_REF = "urn:srcos:agent-machine-local-data-plane:local-default" +MOUNT_POLICY_REF = "urn:srcos:agent-machine-mount-policy:default-deny-scoped-roots" +WORKSPACE_ID = "urn:srcos:agent-machine-workspace:local-default" + SENSITIVE_PATTERNS = [ "$HOME", "~/.ssh", @@ -66,9 +72,13 @@ def _expand(path: str) -> str: return os.path.abspath(os.path.expanduser(path)) +def _home() -> str: + return str(Path.home()) + + def _redact_home(path: str) -> str: """Redact the concrete home path when printing evidence-like output.""" - home = str(Path.home()) + home = _home() expanded = _expand(path) if expanded == home: return "$HOME" @@ -81,6 +91,34 @@ def _path_exists(path: str) -> bool: return Path(_expand(path)).exists() +def _policy_hash(payload: Dict[str, Any]) -> str: + normalized = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8") + return "sha256:" + hashlib.sha256(normalized).hexdigest() + + +def _is_whole_home(path: str) -> bool: + return _expand(path) == _home() + + +def _is_unscoped_downloads(path: str) -> bool: + return _expand(path) == _expand("~/Downloads") + + +def _validate_mount_plan(plan: Dict[str, Any]) -> list[str]: + errors: list[str] = [] + for mount in plan["mounts"]: + host_path = mount["hostPath"] + if _is_whole_home(host_path): + errors.append(f"{mount['mountId']}: whole-home mount is forbidden: {mount['resolvedHostPath']}") + if mount["pathClass"] == "downloads" and _is_unscoped_downloads(host_path): + errors.append("browser-downloads: use ~/Downloads/SourceOS/agent-downloads, not ~/Downloads") + redacted = mount["resolvedHostPath"] + for sensitive in [".ssh", ".gnupg", "Keychains", ".aws", ".config/gcloud", ".azure", ".kube"]: + if sensitive in redacted: + errors.append(f"{mount['mountId']}: sensitive host path denied: {redacted}") + return errors + + def _mount( mount_id: str, path_class: str, @@ -115,7 +153,7 @@ def _build_mount_plan(args) -> Dict[str, Any]: downloads_root = getattr(args, "downloads_root", None) or DEFAULT_DOWNLOADS_ROOT profile = getattr(args, "profile", None) or "macos-podman" - return { + plan = { "type": "AgentMachineMountPlan", "specVersion": "0.1.0", "profile": profile, @@ -179,6 +217,60 @@ def _build_mount_plan(args) -> Dict[str, Any]: }, "dryRun": True, } + plan["policyHash"] = _policy_hash({"mounts": plan["mounts"], "deniedPatterns": plan["deniedPatterns"]}) + return plan + + +def _build_mount_evidence(plan: Dict[str, Any], created: list[str], denied: list[str]) -> Dict[str, Any]: + return { + "kind": "AgentMachineMountEvidence", + "capturedAt": _dt.datetime.now(_dt.timezone.utc).isoformat(), + "workspaceId": WORKSPACE_ID, + "bundle": None, + "executor": "sourceosctl-local", + "backendIntent": "agent-machine", + "localDataPlaneRef": LOCAL_DATA_PLANE_REF, + "mountPolicyRef": MOUNT_POLICY_REF, + "secureHostInterfaceRef": None, + "topolvmPlacementProfileRef": None, + "storageBackend": plan["storageBackend"], + "policyHash": plan["policyHash"], + "gitRef": None, + "mounts": [ + { + "mountId": m["mountId"], + "pathClass": m["pathClass"], + "hostPathRef": m["resolvedHostPath"], + "agentPath": m["agentPath"], + "accessMode": m["accessMode"], + "storageBackend": plan["storageBackend"], + "gitRef": None, + "contentHash": None, + "secretsProhibited": m["secretsProhibited"], + "directExecutionAllowed": m["directExecutionAllowed"], + "existsAtRunStart": m["exists"], + } + for m in plan["mounts"] + ], + "deniedAttempts": [ + {"pathRef": item, "reason": "Denied by Agent Machine mount policy", "severity": "deny"} + for item in denied + ], + "downloadArtifacts": [], + "topolvmPlacement": None, + "redactionSummary": { + "hostUserRedacted": True, + "secretLikeValuesRedacted": 0, + "notes": "sourceosctl local guarded materialization evidence", + }, + "createdHostPaths": created, + } + + +def _write_json(path: str, payload: Dict[str, Any]) -> None: + target = Path(_expand(path)) + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8") def _print_json(payload: Dict[str, Any]) -> int: @@ -188,30 +280,67 @@ def _print_json(payload: Dict[str, Any]) -> int: def mounts_plan(args) -> int: """Render a dry-run mount plan for an Agent Machine profile.""" - return _print_json(_build_mount_plan(args)) + plan = _build_mount_plan(args) + errors = _validate_mount_plan(plan) + if errors: + plan["policyErrors"] = errors + return _print_json(plan) def mounts_init(args) -> int: - """Render the mount initialization plan. + """Render or execute guarded initialization for scoped local directories. - The current implementation remains dry-run only. It tells the operator - which directories would be created and which mounts would be declared. + Execution creates only declared, create-if-missing local directories such as + the docs output root and scoped browser downloads root. It does not mount + Podman volumes or create containers. """ - if not getattr(args, "dry_run", True): - print( - "error: mount initialization is dry-run only in this release", - file=sys.stderr, - ) + execute = bool(getattr(args, "execute", False)) + policy_ok = bool(getattr(args, "policy_ok", False)) + + if getattr(args, "dry_run", True) is False and not execute: + print("error: non-dry-run mount initialization requires --execute", file=sys.stderr) return 1 plan = _build_mount_plan(args) plan["operation"] = "init" - plan["wouldCreate"] = [ - m["resolvedHostPath"] + errors = _validate_mount_plan(plan) + if errors: + plan["policyErrors"] = errors + print(json.dumps(plan, indent=2, sort_keys=True)) + return 1 + + would_create = [ + m for m in plan["mounts"] if m.get("createIfMissing") and not m.get("exists") ] - return _print_json(plan) + plan["wouldCreate"] = [m["resolvedHostPath"] for m in would_create] + + if not execute: + return _print_json(plan) + + if not policy_ok: + print("error: --execute requires --policy-ok for mount initialization", file=sys.stderr) + return 1 + + created: list[str] = [] + for mount in would_create: + Path(_expand(mount["hostPath"])).mkdir(parents=True, exist_ok=True) + created.append(mount["resolvedHostPath"]) + + evidence = _build_mount_evidence(plan, created=created, denied=[]) + evidence_out = getattr(args, "evidence_out", None) + if evidence_out: + _write_json(evidence_out, evidence) + + result = { + "type": "AgentMachineMountInitResult", + "executed": True, + "created": created, + "evidenceOut": _redact_home(evidence_out) if evidence_out else None, + "evidence": evidence if not evidence_out else None, + } + return _print_json(result) def mounts_inspect(args) -> int: @@ -236,9 +365,10 @@ def mounts_evidence_inspect(args) -> int: print(f"error: invalid JSON: {exc}", file=sys.stderr) return 1 + kind = payload.get("kind") or payload.get("type") summary = { "path": str(path), - "type": payload.get("type"), + "kind": kind, "workspaceId": payload.get("workspaceId"), "policyHash": payload.get("policyHash"), "mountCount": len(payload.get("mounts", [])) if isinstance(payload.get("mounts"), list) else 0, diff --git a/sourceosctl/commands/office.py b/sourceosctl/commands/office.py index e8a9e53..33af0af 100644 --- a/sourceosctl/commands/office.py +++ b/sourceosctl/commands/office.py @@ -1,20 +1,21 @@ """office command helpers. -This module implements the first read-only / dry-run slice of the SourceOS -Office Plane. It does not create, convert, or modify files. It renders -structured plans that can later be executed under policy by LibreOffice, -Collabora, ONLYOFFICE, Microsoft Graph, Google Workspace, or SourceOS-native -backends. +This module implements SourceOS Office Plane planning plus the first guarded +local execution slice. Dry-run remains the default. File-writing behavior is +available only behind --execute --policy-ok, writes only to explicit output +roots, and emits OfficeArtifactEvidence-compatible JSON. """ from __future__ import annotations +import datetime as _dt import hashlib import json import mimetypes import os import platform import shutil +import subprocess import sys from pathlib import Path from typing import Any, Dict, Optional @@ -27,6 +28,7 @@ OFFICE_ARTIFACT_SCHEMA = "https://socioprophet.io/schemas/workspace/office-artifact.schema.json" PROFESSIONAL_WORKROOM_SCHEMA = "https://socioprophet.io/schemas/workspace/professional-workroom.schema.json" +OFFICE_EVIDENCE_SCHEMA = "https://github.com/SocioProphet/agentplane/blob/main/schemas/office-artifact-evidence.schema.v0.1.json" SUPPORTED_ARTIFACT_TYPES = [ "document", @@ -60,6 +62,8 @@ "m4a", ] +TEXT_GENERATION_FORMATS = {"txt", "md", "json"} + DEFAULT_BACKEND_BY_MODE = { "local-headless": "libreoffice", "browser-collab": "collabora", @@ -73,8 +77,14 @@ def _expand(path: str) -> str: return os.path.abspath(os.path.expanduser(path)) -def _redact_home(path: str) -> str: - home = str(Path.home()) +def _home() -> str: + return str(Path.home()) + + +def _redact_home(path: str | None) -> str | None: + if path is None: + return None + home = _home() expanded = _expand(path) if expanded == home: return "$HOME" @@ -111,6 +121,44 @@ def _sha256(path: Path) -> Optional[str]: return "sha256:" + digest.hexdigest() +def _policy_hash(payload: Dict[str, Any]) -> str: + normalized = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8") + return "sha256:" + hashlib.sha256(normalized).hexdigest() + + +def _safe_slug(title: str) -> str: + slug = title.lower().strip().replace(" ", "-") or "office-artifact" + return "".join(ch for ch in slug if ch.isalnum() or ch in "-_")[:80] or "office-artifact" + + +def _is_forbidden_output_root(path: str) -> Optional[str]: + expanded = _expand(path) + if expanded == _home(): + return "whole-home output root is forbidden" + if expanded == _expand("~/Downloads"): + return "whole Downloads directory is forbidden; use ~/Downloads/SourceOS/agent-downloads" + forbidden_fragments = [ + ".ssh", + ".gnupg", + "Library/Keychains", + "Library/Application Support/Google/Chrome", + "Library/Application Support/Firefox", + ".aws", + ".config/gcloud", + ".azure", + ".kube", + "group.com.apple.notes", + "Photos.photoslibrary", + "Voice Memos", + "VoiceMemos", + "Reminders", + ] + for fragment in forbidden_fragments: + if fragment in expanded: + return f"sensitive output path fragment denied: {fragment}" + return None + + def _artifact_plan(args, operation: str, format_override: Optional[str] = None) -> Dict[str, Any]: artifact_type = getattr(args, "artifact_type", None) or "document" fmt = format_override or getattr(args, "format", None) or "docx" @@ -120,18 +168,18 @@ def _artifact_plan(args, operation: str, format_override: Optional[str] = None) backend = getattr(args, "backend", None) or "libreoffice" mode = getattr(args, "mode", None) or "local-headless" - slug = title.lower().strip().replace(" ", "-") or "office-artifact" - safe_slug = "".join(ch for ch in slug if ch.isalnum() or ch in "-_")[:80] or "office-artifact" + safe_slug = _safe_slug(title) storage_ref = f"sourceos-office://{workroom_id}/output/{safe_slug}.{fmt}" - return { + plan = { "type": "OfficeArtifactPlan", "specVersion": "0.1.0", "operation": operation, - "dryRun": True, + "dryRun": not bool(getattr(args, "execute", False)), "contracts": { "officeArtifactSchema": OFFICE_ARTIFACT_SCHEMA, "professionalWorkroomSchema": PROFESSIONAL_WORKROOM_SCHEMA, + "officeArtifactEvidenceSchema": OFFICE_EVIDENCE_SCHEMA, }, "officeArtifact": { "schemaVersion": "v0.1", @@ -167,6 +215,91 @@ def _artifact_plan(args, operation: str, format_override: Optional[str] = None) "mailSendDeniedByDefault": True, }, } + plan["policyHash"] = _policy_hash(plan["officeArtifact"]) + return plan + + +def _artifact_output_path(args, fmt: str) -> Path: + title = getattr(args, "title", None) or "Untitled Office Artifact" + return Path(_expand(getattr(args, "output_root", DEFAULT_OUTPUT_ROOT))) / f"{_safe_slug(title)}.{fmt}" + + +def _build_evidence( + *, + plan: Dict[str, Any], + operation: str, + status: str, + output_path: Path | None, + source_refs: list[str] | None = None, + derived_refs: list[str] | None = None, + conversion: Dict[str, Any] | None = None, + notes: str = "sourceosctl guarded local Office Plane evidence", +) -> Dict[str, Any]: + artifact = plan["officeArtifact"] + artifact_hashes = [] + if output_path and output_path.exists() and output_path.is_file(): + mime_type, _ = mimetypes.guess_type(str(output_path)) + artifact_hashes.append( + { + "ref": artifact["storageRef"], + "sha256": _sha256(output_path), + "mimeType": mime_type, + "sizeBytes": output_path.stat().st_size, + } + ) + return { + "kind": "OfficeArtifactEvidence", + "capturedAt": _dt.datetime.now(_dt.timezone.utc).isoformat(), + "workroomId": artifact["workroomId"], + "artifactId": artifact["artifactId"], + "artifactType": artifact["artifactType"], + "format": artifact["format"], + "operation": operation, + "status": status, + "storageRef": artifact["storageRef"], + "sourceRefs": source_refs or [], + "derivedRefs": derived_refs or [], + "agentRunRef": None, + "mountEvidenceRef": None, + "officeArtifactContractRef": OFFICE_ARTIFACT_SCHEMA, + "backend": artifact["backend"], + "artifactHashes": artifact_hashes, + "conversion": conversion, + "review": { + "required": True, + "decision": "pending", + "decisionRef": None, + "reviewer": None, + }, + "sideEffects": { + "emailSent": False, + "externalPublished": False, + "calendarModified": False, + "requiresPolicyApproval": True, + }, + "policyHash": plan["policyHash"], + "policyRefs": [], + "redactionSummary": { + "hostUserRedacted": True, + "secretLikeValuesRedacted": 0, + "notes": notes, + }, + } + + +def _write_json(path: str, payload: Dict[str, Any]) -> None: + target = Path(_expand(path)) + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8") + + +def _require_execute_policy(args, operation: str) -> Optional[str]: + if not bool(getattr(args, "execute", False)): + return None + if not bool(getattr(args, "policy_ok", False)): + return f"office {operation} --execute requires --policy-ok" + output_root = getattr(args, "output_root", DEFAULT_OUTPUT_ROOT) + return _is_forbidden_output_root(output_root) def doctor(args) -> int: @@ -211,34 +344,125 @@ def plan(args) -> int: def generate(args) -> int: - """Render a generation plan. Dry-run only.""" - if not getattr(args, "dry_run", True): - print("error: office generate is dry-run only in this release", file=sys.stderr) - return 1 + """Render or execute a guarded text/json/markdown generation plan.""" + execute = bool(getattr(args, "execute", False)) payload = _artifact_plan(args, "generate") payload["templateRef"] = getattr(args, "template", None) payload["generationInputs"] = { "promptRef": getattr(args, "prompt_ref", None), "dataRef": getattr(args, "data_ref", None), } - return _print_json(payload) + if not execute: + return _print_json(payload) + error = _require_execute_policy(args, "generate") + if error: + print(f"error: {error}", file=sys.stderr) + return 1 -def convert(args) -> int: - """Render a conversion plan. Dry-run only.""" - if not getattr(args, "dry_run", True): - print("error: office convert is dry-run only in this release", file=sys.stderr) + fmt = payload["officeArtifact"]["format"] + if fmt not in TEXT_GENERATION_FORMATS: + print( + "error: guarded generation currently supports only txt, md, or json; use convert for Office binary formats", + file=sys.stderr, + ) return 1 + + output_path = _artifact_output_path(args, fmt) + output_path.parent.mkdir(parents=True, exist_ok=True) + if fmt == "json": + output_path.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8") + else: + output_path.write_text( + f"# {payload['officeArtifact']['title']}\n\n" + "Generated by sourceosctl Office Plane guarded execution.\n\n" + f"Workroom: {payload['officeArtifact']['workroomId']}\n" + f"Artifact: {payload['officeArtifact']['artifactId']}\n", + encoding="utf-8", + ) + + evidence = _build_evidence(plan=payload, operation="generate", status="requires-review", output_path=output_path) + evidence_out = getattr(args, "evidence_out", None) + if evidence_out: + _write_json(evidence_out, evidence) + + return _print_json( + { + "type": "OfficeGenerateResult", + "executed": True, + "outputPath": _redact_home(str(output_path)), + "evidenceOut": _redact_home(evidence_out) if evidence_out else None, + "evidence": None if evidence_out else evidence, + } + ) + + +def convert(args) -> int: + """Render or execute a guarded LibreOffice conversion.""" + execute = bool(getattr(args, "execute", False)) payload = _artifact_plan(args, "convert", format_override=args.to) + input_path = Path(_expand(args.input)) + output_root = Path(_expand(getattr(args, "output_root", DEFAULT_OUTPUT_ROOT))) + inferred_output = output_root / f"{input_path.stem}.{args.to}" payload["conversion"] = { "input": _redact_home(args.input), - "inputExists": Path(_expand(args.input)).exists(), + "inputExists": input_path.exists(), "toFormat": args.to, - "outputRoot": _redact_home(getattr(args, "output_root", DEFAULT_OUTPUT_ROOT)), + "outputRoot": _redact_home(str(output_root)), "backendCommand": "soffice --headless --convert-to --outdir ", - "willExecute": False, + "willExecute": execute, } - return _print_json(payload) + if not execute: + return _print_json(payload) + + error = _require_execute_policy(args, "convert") + if error: + print(f"error: {error}", file=sys.stderr) + return 1 + if not input_path.exists() or not input_path.is_file(): + print(f"error: input file not found: {args.input}", file=sys.stderr) + return 1 + if args.to not in SUPPORTED_FORMATS: + print(f"error: unsupported target format: {args.to}", file=sys.stderr) + return 1 + lo = _libreoffice_path() + if not lo: + print("error: LibreOffice/soffice not found on PATH", file=sys.stderr) + return 1 + + output_root.mkdir(parents=True, exist_ok=True) + cmd = [lo, "--headless", "--convert-to", args.to, "--outdir", str(output_root), str(input_path)] + completed = subprocess.run(cmd, check=False, capture_output=True, text=True, timeout=180) + status = "success" if completed.returncode == 0 and inferred_output.exists() else "failure" + evidence = _build_evidence( + plan=payload, + operation="convert", + status=status, + output_path=inferred_output if inferred_output.exists() else None, + source_refs=[_redact_home(str(input_path)) or str(input_path)], + derived_refs=[payload["officeArtifact"]["storageRef"]] if inferred_output.exists() else [], + conversion={ + "fromFormat": input_path.suffix.lower().lstrip(".") or None, + "toFormat": args.to, + "commandRef": "sourceosctl.office.convert.local-headless", + "executed": True, + }, + notes=f"stdout={completed.stdout[-200:]!r}; stderr={completed.stderr[-200:]!r}", + ) + evidence_out = getattr(args, "evidence_out", None) + if evidence_out: + _write_json(evidence_out, evidence) + + result = { + "type": "OfficeConvertResult", + "executed": True, + "returnCode": completed.returncode, + "status": status, + "outputPath": _redact_home(str(inferred_output)) if inferred_output.exists() else None, + "evidenceOut": _redact_home(evidence_out) if evidence_out else None, + "evidence": None if evidence_out else evidence, + } + return _print_json(result) if status == "success" else (_print_json(result) or 1) def inspect(args) -> int: @@ -285,6 +509,7 @@ def evidence_inspect(args) -> int: office_artifact = payload.get("officeArtifact", {}) if isinstance(payload, dict) else {} summary = { "path": str(path), + "kind": payload.get("kind") if isinstance(payload, dict) else None, "type": payload.get("type") if isinstance(payload, dict) else None, "artifactId": office_artifact.get("artifactId") if isinstance(office_artifact, dict) else payload.get("artifactId"), "workroomId": office_artifact.get("workroomId") if isinstance(office_artifact, dict) else payload.get("workroomId"), diff --git a/tests/test_agent_machine_execution.py b/tests/test_agent_machine_execution.py new file mode 100644 index 0000000..e4c4bef --- /dev/null +++ b/tests/test_agent_machine_execution.py @@ -0,0 +1,92 @@ +"""Guarded execution tests for Agent Machine mount materialization.""" + +import json +import os +import pathlib +import sys +import tempfile +import unittest + +_REPO_ROOT = pathlib.Path(__file__).parent.parent +sys.path.insert(0, str(_REPO_ROOT)) + +from sourceosctl.cli import main + + +class TestAgentMachineGuardedExecution(unittest.TestCase): + def test_mounts_init_execute_requires_policy_ok(self): + with tempfile.TemporaryDirectory() as tmpdir: + rc = main([ + "agent-machine", + "mounts", + "init", + "--execute", + "--dev-root", + os.path.join(tmpdir, "dev"), + "--docs-root", + os.path.join(tmpdir, "office-output"), + "--downloads-root", + os.path.join(tmpdir, "agent-downloads"), + ]) + self.assertEqual(rc, 1) + + def test_mounts_init_execute_creates_only_scoped_dirs_and_evidence(self): + with tempfile.TemporaryDirectory() as tmpdir: + dev_root = os.path.join(tmpdir, "dev") + docs_root = os.path.join(tmpdir, "office-output") + downloads_root = os.path.join(tmpdir, "agent-downloads") + evidence_path = os.path.join(tmpdir, "evidence", "mounts.json") + + os.makedirs(dev_root) + + rc = main([ + "agent-machine", + "mounts", + "init", + "--execute", + "--policy-ok", + "--dev-root", + dev_root, + "--docs-root", + docs_root, + "--downloads-root", + downloads_root, + "--evidence-out", + evidence_path, + ]) + + self.assertEqual(rc, 0) + self.assertTrue(os.path.isdir(dev_root)) + self.assertTrue(os.path.isdir(docs_root)) + self.assertTrue(os.path.isdir(downloads_root)) + self.assertTrue(os.path.exists(evidence_path)) + + with open(evidence_path, "r", encoding="utf-8") as handle: + evidence = json.load(handle) + + self.assertEqual(evidence["kind"], "AgentMachineMountEvidence") + self.assertEqual(evidence["backendIntent"], "agent-machine") + self.assertEqual(evidence["mountPolicyRef"], "urn:srcos:agent-machine-mount-policy:default-deny-scoped-roots") + self.assertEqual(len(evidence["mounts"]), 3) + self.assertTrue(any(m["pathClass"] == "downloads" for m in evidence["mounts"])) + + def test_mounts_init_rejects_unscoped_downloads(self): + with tempfile.TemporaryDirectory() as tmpdir: + rc = main([ + "agent-machine", + "mounts", + "init", + "--execute", + "--policy-ok", + "--dev-root", + os.path.join(tmpdir, "dev"), + "--docs-root", + os.path.join(tmpdir, "office-output"), + "--downloads-root", + "~/Downloads", + ]) + self.assertEqual(rc, 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_office_cli.py b/tests/test_office_cli.py index 1649d29..4ce7de5 100644 --- a/tests/test_office_cli.py +++ b/tests/test_office_cli.py @@ -31,6 +31,9 @@ def _office_args(**overrides): "output_root": "~/Documents/SourceOS/agent-output", "downloads_root": "~/Downloads/SourceOS/agent-downloads", "template_root": "~/dev", + "execute": False, + "policy_ok": False, + "evidence_out": None, } values.update(overrides) return _Args(**values) @@ -77,8 +80,67 @@ def test_office_generate_dry_run_via_main(self): ]) self.assertEqual(rc, 0) - def test_office_generate_no_dry_run_rejected(self): - args = _office_args(dry_run=False, template=None, prompt_ref=None, data_ref=None) + def test_office_generate_execute_requires_policy_ok(self): + args = _office_args( + execute=True, + policy_ok=False, + format="md", + template=None, + prompt_ref=None, + data_ref=None, + ) + self.assertEqual(office.generate(args), 1) + + def test_office_generate_execute_rejects_binary_formats(self): + with tempfile.TemporaryDirectory() as tmpdir: + args = _office_args( + execute=True, + policy_ok=True, + format="docx", + output_root=tmpdir, + template=None, + prompt_ref=None, + data_ref=None, + ) + self.assertEqual(office.generate(args), 1) + + def test_office_generate_execute_writes_markdown_and_evidence(self): + with tempfile.TemporaryDirectory() as tmpdir: + evidence_path = os.path.join(tmpdir, "evidence", "office.json") + rc = main([ + "office", + "generate", + "--execute", + "--policy-ok", + "--artifact-type", + "document", + "--format", + "md", + "--title", + "Safe Report", + "--output-root", + tmpdir, + "--evidence-out", + evidence_path, + ]) + self.assertEqual(rc, 0) + self.assertTrue(os.path.exists(os.path.join(tmpdir, "safe-report.md"))) + with open(evidence_path, "r", encoding="utf-8") as handle: + evidence = json.load(handle) + self.assertEqual(evidence["kind"], "OfficeArtifactEvidence") + self.assertEqual(evidence["operation"], "generate") + self.assertEqual(evidence["status"], "requires-review") + + def test_office_generate_execute_rejects_whole_home_output_root(self): + args = _office_args( + execute=True, + policy_ok=True, + format="md", + output_root="~", + template=None, + prompt_ref=None, + data_ref=None, + ) self.assertEqual(office.generate(args), 1) def test_office_convert_dry_run_via_main(self): @@ -91,10 +153,21 @@ def test_office_convert_dry_run_via_main(self): finally: os.unlink(tmp_path) - def test_office_convert_no_dry_run_rejected(self): - args = _office_args(dry_run=False, input="/tmp/example.docx", to="pdf") + def test_office_convert_execute_requires_policy_ok(self): + args = _office_args(execute=True, policy_ok=False, input="/tmp/example.docx", to="pdf") self.assertEqual(office.convert(args), 1) + def test_office_convert_execute_missing_input_rejected(self): + with tempfile.TemporaryDirectory() as tmpdir: + args = _office_args( + execute=True, + policy_ok=True, + input="/nonexistent/example.docx", + to="pdf", + output_root=tmpdir, + ) + self.assertEqual(office.convert(args), 1) + def test_office_inspect_valid_file(self): with tempfile.NamedTemporaryFile(suffix=".txt", mode="w", delete=False) as handle: handle.write("hello") @@ -109,14 +182,12 @@ def test_office_inspect_missing_file(self): def test_office_evidence_inspect_valid(self): payload = { - "type": "OfficeArtifactEvidence", - "officeArtifact": { - "artifactId": "office-artifact-test", - "workroomId": "workroom-test", - "artifactType": "document", - "format": "docx", - "evidenceRefs": ["evidence://office/test"], - }, + "kind": "OfficeArtifactEvidence", + "artifactId": "office-artifact-test", + "workroomId": "workroom-test", + "artifactType": "document", + "format": "docx", + "evidenceRefs": ["evidence://office/test"], } with tempfile.NamedTemporaryFile(suffix=".json", mode="w", delete=False) as handle: json.dump(payload, handle)