Skip to content
Merged
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
15 changes: 15 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,21 @@ if durable storage is needed. New Docker named volumes inherit image directory
ownership; existing or externally provisioned volumes need permissions set by
the operator. See [COLD_START](./docs/COLD_START.md) for commands and backup notes.

## Attestation audit metadata (P1-1)

Attestation is an operator acknowledgment, not authorization. Single-tenant
authorization remains possession of `MUTINY_API_TOKEN` when configured; the
shared token does not identify an individual person. Loopback demos may run
without authentication under the rules above.

Accepted campaign starts persist `attestation: true` and a server-generated UTC
`attested_at` in the `attestation.accepted` event. Campaign ingestion persists
the same fields in campaign config. For ingestion, this is the server receipt
time, not the local execution time; client timestamps cannot override it, and
idempotent retries retain the original value. Existing records are not backfilled.
This audit metadata stores neither the raw bearer token nor a token fingerprint.
No individual identity, multi-tenant authorization, or customer execution is added.

## Reporting a vulnerability

Please **do not** open a public GitHub issue for security-sensitive reports.
Expand Down
6 changes: 5 additions & 1 deletion apps/api/src/mutiny_api/ingest.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
import json
import logging
import os
from datetime import datetime, timezone
from typing import Any

from mutiny_core.redact import REDACTED, redact_secrets
Expand Down Expand Up @@ -113,7 +114,7 @@ def enforce_envelope(body: IngestEnvelopeBase) -> None:

def _campaign_config_fingerprint(config: dict[str, Any]) -> str:
"""Compare durable ingest config identity (ignore volatile timestamps)."""
skip = {"started_at", "completed_at", "seq", "mutiny_version"}
skip = {"started_at", "completed_at", "seq", "mutiny_version", "attested_at"}
filtered = {k: v for k, v in config.items() if k not in skip}
return stable_json_hash(filtered)

