Skip to content

Commit 3bc4f4c

Browse files
committed
verify exact BYOC bundle archives
1 parent 23be20f commit 3bc4f4c

6 files changed

Lines changed: 196 additions & 43 deletions

File tree

docs/BYOC_CONNECTOR.md

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -69,13 +69,21 @@ env var (`CONTROL_PLANE_URL`, `BYOC_CONNECTOR_TOKEN`, `BYOC_ORG_ID`, ...).
6969
* **The bundle and report bytes** are read from / written to the **customer's own
7070
storage** (`--storage-root`, a local encrypted volume). Our control plane holds
7171
no URL to them and signs no access.
72+
* **The staged bundle is the exact approved ZIP archive.** `bundle_sha256` binds
73+
the dispatch to those archive bytes (separately from the bundle's semantic
74+
content digest). The Connector copies and hashes that file before extraction,
75+
refuses a missing/different digest or an unpacked directory, then safe-extracts
76+
only the verified copy. A pre-verification failure never echoes the unverified
77+
digest claim in its callback.
7278

7379
**Fail-closed everywhere.** A dispatch is refused (reported `failed`, no GUI
7480
touched) when it is missing the governed safety policy, missing the run-scoped
75-
callback token, carries an our-owned signed URL, or when the org enabled a
76-
grounding rung whose API key env is not set on this machine. The governed `run`
77-
itself refuses any bundle that is not certified, identity-armed, effect-verified,
78-
and encrypted — those engine gates are unchanged, so identity checks, effect
81+
callback token, exact archive digest, or execution substrate; carries an
82+
our-owned signed URL; points at archive bytes that do not match the signed
83+
digest; conflicts with the substrate compatibility hint; or enables a grounding
84+
rung whose API key env is not set on this machine. The governed `run` itself
85+
refuses any bundle that is not certified, identity-armed, effect-verified, and
86+
encrypted — those engine gates are unchanged, so identity checks, effect
7987
verification, and halt-don't-guess all remain intact.
8088

8189
## Enabling the lane (control plane)

