From 63df92fc14403d2012fab11cd98865d5655a5506 Mon Sep 17 00:00:00 2001 From: Sinity Date: Tue, 25 Aug 2026 04:42:26 +0200 Subject: [PATCH 1/6] feat(agentctl): reject incomplete packet results --- docs/sinnixd.md | 17 + pkgs/sinnixd/sinnixd/packet_completion.py | 585 ++++++++++++++++++++++ pkgs/sinnixd/sinnixd/service.py | 49 ++ pkgs/sinnixd/test_packet_completion.py | 260 ++++++++++ pkgs/sinnixd/test_service.py | 45 ++ 5 files changed, 956 insertions(+) create mode 100644 pkgs/sinnixd/sinnixd/packet_completion.py create mode 100644 pkgs/sinnixd/test_packet_completion.py diff --git a/docs/sinnixd.md b/docs/sinnixd.md index 11f36adc..dd5c43cc 100644 --- a/docs/sinnixd.md +++ b/docs/sinnixd.md @@ -223,6 +223,23 @@ If any verification or cutover command fails, leave Sinnixd stopped. Before the Both routes use the same UUID job ID, transient user service, cancellation, reconciliation, `job get/list/logs/result/wait`, and bounded artifact readers as declared operations. Their durable public record contains the principal, job kind, canonical project and checkout identity, redacted argv digest or prompt digest, and bounded artifact references. It never stores raw shell argv arguments after launch, prompt text, environment values, or credentials. +`job.packet-completion` is the generic AgentCTL handoff for an agent packet. It +requires the bound job and workspace IDs, the packet write scope, a structured +worker delivery record, typed verification receipts (or a project-owned receipt +provider), and explicit delegation capability metadata. The inspector composes +the job terminal state, live workspace/Git facts, receipt heads, worker command +results, anti-vacuity evidence, unresolved items, deletion ledger, and optional +independent review into one bounded result. A successful process is only one +input: dirty or untracked work, a missing commit, divergent or out-of-scope +changes, missing/stale final-head receipts, lost worker results, unresolved +structured delegation, or required review failure remains non-complete. + +Evidence-only packets must opt into no-change completion and name immutable +evidence references. Delegation visibility is `supported` with a structured +pending boolean or `unsupported` with no pending claim; last-message prose is +never parsed. Project adapters own semantic receipt production, Beads remains +task authority, and model/backend names have no completion-policy meaning. + Typed jobs accept no environment overlay. The daemon creates the `env -i` environment from the declared project environment and fixed `SINNIXD_*` identity fields. Immediately before execution, the contract runner verifies those fields, rechecks the exact registered project, canonical worktree root, common Git directory, porcelain worktree membership, and recorded HEAD. A changed, missing, symlinked, or spoofed identity fails closed. Agent handoff includes `--registered-project`, `--expected-git-common-dir`, and the canonical checkout path; nested scope creation remains disabled, so the native runner provides backend execution and native attestation while the shared transient user service remains the sole process, cgroup, timeout, and cancellation authority. Private launch inputs are mode 0600, removed before shell execution, and removed after agent handoff or every terminal lifecycle outcome, including confirmed launch failure. Native private logs are removed after handoff; only the bounded shared log and result artifacts remain addressable. Each record is stored under `$XDG_STATE_HOME/sinnixd` and contains safe operation identity, environment key names, and its bounded-read log artifact path. Record replacement fsyncs the containing directory, and newly created state directories are synchronized before they contain durable evidence. The `sinnixd-job-*.service` dynamic runtime surface and its record capture lane are declared with the daemon, rather than with any MCP frontend. Internal foreground argv is launch-only: the durable record has only a SHA-256 digest and constant display metadata, never raw argv or environment values. The systemd-launched capture helper drains output but writes at most 1 MiB per job; it creates its overflow marker with the first discarded byte, so a live log reader can see truncation before the producer exits. It also fsyncs a completion marker only after the captured process exits successfully and all bounded outputs are durable. It does not own a PID, process state, queue, task, workspace, or retry policy. A job ID deterministically derives its unit name. Every `systemd-run` and `systemctl` call has a short finite bound. `job.wait` caps each reconciliation call to its remaining deadline, so a stalled user manager cannot hold a wait or reserved control worker indefinitely. After a daemon restart, `get`, `list`, `wait`, and `cancel` reload the record and reconcile with the user manager. If `systemd-run` loses its reply but `show` finds the transient unit, `job start` returns the reconciled systemd state. If both the launch reply and its first reconciliation are unavailable, `job start` returns a durable nonterminal `launch-unknown` result with the stable job ID and unit. Later `get`, `wait`, and `cancel` use that same identity to reconcile it. A confirmed absent launch becomes terminal `launch-failed`. A confirmed missing unit after launch remains terminal `missing`; an unreachable or timed-out systemd observation is durable nonterminal `observation-unknown` until a later observation repairs it. Cancellation persists its intent before asking systemd to stop the service, then preserves an observed systemd success, timeout, or failure result. A `cancelled` result needs matching systemd signal evidence, or a durably recorded successful stop acknowledgement for the observed invocation when systemd has already garbage-collected the transient unit. If a stop times out and the unit later disappears, the job remains nonterminal `outcome-unknown` instead of treating the missing unit's default success fields as an exit result. A later authoritative systemd observation can repair that state. A typed result can prove semantic success after collection only when its content is valid and the capture completion marker proves the producer exited successfully; an empty, partial, malformed, or unmarked result is not completion evidence. A schema-v3 attested-agent record also carries forward its native completion only when systemd still reports an inactive loaded success, its durable lifecycle is `succeeded` with exit status zero, its bounded last-message artifact is valid, and no cancellation intent exists. Existing false terminal success or cancellation records without this evidence are reopened lazily by `get`, `list`, `wait`, or `cancel` and reconciled under the same rules. Systemd remains authoritative for the process, cgroup, timeout, terminal result, cancellation, and journal evidence. diff --git a/pkgs/sinnixd/sinnixd/packet_completion.py b/pkgs/sinnixd/sinnixd/packet_completion.py new file mode 100644 index 00000000..e0879739 --- /dev/null +++ b/pkgs/sinnixd/sinnixd/packet_completion.py @@ -0,0 +1,585 @@ +"""Provider-neutral completion inspection for AgentCTL delivery packets. + +Process termination is deliberately only one input. This module composes +durable AgentCTL job/workspace state with Git and typed provider receipts; it +does not interpret worker prose or own task/campaign state. +""" + +from __future__ import annotations + +import re +import subprocess +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Literal, Mapping, Protocol, Sequence + + +CompletionReason = Literal[ + "job_not_succeeded", + "job_timeout", + "job_result_loss", + "job_binding_mismatch", + "workspace_unavailable", + "workspace_dirty", + "untracked_work", + "workspace_identity_mismatch", + "head_binding_mismatch", + "divergent_head", + "no_commit", + "out_of_scope_path", + "worker_result_missing", + "worker_result_invalid", + "worker_command_failed", + "anti_vacuity_missing", + "unresolved_items", + "delegated_work_pending", + "verification_missing", + "verification_stale", + "verification_failed", + "evidence_only_not_authorized", + "evidence_only_evidence_missing", + "review_missing", + "review_stale", + "review_rejected", + "deletion_ledger_missing", +] + +_HEAD = re.compile(r"[0-9a-fA-F]{40,64}\Z") + + +def _require_head(value: object, name: str) -> str: + if not isinstance(value, str) or _HEAD.fullmatch(value) is None: + raise ValueError(f"{name} must be a Git object ID") + return value + + +def _require_ref(value: object, name: str) -> str: + if not isinstance(value, str) or not value or len(value) > 512: + raise ValueError(f"{name} must be a bounded non-empty reference") + return value + + +def _relative_path(value: object, name: str) -> str: + if not isinstance(value, str) or not value or value.startswith("/"): + raise ValueError(f"{name} must be a relative path") + parts = value.rstrip("/").split("/") + if any(part in {"", ".", ".."} for part in parts): + raise ValueError(f"{name} must be a normalized relative path") + return value + + +@dataclass(frozen=True) +class PacketContract: + """The immutable delivery requirements declared for one AgentCTL packet.""" + + job_id: str + workspace_id: str + write_scope: tuple[str, ...] + required_verification_refs: tuple[str, ...] = () + allow_evidence_only: bool = False + evidence_only_refs: tuple[str, ...] = () + require_independent_review: bool = False + require_deletion_ledger: bool = True + + def __post_init__(self) -> None: + if not self.job_id or not self.workspace_id: + raise ValueError("packet job_id and workspace_id are required") + if not self.write_scope or len(set(self.write_scope)) != len(self.write_scope): + raise ValueError("packet write_scope must be non-empty and unique") + for path in self.write_scope: + _relative_path(path, "packet write_scope") + refs = (*self.required_verification_refs, *self.evidence_only_refs) + if len(set(refs)) != len(refs): + raise ValueError("packet receipt references must be unique") + for ref in refs: + _require_ref(ref, "packet receipt reference") + if self.evidence_only_refs and not self.allow_evidence_only: + raise ValueError("evidence-only references require an explicit evidence-only contract") + + @classmethod + def from_mapping(cls, value: Mapping[str, Any]) -> PacketContract: + allowed = { + "job_id", "workspace_id", "write_scope", "required_verification_refs", + "allow_evidence_only", "evidence_only_refs", "require_independent_review", + "require_deletion_ledger", + } + if set(value) - allowed: + raise ValueError("packet contract has unknown fields") + try: + return cls( + job_id=value["job_id"], + workspace_id=value["workspace_id"], + write_scope=tuple(value["write_scope"]), + required_verification_refs=tuple(value.get("required_verification_refs", ())), + allow_evidence_only=value.get("allow_evidence_only", False), + evidence_only_refs=tuple(value.get("evidence_only_refs", ())), + require_independent_review=value.get("require_independent_review", False), + require_deletion_ledger=value.get("require_deletion_ledger", True), + ) + except (KeyError, TypeError) as error: + raise ValueError("packet contract is malformed") from error + + +@dataclass(frozen=True) +class DelegationCapability: + """Backend/runtime delegation visibility, never inferred from model text.""" + + visibility: Literal["supported", "unsupported"] + pending: bool | None + + def __post_init__(self) -> None: + if self.visibility not in {"supported", "unsupported"}: + raise ValueError("delegation visibility is invalid") + if self.visibility == "supported" and not isinstance(self.pending, bool): + raise ValueError("supported delegation visibility requires pending state") + if self.visibility == "unsupported" and self.pending is not None: + raise ValueError("unsupported delegation visibility cannot claim pending state") + + @classmethod + def from_mapping(cls, value: Mapping[str, Any]) -> DelegationCapability: + if set(value) != {"visibility", "pending"}: + raise ValueError("delegation capability is malformed") + return cls(visibility=value["visibility"], pending=value["pending"]) + + def to_dict(self) -> dict[str, Any]: + return {"visibility": self.visibility, "pending": self.pending} + + +@dataclass(frozen=True) +class VerificationReceipt: + ref: str + operation: str + head: str + passed: bool + immutable: bool + + def __post_init__(self) -> None: + _require_ref(self.ref, "verification receipt ref") + _require_ref(self.operation, "verification receipt operation") + _require_head(self.head, "verification receipt head") + if not isinstance(self.passed, bool) or not isinstance(self.immutable, bool): + raise ValueError("verification receipt outcome is invalid") + + @classmethod + def from_mapping(cls, value: Mapping[str, Any]) -> VerificationReceipt: + try: + return cls( + ref=value["ref"], + operation=value["operation"], + head=value["head"], + passed=value["passed"], + immutable=value["immutable"], + ) + except (KeyError, TypeError) as error: + raise ValueError("verification receipt is malformed") from error + + +class VerificationReceiptProvider(Protocol): + """Project-owned seam for immutable semantic receipts; no project import is needed.""" + + def get_receipts(self, refs: Sequence[str]) -> Sequence[VerificationReceipt]: + """Return the requested receipts, preserving their provider references.""" + + +@dataclass(frozen=True) +class EvidenceReceipt: + ref: str + head: str + passed: bool + immutable: bool + + def __post_init__(self) -> None: + _require_ref(self.ref, "evidence receipt ref") + _require_head(self.head, "evidence receipt head") + if not isinstance(self.passed, bool) or not isinstance(self.immutable, bool): + raise ValueError("evidence receipt outcome is invalid") + + @classmethod + def from_mapping(cls, value: Mapping[str, Any]) -> EvidenceReceipt: + try: + return cls( + ref=value["ref"], + head=value["head"], + passed=value["passed"], + immutable=value["immutable"], + ) + except (KeyError, TypeError) as error: + raise ValueError("evidence receipt is malformed") from error + + +@dataclass(frozen=True) +class IndependentReviewReceipt: + ref: str + head: str + passed: bool + immutable: bool + + def __post_init__(self) -> None: + _require_ref(self.ref, "review receipt ref") + _require_head(self.head, "review receipt head") + if not isinstance(self.passed, bool) or not isinstance(self.immutable, bool): + raise ValueError("review receipt outcome is invalid") + + @classmethod + def from_mapping(cls, value: Mapping[str, Any]) -> IndependentReviewReceipt: + try: + return cls( + ref=value["ref"], + head=value["head"], + passed=value["passed"], + immutable=value["immutable"], + ) + except (KeyError, TypeError) as error: + raise ValueError("review receipt is malformed") from error + + +@dataclass(frozen=True) +class GitCompletionEvidence: + """A bounded snapshot of Git facts used by completion inspection.""" + + start_head: str + final_head: str + is_descendant: bool + commits: tuple[str, ...] + changed_paths: tuple[str, ...] + working_tree_paths: tuple[str, ...] + untracked_paths: tuple[str, ...] + + def __post_init__(self) -> None: + _require_head(self.start_head, "Git start_head") + _require_head(self.final_head, "Git final_head") + if not isinstance(self.is_descendant, bool): + raise ValueError("Git ancestry evidence is invalid") + for commit in self.commits: + _require_head(commit, "Git commit") + for path in (*self.changed_paths, *self.working_tree_paths, *self.untracked_paths): + _relative_path(path, "Git changed path") + + @property + def dirty(self) -> bool: + return bool(self.working_tree_paths) + + +class GitCompletionEvidenceProvider(Protocol): + def inspect(self, *, path: str, start_head: str, final_head: str) -> GitCompletionEvidence: + """Return one bounded, same-checkout Git snapshot.""" + + +class SubprocessGitCompletionEvidence: + """Read Git authority without making any repository mutation.""" + + def inspect(self, *, path: str, start_head: str, final_head: str) -> GitCompletionEvidence: + root = Path(path) + status = self._run(root, "status", "--porcelain=v1", "--untracked-files=all").stdout + working_tree_paths = tuple( + line[3:].strip() for line in status.splitlines() if len(line) >= 4 and line[3:].strip() + ) + changed = self._run(root, "diff", "--name-only", f"{start_head}..{final_head}", "--").stdout + commits = self._run(root, "rev-list", "--reverse", f"{start_head}..{final_head}").stdout + ancestry = subprocess.run( + ["git", "-C", str(root), "merge-base", "--is-ancestor", start_head, final_head], + capture_output=True, + text=True, + timeout=2, + check=False, + ) + if ancestry.returncode not in {0, 1}: + raise ValueError("could not inspect Git ancestry") + paths = tuple(line for line in changed.splitlines() if line) + working = tuple(path for path in working_tree_paths if path) + return GitCompletionEvidence( + start_head=start_head, + final_head=final_head, + is_descendant=ancestry.returncode == 0, + commits=tuple(line for line in commits.splitlines() if line), + changed_paths=paths, + working_tree_paths=working, + untracked_paths=tuple( + line[3:].strip() for line in status.splitlines() + if line.startswith("?? ") and line[3:].strip() + ), + ) + + @staticmethod + def _run(root: Path, *arguments: str) -> subprocess.CompletedProcess[str]: + try: + result = subprocess.run( + ["git", "-C", str(root), *arguments], + capture_output=True, + text=True, + timeout=2, + check=False, + ) + except (OSError, subprocess.SubprocessError) as error: + raise ValueError("could not inspect Git workspace") from error + if result.returncode != 0: + raise ValueError("could not inspect Git workspace") + return result + + +@dataclass(frozen=True) +class WorkerDeliveryRecord: + """Structured worker handoff; ``last_message`` is intentionally ignored.""" + + result_ref: str + commands: tuple[Mapping[str, Any], ...] + anti_vacuity: Mapping[str, Any] + unresolved_items: tuple[str, ...] + deletion_ledger: tuple[Mapping[str, Any], ...] | None + last_message: str = "" + + def __post_init__(self) -> None: + _require_ref(self.result_ref, "worker result ref") + if not self.commands: + raise ValueError("worker delivery commands are missing") + for command in self.commands: + if ( + not isinstance(command, Mapping) + or set(command) != {"argv", "result"} + or not isinstance(command["argv"], (list, tuple)) + or not command["argv"] + or any(not isinstance(item, str) or not item for item in command["argv"]) + or command["result"] not in {"passed", "failed"} + ): + raise ValueError("worker delivery command result is malformed") + expected = {"checked", "mutation", "passed", "evidence_ref"} + if ( + not isinstance(self.anti_vacuity, Mapping) + or set(self.anti_vacuity) != expected + or not isinstance(self.anti_vacuity["checked"], bool) + or not isinstance(self.anti_vacuity["passed"], bool) + or not isinstance(self.anti_vacuity["mutation"], str) + ): + raise ValueError("worker anti-vacuity evidence is malformed") + _require_ref(self.anti_vacuity["evidence_ref"], "worker anti-vacuity evidence ref") + if any(not isinstance(item, str) or not item for item in self.unresolved_items): + raise ValueError("worker unresolved items are malformed") + if self.deletion_ledger is not None: + for item in self.deletion_ledger: + if not isinstance(item, Mapping) or set(item) != {"path", "action"}: + raise ValueError("worker deletion ledger is malformed") + _relative_path(item["path"], "worker deletion ledger path") + if not isinstance(item["action"], str) or not item["action"]: + raise ValueError("worker deletion ledger action is malformed") + + @classmethod + def from_mapping(cls, value: Mapping[str, Any]) -> WorkerDeliveryRecord: + required = {"result_ref", "commands", "anti_vacuity", "unresolved_items", "deletion_ledger"} + if not isinstance(value, Mapping) or not required <= set(value): + raise ValueError("worker delivery record is missing fields") + try: + return cls( + result_ref=value["result_ref"], + commands=tuple(value["commands"]), + anti_vacuity=value["anti_vacuity"], + unresolved_items=tuple(value["unresolved_items"]), + deletion_ledger=( + tuple(value["deletion_ledger"]) if value["deletion_ledger"] is not None else None + ), + last_message=value.get("last_message", ""), + ) + except (KeyError, TypeError) as error: + raise ValueError("worker delivery record is malformed") from error + + def to_dict(self) -> dict[str, Any]: + return { + "result_ref": self.result_ref, + "commands": [dict(item) for item in self.commands], + "anti_vacuity": dict(self.anti_vacuity), + "unresolved_items": list(self.unresolved_items), + "deletion_ledger": [dict(item) for item in self.deletion_ledger] if self.deletion_ledger is not None else None, + } + + +@dataclass(frozen=True) +class PacketCompletionResult: + complete: bool + reasons: tuple[CompletionReason, ...] + job_id: str + workspace_id: str + start_head: str | None + final_head: str | None + commits: tuple[str, ...] + changed_paths: tuple[str, ...] + write_scope: tuple[str, ...] + dirty: bool | None + divergent: bool | None + worker_delivery: WorkerDeliveryRecord | None + required_verification_refs: tuple[str, ...] + verification_refs: tuple[str, ...] + delegation: DelegationCapability + review_ref: str | None + + def to_dict(self) -> dict[str, Any]: + return { + "complete": self.complete, + "reasons": list(self.reasons), + "job_id": self.job_id, + "workspace_id": self.workspace_id, + "start_head": self.start_head, + "final_head": self.final_head, + "commits": list(self.commits), + "changed_paths": list(self.changed_paths), + "write_scope": list(self.write_scope), + "dirty": self.dirty, + "divergent": self.divergent, + "worker_delivery": self.worker_delivery.to_dict() if self.worker_delivery else None, + "required_verification_refs": list(self.required_verification_refs), + "verification_refs": list(self.verification_refs), + "delegation": self.delegation.to_dict(), + "review_ref": self.review_ref, + } + + +class PacketCompletionInspector: + """Compose one job/workspace snapshot into a typed completion verdict.""" + + def __init__(self, git_provider: GitCompletionEvidenceProvider | None = None) -> None: + self.git_provider = git_provider or SubprocessGitCompletionEvidence() + + def inspect( + self, + *, + job: Mapping[str, Any], + workspace: Mapping[str, Any], + contract: PacketContract, + worker_result: WorkerDeliveryRecord | None, + verification_receipts: Sequence[VerificationReceipt] | None, + delegation: DelegationCapability, + evidence_receipts: Sequence[EvidenceReceipt] = (), + review: IndependentReviewReceipt | None = None, + git: GitCompletionEvidence | None = None, + verification_provider: VerificationReceiptProvider | None = None, + ) -> PacketCompletionResult: + if verification_receipts is None: + if verification_provider is None: + raise ValueError("completion inspection requires verification receipts or a provider") + verification_receipts = verification_provider.get_receipts(contract.required_verification_refs) + reasons: list[CompletionReason] = [] + state = job.get("state") if isinstance(job.get("state"), Mapping) else {} + checkout = job.get("checkout") if isinstance(job.get("checkout"), Mapping) else {} + start_head = checkout.get("head") if isinstance(checkout.get("head"), str) else None + final_head = workspace.get("head") if isinstance(workspace.get("head"), str) else None + if job.get("job_id") != contract.job_id or workspace.get("workspace_id") != contract.workspace_id: + reasons.append("job_binding_mismatch") + if checkout.get("checkout_id") != workspace.get("checkout_id"): + reasons.append("job_binding_mismatch") + + phase = state.get("phase") + if phase == "timed_out": + reasons.append("job_timeout") + if phase != "succeeded" or state.get("terminal") is not True: + reasons.append("job_not_succeeded") + systemd = state.get("systemd") if isinstance(state.get("systemd"), Mapping) else {} + exit_status = systemd.get("ExecMainStatus", state.get("exit_status")) + if exit_status is not None and str(exit_status) != "0": + reasons.append("job_result_loss" if phase == "succeeded" else "job_not_succeeded") + + if workspace.get("state") != "available": + reasons.append("workspace_unavailable") + if workspace.get("dirty") is True: + reasons.append("workspace_dirty") + if workspace.get("identity_matches") is not True: + reasons.append("workspace_identity_mismatch") + + if git is None: + if not isinstance(workspace.get("path"), str) or start_head is None or final_head is None: + git = None + else: + try: + git = self.git_provider.inspect(path=workspace["path"], start_head=start_head, final_head=final_head) + except ValueError: + git = None + if git is not None: + if start_head != git.start_head or final_head != git.final_head: + reasons.append("head_binding_mismatch") + if not git.is_descendant: + reasons.append("divergent_head") + if git.dirty and "workspace_dirty" not in reasons: + reasons.append("workspace_dirty") + if git.untracked_paths: + reasons.append("untracked_work") + if not git.commits and not contract.allow_evidence_only: + reasons.append("no_commit") + if any( + not self._in_scope(path, contract.write_scope) + for path in (*git.changed_paths, *git.working_tree_paths, *git.untracked_paths) + ): + reasons.append("out_of_scope_path") + else: + reasons.append("head_binding_mismatch") + + if worker_result is None: + reasons.append("worker_result_missing") + else: + artifacts = job.get("artifacts") + if isinstance(artifacts, Mapping): + result_artifact = artifacts.get("result") + if result_artifact is None: + reasons.append("job_result_loss") + elif isinstance(result_artifact, Mapping) and result_artifact.get("ref") != worker_result.result_ref: + reasons.append("worker_result_invalid") + if any(command["result"] != "passed" for command in worker_result.commands): + reasons.append("worker_command_failed") + if not worker_result.anti_vacuity["checked"] or not worker_result.anti_vacuity["passed"]: + reasons.append("anti_vacuity_missing") + if worker_result.unresolved_items: + reasons.append("unresolved_items") + if contract.require_deletion_ledger and worker_result.deletion_ledger is None: + reasons.append("deletion_ledger_missing") + if delegation.visibility == "supported" and delegation.pending: + reasons.append("delegated_work_pending") + + receipts = {receipt.ref: receipt for receipt in verification_receipts} + for ref in contract.required_verification_refs: + receipt = receipts.get(ref) + if receipt is None: + reasons.append("verification_missing") + elif git is None or receipt.head != final_head: + reasons.append("verification_stale") + elif not receipt.immutable or not receipt.passed: + reasons.append("verification_failed") + + if git is not None and not git.commits: + if not contract.allow_evidence_only: + reasons.append("evidence_only_not_authorized") + else: + evidence = {receipt.ref: receipt for receipt in evidence_receipts} + for ref in contract.evidence_only_refs: + receipt = evidence.get(ref) + if receipt is None or receipt.head != final_head or not receipt.immutable or not receipt.passed: + reasons.append("evidence_only_evidence_missing") + if not contract.evidence_only_refs: + reasons.append("evidence_only_evidence_missing") + + if contract.require_independent_review: + if review is None: + reasons.append("review_missing") + elif review.head != final_head: + reasons.append("review_stale") + elif not review.immutable or not review.passed: + reasons.append("review_rejected") + + unique_reasons = tuple(dict.fromkeys(reasons)) + return PacketCompletionResult( + complete=not unique_reasons, + reasons=unique_reasons, + job_id=contract.job_id, + workspace_id=contract.workspace_id, + start_head=start_head, + final_head=final_head, + commits=git.commits if git is not None else (), + changed_paths=git.changed_paths if git is not None else (), + write_scope=contract.write_scope, + dirty=git.dirty if git is not None else workspace.get("dirty") if isinstance(workspace.get("dirty"), bool) else None, + divergent=(not git.is_descendant) if git is not None else None, + worker_delivery=worker_result, + required_verification_refs=contract.required_verification_refs, + verification_refs=tuple(receipt.ref for receipt in verification_receipts), + delegation=delegation, + review_ref=review.ref if review is not None else None, + ) + + @staticmethod + def _in_scope(path: str, scopes: Sequence[str]) -> bool: + return any(path == scope.rstrip("/") or path.startswith(scope.rstrip("/") + "/") for scope in scopes) diff --git a/pkgs/sinnixd/sinnixd/service.py b/pkgs/sinnixd/sinnixd/service.py index 0c32aca7..fe5c1b44 100644 --- a/pkgs/sinnixd/sinnixd/service.py +++ b/pkgs/sinnixd/sinnixd/service.py @@ -21,6 +21,15 @@ from .contracts import TypedJobContracts from .delivery import DeliveryError, GitHubDelivery from .owner_adapters import DeclaredOwnerAdapters, OwnerAdapterError +from .packet_completion import ( + DelegationCapability, + EvidenceReceipt, + IndependentReviewReceipt, + PacketCompletionInspector, + PacketContract, + VerificationReceipt, + WorkerDeliveryRecord, +) from .projects import ProjectCatalog from .tasks import TaskError, TaskService from .workspaces import GitWorkspaces, WorkspaceError, WorkspaceStore @@ -51,6 +60,7 @@ class SinnixdService: workspaces: GitWorkspaces | None = None delivery: GitHubDelivery | None = None tasks: TaskService | None = None + packet_completion: PacketCompletionInspector = field(default_factory=PacketCompletionInspector) def __post_init__(self) -> None: if self.workspaces is None: @@ -464,6 +474,45 @@ def _dispatch( if not isinstance(max_bytes, int) or isinstance(max_bytes, bool): raise ValueError("job.result max_bytes must be an integer") return self.jobs.result(job_id, max_bytes=max_bytes) + if operation == "job.packet-completion": + if principal not in {"agent-control", "operator"}: + raise ValueError("job.packet-completion requires agent-control or operator") + required = { + "job_id", "workspace_id", "contract", "worker_result", "verification_receipts", + "delegation", "evidence_receipts", "review", + } + if set(arguments) != required: + raise ValueError( + "job.packet-completion requires job_id, workspace_id, contract, worker_result, " + "verification_receipts, delegation, evidence_receipts, and review" + ) + job_id = self._authorize_job(principal, self._job_argument(arguments, "job_id")) + workspace_id = self._job_argument(arguments, "workspace_id") + contract = PacketContract.from_mapping(arguments["contract"]) + if contract.job_id != job_id or contract.workspace_id != workspace_id: + raise ValueError("job.packet-completion contract binding does not match arguments") + raw_worker = arguments["worker_result"] + worker = None if raw_worker is None else WorkerDeliveryRecord.from_mapping(raw_worker) + raw_verifications = arguments["verification_receipts"] + raw_evidence = arguments["evidence_receipts"] + if not isinstance(raw_verifications, list) or not isinstance(raw_evidence, list): + raise ValueError("job.packet-completion receipts must be lists") + verifications = tuple(VerificationReceipt.from_mapping(item) for item in raw_verifications) + evidence = tuple(EvidenceReceipt.from_mapping(item) for item in raw_evidence) + raw_review = arguments["review"] + review = None if raw_review is None else IndependentReviewReceipt.from_mapping(raw_review) + delegation = DelegationCapability.from_mapping(arguments["delegation"]) + assert self.workspaces is not None + return self.packet_completion.inspect( + job=self.jobs.get(job_id), + workspace=self.workspaces.get(workspace_id), + contract=contract, + worker_result=worker, + verification_receipts=verifications, + evidence_receipts=evidence, + delegation=delegation, + review=review, + ).to_dict() if operation == "job.cancel": return self._cleanup_terminal( self.jobs.cancel( diff --git a/pkgs/sinnixd/test_packet_completion.py b/pkgs/sinnixd/test_packet_completion.py new file mode 100644 index 00000000..027dc78a --- /dev/null +++ b/pkgs/sinnixd/test_packet_completion.py @@ -0,0 +1,260 @@ +from __future__ import annotations + +from dataclasses import replace + +from sinnixd.packet_completion import ( + DelegationCapability, + EvidenceReceipt, + GitCompletionEvidence, + IndependentReviewReceipt, + PacketCompletionInspector, + PacketContract, + VerificationReceipt, + WorkerDeliveryRecord, +) + + +START = "1" * 40 +FINAL = "2" * 40 +OTHER = "3" * 40 + + +def git_evidence(**overrides: object) -> GitCompletionEvidence: + values = { + "start_head": START, + "final_head": FINAL, + "is_descendant": True, + "commits": (FINAL,), + "changed_paths": ("src/changed.py",), + "working_tree_paths": (), + "untracked_paths": (), + } + return GitCompletionEvidence(**{**values, **overrides}) + + +def delivery(**overrides: object) -> WorkerDeliveryRecord: + values = { + "result_ref": "sinnix://jobs/job-1/artifacts/result", + "last_message": "", + "commands": ({"argv": ["devtools", "test", "affected"], "result": "passed"},), + "anti_vacuity": { + "checked": True, + "mutation": "replace_inspector_with_process_exit", + "passed": True, + "evidence_ref": "sinnix://evidence/anti-vacuity-1", + }, + "unresolved_items": (), + "deletion_ledger": ({"path": "retired.py", "action": "retained"},), + } + return WorkerDeliveryRecord(**{**values, **overrides}) + + +def verification(*, ref: str = "receipt-1", head: str = FINAL, passed: bool = True) -> VerificationReceipt: + return VerificationReceipt(ref=ref, operation="verify", head=head, passed=passed, immutable=True) + + +def base_contract(**overrides: object) -> PacketContract: + values = { + "job_id": "job-1", + "workspace_id": "workspace-1", + "write_scope": ("src/",), + "required_verification_refs": ("receipt-1",), + } + return PacketContract(**{**values, **overrides}) + + +def inspect(**overrides: object): + values = { + "job": { + "job_id": "job-1", + "state": {"phase": "succeeded", "terminal": True}, + "checkout": {"checkout_id": "workspace-1", "head": START}, + }, + "workspace": { + "workspace_id": "workspace-1", + "checkout_id": "workspace-1", + "state": "available", + "identity_matches": True, + "head": FINAL, + "dirty": False, + }, + "git": git_evidence(), + "contract": base_contract(), + "worker_result": delivery(), + "verification_receipts": (verification(),), + "delegation": DelegationCapability(visibility="supported", pending=False), + } + return PacketCompletionInspector().inspect(**{**values, **overrides}) + + +def test_clean_committed_exact_head_delivery_is_complete() -> None: + result = inspect() + + assert result.complete + assert result.reasons == () + assert result.job_id == "job-1" + assert result.workspace_id == "workspace-1" + assert result.start_head == START + assert result.final_head == FINAL + assert result.commits == (FINAL,) + assert result.changed_paths == ("src/changed.py",) + + +def test_observed_failed_packet_shape_is_rejected_without_prose_matching() -> None: + result = inspect( + job={ + "job_id": "job-1", + "state": {"phase": "succeeded", "terminal": True}, + "checkout": {"checkout_id": "workspace-1", "head": START}, + }, + git=git_evidence( + final_head=START, + commits=(), + changed_paths=(), + working_tree_paths=("scratch.txt",), + untracked_paths=("scratch.txt",), + ), + worker_result=None, + delegation=DelegationCapability(visibility="supported", pending=True), + ) + + assert not result.complete + assert { + "workspace_dirty", + "untracked_work", + "no_commit", + "worker_result_missing", + "delegated_work_pending", + } <= set(result.reasons) + + +def test_dirty_workspace_is_rejected_even_when_process_succeeded() -> None: + result = inspect(git=git_evidence(working_tree_paths=("src/changed.py",))) + + assert not result.complete + assert "workspace_dirty" in result.reasons + + +def test_stale_receipt_is_distinct_from_missing_receipt() -> None: + stale = inspect(verification_receipts=(verification(head=START),)) + missing = inspect(verification_receipts=()) + + assert "verification_stale" in stale.reasons + assert "verification_missing" in missing.reasons + + +def test_out_of_scope_paths_are_rejected() -> None: + result = inspect(git=git_evidence(changed_paths=("docs/outside.md",))) + + assert not result.complete + assert "out_of_scope_path" in result.reasons + + +def test_divergent_head_is_rejected() -> None: + result = inspect(git=git_evidence(final_head=OTHER, is_descendant=False)) + + assert not result.complete + assert "divergent_head" in result.reasons + + +def test_evidence_only_requires_explicit_contract_and_immutable_evidence() -> None: + evidence = EvidenceReceipt(ref="evidence-1", head=START, passed=True, immutable=True) + result = inspect( + git=git_evidence(final_head=START, commits=(), changed_paths=()), + contract=base_contract( + required_verification_refs=(), + allow_evidence_only=True, + evidence_only_refs=("evidence-1",), + ), + workspace={ + "workspace_id": "workspace-1", + "checkout_id": "workspace-1", + "state": "available", + "identity_matches": True, + "head": START, + "dirty": False, + }, + evidence_receipts=(evidence,), + ) + + assert result.complete + + accidental = inspect( + git=git_evidence(final_head=START, commits=(), changed_paths=()), + contract=base_contract(required_verification_refs=()), + ) + assert not accidental.complete + assert "evidence_only_not_authorized" in accidental.reasons + + +def test_missing_required_review_and_rejected_review_are_structural() -> None: + missing = inspect(contract=base_contract(require_independent_review=True)) + rejected = inspect( + contract=base_contract(require_independent_review=True), + review=IndependentReviewReceipt(ref="review-1", head=FINAL, passed=False, immutable=True), + ) + + assert "review_missing" in missing.reasons + assert "review_rejected" in rejected.reasons + + accepted = inspect( + contract=base_contract(require_independent_review=True), + review=IndependentReviewReceipt(ref="review-1", head=FINAL, passed=True, immutable=True), + ) + assert accepted.complete + + +def test_timeout_and_result_loss_are_not_completion() -> None: + timed_out = inspect( + job={"job_id": "job-1", "state": {"phase": "timed_out", "terminal": True}, "checkout": {"checkout_id": "workspace-1", "head": START}} + ) + lost = inspect(worker_result=None) + + assert "job_timeout" in timed_out.reasons + assert "worker_result_missing" in lost.reasons + + artifact_lost = inspect( + job={ + "job_id": "job-1", + "state": {"phase": "succeeded", "terminal": True}, + "checkout": {"checkout_id": "workspace-1", "head": START}, + "artifacts": {"result": None}, + } + ) + recovered = inspect( + job={ + "job_id": "job-1", + "state": {"phase": "succeeded", "terminal": True}, + "checkout": {"checkout_id": "workspace-1", "head": START}, + "artifacts": {"result": {"ref": "sinnix://jobs/job-1/artifacts/result"}}, + } + ) + assert "job_result_loss" in artifact_lost.reasons + assert recovered.complete + + +def test_required_deletion_ledger_cannot_be_omitted() -> None: + result = inspect(worker_result=replace(delivery(), deletion_ledger=None)) + + assert not result.complete + assert "deletion_ledger_missing" in result.reasons + + +def test_unsupported_delegation_visibility_is_explicit_but_not_prose_inferred() -> None: + result = inspect( + delegation=DelegationCapability(visibility="unsupported", pending=None), + worker_result=replace(delivery(), last_message="waiting for a background task"), + ) + + assert result.complete + assert result.delegation.visibility == "unsupported" + + +def test_pending_delegation_is_consumed_from_structured_capability() -> None: + result = inspect( + delegation=DelegationCapability(visibility="supported", pending=True), + worker_result=replace(delivery(), last_message="completed despite waiting in the text"), + ) + + assert not result.complete + assert "delegated_work_pending" in result.reasons diff --git a/pkgs/sinnixd/test_service.py b/pkgs/sinnixd/test_service.py index 694e9b06..17947ceb 100644 --- a/pkgs/sinnixd/test_service.py +++ b/pkgs/sinnixd/test_service.py @@ -4463,6 +4463,51 @@ def test_declared_job_binds_workspace_and_exact_head(tmp_path: Path) -> None: assert record.spec.checkout["head"] == workspace["head"] +def test_packet_completion_dispatch_composes_job_and_workspace_bindings(tmp_path: Path) -> None: + write_adapter(tmp_path) + initialize_git_checkout(tmp_path) + jobs = generic_jobs(tmp_path) + service = SinnixdService(ProjectCatalog([tmp_path]), jobs=jobs) + workspace = service.workspaces.create( + project_id="fixture", name="packet-lane", branch="feature/packet-lane", base="HEAD" + ) + started = service.dispatch( + request( + "job.start", + "systemd-jobs", + {"project_id": "fixture", "operation": "check", "workspace_id": workspace["workspace_id"]}, + ) + ) + assert started.ok and started.payload is not None + job_id = started.payload.inline["job_id"] + + response = service.dispatch( + request( + "job.packet-completion", + "systemd-jobs", + { + "job_id": job_id, + "workspace_id": workspace["workspace_id"], + "contract": { + "job_id": job_id, + "workspace_id": workspace["workspace_id"], + "write_scope": ["src/"], + "required_verification_refs": [], + }, + "worker_result": None, + "verification_receipts": [], + "delegation": {"visibility": "unsupported", "pending": None}, + "evidence_receipts": [], + "review": None, + }, + ) + ) + + assert response.ok and response.payload is not None + assert not response.payload.inline["complete"] + assert "worker_result_missing" in response.payload.inline["reasons"] + + def test_admission_revalidates_queued_declared_workspace_before_systemd_launch(tmp_path: Path) -> None: """A queued declared service whose checkout HEAD moved must terminalize before it reaches systemd.""" write_adapter(tmp_path) From 369eb96cb9a280c755060c7edf28c557d3a2f70d Mon Sep 17 00:00:00 2001 From: Sinity Date: Tue, 25 Aug 2026 04:44:51 +0200 Subject: [PATCH 2/6] test(agentctl): cover real packet completion pair --- pkgs/sinnixd/test_packet_completion.py | 46 ++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/pkgs/sinnixd/test_packet_completion.py b/pkgs/sinnixd/test_packet_completion.py index 027dc78a..750a5754 100644 --- a/pkgs/sinnixd/test_packet_completion.py +++ b/pkgs/sinnixd/test_packet_completion.py @@ -1,6 +1,8 @@ from __future__ import annotations +import subprocess from dataclasses import replace +from pathlib import Path from sinnixd.packet_completion import ( DelegationCapability, @@ -258,3 +260,47 @@ def test_pending_delegation_is_consumed_from_structured_capability() -> None: assert not result.complete assert "delegated_work_pending" in result.reasons + + +def test_disposable_real_git_success_and_failed_packet_pair(tmp_path: Path) -> None: + subprocess.run(["git", "init", "--quiet", str(tmp_path)], check=True) + subprocess.run(["git", "-C", str(tmp_path), "config", "user.name", "Fixture"], check=True) + subprocess.run(["git", "-C", str(tmp_path), "config", "user.email", "fixture@example.test"], check=True) + subprocess.run(["git", "-C", str(tmp_path), "commit", "--quiet", "--allow-empty", "-m", "base"], check=True) + start = subprocess.run( + ["git", "-C", str(tmp_path), "rev-parse", "HEAD"], check=True, capture_output=True, text=True + ).stdout.strip() + (tmp_path / "src").mkdir() + (tmp_path / "src" / "changed.py").write_text("pass\n") + subprocess.run(["git", "-C", str(tmp_path), "add", "src/changed.py"], check=True) + subprocess.run(["git", "-C", str(tmp_path), "commit", "--quiet", "-m", "change"], check=True) + final = subprocess.run( + ["git", "-C", str(tmp_path), "rev-parse", "HEAD"], check=True, capture_output=True, text=True + ).stdout.strip() + common = { + "job": { + "job_id": "job-1", + "state": {"phase": "succeeded", "terminal": True}, + "checkout": {"checkout_id": "checkout-1", "head": start}, + }, + "workspace": { + "workspace_id": "workspace-1", + "checkout_id": "checkout-1", + "path": str(tmp_path), + "state": "available", + "identity_matches": True, + "head": final, + "dirty": False, + }, + "contract": base_contract(), + "worker_result": delivery(), + "verification_receipts": (verification(head=final),), + "delegation": DelegationCapability(visibility="supported", pending=False), + } + assert PacketCompletionInspector().inspect(**common).complete + + (tmp_path / "untracked.txt").write_text("unfinished\n") + failed = PacketCompletionInspector().inspect(**common) + assert not failed.complete + assert "workspace_dirty" in failed.reasons + assert "untracked_work" in failed.reasons From b169974f85871dcdc8a9e60d242a14828ace5a8c Mon Sep 17 00:00:00 2001 From: Sinity Date: Tue, 25 Aug 2026 05:09:59 +0200 Subject: [PATCH 3/6] fix(agentctl): gate delivery on exact-head evidence --- docs/sinnixd.md | 17 +- pkgs/sinnixd/sinnixd/contracts.py | 16 +- pkgs/sinnixd/sinnixd/delivery.py | 67 ++- pkgs/sinnixd/sinnixd/packet_completion.py | 585 ---------------------- pkgs/sinnixd/sinnixd/service.py | 49 -- pkgs/sinnixd/sinnixd/workspaces.py | 79 ++- pkgs/sinnixd/test_packet_completion.py | 306 ----------- pkgs/sinnixd/test_service.py | 30 +- 8 files changed, 180 insertions(+), 969 deletions(-) delete mode 100644 pkgs/sinnixd/sinnixd/packet_completion.py delete mode 100644 pkgs/sinnixd/test_packet_completion.py diff --git a/docs/sinnixd.md b/docs/sinnixd.md index dd5c43cc..74635395 100644 --- a/docs/sinnixd.md +++ b/docs/sinnixd.md @@ -223,22 +223,7 @@ If any verification or cutover command fails, leave Sinnixd stopped. Before the Both routes use the same UUID job ID, transient user service, cancellation, reconciliation, `job get/list/logs/result/wait`, and bounded artifact readers as declared operations. Their durable public record contains the principal, job kind, canonical project and checkout identity, redacted argv digest or prompt digest, and bounded artifact references. It never stores raw shell argv arguments after launch, prompt text, environment values, or credentials. -`job.packet-completion` is the generic AgentCTL handoff for an agent packet. It -requires the bound job and workspace IDs, the packet write scope, a structured -worker delivery record, typed verification receipts (or a project-owned receipt -provider), and explicit delegation capability metadata. The inspector composes -the job terminal state, live workspace/Git facts, receipt heads, worker command -results, anti-vacuity evidence, unresolved items, deletion ledger, and optional -independent review into one bounded result. A successful process is only one -input: dirty or untracked work, a missing commit, divergent or out-of-scope -changes, missing/stale final-head receipts, lost worker results, unresolved -structured delegation, or required review failure remains non-complete. - -Evidence-only packets must opt into no-change completion and name immutable -evidence references. Delegation visibility is `supported` with a structured -pending boolean or `unsupported` with no pending claim; last-message prose is -never parsed. Project adapters own semantic receipt production, Beads remains -task authority, and model/backend names have no completion-policy meaning. +Delivery is a precondition of `workspace.publish` and `workspace.land`, not a caller-fed completion route. It reads the declared verification job through `job.result`, snapshots the bound workspace from Git at the job's exact launch head, and repeats that precondition after push and after review inspection. A JSON or pytest result may carry the bounded `delivery` object with only anti-vacuity, unresolved-work, delegation-visibility, deletion-evidence, and evidence-only fields. The packet write scope comes from the immutable Beads binding, never that result. Git owns paths, dirtiness, commits, and heads; the project verifier owns its result artifact; GitHub owns independent review state. A missing Beads write scope means a structured packet cannot be delivered. Beads closure consumes the returned completion artifact reference in its own owner; wiring that external closure consumer is not implemented by Sinnixd. Typed jobs accept no environment overlay. The daemon creates the `env -i` environment from the declared project environment and fixed `SINNIXD_*` identity fields. Immediately before execution, the contract runner verifies those fields, rechecks the exact registered project, canonical worktree root, common Git directory, porcelain worktree membership, and recorded HEAD. A changed, missing, symlinked, or spoofed identity fails closed. Agent handoff includes `--registered-project`, `--expected-git-common-dir`, and the canonical checkout path; nested scope creation remains disabled, so the native runner provides backend execution and native attestation while the shared transient user service remains the sole process, cgroup, timeout, and cancellation authority. Private launch inputs are mode 0600, removed before shell execution, and removed after agent handoff or every terminal lifecycle outcome, including confirmed launch failure. Native private logs are removed after handoff; only the bounded shared log and result artifacts remain addressable. diff --git a/pkgs/sinnixd/sinnixd/contracts.py b/pkgs/sinnixd/sinnixd/contracts.py index d31f92cc..60375f96 100644 --- a/pkgs/sinnixd/sinnixd/contracts.py +++ b/pkgs/sinnixd/sinnixd/contracts.py @@ -250,9 +250,23 @@ def _bead_binding( "bead_ref", "project_ref", "checkout_ref", "task_revision", "task_etag", "claim_ref", "claim_receipt", "request_id", "assignment_ref", } - if not isinstance(value, Mapping) or set(value) != expected: + allowed = expected | {"write_scope"} + if not isinstance(value, Mapping) or (set(value) != expected and set(value) != allowed): raise ContractError("agent bead binding is malformed") binding = dict(value) + scope = binding.get("write_scope") + if scope is not None and ( + not isinstance(scope, list) + or not scope + or any( + not isinstance(path, str) + or not path + or path.startswith("/") + or ".." in Path(path).parts + for path in scope + ) + ): + raise ContractError("agent Beads write scope is malformed") project_ref = f"sinnix://projects/{checkout.project_id}" checkout_ref = f"{project_ref}/checkouts/{checkout.checkout_id}" bead_prefix = f"{project_ref}/beads/" diff --git a/pkgs/sinnixd/sinnixd/delivery.py b/pkgs/sinnixd/sinnixd/delivery.py index 42be55da..3980944b 100644 --- a/pkgs/sinnixd/sinnixd/delivery.py +++ b/pkgs/sinnixd/sinnixd/delivery.py @@ -26,13 +26,14 @@ class GitHubDelivery: run: Run = subprocess.run def publish(self, workspace_id: str, job_id: str, title: str, body: str) -> dict[str, Any]: - workspace, project = self._verified_workspace(workspace_id, job_id) + workspace, project, receipt = self._verified_workspace(workspace_id, job_id) if not title.strip() or len(title) > 256 or len(body.encode()) > 64_000: raise DeliveryError("review title or body exceeds its publication bounds") base = self._base_branch(project.workspace.default_base) path = workspace["path"] branch = workspace["branch"] self._command([*project.environment.command, "git", "-C", path, "push", "-u", "origin", branch], cwd=path) + workspace, project, receipt = self._verified_workspace(workspace_id, job_id) existing = self.run( ["gh", "pr", "view", branch, "--json", "url"], cwd=path, capture_output=True, text=True, timeout=60, check=False, @@ -47,7 +48,7 @@ def publish(self, workspace_id: str, job_id: str, title: str, body: str) -> dict ).stdout.strip() created = True review = self._review_after_push(workspace_id) - return {**review, "published": True, "created": created, "publication_output": publication_output} + return {**review, "published": True, "created": created, "publication_output": publication_output, "completion": receipt} def _review_after_push(self, workspace_id: str) -> dict[str, Any]: for attempt in range(10): @@ -92,11 +93,12 @@ def land(self, workspace_id: str, job_id: str) -> dict[str, Any]: or not self._checks_pass(review["statusCheckRollup"]) ): raise DeliveryError("review is not in a landable GitHub state") + _workspace, _project, receipt = self._verified_workspace(workspace_id, job_id) self._command(["gh", "pr", "merge", str(review["number"]), "--squash"], cwd=self.workspaces.get(workspace_id)["path"]) merged = self.review_status(workspace_id) if merged["review"]["state"] != "MERGED": raise DeliveryError("GitHub did not report the review merged") - return {**merged, "landed": True} + return {**merged, "landed": True, "completion": receipt} def finish(self, workspace_id: str) -> dict[str, Any]: status = self.review_status(workspace_id) @@ -106,9 +108,9 @@ def finish(self, workspace_id: str) -> dict[str, Any]: self._delete_remote_branch(workspace["path"], workspace["branch"]) return self.workspaces.finish_merged(workspace_id, status["head"]) - def _verified_workspace(self, workspace_id: str, job_id: str) -> tuple[dict[str, Any], Any]: + def _verified_workspace(self, workspace_id: str, job_id: str) -> tuple[dict[str, Any], Any, dict[str, Any]]: workspace = self.workspaces.get(workspace_id) - if workspace["state"] != "available" or workspace["dirty"] or not workspace["identity_matches"]: + if workspace["state"] != "available" or not workspace["identity_matches"]: raise DeliveryError("publication requires an available clean identity-matching workspace") project = self.projects.get(workspace["project_id"]) assert project.workspace is not None @@ -119,11 +121,62 @@ def _verified_workspace(self, workspace_id: str, job_id: str) -> tuple[dict[str, job["state"].get("phase") != "succeeded" or checkout is None or checkout.get("checkout_id") != workspace["checkout_id"] - or checkout.get("head") != workspace["head"] or record.spec.operation not in project.workspace.verification_operations ): raise DeliveryError("workspace lacks successful declared verification at its exact HEAD") - return workspace, project + try: + result = self.jobs.result(job_id) + delivery_result = self._is_delivery_result(result) + scope = self._packet_scope(record.spec.contract) + snapshot = self.workspaces.delivery_snapshot(workspace_id, checkout["head"], scope=scope or ()) + except (ValueError, WorkspaceError) as error: + raise DeliveryError("workspace lacks an authoritative exact-head completion receipt") from error + if snapshot["head"] != checkout["head"] or not snapshot["descendant"] or snapshot["dirty"]: + raise DeliveryError("workspace lacks successful declared verification at its exact HEAD") + if delivery_result and (scope is None or not snapshot["in_scope"]): + raise DeliveryError("packet delivery is outside its Beads-owned write scope") + self._validate_delivery_result(result, snapshot) + artifact = result.get("artifact") if isinstance(result, Mapping) else None + return workspace, project, {"ref": artifact.get("ref") if isinstance(artifact, Mapping) else f"sinnix://jobs/{job_id}", "job_id": job_id, "workspace_id": workspace_id, "head": snapshot["head"], "verification_operation": record.spec.operation} + + @staticmethod + def _is_delivery_result(result: Mapping[str, Any]) -> bool: + value = result.get("value") + return result.get("kind") in {"json", "pytest"} and isinstance(value, Mapping) and "delivery" in value + + @staticmethod + def _packet_scope(contract: Mapping[str, Any]) -> tuple[str, ...] | None: + binding = contract.get("bead_binding") + if not isinstance(binding, Mapping) or "write_scope" not in binding: + return None + scope = binding["write_scope"] + if not isinstance(scope, list) or not scope or any(not isinstance(path, str) for path in scope): + raise DeliveryError("Beads-owned write scope is malformed") + return tuple(scope) + + @staticmethod + def _validate_delivery_result(result: Mapping[str, Any], snapshot: Mapping[str, Any]) -> None: + if not GitHubDelivery._is_delivery_result(result): + return + value = result.get("value") + delivery = value.get("delivery") if isinstance(value, Mapping) else None + if not isinstance(delivery, Mapping) or set(delivery) != {"anti_vacuity", "unresolved_work", "delegation", "deletion_evidence", "evidence_only"}: + raise DeliveryError("project delivery result is malformed") + unresolved, delegation, deletions = delivery["unresolved_work"], delivery["delegation"], delivery["deletion_evidence"] + if delivery["anti_vacuity"] is not True or not isinstance(unresolved, list) or unresolved or not isinstance(deletions, list) or not isinstance(delivery["evidence_only"], bool) or not isinstance(delegation, Mapping) or set(delegation) != {"visibility", "pending"}: + raise DeliveryError("project delivery result is incomplete") + visibility, pending = delegation["visibility"], delegation["pending"] + if visibility not in {"supported", "unsupported"} or (visibility == "supported" and pending is not False) or (visibility == "unsupported" and pending is not None): + raise DeliveryError("project delivery delegation visibility is invalid") + changes = snapshot.get("changes") + if not isinstance(changes, list): + raise DeliveryError("workspace delivery snapshot is malformed") + if any(isinstance(change, Mapping) and str(change.get("status", ""))[:1] == "D" for change in changes) and not deletions: + raise DeliveryError("project delivery result omits deletion evidence") + if not changes and not delivery["evidence_only"]: + raise DeliveryError("no-change delivery lacks the evidence-only exception") + if changes and delivery["evidence_only"]: + raise DeliveryError("evidence-only delivery contains code changes") @staticmethod def _base_branch(default_base: str) -> str: diff --git a/pkgs/sinnixd/sinnixd/packet_completion.py b/pkgs/sinnixd/sinnixd/packet_completion.py deleted file mode 100644 index e0879739..00000000 --- a/pkgs/sinnixd/sinnixd/packet_completion.py +++ /dev/null @@ -1,585 +0,0 @@ -"""Provider-neutral completion inspection for AgentCTL delivery packets. - -Process termination is deliberately only one input. This module composes -durable AgentCTL job/workspace state with Git and typed provider receipts; it -does not interpret worker prose or own task/campaign state. -""" - -from __future__ import annotations - -import re -import subprocess -from dataclasses import dataclass -from pathlib import Path -from typing import Any, Literal, Mapping, Protocol, Sequence - - -CompletionReason = Literal[ - "job_not_succeeded", - "job_timeout", - "job_result_loss", - "job_binding_mismatch", - "workspace_unavailable", - "workspace_dirty", - "untracked_work", - "workspace_identity_mismatch", - "head_binding_mismatch", - "divergent_head", - "no_commit", - "out_of_scope_path", - "worker_result_missing", - "worker_result_invalid", - "worker_command_failed", - "anti_vacuity_missing", - "unresolved_items", - "delegated_work_pending", - "verification_missing", - "verification_stale", - "verification_failed", - "evidence_only_not_authorized", - "evidence_only_evidence_missing", - "review_missing", - "review_stale", - "review_rejected", - "deletion_ledger_missing", -] - -_HEAD = re.compile(r"[0-9a-fA-F]{40,64}\Z") - - -def _require_head(value: object, name: str) -> str: - if not isinstance(value, str) or _HEAD.fullmatch(value) is None: - raise ValueError(f"{name} must be a Git object ID") - return value - - -def _require_ref(value: object, name: str) -> str: - if not isinstance(value, str) or not value or len(value) > 512: - raise ValueError(f"{name} must be a bounded non-empty reference") - return value - - -def _relative_path(value: object, name: str) -> str: - if not isinstance(value, str) or not value or value.startswith("/"): - raise ValueError(f"{name} must be a relative path") - parts = value.rstrip("/").split("/") - if any(part in {"", ".", ".."} for part in parts): - raise ValueError(f"{name} must be a normalized relative path") - return value - - -@dataclass(frozen=True) -class PacketContract: - """The immutable delivery requirements declared for one AgentCTL packet.""" - - job_id: str - workspace_id: str - write_scope: tuple[str, ...] - required_verification_refs: tuple[str, ...] = () - allow_evidence_only: bool = False - evidence_only_refs: tuple[str, ...] = () - require_independent_review: bool = False - require_deletion_ledger: bool = True - - def __post_init__(self) -> None: - if not self.job_id or not self.workspace_id: - raise ValueError("packet job_id and workspace_id are required") - if not self.write_scope or len(set(self.write_scope)) != len(self.write_scope): - raise ValueError("packet write_scope must be non-empty and unique") - for path in self.write_scope: - _relative_path(path, "packet write_scope") - refs = (*self.required_verification_refs, *self.evidence_only_refs) - if len(set(refs)) != len(refs): - raise ValueError("packet receipt references must be unique") - for ref in refs: - _require_ref(ref, "packet receipt reference") - if self.evidence_only_refs and not self.allow_evidence_only: - raise ValueError("evidence-only references require an explicit evidence-only contract") - - @classmethod - def from_mapping(cls, value: Mapping[str, Any]) -> PacketContract: - allowed = { - "job_id", "workspace_id", "write_scope", "required_verification_refs", - "allow_evidence_only", "evidence_only_refs", "require_independent_review", - "require_deletion_ledger", - } - if set(value) - allowed: - raise ValueError("packet contract has unknown fields") - try: - return cls( - job_id=value["job_id"], - workspace_id=value["workspace_id"], - write_scope=tuple(value["write_scope"]), - required_verification_refs=tuple(value.get("required_verification_refs", ())), - allow_evidence_only=value.get("allow_evidence_only", False), - evidence_only_refs=tuple(value.get("evidence_only_refs", ())), - require_independent_review=value.get("require_independent_review", False), - require_deletion_ledger=value.get("require_deletion_ledger", True), - ) - except (KeyError, TypeError) as error: - raise ValueError("packet contract is malformed") from error - - -@dataclass(frozen=True) -class DelegationCapability: - """Backend/runtime delegation visibility, never inferred from model text.""" - - visibility: Literal["supported", "unsupported"] - pending: bool | None - - def __post_init__(self) -> None: - if self.visibility not in {"supported", "unsupported"}: - raise ValueError("delegation visibility is invalid") - if self.visibility == "supported" and not isinstance(self.pending, bool): - raise ValueError("supported delegation visibility requires pending state") - if self.visibility == "unsupported" and self.pending is not None: - raise ValueError("unsupported delegation visibility cannot claim pending state") - - @classmethod - def from_mapping(cls, value: Mapping[str, Any]) -> DelegationCapability: - if set(value) != {"visibility", "pending"}: - raise ValueError("delegation capability is malformed") - return cls(visibility=value["visibility"], pending=value["pending"]) - - def to_dict(self) -> dict[str, Any]: - return {"visibility": self.visibility, "pending": self.pending} - - -@dataclass(frozen=True) -class VerificationReceipt: - ref: str - operation: str - head: str - passed: bool - immutable: bool - - def __post_init__(self) -> None: - _require_ref(self.ref, "verification receipt ref") - _require_ref(self.operation, "verification receipt operation") - _require_head(self.head, "verification receipt head") - if not isinstance(self.passed, bool) or not isinstance(self.immutable, bool): - raise ValueError("verification receipt outcome is invalid") - - @classmethod - def from_mapping(cls, value: Mapping[str, Any]) -> VerificationReceipt: - try: - return cls( - ref=value["ref"], - operation=value["operation"], - head=value["head"], - passed=value["passed"], - immutable=value["immutable"], - ) - except (KeyError, TypeError) as error: - raise ValueError("verification receipt is malformed") from error - - -class VerificationReceiptProvider(Protocol): - """Project-owned seam for immutable semantic receipts; no project import is needed.""" - - def get_receipts(self, refs: Sequence[str]) -> Sequence[VerificationReceipt]: - """Return the requested receipts, preserving their provider references.""" - - -@dataclass(frozen=True) -class EvidenceReceipt: - ref: str - head: str - passed: bool - immutable: bool - - def __post_init__(self) -> None: - _require_ref(self.ref, "evidence receipt ref") - _require_head(self.head, "evidence receipt head") - if not isinstance(self.passed, bool) or not isinstance(self.immutable, bool): - raise ValueError("evidence receipt outcome is invalid") - - @classmethod - def from_mapping(cls, value: Mapping[str, Any]) -> EvidenceReceipt: - try: - return cls( - ref=value["ref"], - head=value["head"], - passed=value["passed"], - immutable=value["immutable"], - ) - except (KeyError, TypeError) as error: - raise ValueError("evidence receipt is malformed") from error - - -@dataclass(frozen=True) -class IndependentReviewReceipt: - ref: str - head: str - passed: bool - immutable: bool - - def __post_init__(self) -> None: - _require_ref(self.ref, "review receipt ref") - _require_head(self.head, "review receipt head") - if not isinstance(self.passed, bool) or not isinstance(self.immutable, bool): - raise ValueError("review receipt outcome is invalid") - - @classmethod - def from_mapping(cls, value: Mapping[str, Any]) -> IndependentReviewReceipt: - try: - return cls( - ref=value["ref"], - head=value["head"], - passed=value["passed"], - immutable=value["immutable"], - ) - except (KeyError, TypeError) as error: - raise ValueError("review receipt is malformed") from error - - -@dataclass(frozen=True) -class GitCompletionEvidence: - """A bounded snapshot of Git facts used by completion inspection.""" - - start_head: str - final_head: str - is_descendant: bool - commits: tuple[str, ...] - changed_paths: tuple[str, ...] - working_tree_paths: tuple[str, ...] - untracked_paths: tuple[str, ...] - - def __post_init__(self) -> None: - _require_head(self.start_head, "Git start_head") - _require_head(self.final_head, "Git final_head") - if not isinstance(self.is_descendant, bool): - raise ValueError("Git ancestry evidence is invalid") - for commit in self.commits: - _require_head(commit, "Git commit") - for path in (*self.changed_paths, *self.working_tree_paths, *self.untracked_paths): - _relative_path(path, "Git changed path") - - @property - def dirty(self) -> bool: - return bool(self.working_tree_paths) - - -class GitCompletionEvidenceProvider(Protocol): - def inspect(self, *, path: str, start_head: str, final_head: str) -> GitCompletionEvidence: - """Return one bounded, same-checkout Git snapshot.""" - - -class SubprocessGitCompletionEvidence: - """Read Git authority without making any repository mutation.""" - - def inspect(self, *, path: str, start_head: str, final_head: str) -> GitCompletionEvidence: - root = Path(path) - status = self._run(root, "status", "--porcelain=v1", "--untracked-files=all").stdout - working_tree_paths = tuple( - line[3:].strip() for line in status.splitlines() if len(line) >= 4 and line[3:].strip() - ) - changed = self._run(root, "diff", "--name-only", f"{start_head}..{final_head}", "--").stdout - commits = self._run(root, "rev-list", "--reverse", f"{start_head}..{final_head}").stdout - ancestry = subprocess.run( - ["git", "-C", str(root), "merge-base", "--is-ancestor", start_head, final_head], - capture_output=True, - text=True, - timeout=2, - check=False, - ) - if ancestry.returncode not in {0, 1}: - raise ValueError("could not inspect Git ancestry") - paths = tuple(line for line in changed.splitlines() if line) - working = tuple(path for path in working_tree_paths if path) - return GitCompletionEvidence( - start_head=start_head, - final_head=final_head, - is_descendant=ancestry.returncode == 0, - commits=tuple(line for line in commits.splitlines() if line), - changed_paths=paths, - working_tree_paths=working, - untracked_paths=tuple( - line[3:].strip() for line in status.splitlines() - if line.startswith("?? ") and line[3:].strip() - ), - ) - - @staticmethod - def _run(root: Path, *arguments: str) -> subprocess.CompletedProcess[str]: - try: - result = subprocess.run( - ["git", "-C", str(root), *arguments], - capture_output=True, - text=True, - timeout=2, - check=False, - ) - except (OSError, subprocess.SubprocessError) as error: - raise ValueError("could not inspect Git workspace") from error - if result.returncode != 0: - raise ValueError("could not inspect Git workspace") - return result - - -@dataclass(frozen=True) -class WorkerDeliveryRecord: - """Structured worker handoff; ``last_message`` is intentionally ignored.""" - - result_ref: str - commands: tuple[Mapping[str, Any], ...] - anti_vacuity: Mapping[str, Any] - unresolved_items: tuple[str, ...] - deletion_ledger: tuple[Mapping[str, Any], ...] | None - last_message: str = "" - - def __post_init__(self) -> None: - _require_ref(self.result_ref, "worker result ref") - if not self.commands: - raise ValueError("worker delivery commands are missing") - for command in self.commands: - if ( - not isinstance(command, Mapping) - or set(command) != {"argv", "result"} - or not isinstance(command["argv"], (list, tuple)) - or not command["argv"] - or any(not isinstance(item, str) or not item for item in command["argv"]) - or command["result"] not in {"passed", "failed"} - ): - raise ValueError("worker delivery command result is malformed") - expected = {"checked", "mutation", "passed", "evidence_ref"} - if ( - not isinstance(self.anti_vacuity, Mapping) - or set(self.anti_vacuity) != expected - or not isinstance(self.anti_vacuity["checked"], bool) - or not isinstance(self.anti_vacuity["passed"], bool) - or not isinstance(self.anti_vacuity["mutation"], str) - ): - raise ValueError("worker anti-vacuity evidence is malformed") - _require_ref(self.anti_vacuity["evidence_ref"], "worker anti-vacuity evidence ref") - if any(not isinstance(item, str) or not item for item in self.unresolved_items): - raise ValueError("worker unresolved items are malformed") - if self.deletion_ledger is not None: - for item in self.deletion_ledger: - if not isinstance(item, Mapping) or set(item) != {"path", "action"}: - raise ValueError("worker deletion ledger is malformed") - _relative_path(item["path"], "worker deletion ledger path") - if not isinstance(item["action"], str) or not item["action"]: - raise ValueError("worker deletion ledger action is malformed") - - @classmethod - def from_mapping(cls, value: Mapping[str, Any]) -> WorkerDeliveryRecord: - required = {"result_ref", "commands", "anti_vacuity", "unresolved_items", "deletion_ledger"} - if not isinstance(value, Mapping) or not required <= set(value): - raise ValueError("worker delivery record is missing fields") - try: - return cls( - result_ref=value["result_ref"], - commands=tuple(value["commands"]), - anti_vacuity=value["anti_vacuity"], - unresolved_items=tuple(value["unresolved_items"]), - deletion_ledger=( - tuple(value["deletion_ledger"]) if value["deletion_ledger"] is not None else None - ), - last_message=value.get("last_message", ""), - ) - except (KeyError, TypeError) as error: - raise ValueError("worker delivery record is malformed") from error - - def to_dict(self) -> dict[str, Any]: - return { - "result_ref": self.result_ref, - "commands": [dict(item) for item in self.commands], - "anti_vacuity": dict(self.anti_vacuity), - "unresolved_items": list(self.unresolved_items), - "deletion_ledger": [dict(item) for item in self.deletion_ledger] if self.deletion_ledger is not None else None, - } - - -@dataclass(frozen=True) -class PacketCompletionResult: - complete: bool - reasons: tuple[CompletionReason, ...] - job_id: str - workspace_id: str - start_head: str | None - final_head: str | None - commits: tuple[str, ...] - changed_paths: tuple[str, ...] - write_scope: tuple[str, ...] - dirty: bool | None - divergent: bool | None - worker_delivery: WorkerDeliveryRecord | None - required_verification_refs: tuple[str, ...] - verification_refs: tuple[str, ...] - delegation: DelegationCapability - review_ref: str | None - - def to_dict(self) -> dict[str, Any]: - return { - "complete": self.complete, - "reasons": list(self.reasons), - "job_id": self.job_id, - "workspace_id": self.workspace_id, - "start_head": self.start_head, - "final_head": self.final_head, - "commits": list(self.commits), - "changed_paths": list(self.changed_paths), - "write_scope": list(self.write_scope), - "dirty": self.dirty, - "divergent": self.divergent, - "worker_delivery": self.worker_delivery.to_dict() if self.worker_delivery else None, - "required_verification_refs": list(self.required_verification_refs), - "verification_refs": list(self.verification_refs), - "delegation": self.delegation.to_dict(), - "review_ref": self.review_ref, - } - - -class PacketCompletionInspector: - """Compose one job/workspace snapshot into a typed completion verdict.""" - - def __init__(self, git_provider: GitCompletionEvidenceProvider | None = None) -> None: - self.git_provider = git_provider or SubprocessGitCompletionEvidence() - - def inspect( - self, - *, - job: Mapping[str, Any], - workspace: Mapping[str, Any], - contract: PacketContract, - worker_result: WorkerDeliveryRecord | None, - verification_receipts: Sequence[VerificationReceipt] | None, - delegation: DelegationCapability, - evidence_receipts: Sequence[EvidenceReceipt] = (), - review: IndependentReviewReceipt | None = None, - git: GitCompletionEvidence | None = None, - verification_provider: VerificationReceiptProvider | None = None, - ) -> PacketCompletionResult: - if verification_receipts is None: - if verification_provider is None: - raise ValueError("completion inspection requires verification receipts or a provider") - verification_receipts = verification_provider.get_receipts(contract.required_verification_refs) - reasons: list[CompletionReason] = [] - state = job.get("state") if isinstance(job.get("state"), Mapping) else {} - checkout = job.get("checkout") if isinstance(job.get("checkout"), Mapping) else {} - start_head = checkout.get("head") if isinstance(checkout.get("head"), str) else None - final_head = workspace.get("head") if isinstance(workspace.get("head"), str) else None - if job.get("job_id") != contract.job_id or workspace.get("workspace_id") != contract.workspace_id: - reasons.append("job_binding_mismatch") - if checkout.get("checkout_id") != workspace.get("checkout_id"): - reasons.append("job_binding_mismatch") - - phase = state.get("phase") - if phase == "timed_out": - reasons.append("job_timeout") - if phase != "succeeded" or state.get("terminal") is not True: - reasons.append("job_not_succeeded") - systemd = state.get("systemd") if isinstance(state.get("systemd"), Mapping) else {} - exit_status = systemd.get("ExecMainStatus", state.get("exit_status")) - if exit_status is not None and str(exit_status) != "0": - reasons.append("job_result_loss" if phase == "succeeded" else "job_not_succeeded") - - if workspace.get("state") != "available": - reasons.append("workspace_unavailable") - if workspace.get("dirty") is True: - reasons.append("workspace_dirty") - if workspace.get("identity_matches") is not True: - reasons.append("workspace_identity_mismatch") - - if git is None: - if not isinstance(workspace.get("path"), str) or start_head is None or final_head is None: - git = None - else: - try: - git = self.git_provider.inspect(path=workspace["path"], start_head=start_head, final_head=final_head) - except ValueError: - git = None - if git is not None: - if start_head != git.start_head or final_head != git.final_head: - reasons.append("head_binding_mismatch") - if not git.is_descendant: - reasons.append("divergent_head") - if git.dirty and "workspace_dirty" not in reasons: - reasons.append("workspace_dirty") - if git.untracked_paths: - reasons.append("untracked_work") - if not git.commits and not contract.allow_evidence_only: - reasons.append("no_commit") - if any( - not self._in_scope(path, contract.write_scope) - for path in (*git.changed_paths, *git.working_tree_paths, *git.untracked_paths) - ): - reasons.append("out_of_scope_path") - else: - reasons.append("head_binding_mismatch") - - if worker_result is None: - reasons.append("worker_result_missing") - else: - artifacts = job.get("artifacts") - if isinstance(artifacts, Mapping): - result_artifact = artifacts.get("result") - if result_artifact is None: - reasons.append("job_result_loss") - elif isinstance(result_artifact, Mapping) and result_artifact.get("ref") != worker_result.result_ref: - reasons.append("worker_result_invalid") - if any(command["result"] != "passed" for command in worker_result.commands): - reasons.append("worker_command_failed") - if not worker_result.anti_vacuity["checked"] or not worker_result.anti_vacuity["passed"]: - reasons.append("anti_vacuity_missing") - if worker_result.unresolved_items: - reasons.append("unresolved_items") - if contract.require_deletion_ledger and worker_result.deletion_ledger is None: - reasons.append("deletion_ledger_missing") - if delegation.visibility == "supported" and delegation.pending: - reasons.append("delegated_work_pending") - - receipts = {receipt.ref: receipt for receipt in verification_receipts} - for ref in contract.required_verification_refs: - receipt = receipts.get(ref) - if receipt is None: - reasons.append("verification_missing") - elif git is None or receipt.head != final_head: - reasons.append("verification_stale") - elif not receipt.immutable or not receipt.passed: - reasons.append("verification_failed") - - if git is not None and not git.commits: - if not contract.allow_evidence_only: - reasons.append("evidence_only_not_authorized") - else: - evidence = {receipt.ref: receipt for receipt in evidence_receipts} - for ref in contract.evidence_only_refs: - receipt = evidence.get(ref) - if receipt is None or receipt.head != final_head or not receipt.immutable or not receipt.passed: - reasons.append("evidence_only_evidence_missing") - if not contract.evidence_only_refs: - reasons.append("evidence_only_evidence_missing") - - if contract.require_independent_review: - if review is None: - reasons.append("review_missing") - elif review.head != final_head: - reasons.append("review_stale") - elif not review.immutable or not review.passed: - reasons.append("review_rejected") - - unique_reasons = tuple(dict.fromkeys(reasons)) - return PacketCompletionResult( - complete=not unique_reasons, - reasons=unique_reasons, - job_id=contract.job_id, - workspace_id=contract.workspace_id, - start_head=start_head, - final_head=final_head, - commits=git.commits if git is not None else (), - changed_paths=git.changed_paths if git is not None else (), - write_scope=contract.write_scope, - dirty=git.dirty if git is not None else workspace.get("dirty") if isinstance(workspace.get("dirty"), bool) else None, - divergent=(not git.is_descendant) if git is not None else None, - worker_delivery=worker_result, - required_verification_refs=contract.required_verification_refs, - verification_refs=tuple(receipt.ref for receipt in verification_receipts), - delegation=delegation, - review_ref=review.ref if review is not None else None, - ) - - @staticmethod - def _in_scope(path: str, scopes: Sequence[str]) -> bool: - return any(path == scope.rstrip("/") or path.startswith(scope.rstrip("/") + "/") for scope in scopes) diff --git a/pkgs/sinnixd/sinnixd/service.py b/pkgs/sinnixd/sinnixd/service.py index fe5c1b44..0c32aca7 100644 --- a/pkgs/sinnixd/sinnixd/service.py +++ b/pkgs/sinnixd/sinnixd/service.py @@ -21,15 +21,6 @@ from .contracts import TypedJobContracts from .delivery import DeliveryError, GitHubDelivery from .owner_adapters import DeclaredOwnerAdapters, OwnerAdapterError -from .packet_completion import ( - DelegationCapability, - EvidenceReceipt, - IndependentReviewReceipt, - PacketCompletionInspector, - PacketContract, - VerificationReceipt, - WorkerDeliveryRecord, -) from .projects import ProjectCatalog from .tasks import TaskError, TaskService from .workspaces import GitWorkspaces, WorkspaceError, WorkspaceStore @@ -60,7 +51,6 @@ class SinnixdService: workspaces: GitWorkspaces | None = None delivery: GitHubDelivery | None = None tasks: TaskService | None = None - packet_completion: PacketCompletionInspector = field(default_factory=PacketCompletionInspector) def __post_init__(self) -> None: if self.workspaces is None: @@ -474,45 +464,6 @@ def _dispatch( if not isinstance(max_bytes, int) or isinstance(max_bytes, bool): raise ValueError("job.result max_bytes must be an integer") return self.jobs.result(job_id, max_bytes=max_bytes) - if operation == "job.packet-completion": - if principal not in {"agent-control", "operator"}: - raise ValueError("job.packet-completion requires agent-control or operator") - required = { - "job_id", "workspace_id", "contract", "worker_result", "verification_receipts", - "delegation", "evidence_receipts", "review", - } - if set(arguments) != required: - raise ValueError( - "job.packet-completion requires job_id, workspace_id, contract, worker_result, " - "verification_receipts, delegation, evidence_receipts, and review" - ) - job_id = self._authorize_job(principal, self._job_argument(arguments, "job_id")) - workspace_id = self._job_argument(arguments, "workspace_id") - contract = PacketContract.from_mapping(arguments["contract"]) - if contract.job_id != job_id or contract.workspace_id != workspace_id: - raise ValueError("job.packet-completion contract binding does not match arguments") - raw_worker = arguments["worker_result"] - worker = None if raw_worker is None else WorkerDeliveryRecord.from_mapping(raw_worker) - raw_verifications = arguments["verification_receipts"] - raw_evidence = arguments["evidence_receipts"] - if not isinstance(raw_verifications, list) or not isinstance(raw_evidence, list): - raise ValueError("job.packet-completion receipts must be lists") - verifications = tuple(VerificationReceipt.from_mapping(item) for item in raw_verifications) - evidence = tuple(EvidenceReceipt.from_mapping(item) for item in raw_evidence) - raw_review = arguments["review"] - review = None if raw_review is None else IndependentReviewReceipt.from_mapping(raw_review) - delegation = DelegationCapability.from_mapping(arguments["delegation"]) - assert self.workspaces is not None - return self.packet_completion.inspect( - job=self.jobs.get(job_id), - workspace=self.workspaces.get(workspace_id), - contract=contract, - worker_result=worker, - verification_receipts=verifications, - evidence_receipts=evidence, - delegation=delegation, - review=review, - ).to_dict() if operation == "job.cancel": return self._cleanup_terminal( self.jobs.cancel( diff --git a/pkgs/sinnixd/sinnixd/workspaces.py b/pkgs/sinnixd/sinnixd/workspaces.py index cbfd6cf6..1d7466e8 100644 --- a/pkgs/sinnixd/sinnixd/workspaces.py +++ b/pkgs/sinnixd/sinnixd/workspaces.py @@ -13,7 +13,7 @@ from dataclasses import dataclass from datetime import UTC, datetime from pathlib import Path -from typing import Any, Mapping +from typing import Any, Mapping, Sequence from uuid import uuid4 from sinnix_lib.atomic_json import modify_json, read_json, write_json_atomic @@ -337,6 +337,22 @@ def get(self, workspace_id: str) -> dict[str, Any]: record = self._record(workspace_id) return self._status(record) + def delivery_snapshot(self, workspace_id: str, start_head: str, *, scope: Sequence[str] = ()) -> dict[str, Any]: + """Read one exact-head Git fact set for a delivery precondition.""" + record = self._record(workspace_id) + checkout, _project = self._available(record) + before = self._git(checkout.path, "rev-parse", "HEAD").stdout.strip() + if before != checkout.head: + raise WorkspaceError("workspace HEAD changed during delivery snapshot") + descendant = self._git(checkout.path, "merge-base", "--is-ancestor", start_head, before, check=False).returncode == 0 + changes = self._name_status(checkout.path, start_head, before) + dirty = self._porcelain_status(checkout.path) + after = self._git(checkout.path, "rev-parse", "HEAD").stdout.strip() + if after != before: + raise WorkspaceError("workspace HEAD changed during delivery snapshot") + paths = tuple(path for change in changes for path in change["paths"]) + return {"workspace_id": workspace_id, "checkout_id": checkout.checkout_id, "start_head": start_head, "head": before, "descendant": descendant, "dirty": bool(dirty), "status": dirty, "changes": changes, "in_scope": all(self._scope_contains(path, scope) for path in paths) if scope else True} + def checkout(self, workspace_id: str) -> RegisteredCheckout: record = self._record(workspace_id) checkout, _project = self._available(record) @@ -776,6 +792,67 @@ def _status(self, record: WorkspaceRecord) -> dict[str, Any]: row.update({"state": "missing", "checkout_id": None, "head": None, "current_branch": None, "dirty": None, "identity_matches": False}) return row + @classmethod + def _porcelain_status(cls, path: Path) -> list[dict[str, Any]]: + raw = cls._git_bytes(path, "status", "--porcelain=v1", "-z", "--untracked-files=all") + records = [item for item in raw.split(b"\0") if item] + result: list[dict[str, Any]] = [] + index = 0 + while index < len(records): + entry = records[index] + if len(entry) < 4 or entry[2:3] != b" ": + raise WorkspaceError("Git status porcelain is malformed") + status = entry[:2].decode("ascii", errors="strict") + paths = [cls._decode_git_path(entry[3:])] + index += 1 + if "R" in status or "C" in status: + if index == len(records): + raise WorkspaceError("Git status rename porcelain is malformed") + paths.append(cls._decode_git_path(records[index])) + index += 1 + result.append({"status": status, "paths": paths}) + return result + + @classmethod + def _name_status(cls, path: Path, start_head: str, head: str) -> list[dict[str, Any]]: + raw = cls._git_bytes(path, "diff", "--name-status", "-z", "--find-renames", start_head, head, "--") + records = [item for item in raw.split(b"\0") if item] + result: list[dict[str, Any]] = [] + index = 0 + while index < len(records): + status = records[index].decode("ascii", errors="strict") + if not status or status[0] not in "ACDMRTUXB": + raise WorkspaceError("Git diff name-status porcelain is malformed") + index += 1 + count = 2 if status[0] in {"R", "C"} else 1 + if len(records) - index < count: + raise WorkspaceError("Git diff rename porcelain is malformed") + result.append({"status": status, "paths": [cls._decode_git_path(item) for item in records[index:index + count]]}) + index += count + return result + + @staticmethod + def _decode_git_path(value: bytes) -> str: + try: + path = value.decode() + except UnicodeDecodeError as error: + raise WorkspaceError("Git path is not UTF-8") from error + if not path or Path(path).is_absolute() or ".." in Path(path).parts: + raise WorkspaceError("Git path is unsafe") + return path + + @staticmethod + def _scope_contains(path: str, scope: Sequence[str]) -> bool: + for entry in scope: + if not isinstance(entry, str) or not entry or entry.startswith("/") or ".." in Path(entry).parts: + raise WorkspaceError("delivery scope is unsafe") + if entry.endswith("/"): + if path.startswith(entry): + return True + elif path == entry: + return True + return False + def _record(self, workspace_id: str) -> WorkspaceRecord: for record in self.store.records(): if record.workspace_id == workspace_id: diff --git a/pkgs/sinnixd/test_packet_completion.py b/pkgs/sinnixd/test_packet_completion.py deleted file mode 100644 index 750a5754..00000000 --- a/pkgs/sinnixd/test_packet_completion.py +++ /dev/null @@ -1,306 +0,0 @@ -from __future__ import annotations - -import subprocess -from dataclasses import replace -from pathlib import Path - -from sinnixd.packet_completion import ( - DelegationCapability, - EvidenceReceipt, - GitCompletionEvidence, - IndependentReviewReceipt, - PacketCompletionInspector, - PacketContract, - VerificationReceipt, - WorkerDeliveryRecord, -) - - -START = "1" * 40 -FINAL = "2" * 40 -OTHER = "3" * 40 - - -def git_evidence(**overrides: object) -> GitCompletionEvidence: - values = { - "start_head": START, - "final_head": FINAL, - "is_descendant": True, - "commits": (FINAL,), - "changed_paths": ("src/changed.py",), - "working_tree_paths": (), - "untracked_paths": (), - } - return GitCompletionEvidence(**{**values, **overrides}) - - -def delivery(**overrides: object) -> WorkerDeliveryRecord: - values = { - "result_ref": "sinnix://jobs/job-1/artifacts/result", - "last_message": "", - "commands": ({"argv": ["devtools", "test", "affected"], "result": "passed"},), - "anti_vacuity": { - "checked": True, - "mutation": "replace_inspector_with_process_exit", - "passed": True, - "evidence_ref": "sinnix://evidence/anti-vacuity-1", - }, - "unresolved_items": (), - "deletion_ledger": ({"path": "retired.py", "action": "retained"},), - } - return WorkerDeliveryRecord(**{**values, **overrides}) - - -def verification(*, ref: str = "receipt-1", head: str = FINAL, passed: bool = True) -> VerificationReceipt: - return VerificationReceipt(ref=ref, operation="verify", head=head, passed=passed, immutable=True) - - -def base_contract(**overrides: object) -> PacketContract: - values = { - "job_id": "job-1", - "workspace_id": "workspace-1", - "write_scope": ("src/",), - "required_verification_refs": ("receipt-1",), - } - return PacketContract(**{**values, **overrides}) - - -def inspect(**overrides: object): - values = { - "job": { - "job_id": "job-1", - "state": {"phase": "succeeded", "terminal": True}, - "checkout": {"checkout_id": "workspace-1", "head": START}, - }, - "workspace": { - "workspace_id": "workspace-1", - "checkout_id": "workspace-1", - "state": "available", - "identity_matches": True, - "head": FINAL, - "dirty": False, - }, - "git": git_evidence(), - "contract": base_contract(), - "worker_result": delivery(), - "verification_receipts": (verification(),), - "delegation": DelegationCapability(visibility="supported", pending=False), - } - return PacketCompletionInspector().inspect(**{**values, **overrides}) - - -def test_clean_committed_exact_head_delivery_is_complete() -> None: - result = inspect() - - assert result.complete - assert result.reasons == () - assert result.job_id == "job-1" - assert result.workspace_id == "workspace-1" - assert result.start_head == START - assert result.final_head == FINAL - assert result.commits == (FINAL,) - assert result.changed_paths == ("src/changed.py",) - - -def test_observed_failed_packet_shape_is_rejected_without_prose_matching() -> None: - result = inspect( - job={ - "job_id": "job-1", - "state": {"phase": "succeeded", "terminal": True}, - "checkout": {"checkout_id": "workspace-1", "head": START}, - }, - git=git_evidence( - final_head=START, - commits=(), - changed_paths=(), - working_tree_paths=("scratch.txt",), - untracked_paths=("scratch.txt",), - ), - worker_result=None, - delegation=DelegationCapability(visibility="supported", pending=True), - ) - - assert not result.complete - assert { - "workspace_dirty", - "untracked_work", - "no_commit", - "worker_result_missing", - "delegated_work_pending", - } <= set(result.reasons) - - -def test_dirty_workspace_is_rejected_even_when_process_succeeded() -> None: - result = inspect(git=git_evidence(working_tree_paths=("src/changed.py",))) - - assert not result.complete - assert "workspace_dirty" in result.reasons - - -def test_stale_receipt_is_distinct_from_missing_receipt() -> None: - stale = inspect(verification_receipts=(verification(head=START),)) - missing = inspect(verification_receipts=()) - - assert "verification_stale" in stale.reasons - assert "verification_missing" in missing.reasons - - -def test_out_of_scope_paths_are_rejected() -> None: - result = inspect(git=git_evidence(changed_paths=("docs/outside.md",))) - - assert not result.complete - assert "out_of_scope_path" in result.reasons - - -def test_divergent_head_is_rejected() -> None: - result = inspect(git=git_evidence(final_head=OTHER, is_descendant=False)) - - assert not result.complete - assert "divergent_head" in result.reasons - - -def test_evidence_only_requires_explicit_contract_and_immutable_evidence() -> None: - evidence = EvidenceReceipt(ref="evidence-1", head=START, passed=True, immutable=True) - result = inspect( - git=git_evidence(final_head=START, commits=(), changed_paths=()), - contract=base_contract( - required_verification_refs=(), - allow_evidence_only=True, - evidence_only_refs=("evidence-1",), - ), - workspace={ - "workspace_id": "workspace-1", - "checkout_id": "workspace-1", - "state": "available", - "identity_matches": True, - "head": START, - "dirty": False, - }, - evidence_receipts=(evidence,), - ) - - assert result.complete - - accidental = inspect( - git=git_evidence(final_head=START, commits=(), changed_paths=()), - contract=base_contract(required_verification_refs=()), - ) - assert not accidental.complete - assert "evidence_only_not_authorized" in accidental.reasons - - -def test_missing_required_review_and_rejected_review_are_structural() -> None: - missing = inspect(contract=base_contract(require_independent_review=True)) - rejected = inspect( - contract=base_contract(require_independent_review=True), - review=IndependentReviewReceipt(ref="review-1", head=FINAL, passed=False, immutable=True), - ) - - assert "review_missing" in missing.reasons - assert "review_rejected" in rejected.reasons - - accepted = inspect( - contract=base_contract(require_independent_review=True), - review=IndependentReviewReceipt(ref="review-1", head=FINAL, passed=True, immutable=True), - ) - assert accepted.complete - - -def test_timeout_and_result_loss_are_not_completion() -> None: - timed_out = inspect( - job={"job_id": "job-1", "state": {"phase": "timed_out", "terminal": True}, "checkout": {"checkout_id": "workspace-1", "head": START}} - ) - lost = inspect(worker_result=None) - - assert "job_timeout" in timed_out.reasons - assert "worker_result_missing" in lost.reasons - - artifact_lost = inspect( - job={ - "job_id": "job-1", - "state": {"phase": "succeeded", "terminal": True}, - "checkout": {"checkout_id": "workspace-1", "head": START}, - "artifacts": {"result": None}, - } - ) - recovered = inspect( - job={ - "job_id": "job-1", - "state": {"phase": "succeeded", "terminal": True}, - "checkout": {"checkout_id": "workspace-1", "head": START}, - "artifacts": {"result": {"ref": "sinnix://jobs/job-1/artifacts/result"}}, - } - ) - assert "job_result_loss" in artifact_lost.reasons - assert recovered.complete - - -def test_required_deletion_ledger_cannot_be_omitted() -> None: - result = inspect(worker_result=replace(delivery(), deletion_ledger=None)) - - assert not result.complete - assert "deletion_ledger_missing" in result.reasons - - -def test_unsupported_delegation_visibility_is_explicit_but_not_prose_inferred() -> None: - result = inspect( - delegation=DelegationCapability(visibility="unsupported", pending=None), - worker_result=replace(delivery(), last_message="waiting for a background task"), - ) - - assert result.complete - assert result.delegation.visibility == "unsupported" - - -def test_pending_delegation_is_consumed_from_structured_capability() -> None: - result = inspect( - delegation=DelegationCapability(visibility="supported", pending=True), - worker_result=replace(delivery(), last_message="completed despite waiting in the text"), - ) - - assert not result.complete - assert "delegated_work_pending" in result.reasons - - -def test_disposable_real_git_success_and_failed_packet_pair(tmp_path: Path) -> None: - subprocess.run(["git", "init", "--quiet", str(tmp_path)], check=True) - subprocess.run(["git", "-C", str(tmp_path), "config", "user.name", "Fixture"], check=True) - subprocess.run(["git", "-C", str(tmp_path), "config", "user.email", "fixture@example.test"], check=True) - subprocess.run(["git", "-C", str(tmp_path), "commit", "--quiet", "--allow-empty", "-m", "base"], check=True) - start = subprocess.run( - ["git", "-C", str(tmp_path), "rev-parse", "HEAD"], check=True, capture_output=True, text=True - ).stdout.strip() - (tmp_path / "src").mkdir() - (tmp_path / "src" / "changed.py").write_text("pass\n") - subprocess.run(["git", "-C", str(tmp_path), "add", "src/changed.py"], check=True) - subprocess.run(["git", "-C", str(tmp_path), "commit", "--quiet", "-m", "change"], check=True) - final = subprocess.run( - ["git", "-C", str(tmp_path), "rev-parse", "HEAD"], check=True, capture_output=True, text=True - ).stdout.strip() - common = { - "job": { - "job_id": "job-1", - "state": {"phase": "succeeded", "terminal": True}, - "checkout": {"checkout_id": "checkout-1", "head": start}, - }, - "workspace": { - "workspace_id": "workspace-1", - "checkout_id": "checkout-1", - "path": str(tmp_path), - "state": "available", - "identity_matches": True, - "head": final, - "dirty": False, - }, - "contract": base_contract(), - "worker_result": delivery(), - "verification_receipts": (verification(head=final),), - "delegation": DelegationCapability(visibility="supported", pending=False), - } - assert PacketCompletionInspector().inspect(**common).complete - - (tmp_path / "untracked.txt").write_text("unfinished\n") - failed = PacketCompletionInspector().inspect(**common) - assert not failed.complete - assert "workspace_dirty" in failed.reasons - assert "untracked_work" in failed.reasons diff --git a/pkgs/sinnixd/test_service.py b/pkgs/sinnixd/test_service.py index 17947ceb..2b72d469 100644 --- a/pkgs/sinnixd/test_service.py +++ b/pkgs/sinnixd/test_service.py @@ -4463,7 +4463,7 @@ def test_declared_job_binds_workspace_and_exact_head(tmp_path: Path) -> None: assert record.spec.checkout["head"] == workspace["head"] -def test_packet_completion_dispatch_composes_job_and_workspace_bindings(tmp_path: Path) -> None: +def test_forged_packet_completion_arguments_have_no_service_route(tmp_path: Path) -> None: write_adapter(tmp_path) initialize_git_checkout(tmp_path) jobs = generic_jobs(tmp_path) @@ -4503,9 +4503,31 @@ def test_packet_completion_dispatch_composes_job_and_workspace_bindings(tmp_path ) ) - assert response.ok and response.payload is not None - assert not response.payload.inline["complete"] - assert "worker_result_missing" in response.payload.inline["reasons"] + assert response.error is not None + assert response.error.code.value == "INVALID_ARGUMENT" + + +def test_delivery_snapshot_is_nul_safe_and_exact_file_scope_does_not_include_descendants(tmp_path: Path) -> None: + write_adapter(tmp_path) + service = SinnixdService(ProjectCatalog([tmp_path]), jobs=generic_jobs(tmp_path)) + workspace = service.workspaces.create(project_id="fixture", name="snapshot-lane", branch="feature/snapshot", base="HEAD") + path = Path(workspace["path"]) + (path / "dir").mkdir() + (path / "dir" / "exact").write_text("old\n") + (path / "dir" / "delete\nfile").write_text("delete\n") + subprocess.run(["git", "-C", str(path), "add", "."], check=True) + subprocess.run(["git", "-C", str(path), "-c", "user.name=Fixture", "-c", "user.email=fixture@example.test", "commit", "--quiet", "-m", "seed"], check=True) + start = service.workspaces.get(workspace["workspace_id"])["head"] + subprocess.run(["git", "-C", str(path), "mv", "dir/exact", "dir/renamed\nfile"], check=True) + (path / "dir" / "delete\nfile").unlink() + (path / "dir" / "exact.child").write_text("outside exact-file scope\n") + subprocess.run(["git", "-C", str(path), "add", "-A"], check=True) + subprocess.run(["git", "-C", str(path), "-c", "user.name=Fixture", "-c", "user.email=fixture@example.test", "commit", "--quiet", "-m", "paths"], check=True) + snapshot = service.workspaces.delivery_snapshot(workspace["workspace_id"], start, scope=("dir/exact",)) + assert not snapshot["in_scope"] + assert {change["status"][0] for change in snapshot["changes"]} >= {"D", "R", "A"} + assert any("\n" in item for change in snapshot["changes"] for item in change["paths"]) + assert service.workspaces.delivery_snapshot(workspace["workspace_id"], start, scope=("dir/",))["in_scope"] def test_admission_revalidates_queued_declared_workspace_before_systemd_launch(tmp_path: Path) -> None: From 2a2f30ea75be5f154057be2a68ff2e7a9ef89eeb Mon Sep 17 00:00:00 2001 From: Sinity Date: Tue, 25 Aug 2026 05:35:10 +0200 Subject: [PATCH 4/6] Make packet delivery runtime-authoritative --- docs/sinnixd.md | 2 +- .../sinnix_agent_gateway/runtime.py | 9 + .../test_execution_jobs.py | 3 +- pkgs/sinnixd/sinnixd/cli.py | 26 ++- pkgs/sinnixd/sinnixd/contracts.py | 12 +- pkgs/sinnixd/sinnixd/delivery.py | 140 ++++++++--- pkgs/sinnixd/sinnixd/jobs.py | 6 +- pkgs/sinnixd/sinnixd/runner.py | 49 +++- pkgs/sinnixd/sinnixd/service.py | 32 ++- pkgs/sinnixd/test_service.py | 217 +++++++++++++++++- 10 files changed, 445 insertions(+), 51 deletions(-) diff --git a/docs/sinnixd.md b/docs/sinnixd.md index 74635395..2ca7dc5b 100644 --- a/docs/sinnixd.md +++ b/docs/sinnixd.md @@ -223,7 +223,7 @@ If any verification or cutover command fails, leave Sinnixd stopped. Before the Both routes use the same UUID job ID, transient user service, cancellation, reconciliation, `job get/list/logs/result/wait`, and bounded artifact readers as declared operations. Their durable public record contains the principal, job kind, canonical project and checkout identity, redacted argv digest or prompt digest, and bounded artifact references. It never stores raw shell argv arguments after launch, prompt text, environment values, or credentials. -Delivery is a precondition of `workspace.publish` and `workspace.land`, not a caller-fed completion route. It reads the declared verification job through `job.result`, snapshots the bound workspace from Git at the job's exact launch head, and repeats that precondition after push and after review inspection. A JSON or pytest result may carry the bounded `delivery` object with only anti-vacuity, unresolved-work, delegation-visibility, deletion-evidence, and evidence-only fields. The packet write scope comes from the immutable Beads binding, never that result. Git owns paths, dirtiness, commits, and heads; the project verifier owns its result artifact; GitHub owns independent review state. A missing Beads write scope means a structured packet cannot be delivered. Beads closure consumes the returned completion artifact reference in its own owner; wiring that external closure consumer is not implemented by Sinnixd. +Delivery is a precondition of `workspace.publish` and `workspace.land`, not a caller-fed completion route. Ordinary delivery reads the exact-head declared verification job through `job.result`. Packet delivery additionally names the Beads-bound attested-agent job with `--packet-job`. The declared verification job receives the same immutable Beads binding at dispatch, including its initial head, bead identity, and write scope; the contract runner seals the worker's structured report to the Git head observed when the runner exits. Delivery requires the bindings to match, the later semantic verifier to succeed at that same final head, and snapshots the initial-to-final Git range. It rejects dirty, divergent, stale, or out-of-scope work and repeats the complete precondition after push and after review inspection. The worker report can only tighten acceptance through bounded anti-vacuity, unresolved-work, delegation-visibility, deletion-evidence, and evidence-only fields. Git owns paths, commits, and heads; the project verifier owns semantic success; GitHub owns independent review state. Beads closure consumes the returned completion artifact and bead references in its own owner; wiring that external closure consumer is not implemented by Sinnixd. Typed jobs accept no environment overlay. The daemon creates the `env -i` environment from the declared project environment and fixed `SINNIXD_*` identity fields. Immediately before execution, the contract runner verifies those fields, rechecks the exact registered project, canonical worktree root, common Git directory, porcelain worktree membership, and recorded HEAD. A changed, missing, symlinked, or spoofed identity fails closed. Agent handoff includes `--registered-project`, `--expected-git-common-dir`, and the canonical checkout path; nested scope creation remains disabled, so the native runner provides backend execution and native attestation while the shared transient user service remains the sole process, cgroup, timeout, and cancellation authority. Private launch inputs are mode 0600, removed before shell execution, and removed after agent handoff or every terminal lifecycle outcome, including confirmed launch failure. Native private logs are removed after handoff; only the bounded shared log and result artifacts remain addressable. diff --git a/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/runtime.py b/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/runtime.py index 8fb00439..8069c2bb 100644 --- a/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/runtime.py +++ b/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/runtime.py @@ -875,6 +875,15 @@ def v2_run_for_bead( "request_id": request_id, "assignment_ref": parent_assignment_ref, } + metadata = bead.get("metadata") + encoded_scope = metadata.get("write_scope") if isinstance(metadata, Mapping) else None + if isinstance(encoded_scope, str): + try: + write_scope = json.loads(encoded_scope) + except json.JSONDecodeError: + write_scope = None + if isinstance(write_scope, list): + binding["write_scope"] = write_scope assigned_context = { "bead": bead, "project_ref": project_ref, diff --git a/pkgs/sinnix-agent-gateway/test_execution_jobs.py b/pkgs/sinnix-agent-gateway/test_execution_jobs.py index a2463b7b..7cd57b99 100644 --- a/pkgs/sinnix-agent-gateway/test_execution_jobs.py +++ b/pkgs/sinnix-agent-gateway/test_execution_jobs.py @@ -610,7 +610,7 @@ def test_agent_control_bead_scope_requires_matching_current_assignment( runtime, daemon = runtime_with_daemon(tmp_path, "agent-control") assignment_id = "3b0237a0-32a9-4f6b-a014-2a0ecfd2f75c" assignment_ref = f"sinnix://jobs/{assignment_id}" - bead = {"ref": "sinnix://projects/fixture/beads/fixture-1", "task_revision": "a" * 64, "etag": "b" * 64, "fields": {"title": "assigned"}} + bead = {"ref": "sinnix://projects/fixture/beads/fixture-1", "task_revision": "a" * 64, "etag": "b" * 64, "fields": {"title": "assigned"}, "metadata": {"write_scope": '["pkgs/sinnixd/"]'}} binding = {"bead_ref": bead["ref"], "project_ref": "sinnix://projects/fixture", "checkout_ref": "sinnix://projects/fixture/checkouts/default", "task_revision": "a" * 64, "task_etag": "b" * 64, "claim_ref": None, "claim_receipt": None, "request_id": "2e46daf5-e9b1-4c6e-b99d-bcd46631730b", "assignment_ref": None} daemon.responses["job.get"] = {"job_id": assignment_id, "principal": "agent-control", "state": {"phase": "running"}, "checkout": {"checkout_id": "default", "head": "c" * 40}, "contract": {"bead_binding": binding}, "artifacts": {"result": None}} daemon.responses["job.agent.start"] = {"job_id": "4a42f848-9057-4cef-9d27-80a022c0e16f", "state": {"phase": "running"}} @@ -630,6 +630,7 @@ def test_agent_control_bead_scope_requires_matching_current_assignment( assert started["assignment_ref"] == assignment_ref assert daemon.calls[-1].principal == "agent-control" assert daemon.calls[-1].arguments["bead_binding"]["assignment_ref"] == assignment_ref + assert daemon.calls[-1].arguments["bead_binding"]["write_scope"] == ["pkgs/sinnixd/"] assert "private launch instruction" not in daemon.calls[-1].arguments["bead_binding"].values() foreign = {**binding, "bead_ref": "sinnix://projects/fixture/beads/fixture-2"} diff --git a/pkgs/sinnixd/sinnixd/cli.py b/pkgs/sinnixd/sinnixd/cli.py index a11a605f..5b3517e3 100644 --- a/pkgs/sinnixd/sinnixd/cli.py +++ b/pkgs/sinnixd/sinnixd/cli.py @@ -89,6 +89,7 @@ def parser() -> argparse.ArgumentParser: workspace_publish = workspace_subcommands.add_parser("publish") workspace_publish.add_argument("workspace_id") workspace_publish.add_argument("--job", required=True) + workspace_publish.add_argument("--packet-job") workspace_publish.add_argument("--title", required=True) workspace_publish.add_argument("--body", default="") workspace_review = workspace_subcommands.add_parser("review-status") @@ -96,6 +97,7 @@ def parser() -> argparse.ArgumentParser: workspace_land = workspace_subcommands.add_parser("land") workspace_land.add_argument("workspace_id") workspace_land.add_argument("--job", required=True) + workspace_land.add_argument("--packet-job") workspace_finish = workspace_subcommands.add_parser("finish") workspace_finish.add_argument("workspace_id") workspace_finish_integrated = workspace_subcommands.add_parser("finish-integrated") @@ -108,6 +110,7 @@ def parser() -> argparse.ArgumentParser: start.add_argument("operation") start.add_argument("--workspace") start.add_argument("--parameters-json", default="{}") + start.add_argument("--bead-binding-json") get = job_subcommands.add_parser("get") get.add_argument("job_id") status = job_subcommands.add_parser("status") @@ -366,7 +369,13 @@ def main() -> int: elif arguments.command == "workspace" and arguments.workspace_command == "publish": request = _request( "workspace.publish", "git-workspaces", - {"workspace_id": arguments.workspace_id, "job_id": arguments.job, "title": arguments.title, "body": arguments.body}, + { + "workspace_id": arguments.workspace_id, + "job_id": arguments.job, + "title": arguments.title, + "body": arguments.body, + **({"packet_job_id": arguments.packet_job} if arguments.packet_job else {}), + }, "agent-control", ) elif arguments.command == "workspace" and arguments.workspace_command == "review-status": @@ -374,7 +383,11 @@ def main() -> int: elif arguments.command == "workspace" and arguments.workspace_command == "land": request = _request( "workspace.land", "git-workspaces", - {"workspace_id": arguments.workspace_id, "job_id": arguments.job}, "agent-control", + { + "workspace_id": arguments.workspace_id, + "job_id": arguments.job, + **({"packet_job_id": arguments.packet_job} if arguments.packet_job else {}), + }, "agent-control", ) elif arguments.command == "workspace" and arguments.workspace_command == "finish-integrated": request = _request( @@ -394,6 +407,14 @@ def main() -> int: parser().error(f"--parameters-json must be valid JSON: {error.msg}") if not isinstance(parameters, dict): parser().error("--parameters-json must be a JSON object") + binding = None + if arguments.bead_binding_json is not None: + try: + binding = json.loads(arguments.bead_binding_json) + except json.JSONDecodeError as error: + parser().error(f"--bead-binding-json must be valid JSON: {error.msg}") + if not isinstance(binding, dict): + parser().error("--bead-binding-json must be a JSON object") request = _request( "job.start", "systemd-jobs", @@ -402,6 +423,7 @@ def main() -> int: "operation": arguments.operation, "workspace_id": arguments.workspace, "parameters": parameters, + **({"bead_binding": binding} if binding is not None else {}), }, ) elif arguments.command == "job" and arguments.job_command in {"get", "status"}: diff --git a/pkgs/sinnixd/sinnixd/contracts.py b/pkgs/sinnixd/sinnixd/contracts.py index 60375f96..7259681f 100644 --- a/pkgs/sinnixd/sinnixd/contracts.py +++ b/pkgs/sinnixd/sinnixd/contracts.py @@ -134,7 +134,7 @@ def start_agent( if not self.native_runner.is_file() or not os.access(self.native_runner, os.X_OK): raise ContractError("native agent runner is unavailable") checkout = self.projects.checkout(project_id, checkout_id) - binding = self._bead_binding(bead_binding, checkout) + binding = self.bead_binding(bead_binding, checkout) job_id = str(uuid4()) prompt_path = self.inputs_root / f"{job_id}.prompt" public_contract = { @@ -240,10 +240,10 @@ def _start( return response @staticmethod - def _bead_binding( + def bead_binding( value: Mapping[str, Any] | None, checkout: RegisteredCheckout ) -> dict[str, Any] | None: - """Validate public Beads provenance carried by an attested agent job.""" + """Validate public Beads provenance frozen into a packet job contract.""" if value is None: return None expected = { @@ -258,9 +258,11 @@ def _bead_binding( if scope is not None and ( not isinstance(scope, list) or not scope + or len(scope) > 128 or any( not isinstance(path, str) or not path + or len(path.encode()) > 1024 or path.startswith("/") or ".." in Path(path).parts for path in scope @@ -307,7 +309,9 @@ def _bead_binding( UUID(str(binding["request_id"])) except (TypeError, ValueError, AttributeError) as error: raise ContractError("agent bead binding request_id is malformed") from error - return binding + # The caller retains its request object. Persist an independent JSON value so + # neither it nor a nested claim receipt can mutate a launched job's binding. + return json.loads(json.dumps(binding, sort_keys=True, separators=(",", ":"))) def _environment( self, checkout: RegisteredCheckout, job_id: str, principal: str, timeout_seconds: int diff --git a/pkgs/sinnixd/sinnixd/delivery.py b/pkgs/sinnixd/sinnixd/delivery.py index 3980944b..23ab639e 100644 --- a/pkgs/sinnixd/sinnixd/delivery.py +++ b/pkgs/sinnixd/sinnixd/delivery.py @@ -4,6 +4,7 @@ import subprocess import time from dataclasses import dataclass +from pathlib import Path from typing import Any, Callable, Mapping, Sequence from .jobs import GenericJobs @@ -25,15 +26,17 @@ class GitHubDelivery: jobs: GenericJobs run: Run = subprocess.run - def publish(self, workspace_id: str, job_id: str, title: str, body: str) -> dict[str, Any]: - workspace, project, receipt = self._verified_workspace(workspace_id, job_id) + def publish( + self, workspace_id: str, job_id: str, title: str, body: str, packet_job_id: str | None = None + ) -> dict[str, Any]: + workspace, project, receipt = self._verified_workspace(workspace_id, job_id, packet_job_id) if not title.strip() or len(title) > 256 or len(body.encode()) > 64_000: raise DeliveryError("review title or body exceeds its publication bounds") base = self._base_branch(project.workspace.default_base) path = workspace["path"] branch = workspace["branch"] self._command([*project.environment.command, "git", "-C", path, "push", "-u", "origin", branch], cwd=path) - workspace, project, receipt = self._verified_workspace(workspace_id, job_id) + workspace, project, receipt = self._verified_workspace(workspace_id, job_id, packet_job_id) existing = self.run( ["gh", "pr", "view", branch, "--json", "url"], cwd=path, capture_output=True, text=True, timeout=60, check=False, @@ -82,8 +85,8 @@ def review_status(self, workspace_id: str) -> dict[str, Any]: raise DeliveryError("GitHub review head does not match workspace HEAD") return {"workspace_id": workspace_id, "head": workspace["head"], "review": dict(review)} - def land(self, workspace_id: str, job_id: str) -> dict[str, Any]: - self._verified_workspace(workspace_id, job_id) + def land(self, workspace_id: str, job_id: str, packet_job_id: str | None = None) -> dict[str, Any]: + self._verified_workspace(workspace_id, job_id, packet_job_id) status = self.review_status(workspace_id) review = status["review"] if ( @@ -93,7 +96,7 @@ def land(self, workspace_id: str, job_id: str) -> dict[str, Any]: or not self._checks_pass(review["statusCheckRollup"]) ): raise DeliveryError("review is not in a landable GitHub state") - _workspace, _project, receipt = self._verified_workspace(workspace_id, job_id) + _workspace, _project, receipt = self._verified_workspace(workspace_id, job_id, packet_job_id) self._command(["gh", "pr", "merge", str(review["number"]), "--squash"], cwd=self.workspaces.get(workspace_id)["path"]) merged = self.review_status(workspace_id) if merged["review"]["state"] != "MERGED": @@ -108,7 +111,9 @@ def finish(self, workspace_id: str) -> dict[str, Any]: self._delete_remote_branch(workspace["path"], workspace["branch"]) return self.workspaces.finish_merged(workspace_id, status["head"]) - def _verified_workspace(self, workspace_id: str, job_id: str) -> tuple[dict[str, Any], Any, dict[str, Any]]: + def _verified_workspace( + self, workspace_id: str, job_id: str, packet_job_id: str | None = None + ) -> tuple[dict[str, Any], Any, dict[str, Any]]: workspace = self.workspaces.get(workspace_id) if workspace["state"] != "available" or not workspace["identity_matches"]: raise DeliveryError("publication requires an available clean identity-matching workspace") @@ -125,41 +130,101 @@ def _verified_workspace(self, workspace_id: str, job_id: str) -> tuple[dict[str, ): raise DeliveryError("workspace lacks successful declared verification at its exact HEAD") try: - result = self.jobs.result(job_id) - delivery_result = self._is_delivery_result(result) - scope = self._packet_scope(record.spec.contract) - snapshot = self.workspaces.delivery_snapshot(workspace_id, checkout["head"], scope=scope or ()) + self.jobs.result(job_id) + binding = self._binding(record, checkout, workspace) if packet_job_id is not None else None + packet = self._packet(packet_job_id, workspace, binding) if packet_job_id is not None else None + start_head = packet["start_head"] if packet is not None else checkout["head"] + scope = packet["scope"] if packet is not None else () + snapshot = self.workspaces.delivery_snapshot(workspace_id, start_head, scope=scope) + except DeliveryError: + raise except (ValueError, WorkspaceError) as error: raise DeliveryError("workspace lacks an authoritative exact-head completion receipt") from error if snapshot["head"] != checkout["head"] or not snapshot["descendant"] or snapshot["dirty"]: raise DeliveryError("workspace lacks successful declared verification at its exact HEAD") - if delivery_result and (scope is None or not snapshot["in_scope"]): + if packet is not None and (packet["final_head"] != snapshot["head"] or not snapshot["in_scope"]): raise DeliveryError("packet delivery is outside its Beads-owned write scope") - self._validate_delivery_result(result, snapshot) - artifact = result.get("artifact") if isinstance(result, Mapping) else None - return workspace, project, {"ref": artifact.get("ref") if isinstance(artifact, Mapping) else f"sinnix://jobs/{job_id}", "job_id": job_id, "workspace_id": workspace_id, "head": snapshot["head"], "verification_operation": record.spec.operation} + if packet is not None: + self._validate_delivery_result(packet["delivery"], snapshot) + return workspace, project, { + "ref": packet["artifact_ref"] if packet is not None else f"sinnix://jobs/{job_id}", + "job_id": job_id, + "packet_job_id": packet_job_id, + "bead_ref": packet["bead_ref"] if packet is not None else None, + "workspace_id": workspace_id, + "head": snapshot["head"], + "verification_operation": record.spec.operation, + } - @staticmethod - def _is_delivery_result(result: Mapping[str, Any]) -> bool: - value = result.get("value") - return result.get("kind") in {"json", "pytest"} and isinstance(value, Mapping) and "delivery" in value + def _binding( + self, record: Any, checkout: Mapping[str, Any], workspace: Mapping[str, Any] + ) -> Mapping[str, Any]: + binding = record.spec.contract.get("bead_binding") + scope = binding.get("write_scope") if isinstance(binding, Mapping) else None + if ( + not isinstance(binding, Mapping) + or not isinstance(scope, list) + or not scope + or len(scope) > 128 + or any( + not isinstance(path, str) + or not path + or len(path.encode()) > 1024 + or path.startswith("/") + or ".." in Path(path).parts + for path in scope + ) + or checkout.get("checkout_id") != workspace.get("checkout_id") + ): + raise DeliveryError("declared verification lacks an authoritative Beads packet binding") + return binding - @staticmethod - def _packet_scope(contract: Mapping[str, Any]) -> tuple[str, ...] | None: - binding = contract.get("bead_binding") - if not isinstance(binding, Mapping) or "write_scope" not in binding: - return None - scope = binding["write_scope"] - if not isinstance(scope, list) or not scope or any(not isinstance(path, str) for path in scope): - raise DeliveryError("Beads-owned write scope is malformed") - return tuple(scope) + def _packet( + self, job_id: str, workspace: Mapping[str, Any], binding: Mapping[str, Any] + ) -> dict[str, Any]: + job = self.jobs.get(job_id) + record = self.jobs.store.load(job_id) + checkout = record.spec.checkout + packet_binding = record.spec.contract.get("bead_binding") + result = self.jobs.result(job_id) + if ( + job["state"].get("phase") != "succeeded" + or record.spec.kind != "attested-agent" + or not isinstance(checkout, Mapping) + or checkout.get("checkout_id") != workspace.get("checkout_id") + or packet_binding != binding + or result.get("kind") != "last-message" + or result.get("truncated") is not False + or not isinstance(result.get("content"), str) + ): + raise DeliveryError("packet job lacks an authoritative Beads-bound result") + try: + envelope = json.loads(result["content"]) + except json.JSONDecodeError as error: + raise DeliveryError("packet job result is malformed") from error + if ( + not isinstance(envelope, Mapping) + or set(envelope) != {"schema_version", "job_id", "start_head", "final_head", "delivery"} + or envelope.get("schema_version") != 1 + or envelope.get("job_id") != job_id + or envelope.get("start_head") != checkout.get("head") + or not isinstance(envelope.get("final_head"), str) + or len(envelope["final_head"]) != 40 + or any(value not in "0123456789abcdef" for value in envelope["final_head"]) + ): + raise DeliveryError("packet job result identity is malformed") + artifact = result.get("artifact") + return { + "start_head": envelope["start_head"], + "final_head": envelope["final_head"], + "delivery": envelope["delivery"], + "scope": tuple(binding["write_scope"]), + "bead_ref": binding.get("bead_ref"), + "artifact_ref": artifact.get("ref") if isinstance(artifact, Mapping) else f"sinnix://jobs/{job_id}", + } @staticmethod - def _validate_delivery_result(result: Mapping[str, Any], snapshot: Mapping[str, Any]) -> None: - if not GitHubDelivery._is_delivery_result(result): - return - value = result.get("value") - delivery = value.get("delivery") if isinstance(value, Mapping) else None + def _validate_delivery_result(delivery: Any, snapshot: Mapping[str, Any]) -> None: if not isinstance(delivery, Mapping) or set(delivery) != {"anti_vacuity", "unresolved_work", "delegation", "deletion_evidence", "evidence_only"}: raise DeliveryError("project delivery result is malformed") unresolved, delegation, deletions = delivery["unresolved_work"], delivery["delegation"], delivery["deletion_evidence"] @@ -171,7 +236,14 @@ def _validate_delivery_result(result: Mapping[str, Any], snapshot: Mapping[str, changes = snapshot.get("changes") if not isinstance(changes, list): raise DeliveryError("workspace delivery snapshot is malformed") - if any(isinstance(change, Mapping) and str(change.get("status", ""))[:1] == "D" for change in changes) and not deletions: + deleted = { + path + for change in changes + if isinstance(change, Mapping) and str(change.get("status", ""))[:1] == "D" + for path in change.get("paths", []) + if isinstance(path, str) + } + if deleted and (any(not isinstance(path, str) for path in deletions) or not deleted <= set(deletions)): raise DeliveryError("project delivery result omits deletion evidence") if not changes and not delivery["evidence_only"]: raise DeliveryError("no-change delivery lacks the evidence-only exception") diff --git a/pkgs/sinnixd/sinnixd/jobs.py b/pkgs/sinnixd/sinnixd/jobs.py index c49cef91..a6029133 100644 --- a/pkgs/sinnixd/sinnixd/jobs.py +++ b/pkgs/sinnixd/sinnixd/jobs.py @@ -1735,6 +1735,7 @@ def start_declared( parameters: Mapping[str, Any], checkout: RegisteredCheckout | None = None, principal: str = "operator", + contract: Mapping[str, Any] | None = None, ) -> dict[str, Any]: if principal not in {"agent-control", "operator"}: raise ValueError("declared operations require agent-control or operator principal") @@ -1742,7 +1743,7 @@ def start_declared( raise ValueError("declared job checkout belongs to another project") with self._admission_lock: return self._start_declared_locked( - project, operation, correlation_id, principal, parameters, checkout, () + project, operation, correlation_id, principal, parameters, checkout, (), contract or {} ) def _start_declared_locked( @@ -1754,6 +1755,7 @@ def _start_declared_locked( parameters: Mapping[str, Any], checkout: RegisteredCheckout | None, lineage: tuple[str, ...], + contract: Mapping[str, Any], ) -> dict[str, Any]: if operation.name in lineage: raise ValueError("declared operation dependency cycle") @@ -1766,6 +1768,7 @@ def _start_declared_locked( {}, checkout, (*lineage, operation.name), + {}, ) for name in operation.dependencies ) @@ -1847,6 +1850,7 @@ def build_spec(lease: ServiceLease | None) -> GenericJobSpec: principal=principal, timeout_seconds=operation.timeout_seconds, checkout=checkout.to_dict() if checkout is not None else None, + contract=dict(contract), result_kind={"exit": "exit-status", "json": "json", "pytest": "pytest"}[operation.result], pool=operation.pool, exclusive_keys=operation.exclusive_keys, dependency_job_ids=dependency_ids, cache_key=cache_key, estimate_key=estimate_key, estimate_memory_bytes=estimate, diff --git a/pkgs/sinnixd/sinnixd/runner.py b/pkgs/sinnixd/sinnixd/runner.py index ab27bec5..f53f513e 100644 --- a/pkgs/sinnixd/sinnixd/runner.py +++ b/pkgs/sinnixd/sinnixd/runner.py @@ -7,7 +7,12 @@ from pathlib import Path from typing import Any, Mapping, Sequence -from .jobs import GenericJobStore, JobRecordError, MAX_RESULT_BYTES +from .jobs import ( + GenericJobStore, + JobRecordError, + MAX_RESULT_BYTES, + _open_preallocated_private_artifact, +) from .limits import maximum_timeout_seconds, valid_timeout_seconds from .projects import ProjectConfigError, revalidate_registered_checkout @@ -161,6 +166,9 @@ def _run_agent( ] try: completed = subprocess.run(command, cwd=checkout, check=False) + binding = value.get("bead_binding") + if isinstance(binding, Mapping) and "write_scope" in binding: + _seal_packet_result(value, checkout, result_path) if result_path.exists() and result_path.stat().st_size > MAX_RESULT_BYTES: result_path.write_bytes(result_path.read_bytes()[:MAX_RESULT_BYTES]) return completed.returncode @@ -168,6 +176,45 @@ def _run_agent( prompt_path.unlink(missing_ok=True) +def _seal_packet_result(value: Mapping[str, Any], checkout: Path, result_path: Path) -> None: + """Bind a structured worker report to the runtime-observed terminal Git head.""" + try: + if result_path.stat().st_size > MAX_RESULT_BYTES: + raise RunnerError("packet result exceeds the artifact limit") + raw = result_path.read_bytes() + delivery = json.loads(raw) + except (OSError, json.JSONDecodeError): + delivery = None + observed = subprocess.run( + ["git", "-C", str(checkout), "rev-parse", "HEAD"], + capture_output=True, + text=True, + timeout=10, + check=False, + ) + final_head = observed.stdout.strip() + if observed.returncode != 0 or len(final_head) != 40 or any(value not in "0123456789abcdef" for value in final_head): + raise RunnerError("packet final Git head is unavailable") + envelope = json.dumps( + { + "schema_version": 1, + "job_id": value["job_id"], + "start_head": value["checkout"]["head"], + "final_head": final_head, + "delivery": delivery, + }, + sort_keys=True, + separators=(",", ":"), + ).encode() + if len(envelope) > MAX_RESULT_BYTES: + raise RunnerError("packet result exceeds the artifact limit") + with _open_preallocated_private_artifact(result_path) as result_file: + os.ftruncate(result_file.fileno(), 0) + result_file.write(envelope) + result_file.flush() + os.fsync(result_file.fileno()) + + def main(arguments: Sequence[str] | None = None) -> int: parser = argparse.ArgumentParser(prog="sinnixd-contract-runner") parser.add_argument("--input", type=Path) diff --git a/pkgs/sinnixd/sinnixd/service.py b/pkgs/sinnixd/sinnixd/service.py index 0c32aca7..34d795f5 100644 --- a/pkgs/sinnixd/sinnixd/service.py +++ b/pkgs/sinnixd/sinnixd/service.py @@ -298,24 +298,35 @@ def _dispatch( if operation == "workspace.publish": if principal not in {"agent-control", "operator"}: raise ValueError("workspace publication requires agent-control or operator principal") - if set(arguments) != {"workspace_id", "job_id", "title", "body"}: + if set(arguments) - {"workspace_id", "job_id", "packet_job_id", "title", "body"} or not { + "workspace_id", "job_id", "title", "body" + } <= set(arguments): raise ValueError("workspace.publish requires workspace_id, job_id, title, and body") assert self.delivery is not None - return self.delivery.publish( + publish_arguments = ( self._job_argument(arguments, "workspace_id"), self._job_argument(arguments, "job_id"), self._job_argument(arguments, "title"), arguments.get("body") if isinstance(arguments.get("body"), str) else "", ) + packet_job_id = arguments.get("packet_job_id") + return self.delivery.publish( + *publish_arguments, + **({"packet_job_id": packet_job_id} if isinstance(packet_job_id, str) else {}), + ) if operation == "workspace.review-status": assert self.delivery is not None return self.delivery.review_status(self._single_workspace_id(arguments, "workspace.review-status")) if operation == "workspace.land": - if principal not in {"agent-control", "operator"} or set(arguments) != {"workspace_id", "job_id"}: + if principal not in {"agent-control", "operator"} or set(arguments) - { + "workspace_id", "job_id", "packet_job_id" + } or not {"workspace_id", "job_id"} <= set(arguments): raise ValueError("workspace.land requires agent-control or operator plus workspace_id and job_id") assert self.delivery is not None + packet_job_id = arguments.get("packet_job_id") return self.delivery.land( - self._job_argument(arguments, "workspace_id"), self._job_argument(arguments, "job_id") + self._job_argument(arguments, "workspace_id"), self._job_argument(arguments, "job_id"), + **({"packet_job_id": packet_job_id} if isinstance(packet_job_id, str) else {}), ) if operation == "workspace.finish": if principal not in {"agent-control", "operator"}: @@ -337,8 +348,8 @@ def _dispatch( ) project_id = self._job_argument(arguments, "project_id") operation_name = self._job_argument(arguments, "operation") - if set(arguments) - {"project_id", "operation", "workspace_id", "parameters"}: - raise ValueError("job.start accepts project_id, operation, optional workspace_id, and optional parameters") + if set(arguments) - {"project_id", "operation", "workspace_id", "parameters", "bead_binding"}: + raise ValueError("job.start accepts project_id, operation, optional workspace_id, optional parameters, and optional bead_binding") parameters = arguments.get("parameters", {}) if not isinstance(parameters, Mapping): raise ValueError("job.start parameters must be an object") @@ -352,6 +363,14 @@ def _dispatch( if workspace_id is not None else self.projects.checkout(project_id, "default") ) + binding = arguments.get("bead_binding") + if binding is not None and operation_name not in project.workspace.verification_operations: + raise ValueError("a Beads packet binding requires a declared verification operation") + packet_contract = ( + {"bead_binding": self.job_contracts.bead_binding(binding, checkout)} + if binding is not None + else {} + ) return self._cleanup_terminal(self.jobs.start_declared( project=project, operation=project.operation(operation_name), @@ -359,6 +378,7 @@ def _dispatch( principal=principal, parameters=parameters, checkout=checkout, + contract=packet_contract, )) if operation == "job.shell.start": required = {"project_id", "checkout_id", "argv", "cwd", "timeout_seconds", "result"} diff --git a/pkgs/sinnixd/test_service.py b/pkgs/sinnixd/test_service.py index 2b72d469..53d1a4f9 100644 --- a/pkgs/sinnixd/test_service.py +++ b/pkgs/sinnixd/test_service.py @@ -59,7 +59,14 @@ from sinnixd.limits import MAX_DECLARED_OPERATION_TIMEOUT_SECONDS from sinnixd.owner_adapters import DeclaredOwnerAdapters, OwnerAdapterError from sinnixd.projects import ProjectCatalog, ProjectConfigError, RegisteredCheckout, parse_worktree_records -from sinnixd.runner import RunnerError, _exec_shell, _require_environment, _revalidate_checkout, _run_declared +from sinnixd.runner import ( + RunnerError, + _exec_shell, + _require_environment, + _revalidate_checkout, + _run_declared, + _seal_packet_result, +) from sinnixd.service import SinnixdService from sinnixd.tasks import ( BeadsCommandBoundary, @@ -4530,6 +4537,214 @@ def test_delivery_snapshot_is_nul_safe_and_exact_file_scope_does_not_include_des assert service.workspaces.delivery_snapshot(workspace["workspace_id"], start, scope=("dir/",))["in_scope"] +def test_beads_bound_packet_and_exact_head_verifier_compose_into_delivery(tmp_path: Path) -> None: + """The accepting path joins two authoritative jobs; neither can substitute for the other.""" + write_adapter(tmp_path) + initialize_git_checkout(tmp_path) + native = tmp_path / "native-runner" + native_runner(native) + systemd = FakeSystemdJobs() + jobs = generic_jobs(tmp_path, systemd) + service = SinnixdService(ProjectCatalog([tmp_path]), jobs=jobs, native_runner=native) + workspace = service.workspaces.create( + project_id="fixture", name="packet-delivery", branch="feature/packet-delivery", base="HEAD" + ) + checkout_id = workspace["checkout_id"] + binding = { + "bead_ref": "sinnix://projects/fixture/beads/fixture-1", + "project_ref": "sinnix://projects/fixture", + "checkout_ref": f"sinnix://projects/fixture/checkouts/{checkout_id}", + "task_revision": "a" * 64, + "task_etag": "b" * 64, + "claim_ref": f"sinnix://projects/fixture/beads/fixture-1/claims/{'c' * 64}", + "claim_receipt": { + "ref": f"sinnix://projects/fixture/beads/fixture-1/claims/{'c' * 64}", + "owner_route": "beads.cli", + }, + "request_id": "2e46daf5-e9b1-4c6e-b99d-bcd46631730b", + "assignment_ref": None, + "write_scope": ["delivery.txt", "obsolete.txt"], + } + path = Path(workspace["path"]) + (path / "obsolete.txt").write_text("remove me\n") + subprocess.run(["git", "-C", str(path), "add", "obsolete.txt"], check=True) + subprocess.run( + ["git", "-C", str(path), "-c", "user.name=Fixture", "-c", "user.email=fixture@example.test", "commit", "--quiet", "-m", "seed deletion"], + check=True, + ) + packet = service.dispatch( + request( + "job.agent.start", + "systemd-jobs", + { + "project_id": "fixture", "checkout_id": checkout_id, "prompt": "return structured delivery", + "backend": "codex", "model": "fixture", "effort": "high", + "credential_profile": "subscription", "timeout_seconds": 60, + "result": "last-message", "bead_binding": binding, + }, + "agent-control", + ) + ) + assert packet.ok and packet.payload is not None + packet_id = packet.payload.inline["job_id"] + packet_record = jobs.store.load(packet_id) + start_head = packet_record.spec.checkout["head"] + (path / "delivery.txt").write_text("delivered\n") + (path / "obsolete.txt").unlink() + subprocess.run(["git", "-C", str(path), "add", "-A"], check=True) + subprocess.run( + ["git", "-C", str(path), "-c", "user.name=Fixture", "-c", "user.email=fixture@example.test", "commit", "--quiet", "-m", "delivery"], + check=True, + ) + final_head = subprocess.run( + ["git", "-C", str(path), "rev-parse", "HEAD"], capture_output=True, text=True, check=True + ).stdout.strip() + worker_delivery = { + "anti_vacuity": True, + "unresolved_work": [], + "delegation": {"visibility": "unsupported", "pending": None}, + "deletion_evidence": ["obsolete.txt"], + "evidence_only": False, + } + assert packet_record.result_path is not None + packet_record.result_path.write_text(json.dumps({ + "schema_version": 1, "job_id": packet_id, "start_head": start_head, + "final_head": final_head, "delivery": worker_delivery, + })) + jobs_module._write_private_marker(jobs_module._completion_marker_path(packet_record.log_path)) + systemd.properties = { + "LoadState": "loaded", "ActiveState": "inactive", "Result": "success", + "ExecMainStatus": "0", "InvocationID": "fixture-invocation", + } + assert jobs.get(packet_id)["state"]["phase"] == "succeeded" + verifier = service.dispatch(request( + "job.start", "systemd-jobs", + { + "project_id": "fixture", "operation": "check", "workspace_id": workspace["workspace_id"], + "bead_binding": binding, + }, + )) + assert verifier.ok and verifier.payload is not None + verifier_id = verifier.payload.inline["job_id"] + assert jobs.get(verifier_id)["state"]["phase"] == "succeeded" + + delivery = GitHubDelivery(service.projects, service.workspaces, jobs) + _workspace, _project, receipt = delivery._verified_workspace( + workspace["workspace_id"], verifier_id, packet_id + ) + assert receipt["bead_ref"] == binding["bead_ref"] + assert receipt["head"] == final_head + + (path / "dirty.txt").write_text("uncommitted\n") + with pytest.raises(DeliveryError, match="exact HEAD"): + delivery._verified_workspace(workspace["workspace_id"], verifier_id, packet_id) + (path / "dirty.txt").unlink() + + bad_binding = {**binding, "write_scope": ["other.txt"]} + jobs.store.save(replace( + packet_record, + spec=replace(packet_record.spec, contract={**packet_record.spec.contract, "bead_binding": bad_binding}), + )) + verifier_record = jobs.store.load(verifier_id) + jobs.store.save(replace( + verifier_record, + spec=replace(verifier_record.spec, contract={**verifier_record.spec.contract, "bead_binding": bad_binding}), + )) + with pytest.raises(DeliveryError, match="write scope"): + delivery._verified_workspace(workspace["workspace_id"], verifier_id, packet_id) + jobs.store.save(packet_record) + jobs.store.save(verifier_record) + + for evidence in ([], ["unrelated.txt"]): + packet_record.result_path.write_text(json.dumps({ + "schema_version": 1, "job_id": packet_id, "start_head": start_head, + "final_head": final_head, + "delivery": {**worker_delivery, "deletion_evidence": evidence}, + })) + with pytest.raises(DeliveryError, match="deletion evidence"): + delivery._verified_workspace(workspace["workspace_id"], verifier_id, packet_id) + + packet_record.result_path.write_text(json.dumps({ + "schema_version": 1, "job_id": packet_id, "start_head": start_head, + "final_head": final_head, + "delivery": {**worker_delivery, "unresolved_work": ["still running"]}, + })) + with pytest.raises(DeliveryError, match="incomplete"): + delivery._verified_workspace(workspace["workspace_id"], verifier_id, packet_id) + packet_record.result_path.write_text(json.dumps({ + "schema_version": 1, "job_id": packet_id, "start_head": start_head, + "final_head": final_head, "delivery": worker_delivery, + })) + + packet_record.result_path.write_text(json.dumps({ + "schema_version": 1, "job_id": packet_id, "start_head": start_head, + "final_head": final_head, "delivery": None, + })) + with pytest.raises(DeliveryError, match="malformed"): + delivery._verified_workspace(workspace["workspace_id"], verifier_id, packet_id) + packet_record.result_path.write_text(json.dumps({ + "schema_version": 1, "job_id": packet_id, "start_head": start_head, + "final_head": final_head, "delivery": worker_delivery, + })) + + for scope in (["../outside"], [f"entry-{index}" for index in range(129)]): + rejected = service.dispatch(request( + "job.start", "systemd-jobs", + { + "project_id": "fixture", "operation": "check", "workspace_id": workspace["workspace_id"], + "bead_binding": {**binding, "write_scope": scope}, + }, + )) + assert not rejected.ok + + (path / "later.txt").write_text("post-terminal\n") + subprocess.run(["git", "-C", str(path), "add", "later.txt"], check=True) + subprocess.run( + ["git", "-C", str(path), "-c", "user.name=Fixture", "-c", "user.email=fixture@example.test", "commit", "--quiet", "-m", "later"], + check=True, + ) + with pytest.raises(DeliveryError, match="exact HEAD"): + delivery._verified_workspace(workspace["workspace_id"], verifier_id, packet_id) + + +def test_packet_runner_seals_worker_report_to_runtime_observed_head(tmp_path: Path) -> None: + initialize_git_checkout(tmp_path) + start_head = subprocess.run( + ["git", "-C", str(tmp_path), "rev-parse", "HEAD"], capture_output=True, text=True, check=True + ).stdout.strip() + (tmp_path / "change.txt").write_text("change\n") + subprocess.run(["git", "-C", str(tmp_path), "add", "change.txt"], check=True) + subprocess.run( + ["git", "-C", str(tmp_path), "-c", "user.name=Fixture", "-c", "user.email=fixture@example.test", "commit", "--quiet", "-m", "change"], + check=True, + ) + final_head = subprocess.run( + ["git", "-C", str(tmp_path), "rev-parse", "HEAD"], capture_output=True, text=True, check=True + ).stdout.strip() + result_root = tmp_path / "private-results" + result_root.mkdir(mode=0o700) + result_path = result_root / "packet.result" + result_path.touch(mode=0o600) + delivery = { + "anti_vacuity": True, "unresolved_work": [], + "delegation": {"visibility": "unsupported", "pending": None}, + "deletion_evidence": [], "evidence_only": False, + } + result_path.write_text(json.dumps(delivery)) + + _seal_packet_result( + {"job_id": "packet-job", "checkout": {"head": start_head}}, tmp_path, result_path + ) + + assert json.loads(result_path.read_text()) == { + "schema_version": 1, + "job_id": "packet-job", + "start_head": start_head, + "final_head": final_head, + "delivery": delivery, + } + + def test_admission_revalidates_queued_declared_workspace_before_systemd_launch(tmp_path: Path) -> None: """A queued declared service whose checkout HEAD moved must terminalize before it reaches systemd.""" write_adapter(tmp_path) From bbeb98bf68441caea6858ae9f6cc91bb6c30aa20 Mon Sep 17 00:00:00 2001 From: Sinity Date: Tue, 25 Aug 2026 06:07:17 +0200 Subject: [PATCH 5/6] fix(agentctl): five post-PASS hardening corrections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. docs/sinnixd.md: Clarify that packet scope covers the immutable start→sealed-final range; start/final heads come from the two job records, not from fields in the Beads binding; GitHub branch protection owns review/check authority, not AgentCTL. 2. contracts.py: Reject write_scope: null at the gateway/launch boundary. The prior guard used `if scope is not None`, which silently accepted null and created a scope-less packet. The fix checks `if "write_scope" in binding` so any present value must be a valid non-empty path list. 3. delivery.py: Deletion evidence must exactly equal the deleted path set for the packet range. The prior check allowed overclaims (set(deletions) >= deleted); the fix requires set(deletions) == deleted so unrelated paths are also rejected. 4. runner.py: Preserve the underlying cause when _seal_packet_result cannot read or parse the worker result. The prior code set delivery = None on OSError or JSONDecodeError, losing the reason. The fix re-raises as RunnerError with a typed, non-sensitive message using the existing bounded error mechanism. 5. test_service.py: Add a composed test that runs the real runner seal through exact-head verification into delivery validation, proving one accepted add/delete range and one tampered sealed envelope rejection. Reuses existing fixtures and helpers; no new harness. Inherited failures: test_agent_runner_revalidates_checkout_and_writes_ a_bounded_result_fixture (SINNIX env identity), test_workspace_restack_ detaches_child_after_squash_equivalent_parent_disappears (git merge error), test_real_user_systemd_service_cgroup_cancels_descendants (systemd/cgroup access) — all pre-exist on 2a2f30ea. Co-Authored-By: Claude Sonnet 4.6 --- docs/sinnixd.md | 2 +- pkgs/sinnixd/sinnixd/contracts.py | 2 +- pkgs/sinnixd/sinnixd/delivery.py | 4 +- pkgs/sinnixd/sinnixd/runner.py | 7 +- pkgs/sinnixd/test_service.py | 127 ++++++++++++++++++++++++++++++ 5 files changed, 136 insertions(+), 6 deletions(-) diff --git a/docs/sinnixd.md b/docs/sinnixd.md index 2ca7dc5b..8c6e62e1 100644 --- a/docs/sinnixd.md +++ b/docs/sinnixd.md @@ -223,7 +223,7 @@ If any verification or cutover command fails, leave Sinnixd stopped. Before the Both routes use the same UUID job ID, transient user service, cancellation, reconciliation, `job get/list/logs/result/wait`, and bounded artifact readers as declared operations. Their durable public record contains the principal, job kind, canonical project and checkout identity, redacted argv digest or prompt digest, and bounded artifact references. It never stores raw shell argv arguments after launch, prompt text, environment values, or credentials. -Delivery is a precondition of `workspace.publish` and `workspace.land`, not a caller-fed completion route. Ordinary delivery reads the exact-head declared verification job through `job.result`. Packet delivery additionally names the Beads-bound attested-agent job with `--packet-job`. The declared verification job receives the same immutable Beads binding at dispatch, including its initial head, bead identity, and write scope; the contract runner seals the worker's structured report to the Git head observed when the runner exits. Delivery requires the bindings to match, the later semantic verifier to succeed at that same final head, and snapshots the initial-to-final Git range. It rejects dirty, divergent, stale, or out-of-scope work and repeats the complete precondition after push and after review inspection. The worker report can only tighten acceptance through bounded anti-vacuity, unresolved-work, delegation-visibility, deletion-evidence, and evidence-only fields. Git owns paths, commits, and heads; the project verifier owns semantic success; GitHub owns independent review state. Beads closure consumes the returned completion artifact and bead references in its own owner; wiring that external closure consumer is not implemented by Sinnixd. +Delivery is a precondition of `workspace.publish` and `workspace.land`, not a caller-fed completion route. Ordinary delivery reads the exact-head declared verification job through `job.result`. Packet delivery additionally names the Beads-bound attested-agent job with `--packet-job`. Both jobs receive the same immutable Beads binding at dispatch, which carries bead identity and write scope. The write scope covers exactly the packet job's immutable range from the start head recorded in the packet job's durable record to the final head the contract runner observes when it exits and seals the worker's structured report; it does not extend to arbitrary pre-existing branch content. The start head and final head are read exclusively from the two job records — the packet job's durable checkout head is the start, and the sealed envelope's `final_head` field (written by the runner at exit) is the end. Neither value comes from a field inside the Beads binding. Delivery requires the bindings to match, the later semantic verifier to succeed at that same final head, and snapshots the start-to-final Git range. It rejects dirty, divergent, stale, or out-of-scope work and repeats the complete precondition after push and after review inspection. The worker report can only tighten acceptance through bounded anti-vacuity, unresolved-work, delegation-visibility, deletion-evidence, and evidence-only fields. Deletion evidence must exactly equal the set of paths deleted in the packet range; overclaims (listing unrelated paths) are rejected as well as omissions. Git owns paths, commits, and heads; the project verifier owns semantic success; GitHub branch protection supplies independent review and required-check authority. Repositories without branch protection rules do not gain those approval requirements from AgentCTL. Beads closure consumes the returned completion artifact and bead references in its own owner; wiring that external closure consumer is not implemented by Sinnixd. Typed jobs accept no environment overlay. The daemon creates the `env -i` environment from the declared project environment and fixed `SINNIXD_*` identity fields. Immediately before execution, the contract runner verifies those fields, rechecks the exact registered project, canonical worktree root, common Git directory, porcelain worktree membership, and recorded HEAD. A changed, missing, symlinked, or spoofed identity fails closed. Agent handoff includes `--registered-project`, `--expected-git-common-dir`, and the canonical checkout path; nested scope creation remains disabled, so the native runner provides backend execution and native attestation while the shared transient user service remains the sole process, cgroup, timeout, and cancellation authority. Private launch inputs are mode 0600, removed before shell execution, and removed after agent handoff or every terminal lifecycle outcome, including confirmed launch failure. Native private logs are removed after handoff; only the bounded shared log and result artifacts remain addressable. diff --git a/pkgs/sinnixd/sinnixd/contracts.py b/pkgs/sinnixd/sinnixd/contracts.py index 7259681f..6a8cdc9a 100644 --- a/pkgs/sinnixd/sinnixd/contracts.py +++ b/pkgs/sinnixd/sinnixd/contracts.py @@ -255,7 +255,7 @@ def bead_binding( raise ContractError("agent bead binding is malformed") binding = dict(value) scope = binding.get("write_scope") - if scope is not None and ( + if "write_scope" in binding and ( not isinstance(scope, list) or not scope or len(scope) > 128 diff --git a/pkgs/sinnixd/sinnixd/delivery.py b/pkgs/sinnixd/sinnixd/delivery.py index 23ab639e..b167cf41 100644 --- a/pkgs/sinnixd/sinnixd/delivery.py +++ b/pkgs/sinnixd/sinnixd/delivery.py @@ -243,8 +243,8 @@ def _validate_delivery_result(delivery: Any, snapshot: Mapping[str, Any]) -> Non for path in change.get("paths", []) if isinstance(path, str) } - if deleted and (any(not isinstance(path, str) for path in deletions) or not deleted <= set(deletions)): - raise DeliveryError("project delivery result omits deletion evidence") + if any(not isinstance(path, str) for path in deletions) or set(deletions) != deleted: + raise DeliveryError("project delivery result deletion evidence does not exactly match deleted paths") if not changes and not delivery["evidence_only"]: raise DeliveryError("no-change delivery lacks the evidence-only exception") if changes and delivery["evidence_only"]: diff --git a/pkgs/sinnixd/sinnixd/runner.py b/pkgs/sinnixd/sinnixd/runner.py index f53f513e..55b1289a 100644 --- a/pkgs/sinnixd/sinnixd/runner.py +++ b/pkgs/sinnixd/sinnixd/runner.py @@ -182,9 +182,12 @@ def _seal_packet_result(value: Mapping[str, Any], checkout: Path, result_path: P if result_path.stat().st_size > MAX_RESULT_BYTES: raise RunnerError("packet result exceeds the artifact limit") raw = result_path.read_bytes() + except OSError as error: + raise RunnerError("packet result is unreadable") from error + try: delivery = json.loads(raw) - except (OSError, json.JSONDecodeError): - delivery = None + except json.JSONDecodeError as error: + raise RunnerError("packet result is not valid JSON") from error observed = subprocess.run( ["git", "-C", str(checkout), "rev-parse", "HEAD"], capture_output=True, diff --git a/pkgs/sinnixd/test_service.py b/pkgs/sinnixd/test_service.py index 53d1a4f9..a41f7ecd 100644 --- a/pkgs/sinnixd/test_service.py +++ b/pkgs/sinnixd/test_service.py @@ -4745,6 +4745,133 @@ def test_packet_runner_seals_worker_report_to_runtime_observed_head(tmp_path: Pa } +def test_seal_output_composes_through_exact_head_into_delivery_validation(tmp_path: Path) -> None: + """Composed: real runner seal output flows through exact-head evidence into delivery acceptance and tamper rejection.""" + write_adapter(tmp_path) + initialize_git_checkout(tmp_path) + native = tmp_path / "native-runner" + native_runner(native) + systemd = FakeSystemdJobs() + jobs = generic_jobs(tmp_path, systemd) + service = SinnixdService(ProjectCatalog([tmp_path]), jobs=jobs, native_runner=native) + workspace = service.workspaces.create( + project_id="fixture", name="seal-compose", branch="feature/seal-compose", base="HEAD" + ) + checkout_id = workspace["checkout_id"] + path = Path(workspace["path"]) + + # Seed a file that will be deleted during the packet range. + (path / "seed.txt").write_text("to be removed\n") + subprocess.run(["git", "-C", str(path), "add", "seed.txt"], check=True) + subprocess.run( + ["git", "-C", str(path), "-c", "user.name=Fixture", "-c", "user.email=fixture@example.test", + "commit", "--quiet", "-m", "seed deletion target"], + check=True, + ) + + binding = { + "bead_ref": "sinnix://projects/fixture/beads/seal-test-1", + "project_ref": "sinnix://projects/fixture", + "checkout_ref": f"sinnix://projects/fixture/checkouts/{checkout_id}", + "task_revision": "a" * 64, + "task_etag": "b" * 64, + "claim_ref": f"sinnix://projects/fixture/beads/seal-test-1/claims/{'c' * 64}", + "claim_receipt": { + "ref": f"sinnix://projects/fixture/beads/seal-test-1/claims/{'c' * 64}", + "owner_route": "beads.cli", + }, + "request_id": "9f1a2b3c-0000-4d5e-8f6a-7b8c9d0e1f2a", + "assignment_ref": None, + "write_scope": ["added.txt", "seed.txt"], + } + + packet_response = service.dispatch(request( + "job.agent.start", "systemd-jobs", + { + "project_id": "fixture", "checkout_id": checkout_id, + "prompt": "return structured delivery for seal composition test", + "backend": "codex", "model": "fixture", "effort": "high", + "credential_profile": "subscription", "timeout_seconds": 60, + "result": "last-message", "bead_binding": binding, + }, + "agent-control", + )) + assert packet_response.ok and packet_response.payload is not None + packet_id = packet_response.payload.inline["job_id"] + packet_record = jobs.store.load(packet_id) + start_head = packet_record.spec.checkout["head"] + + # Produce the packet range: one add, one delete. + (path / "added.txt").write_text("new content\n") + (path / "seed.txt").unlink() + subprocess.run(["git", "-C", str(path), "add", "-A"], check=True) + subprocess.run( + ["git", "-C", str(path), "-c", "user.name=Fixture", "-c", "user.email=fixture@example.test", + "commit", "--quiet", "-m", "packet range: add+delete"], + check=True, + ) + + # Use the real runner seal on a valid worker delivery report. + assert packet_record.result_path is not None + packet_record.result_path.parent.mkdir(parents=True, exist_ok=True) + packet_record.result_path.touch(mode=0o600) + worker_delivery = { + "anti_vacuity": True, + "unresolved_work": [], + "delegation": {"visibility": "unsupported", "pending": None}, + "deletion_evidence": ["seed.txt"], + "evidence_only": False, + } + packet_record.result_path.write_text(json.dumps(worker_delivery)) + _seal_packet_result( + {"job_id": packet_id, "checkout": {"head": start_head}}, path, packet_record.result_path + ) + sealed = json.loads(packet_record.result_path.read_text()) + final_head = sealed["final_head"] + + # Worker result was sealed by the real runner; mark the job succeeded. + jobs_module._write_private_marker(jobs_module._completion_marker_path(packet_record.log_path)) + systemd.properties = { + "LoadState": "loaded", "ActiveState": "inactive", "Result": "success", + "ExecMainStatus": "0", "InvocationID": "seal-compose-invocation", + } + assert jobs.get(packet_id)["state"]["phase"] == "succeeded" + + # Verifier job runs at final_head with the same binding. + verifier_response = service.dispatch(request( + "job.start", "systemd-jobs", + { + "project_id": "fixture", "operation": "check", + "workspace_id": workspace["workspace_id"], "bead_binding": binding, + }, + )) + assert verifier_response.ok and verifier_response.payload is not None + verifier_id = verifier_response.payload.inline["job_id"] + assert jobs.get(verifier_id)["state"]["phase"] == "succeeded" + + delivery_gate = GitHubDelivery(service.projects, service.workspaces, jobs) + + # Accepting path: real seal output + correct deletion evidence + verifier at final_head. + _workspace, _project, receipt = delivery_gate._verified_workspace( + workspace["workspace_id"], verifier_id, packet_id + ) + assert receipt["head"] == final_head + assert receipt["bead_ref"] == binding["bead_ref"] + + # Tamper: mutate the sealed envelope's final_head to a synthetic value. + tampered = {**sealed, "final_head": "b" * 40} + packet_record.result_path.write_text(json.dumps(tampered)) + with pytest.raises(DeliveryError): + delivery_gate._verified_workspace(workspace["workspace_id"], verifier_id, packet_id) + + # Restore and verify deletion overclaim is now rejected. + packet_record.result_path.write_text(json.dumps(sealed)) + overclaim = {**worker_delivery, "deletion_evidence": ["seed.txt", "unrelated.txt"]} + packet_record.result_path.write_text(json.dumps({**sealed, "delivery": overclaim})) + with pytest.raises(DeliveryError, match="deletion evidence"): + delivery_gate._verified_workspace(workspace["workspace_id"], verifier_id, packet_id) + + def test_admission_revalidates_queued_declared_workspace_before_systemd_launch(tmp_path: Path) -> None: """A queued declared service whose checkout HEAD moved must terminalize before it reaches systemd.""" write_adapter(tmp_path) From b53e2c663f40bc06bf883697c5ecc6594f3d4da1 Mon Sep 17 00:00:00 2001 From: Sinity Date: Tue, 25 Aug 2026 06:17:51 +0200 Subject: [PATCH 6/6] fix(agentctl): type invalid result encoding failures --- pkgs/sinnixd/sinnixd/runner.py | 2 +- pkgs/sinnixd/test_service.py | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/pkgs/sinnixd/sinnixd/runner.py b/pkgs/sinnixd/sinnixd/runner.py index 55b1289a..284a13e0 100644 --- a/pkgs/sinnixd/sinnixd/runner.py +++ b/pkgs/sinnixd/sinnixd/runner.py @@ -186,7 +186,7 @@ def _seal_packet_result(value: Mapping[str, Any], checkout: Path, result_path: P raise RunnerError("packet result is unreadable") from error try: delivery = json.loads(raw) - except json.JSONDecodeError as error: + except (UnicodeDecodeError, json.JSONDecodeError) as error: raise RunnerError("packet result is not valid JSON") from error observed = subprocess.run( ["git", "-C", str(checkout), "rev-parse", "HEAD"], diff --git a/pkgs/sinnixd/test_service.py b/pkgs/sinnixd/test_service.py index a41f7ecd..20121d3d 100644 --- a/pkgs/sinnixd/test_service.py +++ b/pkgs/sinnixd/test_service.py @@ -4745,6 +4745,22 @@ def test_packet_runner_seals_worker_report_to_runtime_observed_head(tmp_path: Pa } +def test_packet_runner_rejects_invalid_utf8_as_typed_json_failure(tmp_path: Path) -> None: + initialize_git_checkout(tmp_path) + start_head = subprocess.run( + ["git", "-C", str(tmp_path), "rev-parse", "HEAD"], capture_output=True, text=True, check=True + ).stdout.strip() + result_path = tmp_path / "packet.result" + result_path.write_bytes(b"\xff") + + with pytest.raises(RunnerError, match="packet result is not valid JSON") as caught: + _seal_packet_result( + {"job_id": "packet-job", "checkout": {"head": start_head}}, tmp_path, result_path + ) + + assert isinstance(caught.value.__cause__, UnicodeDecodeError) + + def test_seal_output_composes_through_exact_head_into_delivery_validation(tmp_path: Path) -> None: """Composed: real runner seal output flows through exact-head evidence into delivery acceptance and tamper rejection.""" write_adapter(tmp_path)