Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ sourceosctl [--version] <command> [<subcommand>] [options]
| `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 generate --execute --policy-ok --format docx|xlsx|pptx` | Write a guarded minimal OOXML artifact and emit OfficeArtifactEvidence |
| `sourceosctl office convert <path> --to <format> --dry-run` | Render a LibreOffice-style conversion plan without writing files |
| `sourceosctl office convert <path> --to <format> --execute --policy-ok` | Run guarded local LibreOffice conversion and emit OfficeArtifactEvidence |
| `sourceosctl office inspect <path>` | Inspect a local office artifact file and hash it |
Expand Down Expand Up @@ -96,6 +97,9 @@ 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 generate --execute --policy-ok --artifact-type document --format docx --title "Demo Report" --evidence-out ./office-docx-evidence.json
python3 bin/sourceosctl office generate --execute --policy-ok --artifact-type spreadsheet --format xlsx --title "Demo Workbook" --evidence-out ./office-xlsx-evidence.json
python3 bin/sourceosctl office generate --execute --policy-ok --artifact-type slide-deck --format pptx --title "Demo Deck" --evidence-out ./office-pptx-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
```
Expand Down Expand Up @@ -145,10 +149,11 @@ Backends are modeled as an abstraction:
- Microsoft Graph / Office 365 and Google Workspace: compatibility adapters, not core authority.
- SourceOS-native: future native document surfaces.

Guarded Office execution is intentionally narrow:
Guarded Office execution is intentionally bounded:

- `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 generate --execute --policy-ok` writes `txt`, `md`, `json`, `docx`, `xlsx`, or `pptx` artifacts.
- DOCX/XLSX/PPTX generation uses a minimal dependency-light OOXML bootstrap builder, not a full template or collaboration engine.
- ODT/ODS/ODP and other binary formats remain conversion/backend territory until LibreOffice/Collabora/ONLYOFFICE template 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.
Expand Down
38 changes: 29 additions & 9 deletions sourceosctl/commands/office.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
"""office command helpers.

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.
This module implements SourceOS Office Plane planning plus guarded local
execution. 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
Expand All @@ -20,6 +20,8 @@
from pathlib import Path
from typing import Any, Dict, Optional

from sourceosctl.commands.ooxml import OOXML_GENERATION_FORMATS, write_ooxml_artifact


DEFAULT_WORKROOM_ID = "workroom-local-default"
DEFAULT_OUTPUT_ROOT = "~/Documents/SourceOS/agent-output"
Expand Down Expand Up @@ -63,6 +65,7 @@
]

TEXT_GENERATION_FORMATS = {"txt", "md", "json"}
GUARDED_GENERATION_FORMATS = TEXT_GENERATION_FORMATS | OOXML_GENERATION_FORMATS

DEFAULT_BACKEND_BY_MODE = {
"local-headless": "libreoffice",
Expand Down Expand Up @@ -344,7 +347,7 @@ def plan(args) -> int:


def generate(args) -> int:
"""Render or execute a guarded text/json/markdown generation plan."""
"""Render or execute guarded text/json/OOXML generation."""
execute = bool(getattr(args, "execute", False))
payload = _artifact_plan(args, "generate")
payload["templateRef"] = getattr(args, "template", None)
Expand All @@ -361,9 +364,9 @@ def generate(args) -> int:
return 1

fmt = payload["officeArtifact"]["format"]
if fmt not in TEXT_GENERATION_FORMATS:
if fmt not in GUARDED_GENERATION_FORMATS:
print(
"error: guarded generation currently supports only txt, md, or json; use convert for Office binary formats",
"error: guarded generation currently supports txt, md, json, docx, xlsx, and pptx; use convert or backend adapters for other formats",
file=sys.stderr,
)
return 1
Expand All @@ -372,16 +375,33 @@ def generate(args) -> int:
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:
notes = "sourceosctl guarded JSON Office Plane artifact generation"
elif fmt in TEXT_GENERATION_FORMATS:
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",
)
notes = "sourceosctl guarded text/Markdown Office Plane artifact generation"
else:
write_ooxml_artifact(
fmt=fmt,
path=output_path,
title=payload["officeArtifact"]["title"],
workroom_id=payload["officeArtifact"]["workroomId"],
artifact_id=payload["officeArtifact"]["artifactId"],
)
notes = "sourceosctl guarded minimal OOXML Office Plane artifact generation"

evidence = _build_evidence(plan=payload, operation="generate", status="requires-review", output_path=output_path)
evidence = _build_evidence(
plan=payload,
operation="generate",
status="requires-review",
output_path=output_path,
notes=notes,
)
evidence_out = getattr(args, "evidence_out", None)
if evidence_out:
_write_json(evidence_out, evidence)
Expand Down
192 changes: 192 additions & 0 deletions sourceosctl/commands/ooxml.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
"""Minimal OOXML artifact builders for SourceOS Office Plane.

These helpers intentionally use only Python's standard library. They create
small, deterministic-enough DOCX/XLSX/PPTX ZIP packages for guarded local
artifact generation. They are not a replacement for LibreOffice, Collabora,
ONLYOFFICE, or a full template engine; they are the safe local bootstrap path
for simple agent-authored workroom artifacts.
"""

from __future__ import annotations

from html import escape
from pathlib import Path
from zipfile import ZIP_DEFLATED, ZipFile


OOXML_GENERATION_FORMATS = {"docx", "xlsx", "pptx"}


def _xml(text: str) -> str:
return escape(text, quote=True)


def _write_zip(path: Path, files: dict[str, str]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with ZipFile(path, "w", ZIP_DEFLATED) as zf:
for name in sorted(files):
zf.writestr(name, files[name])


def write_ooxml_artifact(
*,
fmt: str,
path: Path,
title: str,
workroom_id: str,
artifact_id: str,
) -> None:
"""Write a minimal OOXML artifact to path.

Args:
fmt: one of docx, xlsx, pptx.
path: output path.
title: human-readable artifact title.
workroom_id: Professional Workroom id.
artifact_id: OfficeArtifact id.
"""
if fmt == "docx":
_write_docx(path=path, title=title, workroom_id=workroom_id, artifact_id=artifact_id)
return
if fmt == "xlsx":
_write_xlsx(path=path, title=title, workroom_id=workroom_id, artifact_id=artifact_id)
return
if fmt == "pptx":
_write_pptx(path=path, title=title, workroom_id=workroom_id, artifact_id=artifact_id)
return
raise ValueError(f"unsupported OOXML generation format: {fmt}")


def _write_docx(*, path: Path, title: str, workroom_id: str, artifact_id: str) -> None:
document = f'''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
<w:body>
<w:p><w:r><w:t>{_xml(title)}</w:t></w:r></w:p>
<w:p><w:r><w:t>Generated by SourceOS Office Plane guarded OOXML generation.</w:t></w:r></w:p>
<w:p><w:r><w:t>Workroom: {_xml(workroom_id)}</w:t></w:r></w:p>
<w:p><w:r><w:t>Artifact: {_xml(artifact_id)}</w:t></w:r></w:p>
<w:sectPr><w:pgSz w:w="12240" w:h="15840"/><w:pgMar w:top="1440" w:right="1440" w:bottom="1440" w:left="1440"/></w:sectPr>
</w:body>
</w:document>
'''
files = {
"[Content_Types].xml": '''<?xml version="1.0" encoding="UTF-8"?>
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
<Default Extension="xml" ContentType="application/xml"/>
<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
</Types>
''',
"_rels/.rels": '''<?xml version="1.0" encoding="UTF-8"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>
</Relationships>
''',
"word/document.xml": document,
}
_write_zip(path, files)


def _write_xlsx(*, path: Path, title: str, workroom_id: str, artifact_id: str) -> None:
rows = [
("Title", title),
("Generated By", "SourceOS Office Plane guarded OOXML generation"),
("Workroom", workroom_id),
("Artifact", artifact_id),
]
row_xml = []
for idx, (key, value) in enumerate(rows, start=1):
row_xml.append(
f'''<row r="{idx}"><c r="A{idx}" t="inlineStr"><is><t>{_xml(key)}</t></is></c><c r="B{idx}" t="inlineStr"><is><t>{_xml(value)}</t></is></c></row>'''
)
sheet = f'''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
<sheetData>
{''.join(row_xml)}
</sheetData>
</worksheet>
'''
files = {
"[Content_Types].xml": '''<?xml version="1.0" encoding="UTF-8"?>
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
<Default Extension="xml" ContentType="application/xml"/>
<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>
<Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>
</Types>
''',
"_rels/.rels": '''<?xml version="1.0" encoding="UTF-8"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>
</Relationships>
''',
"xl/workbook.xml": '''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
<sheets><sheet name="SourceOS" sheetId="1" r:id="rId1"/></sheets>
</workbook>
''',
"xl/_rels/workbook.xml.rels": '''<?xml version="1.0" encoding="UTF-8"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/>
</Relationships>
''',
"xl/worksheets/sheet1.xml": sheet,
}
_write_zip(path, files)


def _write_pptx(*, path: Path, title: str, workroom_id: str, artifact_id: str) -> None:
slide = f'''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<p:sld xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">
<p:cSld>
<p:spTree>
<p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr>
<p:grpSpPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/><a:chOff x="0" y="0"/><a:chExt cx="0" cy="0"/></a:xfrm></p:grpSpPr>
<p:sp>
<p:nvSpPr><p:cNvPr id="2" name="Title"/><p:cNvSpPr/><p:nvPr/></p:nvSpPr>
<p:spPr><a:xfrm><a:off x="914400" y="914400"/><a:ext cx="7315200" cy="914400"/></a:xfrm></p:spPr>
<p:txBody><a:bodyPr/><a:lstStyle/><a:p><a:r><a:t>{_xml(title)}</a:t></a:r></a:p></p:txBody>
</p:sp>
<p:sp>
<p:nvSpPr><p:cNvPr id="3" name="Body"/><p:cNvSpPr/><p:nvPr/></p:nvSpPr>
<p:spPr><a:xfrm><a:off x="914400" y="2133600"/><a:ext cx="7315200" cy="2743200"/></a:xfrm></p:spPr>
<p:txBody><a:bodyPr/><a:lstStyle/>
<a:p><a:r><a:t>Generated by SourceOS Office Plane guarded OOXML generation.</a:t></a:r></a:p>
<a:p><a:r><a:t>Workroom: {_xml(workroom_id)}</a:t></a:r></a:p>
<a:p><a:r><a:t>Artifact: {_xml(artifact_id)}</a:t></a:r></a:p>
</p:txBody>
</p:sp>
</p:spTree>
</p:cSld>
<p:clrMapOvr><a:masterClrMapping/></p:clrMapOvr>
</p:sld>
'''
files = {
"[Content_Types].xml": '''<?xml version="1.0" encoding="UTF-8"?>
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
<Default Extension="xml" ContentType="application/xml"/>
<Override PartName="/ppt/presentation.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml"/>
<Override PartName="/ppt/slides/slide1.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slide+xml"/>
</Types>
''',
"_rels/.rels": '''<?xml version="1.0" encoding="UTF-8"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="ppt/presentation.xml"/>
</Relationships>
''',
"ppt/presentation.xml": '''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<p:presentation xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
<p:sldIdLst><p:sldId id="256" r:id="rId1"/></p:sldIdLst>
<p:sldSz cx="9144000" cy="6858000" type="screen4x3"/>
<p:notesSz cx="6858000" cy="9144000"/>
</p:presentation>
''',
"ppt/_rels/presentation.xml.rels": '''<?xml version="1.0" encoding="UTF-8"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide" Target="slides/slide1.xml"/>
</Relationships>
''',
"ppt/slides/slide1.xml": slide,
}
_write_zip(path, files)
Loading
Loading