openadapt_flow/connector/daemon.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,6 @@ def phi_free_callback_body(job: ByocJob, result: ExecutionResult) -> dict[str, A
4040
"workflow_id": job.workflow_id,
4141
"bundle_version_id": job.bundle_version_id,
4242
"runtime_validation_id": job.runtime_validation_id,
43-
"bundle_sha256": job.bundle_sha256,
4443
"mode": job.mode,
4544
"status": result.status,
4645
"report_path": result.report_ref,
@@ -49,6 +48,10 @@ def phi_free_callback_body(job: ByocJob, result: ExecutionResult) -> dict[str, A
4948
# name internal paths). error_code is the exact enum run-callback accepts.
5049
"error_code": "runner_failure" if result.status == "failed" else None,
5150
}
51+
if result.verified_bundle_sha256 is not None:
52+
# Never echo the job's untrusted digest claim. This field exists only
53+
# after the connector copied, hashed, and matched the exact archive.
54+
body["bundle_sha256"] = result.verified_bundle_sha256
5255
if result.halt is not None:
5356
# run-callback reads only halt.present; the structured block is additive.
5457
body["halt"] = {"present": True}

openadapt_flow/connector/executor.py

Lines changed: 45 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,10 @@
99
1010
Fail-closed application of the control-plane-delivered policy:
1111
12-
* :meth:`ByocJob.ensure_governed` must pass (policy present, callback token
13-
present, bundle resolvable) or the job is refused before any GUI is touched;
12+
* :meth:`ByocJob.ensure_governed` must pass (policy, callback token, closed
13+
substrate, and exact archive SHA present) or the job is refused before any
14+
GUI is touched; customer storage must then return the exact approved ZIP
15+
bytes, whose digest is checked before safe extraction;
1416
* if the org enabled a grounding rung (``grounding_model.enabled``) whose
1517
``api_key_env`` is NOT set in the Connector's own environment, the job is
1618
refused — the org required a governed control this machine cannot honor, so we
@@ -31,6 +33,7 @@
3133

3234
from __future__ import annotations
3335

36+
import hashlib
3437
import json
3538
import os
3639
import subprocess
@@ -42,7 +45,7 @@
4245

4346
from openadapt_flow.connector.config import ConnectorSettings
4447
from openadapt_flow.connector.protocol import ByocGovernanceError, ByocJob
45-
from openadapt_flow.connector.storage import CustomerStorage
48+
from openadapt_flow.connector.storage import CustomerStorage, extract_bundle_archive
4649

4750
#: A run-gate refusal (fail-closed admission denied) exits 2 before the replay
4851
#: creates report.json.
@@ -69,6 +72,7 @@ class ExecutionResult:
6972
halt: Optional[dict[str, Any]]
7073
report_ref: Optional[str]
7174
error: Optional[str] = None
75+
verified_bundle_sha256: Optional[str] = None
7276

7377

7478
def _grounding_env_available(job: ByocJob) -> bool:
@@ -82,6 +86,14 @@ def _grounding_env_available(job: ByocJob) -> bool:
8286
return bool(os.environ.get(gm.api_key_env))
8387

8488

89+
def _sha256_file(path: Path) -> str:
90+
digest = hashlib.sha256()
91+
with path.open("rb") as handle:
92+
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
93+
digest.update(chunk)
94+
return digest.hexdigest()
95+
96+
8597
def build_run_argv(
8698
job: ByocJob,
8799
settings: ConnectorSettings,
@@ -248,18 +260,27 @@ def execute_job(
248260

249261
with tempfile.TemporaryDirectory(prefix="oa-byoc-") as tmp:
250262
tmp_path = Path(tmp)
263+
bundle_archive = tmp_path / "approved-bundle.zip"
251264
bundle_scratch = tmp_path / "bundle"
252265
run_dir = tmp_path / "run"
253266
bundle_scratch.mkdir(parents=True, exist_ok=True)
254267
run_dir.mkdir(parents=True, exist_ok=True)
255268

256269
_write_policy_audit(job, run_dir)
257270

258-
# 2. Resolve the bundle from the CUSTOMER'S OWN storage (never our URL).
271+
# 2. Copy and verify the EXACT approved archive bytes from the customer's
272+
# storage before extraction. The content digest inside workflow.json is
273+
# a separate semantic binding; neither can substitute for this signed
274+
# archive-byte identity.
259275
try:
260-
bundle_dir = storage.fetch_bundle(
261-
job.storage.bundle_ref if job.storage else None, bundle_scratch
276+
fetched_archive = storage.fetch_bundle_archive(
277+
job.storage.bundle_ref if job.storage else None, bundle_archive
262278
)
279+
observed_sha256 = _sha256_file(fetched_archive)
280+
if observed_sha256 != job.bundle_sha256:
281+
raise RuntimeError("bundle archive SHA-256 does not match dispatch")
282+
extract_bundle_archive(fetched_archive, bundle_scratch)
283+
bundle_dir = bundle_scratch
263284
except Exception as exc: # storage failure — fail closed, PHI-free msg
264285
return ExecutionResult(
265286
"failed",
@@ -269,11 +290,21 @@ def execute_job(
269290
f"customer-storage bundle fetch failed: {type(exc).__name__}",
270291
)
271292

272-
params_file = _write_params_file(job.params, run_dir)
293+
try:
294+
params_file = _write_params_file(job.params, run_dir)
273295

274-
# 3. The governed, fail-closed child invocation.
275-
argv = build_run_argv(job, settings, Path(bundle_dir), run_dir, params_file)
276-
outcome = runner(argv, run_dir)
296+
# 3. The governed, fail-closed child invocation.
297+
argv = build_run_argv(job, settings, Path(bundle_dir), run_dir, params_file)
298+
outcome = runner(argv, run_dir)
299+
except Exception as exc:
300+
return ExecutionResult(
301+
"failed",
302+
{},
303+
None,
304+
job.report_ref(),
305+
f"governed child invocation failed: {type(exc).__name__}",
306+
verified_bundle_sha256=observed_sha256,
307+
)
277308

278309
report = outcome.report or {}
279310
status = status_from_report(outcome.returncode, report)
@@ -291,13 +322,14 @@ def execute_job(
291322
report_ref = job.report_ref()
292323
try:
293324
storage.write_report(report_ref, report)
294-
except Exception as exc: # pragma: no cover - report persist best-effort
325+
except Exception as exc: # no durable report means the run is not complete
295326
return ExecutionResult(
296-
status,
327+
"failed",
297328
metrics_from_report(report),
298329
halt_object(report),
299330
report_ref,
300331
f"customer-storage report write failed: {type(exc).__name__}",
332+
verified_bundle_sha256=observed_sha256,
301333
)
302334

303335
return ExecutionResult(
@@ -306,4 +338,5 @@ def execute_job(
306338
halt=halt_object(report),
307339
report_ref=report_ref,
308340
error=outcome_error,
341+
verified_bundle_sha256=observed_sha256,
309342
)

openadapt_flow/connector/protocol.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,10 @@ class ByocJob(BaseModel):
105105
run_token: Optional[str] = None
106106
bundle_version_id: Optional[str] = None
107107
runtime_validation_id: Optional[str] = None
108-
bundle_sha256: Optional[str] = None
108+
#: SHA-256 of the exact approved sanitized derivative ZIP bytes staged at
109+
#: ``storage.bundle_ref``. Distinct from the bundle's semantic content
110+
#: digest; required so a valid but different archive cannot be executed.
111+
bundle_sha256: str = Field(pattern=r"^[0-9a-f]{64}$")
109112

110113
# --- Governed policy delivery (fail-closed) --------------------------------
111114
#: The org's resolved (baseline-filled) Tier-3 safety block. Always fully
@@ -159,6 +162,11 @@ def ensure_governed(self, *, require_run_token: bool = True) -> None:
159162
"byoc dispatch is missing a run-scoped callback token; refusing "
160163
"to run a job whose outcome we could not report (fail closed)"
161164
)
165+
if not self.bundle_version_id or not self.runtime_validation_id:
166+
raise ByocGovernanceError(
167+
"byoc dispatch is missing its immutable bundle-version or "
168+
"runtime-validation binding (fail closed)"
169+
)
162170
storage_ref = self.storage.bundle_ref if self.storage else None
163171
if not storage_ref:
164172
raise ByocGovernanceError(

openadapt_flow/connector/storage.py

Lines changed: 31 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,9 @@
1111
1212
* :class:`LocalCustomerStorage` — REAL: refs resolve under a customer directory
1313
(ideally a full-disk-encrypted volume). The on-prem clinic posture.
14-
* :class:`InMemoryCustomerStorage` — tests/dry-run: pretends the bundle is
15-
present and CAPTURES the report in memory, proving the report bytes never
16-
leave for the control plane, with zero infra.
14+
* :class:`InMemoryCustomerStorage` — tests/dry-run: stages exact archive bytes
15+
and CAPTURES the report in memory, proving the report bytes never leave for
16+
the control plane, with zero infra.
1717
1818
S3 / Azure Blob backends are documented as production follow-ups (they need a
1919
customer cloud to exercise); the operator reference agent in openadapt-cloud
@@ -24,6 +24,7 @@
2424

2525
import json
2626
import os
27+
import shutil
2728
import zipfile
2829
from pathlib import Path
2930
from typing import Any, Optional, Protocol
@@ -34,16 +35,16 @@ class CustomerStorage(Protocol):
3435

3536
kind: str
3637

37-
def fetch_bundle(self, ref: Optional[str], dest_dir: Path) -> Path:
38-
"""Resolve ``ref`` to an unpacked bundle directory under ``dest_dir``."""
38+
def fetch_bundle_archive(self, ref: Optional[str], dest_file: Path) -> Path:
39+
"""Copy the exact referenced ZIP bytes to ``dest_file``."""
3940
...
4041

4142
def write_report(self, ref: Optional[str], report: dict[str, Any]) -> Optional[str]:
4243
"""Persist the PHI-bearing report to the customer store; return its key."""
4344
...
4445

4546

46-
def _safe_extract(zip_path: Path, dest: Path) -> None:
47+
def extract_bundle_archive(zip_path: Path, dest: Path) -> None:
4748
"""Extract a zip, refusing any entry that escapes ``dest`` (zip-slip)."""
4849
dest = dest.resolve()
4950
with zipfile.ZipFile(zip_path) as zf:
@@ -62,16 +63,25 @@ class LocalCustomerStorage:
6263
def __init__(self, root: str) -> None:
6364
self.root = Path(root)
6465

65-
def fetch_bundle(self, ref: Optional[str], dest_dir: Path) -> Path:
66+
def fetch_bundle_archive(self, ref: Optional[str], dest_file: Path) -> Path:
6667
if not ref:
6768
raise RuntimeError("byoc job has no storage.bundle_ref to read")
68-
src = self.root / ref
69+
root = self.root.resolve()
70+
src = (root / ref).resolve()
71+
if src != root and root not in src.parents:
72+
raise RuntimeError(
73+
"bundle ref escapes the configured customer storage root"
74+
)
6975
if not src.exists():
7076
raise RuntimeError(f"bundle not found in customer storage: {src}")
71-
if src.is_dir():
72-
return src # already an unpacked bundle directory
73-
_safe_extract(src, dest_dir)
74-
return dest_dir
77+
if not src.is_file():
78+
raise RuntimeError(
79+
"byoc bundle_ref must resolve to the exact approved ZIP archive, "
80+
"not an unpacked directory"
81+
)
82+
dest_file.parent.mkdir(parents=True, exist_ok=True)
83+
shutil.copyfile(src, dest_file)
84+
return dest_file
7585

7686
def write_report(self, ref: Optional[str], report: dict[str, Any]) -> Optional[str]:
7787
if not ref:
@@ -87,20 +97,24 @@ def write_report(self, ref: Optional[str], report: dict[str, Any]) -> Optional[s
8797

8898

8999
class InMemoryCustomerStorage:
90-
"""Tests/dry-run backend: a pre-staged bundle dir + an in-memory report sink.
100+
"""Tests/dry-run backend: exact bundle bytes + an in-memory report sink.
91101
92102
Proves the report bytes are written to the CUSTOMER side and never returned
93103
to the control plane, with zero infra.
94104
"""
95105

96106
kind = "memory"
97107

98-
def __init__(self, bundle_dir: Optional[Path] = None) -> None:
99-
self.bundle_dir = bundle_dir
108+
def __init__(self, bundle_bytes: Optional[bytes] = None) -> None:
109+
self.bundle_bytes = bundle_bytes
100110
self.written: dict[str, dict[str, Any]] = {}
101111

102-
def fetch_bundle(self, ref: Optional[str], dest_dir: Path) -> Path:
103-
return self.bundle_dir if self.bundle_dir is not None else dest_dir
112+
def fetch_bundle_archive(self, ref: Optional[str], dest_file: Path) -> Path:
113+
if self.bundle_bytes is None:
114+
raise RuntimeError("in-memory customer storage has no bundle archive")
115+
dest_file.parent.mkdir(parents=True, exist_ok=True)
116+
dest_file.write_bytes(self.bundle_bytes)
117+
return dest_file
104118

105119
def write_report(self, ref: Optional[str], report: dict[str, Any]) -> Optional[str]:
106120
self.written[ref or "?"] = report

0 commit comments

Comments
 (0)