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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/sinnixd.md
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,8 @@ 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`. 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.

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.
Expand Down
9 changes: 9 additions & 0 deletions pkgs/sinnix-agent-gateway/sinnix_agent_gateway/runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Comment on lines +880 to +885

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject malformed Beads write scopes instead of dropping them

When the Bead contains a write_scope value that is invalid JSON or decodes to anything other than a list, this code silently omits the scope and launches an ordinary unsealed agent job. That is a fail-open downgrade of an explicitly declared packet policy: the worker runs without the packet result contract or eventual scope evidence, rather than receiving the bounded typed failure promised for malformed scopes. Reject the launch when a present scope cannot be decoded and validated.

AGENTS.md reference: AGENTS.md:L40-L42

Useful? React with 👍 / 👎.

binding["write_scope"] = write_scope
Comment on lines +885 to +886

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Tell scoped workers to emit the required delivery JSON

For a Bead with a valid write_scope, this addition makes the runner parse the agent's last message as JSON and later requires an exact five-field delivery schema, but the gateway prompt still merely asks the worker to “report evidence plus residuals” and neither supplies the field names nor requests JSON. Since no managed skill or other prompt surface defines this schema, normal scoped gateway launches will return prose and fail in _seal_packet_result even after successfully completing the task; include the exact output contract in the packet prompt.

Useful? React with 👍 / 👎.

assigned_context = {
"bead": bead,
"project_ref": project_ref,
Expand Down
3 changes: 2 additions & 1 deletion pkgs/sinnix-agent-gateway/test_execution_jobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"}}
Expand All @@ -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"}
Expand Down
26 changes: 24 additions & 2 deletions pkgs/sinnixd/sinnixd/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,13 +89,15 @@ 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")
workspace_review.add_argument("workspace_id")
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")
Expand All @@ -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")
Expand Down Expand Up @@ -366,15 +369,25 @@ 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":
request = _request("workspace.review-status", "git-workspaces", {"workspace_id": arguments.workspace_id})
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(
Expand All @@ -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",
Expand All @@ -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"}:
Expand Down
28 changes: 23 additions & 5 deletions pkgs/sinnixd/sinnixd/contracts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -240,19 +240,35 @@ 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 = {
"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 "write_scope" in binding 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
Comment on lines +261 to +265

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Bound the aggregate write scope to the result artifact limit

A valid binding may contain 128 paths of up to 1024 bytes each, but packet sealing caps the entire JSON envelope at MAX_RESULT_BYTES (64,000 bytes), and deletion evidence must repeat every deleted path. Consequently, a packet deleting enough individually valid scoped paths—such as 128 paths around 500 bytes—can never seal successfully even with a correct report. Validate the aggregate encoded size against the envelope budget or reduce these per-entry/count limits.

Useful? React with 👍 / 👎.

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/"
Expand Down Expand Up @@ -293,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
Expand Down
Loading
Loading