Skip to content

Assync Security Model

Product Boundary

Assync is an external development control plane. It must never become a runtime, build, deployment, or operational dependency of any managed project. The mandatory invariant: if Assync is removed completely, every managed project must still build, test, deploy, and run without modification. Verified structurally in this codebase: tests/test_isolation.py::NoManagedProjectDependencyTests asserts that src/assync/** imports nothing beyond its own package, its three declared dependencies (typer, rich, pyyaml), and the standard library — never a managed project's own code.

Architecture Layering

CLI            Web API (future)      GitHub Integration (future)
        \              |              /
         v             v             v
              Application Services
                       |
                       v
       Domain  +  Storage  +  Adapters

Business logic lives in the service modules (missions/service.py, runs/service.py, orchestrator/service.py, etc.), never directly in CLI handlers (commands/*.py) — so a future Web UI or GitHub App can call the same services the CLI does, without duplicating logic. No web server is required or implemented in this MVP.

Capability Enforcement at the Execution Boundary

See authorization-model.md for the capability-computation model. Computation and enforcement are two distinct steps, and both are real:

  • Computation (authorization/policy.py::effective_capabilities): the intersection of a role's own capabilities and its mission authorization's granted capabilities — no adapter, role, or synthesis result can grant itself a capability.
  • Enforcement (authorization/enforcement.py::authorize_operation): the single central check every non-deterministic (real) adapter execution passes through, called by runs/service.py::execute_agent_run before adapter.execute() is ever invoked — for every entry point (CLI, TUI, MCP, and any future direct orchestration caller), because they all route through this one shared function. A denied operation produces no subprocess, no filesystem change, and no git side effect; the run is persisted with state="denied" and a machine-readable reason (UNKNOWN_OPERATION, MISSING_ASSIGNMENT, MISSING_CAPABILITY, INSUFFICIENT_AUTHORIZATION, HUMAN_DECISION_REQUIRED, ADAPTER_UNSUPPORTED, MISSION_STATE_INVALID, ASSIGNMENT_STATE_INVALID).
  • Adapter defense-in-depth (adapters/local_command.py::execute): a second, independent check inside the adapter itself, which never trusts that the central check ran — git commit specifically can never execute without COMMIT_EXECUTE, checked unconditionally, regardless of what (if anything) the caller declared.

Committing requires COMMIT_EXECUTE, which no built-in role currently holds and no AuthorizationType grants except COMMIT_GO — reachable only via a human-resolved Decision (decisions/service.py::resolve_decision's grant_authorization_type, scoped to one mission and one decision; orchestrator/service.py::retry_operation re-runs the complete enforcement check for the retried attempt, never exempting it). See .assync/reports/p0-capability-enforcement-report.md for the verified bypass this closes and its test evidence.

Subprocess Safety (adapters/local_command.py)

  • Never shell=True — arguments are always an explicit list.
  • Executable allowlist, checked before every execution.
  • git push/merge/clean hard-denied regardless of granted capabilities.
  • Explicit timeout; output capped at 100,000 characters; a credential-pattern regex redacts api_key=/secret_key=/password=/ token=-shaped values from captured output before it is ever persisted or displayed.
  • Only a minimal environment allowlist (PATH, HOME, LANG, LC_ALL plus explicit caller-approved keys) is passed to the child process — never the full process environment, so ambient secrets are not inherited by default.

Filesystem Safety (storage/filesystem.py, local_command.py)

Every record ID and every resolved working directory is checked to remain under its declared root before use — traversal is rejected outright. projects/service.py::register_project additionally rejects a new project path that is nested inside (or contains) an already-registered project.

Prompt-Injection Boundary

This MVP calls no remote LLM, so there is no live prompt-injection surface today — but the request/response contract is designed so that repository or agent-output content can never alter: authorization, capability grants, role identity, allowed paths, the output schema, or human decision authority. Those are all fixed by the orchestrator and enforced before adapter.execute() is called (see "Capability Enforcement at the Execution Boundary" above), and a returned report is independently schema-validated (runs/report_schema.py) rather than trusted verbatim — a successful adapter exit with an invalid report is still recorded as a failed run (runs/service.py::execute_agent_run).

No Hidden Chain-of-Thought

AgentRunResult and the structured report schema only ever carry summary, recommendation, confidence, findings, evidence, assumptions, limitations, and required_human_decisions — never a raw reasoning transcript.

Two BLOCKED Concepts — Deliberately Distinct

RuntimeState.BLOCKED (an agent/mission execution state) and EvidenceClassification.BLOCKED (a specific claim's verification could not be completed) intentionally share the literal string "BLOCKED" — this mirrors .assync/runtime-visibility.md's own documented distinction. Because both are str, Enum subclasses, Python considers RuntimeState.BLOCKED == EvidenceClassification.BLOCKED to be True by value; this codebase never compares across the two enum types, and this exact behavior is documented and asserted directly in tests/test_core.py (see "Known MVP Limitations" below).

Known MVP Limitations

  • No production credentials, autonomous production actions, push, merge, or release execution exist anywhere in this codebase — by omission, not by a runtime guard that could be bypassed. There is nothing to bypass. (Commit execution is different: it is a real, adapter- supported operation, guarded by the runtime enforcement described above — see "Capability Enforcement at the Execution Boundary".)
  • retry_operation's authorization escalation is deliberately narrow: it re-attempts one role's execution under an elevated, decision-granted Authorization, but does not resume start_mission's full per-role loop or re-run mission-level synthesis afterward. A human must inspect the retried run's outcome via existing mission status/run list tooling.
  • An escalated Authorization has no default expiry and no supported revocation path. create_authorization's expires_at is None unless a caller explicitly sets one, and decision approve --grant-authorization-type does not expose a way to set one. A persisted Authorization's status field can represent revoked, and is_usable() correctly denies a revoked or expired one — but no function anywhere in this codebase actually transitions an existing, persisted Authorization into that state. In practice, a human-approved escalation is valid indefinitely and may be used for more than one retry. This is not an enforcement bypass: every single reuse is independently and fully re-checked by authorize_operation, exactly as a first attempt would be. It is a deliberately accepted MVP risk — expiry, revocation, and possibly single-use semantics are future hardening work, not yet designed or implemented.
  • The credential-redaction regex in local_command.py is a defense-in-depth measure, not a guarantee — callers must still avoid passing secrets into adapter input in the first place.
  • No sandboxing (containers, seccomp, etc.) is applied to the local command adapter beyond the allowlist/timeout/output-cap controls described above.