Security¶
El Hidratador is a local-first tool. Its security model is mostly about what it refuses to do: there is no network path in the core, no shell execution in the core, no telemetry anywhere, and no credential handling anywhere.
The repository root also carries a SECURITY.md with the supported versions and the reporting address. This page explains the boundaries and the reasoning.
Security boundaries¶
| Boundary | Rule | Enforced by |
|---|---|---|
| Core | Standard library only. No network calls, no shell execution, no telemetry, no provider credentials | core.py, errors.py, context_expander.py, dag.py, hardware_router.py, tickets.py have no network or process-spawning imports; pyproject.toml declares dependencies = [] |
| Ledger | SQLite in WAL mode with PRAGMA foreign_keys=ON; file 0600, parent directory 0700 |
Ledger.__init__ |
| GitHub adapter | gh CLI only. No token read, stored, or passed. 30-second timeout. 16 MiB response limit. Fixed argv, no shell |
GitHubSync._gh |
| Docker adapter | docker CLI only. Fixed argv, no shell. Timeout. Dry run by default. Minimal environment — only PATH and HOME are inherited |
sandbox.apply_sandbox, sandbox.teardown_sandbox |
| Dashboard adapter | HTTP only, loopback only, no redirects, 8 MiB response limit, 10-second timeout, no credentials in the URL | dashboard.validate_url, dashboard.fetch |
| Secrets | Never in the repository. Injected through the environment at deploy time. HIDRATADOR_DB and HIDRATADOR_GITHUB_DB are the only environment variables the package reads |
core.default_db_path, GitHubSync.__init__ |
| Context | Untrusted reference data. Quoted inside explicit banners. Never treated as an instruction, never treated as authorization | context_expander.UNTRUSTED_BANNER, the trust key on every packet |
| Ticket files | Ticket ids are validated against ^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$, so no id can escape the evidence directory |
tickets.TICKET_ID |
| Sandbox | Read-only root filesystem, network none, all capabilities dropped, no-new-privileges, one writable mount |
sandbox.build_sandbox |
What is deliberately not done¶
These are design decisions, not gaps.
No network in the core. The core cannot reach the internet, a package index, or a remote
database. A ticket, a checkpoint, and a failure record never leave the machine. There is no
update check, no usage ping, and no telemetry of any kind. measured_cash_cost and
measured_token_savings are null in the status output for the same reason: the package cannot
measure what it does not observe.
No shell execution in the core. The core spawns no processes. The two adapters that do
(gh, docker) use fixed argument vectors with shell disabled, so nothing in a ticket, a
context blob, or a commit message can be interpolated into a command line.
No credential storage. No token, password, or key is read, written, or logged. The GitHub
adapter delegates authentication entirely to gh, using whatever the operator has already
configured. The Docker adapter passes a deliberately minimal environment to the child process
(PATH and HOME only), so a container cannot read the operator's credential environment through
the Docker CLI.
No telemetry. Nothing is reported outward. There is no usage analytics, no crash reporting, and no phone-home.
No automatic acceptance. A task cannot reach DONE without an explicit decide accept. There
is no timeout that promotes REVIEW to DONE, no heuristic that accepts on "looks good", and no
path in the code that sets DONE other than the acceptance branch of decide().
No automatic execution. The package produces prepared state. apply_sandbox() defaults to a
dry run. GitHubSync.publish() defaults to a dry run. dispatch_plan() returns a plan with
execution_authorized: false. manifest() and handoff_payload() both carry
execution_authorized: false / authorized: false.
The trust model for untrusted context¶
Context arrives from outside the ledger: a file on disk, a note typed by an operator, a snippet pulled from a repository. It cannot be trusted to be true, and it cannot be trusted to be an instruction.
The model has three layers.
1. Storage. Context bodies are stored content-addressed by SHA-256 hash. The hash identifies context in reports and omission lists without exposing the body.
2. Labelling. Every packet carries an explicit trust statement:
and every rendered prompt wraps the context bodies in fixed banners:
The banner text is a module constant, so it is identical in every prompt the package produces.
3. Separation. The untrusted block is visually and structurally distinct from the mandatory structure above it and the output contract below it. The prompt's own contract makes the boundary explicit to a reader:
1. Work only inside the scope keys listed above.
2. Record a checkpoint with summary, evidence, next step, and cumulative tokens.
3. Report failures with a category; never hide a failed step.
4. Stop and ask when a decision needs an operator.
The banner is a label, not an enforcement mechanism. It tells a reader — human or model — where untrusted data begins. It does not prevent a context blob from containing text that looks like an instruction. Two consequences follow:
- The consumer of a packet must treat the whole packet as untrusted input, not just the block.
- Nothing in the context array can ever authorise an action. Authorisation lives in the lifecycle: a lease, a decision, an operator.
The dashboard boundary¶
The dashboard is a local operator surface, not a public API. dashboard.validate_url() enforces
every limit before the socket opens:
| Rule | Value | Refusal message |
|---|---|---|
| Scheme | http only |
dashboard url must use http on loopback |
| Host | localhost, 127.0.0.1, ::1, [::1] |
dashboard host '<host>' is not loopback |
| Credentials | none in the URL | dashboard url must not carry credentials |
| Empty URL | refused | dashboard url must be nonempty |
Then, during the request:
| Rule | Value | Behaviour |
|---|---|---|
| Redirects | none are followed | A redirect raises HTTPError, which surfaces as AdapterError: dashboard returned HTTP <code> |
| Response size | 8 MiB maximum | AdapterError: dashboard response exceeded the 8 MiB limit |
| Timeout | 10 seconds | AdapterError: dashboard request timed out |
| Content | JSON only | AdapterError: dashboard response was not valid JSON |
| Unreachable | refused | AdapterError: dashboard unreachable: <reason> |
A dashboard preview is explicitly not a source of truth. The response carries
"note": "Preview reflects a local operator surface; it is not a source of truth." The ledger is
the authority for state; the dashboard is a view.
The gh CLI boundary¶
| Property | Value |
|---|---|
| Binary | gh only; it must be on PATH |
| Authentication | Owned entirely by gh. This package never reads or stores a token |
| Timeout | 30 seconds per invocation |
| Response limit | 16 MiB of stdout |
| Invocation | Fixed argv, shell disabled |
| Writes | Gated behind an explicit dry-run-first default |
The outbox adds two more guarantees on the write path:
- The idempotency key may not contain a newline or
<!--. This prevents a key from escaping its HTML comment marker and forging a different entry's marker. - Every published body ends with a marker comment,
<!-- hidratador:{key}:{digest} -->, and the publisher reconciles against existing comments before sending. A repeated run does not double-post.
File permissions¶
| Path | Permission | Set by |
|---|---|---|
| Ledger directory (created by the package) | 0700 |
Ledger.__init__ — mkdir(parents=True, exist_ok=True, mode=0o700) |
| Ledger database file | 0600 |
Ledger.__init__ — chmod(0o600) |
| GitHub adapter directory (created by the package) | 0700 |
GitHubSync.__init__ |
| Ticket files | whatever the directory allows | TicketStore._atomic_write — written to a temporary file in the same directory, then os.replaced into place |
The 0600/0700 chmod is best-effort: on a platform where it fails, the Ledger constructor
swallows the OSError and continues. Verify the permissions yourself on an unusual filesystem.
Atomic writes matter for a different reason: an interrupted export must never leave a torn ticket
file behind. _atomic_write writes to a tempfile.mkstemp file in the target directory, then
renames it into place, and removes the temporary file if anything fails.
What the ledger contains¶
The ledger holds objectives, acceptance criteria, evidence strings, failure summaries, and attached context bodies. That is project content and it can be sensitive even though no credentials are stored. Treat the ledger file with the same care as the repository it describes.
Sandboxing as a boundary¶
The sandbox specification is a declared boundary, not a running one. build_sandbox() returns
data with "created": false and the note "declared staging boundary; not a running container".
The enforced properties, when a container is actually created:
| Property | Setting |
|---|---|
| Root filesystem | read-only |
| Network | none |
| Capabilities | all dropped |
| Privilege escalation | no-new-privileges:true |
| Writable paths | /workspace only |
| Read-only paths | /repo, /evidence |
| Resource ceilings | memory, cpu count, process count |
boundary_report() summarises this and ends with the caveat that matters:
"Scope enforcement is a container boundary, not an application check." The ledger's scope keys
schedule who may work; the container decides what a worker can touch. Neither substitutes for the
other.
Reporting a vulnerability¶
Report through the channel described in the repository's SECURITY.md. Include:
- the affected component (core, GitHub adapter, Docker adapter, dashboard adapter, or ticket store),
- reproduction steps,
- an impact assessment,
- a suggested fix, if you have one.
The supported version line and the acknowledgement target are maintained in SECURITY.md, not here.
Related pages¶
- Architecture — where each boundary sits
- GitHub sync — the adapter in detail
- DAG and sandbox — the sandbox specification
- Operations — protecting and backing up the ledger