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 byruns/service.py::execute_agent_runbeforeadapter.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 withstate="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 commitspecifically can never execute withoutCOMMIT_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/cleanhard-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_ALLplus 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 resumestart_mission's full per-role loop or re-run mission-level synthesis afterward. A human must inspect the retried run's outcome via existingmission status/run listtooling.- An escalated Authorization has no default expiry and no supported
revocation path.
create_authorization'sexpires_atisNoneunless a caller explicitly sets one, anddecision approve --grant-authorization-typedoes not expose a way to set one. A persisted Authorization'sstatusfield can representrevoked, andis_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 byauthorize_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.pyis 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.