Expand Down Expand Up @@ -213,6 +214,9 @@ def _build_campaign_config(
cfg["execution_mode"] = "local_cli"
cfg["ingest"] = True
cfg["attestation"] = True
# Server receipt time, never an uploaded timestamp. Retries retain the
# original stored config and exclude this field from identity checks.
cfg["attested_at"] = datetime.now(timezone.utc).isoformat()
if body.mutiny_version:
cfg["mutiny_version"] = body.mutiny_version
if body.adapter:
Expand Down
2 changes: 2 additions & 0 deletions apps/api/src/mutiny_api/supervisor.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import time
import uuid
from collections import defaultdict
from datetime import datetime, timezone
from pathlib import Path
from typing import Any

Expand Down Expand Up @@ -345,6 +346,7 @@ def start_campaign(
"attestation.accepted",
{
"attestation": True,
"attested_at": datetime.now(timezone.utc).isoformat(),
"request_id": request_id,
"target": campaign["config"].get("target"),
"project_path": campaign["config"].get("project_path"),
Expand Down
13 changes: 7 additions & 6 deletions docs/PRODUCTION_READINESS.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ MVP limitations are labeled as such, not “bugs.”

| ID | Finding | Evidence | Targets |
|---|---|---|---|
| P1-1 | Attestation is **not authorization** (boolean only) | `CampaignStartRequest.attestation`; supervisor `PermissionError` if false | Hosted (and weak Local messaging) |
| P1-1 | **Audit metadata shipped (#32)**; attestation remains acknowledgment, not authorization | Server UTC `attested_at` in start events / ingest config; false still rejected; no bearer token stored | Single-tenant token possession; individual identity and multi-tenant AuthZ remain out of scope |
| P1-2 | Docs/architecture claim **rate limits** and **path/URL allowlisting** | **Partial:** in-process Hosted rate limits **shipped (P1-2)**; path/URL allowlisting still only the target enum | Hosted |
| P1-3 | **Secret redaction** required by PRD — **implemented (M-PR3)** | `mutiny_core.redact`; persist/display wiring | Both (resolved for common patterns) |
| P1-4 | CLI **Hosted-first** when `api_url` reachable — surprises operators; pushes `project_path` to API | ~~`run_cmd.py`~~ **Resolved (M-PR2):** local default; Hosted only via `--hosted` / `--hosted-url` | Local (ops safety); Hosted blast radius |
Expand Down Expand Up @@ -227,7 +227,7 @@ MVP limitations are labeled as such, not “bugs.”
| Control | Local CLI | Hosted today |
|---|---|---|
| AuthN | OS user | **Bearer when configured** (`MUTINY_API_TOKEN`, M-PR7). Loopback may omit for local demo; **non-loopback fails closed without token** (P0-1/P0-4). Supported entrypoint: `python -m mutiny_api` |
| AuthZ | Operator discipline | Checkbox attestation (not identity) — **P1-1 open** |
| AuthZ | Operator discipline | Token possession; timestamped attestation acknowledgment (P1-1 audit metadata shipped), not individual identity |
| Target isolation | Same process as developer agent | **M-PR8E + P0-3:** customer adapter exec + customer FS access permanently removed from Hosted; trusted `in_process_demo` only. **ADR-019 observe-only implemented** |
| Network bind | N/A (CLI) | Deploy templates may bind `0.0.0.0` **only with** non-empty `MUTINY_API_TOKEN` |
| Rate limit | N/A (CLI execution unlimited) | **In-process** token-bucket (P1-2; not distributed). Enabled by default when token set |
Expand Down Expand Up @@ -557,7 +557,8 @@ Scheduled after Target A gate; do not block 0.2.0.
- [x] DB backup/export procedure (P2-6 — `mutiny db backup` / `restore`); `MUTINY_DB_PATH` configurable (**done M-PR4**)
- [x] Threat model in SECURITY.md matches observe-only + bind/auth + rate limits
- [ ] Version **1.0.0** (or explicit 0.4+ “Hosted beta” with same technical bar, labeled beta)
- [ ] AuthZ beyond attestation (P1-1); multi-tenant / durable ops as needed for full Target B
- [x] P1-1 audit metadata: server UTC attestation timestamp persisted for starts and ingest; retries retain original ingest timestamp; no raw bearer token stored (#32).
- [ ] Multi-tenant AuthZ / durable ops as needed for full Target B; timestamped acknowledgment is not authorization.

### Future implementation protocol (ONE problem at a time)

Expand Down Expand Up @@ -607,7 +608,7 @@ Scheduled after Target A gate; do not block 0.2.0.
| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
| Public Hosted RCE via adapter load | Low (path removed) | Critical | M-PR8E permanent remove; observe-only ingest; no public multi-tenant claim |
| Operators trust attestation as security | High | High | Docs + M-PR7 auth; P1-1 remains |
| Operators trust attestation as security | High | High | Docs + M-PR7 auth; P1-1 timestamps acknowledgment, not identity or authorization |
| Doc drift causes wrong roadmap work | Med | Med | P1-7 reconcile; this doc as gate |
| Refund hardcoding → false “engine doesn’t work” on other tools | Med | Med | M-PR6 |
| Secret leakage in SQLite/SSE | Med | High | M-PR3 |
Expand Down Expand Up @@ -675,7 +676,7 @@ ADR-017 already supersedes ADR-001 for **product priority**. **M-PR2** aligns ru
### Critical Findings (P0/P1)

- **P0:** ~~Unauthenticated Hosted + in-process adapter exec + policy write + public bind~~ — **mitigated** (M-PR8E, P0-3, P0-1/P0-4).
- **P1 open:** Attestation≠authorization (P1-1); path/URL allowlisting still target enum (remainder of P1-2). *(P1-2 rate limits shipped; P1-3…P1-7 resolved.)*
- **P1 status:** P1-1 audit metadata shipped (#32); attestation still does not grant authorization. Path/URL allowlisting remains a target enum (remainder of P1-2). *(P1-2 rate limits shipped; P1-3…P1-7 resolved.)*

### Architectural Findings (ADR needed)

Expand Down Expand Up @@ -710,7 +711,7 @@ ADR-017 already supersedes ADR-001 for **product priority**. **M-PR2** aligns ru
9. M-PR8 ADR-019 Hosted execution model
10. **P0-3 / P0-1/P0-4 / P1-2 / P1-7** — FS-inert, bind fail-closed, in-process rate limits, docs reconcile (**done**)

Then remaining P1-1 / P2-* / P3/P4; **1.0.0** only after Target B DoD.
Then remaining authorization / P2-* / P3/P4 work; **1.0.0** only after Target B DoD.

### Release Plan

Expand Down
44 changes: 44 additions & 0 deletions tests/integration/test_api_hosted_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

import json
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from unittest.mock import MagicMock
Expand All @@ -19,6 +20,8 @@
tokens_match,
)
from mutiny_cli import run_cmd
from mutiny_api.db import connect
from mutiny_api.repository import Repository


FAKE_TOKEN = "test-mutiny-api-token-mpr7-not-real"
Expand Down Expand Up @@ -468,3 +471,44 @@ def test_e2e_campaign_auth_gate(client: TestClient) -> None:
def test_auth_disabled_allows_local_demo(client_no_auth: TestClient) -> None:
r = client_no_auth.get("/api/campaigns")
assert r.status_code == 200


@pytest.mark.parametrize("client_fixture", ["client", "client_no_auth"])
def test_start_persists_attestation_timestamp(
client_fixture, request, api_db, caplog
) -> None:
client = request.getfixturevalue(client_fixture)
headers = _auth_headers() if client_fixture == "client" else {}
created = client.post(
"/api/campaigns", headers=headers,
json={
"population_size": 2, "max_generations": 1, "elite_count": 1,
"max_turns": 2, "target": "in_process_demo",
},
)
assert created.status_code == 201, created.text
cid = created.json()["id"]
denied = client.post(
f"/api/campaigns/{cid}/start", headers=headers,
json={"attestation": False},
)
assert denied.status_code == 403
before = datetime.now(timezone.utc)
started = client.post(
f"/api/campaigns/{cid}/start", headers=headers,
json={"attestation": True},
)
assert started.status_code == 200, started.text
after = datetime.now(timezone.utc)
conn = connect(api_db)
try:
events = Repository(conn).list_events(cid)
accepted = [e for e in events if e["type"] == "attestation.accepted"]
assert len(accepted) == 1
payload = accepted[0]["payload"]
assert payload["attestation"] is True
assert before <= datetime.fromisoformat(payload["attested_at"]) <= after
assert FAKE_TOKEN not in "\n".join(conn.iterdump())
finally:
conn.close()
assert FAKE_TOKEN not in caplog.text
33 changes: 33 additions & 0 deletions tests/integration/test_api_hosted_ingest.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import hashlib
import json
import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from unittest.mock import patch
Expand Down Expand Up @@ -100,6 +101,38 @@ def _open_campaign(
), cid, payload


def test_attestation_timestamp_is_server_owned_and_stable(client, api_db, caplog):
payload = _envelope(
campaign_id="attestation-audit", local_project_key="audit-project",
attestation=False, config={"attested_at": "1900-01-01T00:00:00+00:00"},
)
url = "/api/ingest/v1/campaigns"
denied = client.post(url, headers=_auth(), json=payload)
assert denied.status_code == 400
assert denied.json()["error"]["code"] == "attestation_required"
payload["attestation"] = True
before = datetime.now(timezone.utc)
opened = client.post(url, headers=_auth(), json=payload)
after = datetime.now(timezone.utc)
assert opened.status_code == 201, opened.text
timestamp = opened.json()["campaign"]["config"]["attested_at"]
assert before <= datetime.fromisoformat(timestamp) <= after
payload["config"]["attested_at"] = "2099-01-01T00:00:00+00:00"
retry = client.post(url, headers=_auth(), json=payload)
assert retry.status_code == 201, retry.text
assert retry.json()["created"] is False
assert retry.json()["campaign"]["config"]["attested_at"] == timestamp
conn = connect(api_db)
try:
config = Repository(conn).get_campaign(payload["campaign_id"])["config"]
assert config["attestation"] is True
assert config["attested_at"] == timestamp
assert FAKE_TOKEN not in "\n".join(conn.iterdump())
finally:
conn.close()
assert FAKE_TOKEN not in caplog.text


@pytest.mark.parametrize("kind", ["candidate", "trace", "regression"])
def test_artifact_hash_mismatch_rejects_batch_before_writes(
client: TestClient, api_db: Path, kind: str
Expand Down
Loading