Skip to content
Draft
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
55 changes: 49 additions & 6 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -94,10 +94,22 @@ jobs:
tags: agent-control-plane:ci
- name: Smoke packaged service
run: |
docker run --detach --name agent-control-plane-ci --publish 8000:8000 agent-control-plane:ci
if docker run --rm agent-control-plane:ci; then
echo "service started without explicit authentication or development mode"
exit 1
fi
ACP_SMOKE_ADMIN_TOKEN="$(openssl rand -hex 32)"
ACP_SMOKE_READER_TOKEN="$(openssl rand -hex 32)"
ACP_SMOKE_ADMIN_HASH="$(printf '%s' "$ACP_SMOKE_ADMIN_TOKEN" | sha256sum | cut -d ' ' -f1)"
ACP_SMOKE_READER_HASH="$(printf '%s' "$ACP_SMOKE_READER_TOKEN" | sha256sum | cut -d ' ' -f1)"
ACP_AUTH_CONFIG="$(printf '{"principals":[{"subject":"ci@example.test","token_sha256":"%s","permissions":["*"]},{"subject":"reader@example.test","token_sha256":"%s","permissions":["agents:read"]}]}' "$ACP_SMOKE_ADMIN_HASH" "$ACP_SMOKE_READER_HASH")"
export ACP_SMOKE_ADMIN_TOKEN ACP_SMOKE_READER_TOKEN
docker run --detach --name agent-control-plane-ci --publish 8000:8000 \
--env "ACP_AUTH_CONFIG=$ACP_AUTH_CONFIG" agent-control-plane:ci
trap 'docker logs agent-control-plane-ci; docker rm --force agent-control-plane-ci' EXIT
python - <<'PY'
import json
import os
import time
import urllib.error
import urllib.request
Expand All @@ -114,28 +126,59 @@ jobs:
raise
time.sleep(0.5)

def call(path, method="GET", payload=None):
admin_token = os.environ["ACP_SMOKE_ADMIN_TOKEN"]

def call(path, method="GET", payload=None, token=admin_token):
headers = {"Content-Type": "application/json"}
if token is not None:
headers["Authorization"] = f"Bearer {token}"
request = urllib.request.Request(
f"http://127.0.0.1:8000{path}",
data=None if payload is None else json.dumps(payload).encode(),
headers={"Content-Type": "application/json"},
headers=headers,
method=method,
)
with urllib.request.urlopen(request, timeout=2) as response:
return json.load(response)

def expect_http_error(status, path, method="GET", payload=None, token=admin_token):
try:
call(path, method, payload, token)
except urllib.error.HTTPError as error:
assert error.code == status
return json.load(error)
raise AssertionError(f"expected HTTP {status}")

specification = {
"agent_id": "ci-smoke-agent",
"version": "1.0.0",
"display_name": "CI Smoke Agent",
"description": "Validates the packaged service contract.",
"entrypoint": "https://agents.example.test/ci-smoke",
}
assert expect_http_error(401, "/v1/agents", token=None)["detail"]["code"] == "authentication_failed"
assert expect_http_error(401, "/v1/agents", token="invalid")["detail"]["code"] == "authentication_failed"
reader_token = os.environ["ACP_SMOKE_READER_TOKEN"]
registration_payload = {"spec": specification, "actor": "ci@example.test"}
assert expect_http_error(
403,
"/v1/agents",
"POST",
registration_payload,
reader_token,
)["detail"]["code"] == "permission_denied"
mismatched_payload = {"spec": specification, "actor": "impersonated@example.test"}
assert expect_http_error(
403,
"/v1/agents",
"POST",
mismatched_payload,
)["detail"]["code"] == "actor_mismatch"
assert call("/v1/agent-specs/validate", "POST", specification)["valid"] is True
registered = call(
"/v1/agents",
"POST",
{"spec": specification, "actor": "ci@example.test"},
registration_payload,
)
assert registered["revision"] == 1
activated = call(
Expand All @@ -157,7 +200,7 @@ jobs:
"agent_id": "ci-smoke-agent",
"action": "deployment.promote",
"risk": "high",
"actor": "ci-smoke-agent",
"actor": "ci@example.test",
"reason": "Exercise the packaged governance loop.",
},
)
Expand All @@ -166,7 +209,7 @@ jobs:
"POST",
{
"decision": "approve",
"actor": "ci-reviewer@example.test",
"actor": "ci@example.test",
"reason": "Container smoke evidence passed.",
},
)
Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,5 @@ for public contracts once they are declared stable.
- Human approval queue with single-decision enforcement and append-only audit events.
- PostgreSQL system of record with transactional audit writes, Alembic migrations, readiness
checks, and database-level audit mutation protection.
- Bearer-token authentication adapter with scoped permissions, fail-closed durable startup, and
authenticated actor binding for audit-producing writes.
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,4 @@ migrate:
python -m alembic upgrade head

run:
python -m agent_control_plane
ACP_ALLOW_INSECURE_DEV=true python -m agent_control_plane
31 changes: 26 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,8 @@ make check
make run
```

Without `ACP_DATABASE_URL`, the service uses its process-local in-memory adapter. The API is
then available at `http://127.0.0.1:8000`. Important endpoints:
`make run` explicitly enables an unauthenticated, process-local in-memory adapter for
development. The API is then available at `http://127.0.0.1:8000`. Important endpoints:

- `GET /health/live`
- `GET /health/ready`
Expand All @@ -55,15 +55,34 @@ Persistent local execution starts PostgreSQL, runs migrations, and then starts t
docker compose up --build
```

For an externally managed PostgreSQL database, set a `postgresql+psycopg://` URL and migrate
before starting the service:
The Compose profile is protected by the development-only bearer token
`local-dev-control-plane-token`. Send it as `Authorization: Bearer <token>` when calling a
`/v1` endpoint. Health endpoints remain public.

For an externally managed PostgreSQL database, configure principals, set a
`postgresql+psycopg://` URL, and migrate before starting the service:

```bash
export ACP_AUTH_CONFIG='{"principals":[{"subject":"operator@example.test","token_sha256":"<sha-256-of-a-high-entropy-token>","permissions":["*"]}]}'
export ACP_DATABASE_URL='postgresql+psycopg://user:password@host/database'
make migrate
make run
```

Generate a fingerprint without placing the raw token in shell history:

```bash
python -c 'import getpass, hashlib; token = getpass.getpass("Bearer token: "); print(hashlib.sha256(token.encode()).hexdigest())'
```

`ACP_AUTH_CONFIG` stores token fingerprints, subjects, and permissions, never raw bearer
tokens. Generate each token with at least 256 bits of entropy, retain the raw value in the
calling system's secret manager, and send it only over TLS. Available permissions are
`agents:read`, `agents:write`, `approvals:read`, `approvals:request`, `approvals:decide`, and
`audit:read`; `*` is intended only for tightly controlled administrators. This static-token
adapter is the bootstrap authentication mechanism. A future OIDC adapter can replace it
without changing route authorization policy.

## Delivery policy

Every change merged to `main` goes through a pull request, review, and required fast checks.
Expand All @@ -82,7 +101,9 @@ their audit events commit atomically; a database trigger rejects audit updates,
truncation. Readiness fails when the configured database is unavailable or not migrated.

The in-memory adapter remains available for development and evaluation only. Authenticated
actor identity, request idempotency, backup automation, and durable workflows remain planned.
subjects and scoped permissions protect durable deployments, and actor-bearing writes reject
identity mismatches. Request idempotency, OIDC, backup automation, and durable workflows remain
planned.

## License

Expand Down
14 changes: 11 additions & 3 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,14 @@ changes require the high-risk review path described in `docs/QUALITY_GATES.md`.
Never place API keys, customer traces, prompts, memories, production data, or credentials in
the repository or test fixtures.

The credentials in `compose.yaml` are fixed development-only values. Production deployments
must inject a separate database URL through secret management, restrict the application role,
encrypt connections, and run backup/restore exercises before storing customer data.
The PostgreSQL credentials in `compose.yaml` and bearer token documented for local Compose are
fixed development-only values. Production deployments must inject a separate database URL
through secret management, restrict the application role, encrypt connections, and run
backup/restore exercises before storing customer data.

Durable deployments fail startup unless `ACP_AUTH_CONFIG` defines authenticated principals.
The configuration contains only SHA-256 fingerprints of high-entropy bearer tokens; raw tokens
must remain in the caller's secret manager, must be sent only over TLS, and must never appear in
logs. Grant explicit permissions and avoid the `*` administrator permission for routine agent
or reviewer identities. The static-token adapter is an initial bootstrap mechanism, not a
replacement for centrally managed identity, short-lived credentials, or token rotation.
2 changes: 2 additions & 0 deletions compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ services:
control-plane:
build: .
environment:
ACP_AUTH_CONFIG: >-
{"principals":[{"subject":"local-admin","token_sha256":"8fa4550a0cd4c25171ff8010ccd3fa30dd9430e9df0d8b1314b20dfdd4ca729d","permissions":["*"]}]}
ACP_DATABASE_URL: postgresql+psycopg://control_plane:control_plane@postgres:5432/control_plane
ports:
- "${ACP_HTTP_PORT:-8000}:8000"
Expand Down
12 changes: 10 additions & 2 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ Existing Agent
v
Control Plane API
|- AgentSpec validation
|- authenticated principals and scoped permissions
|- agent lifecycle and optimistic revision checks
|- human approval queue and append-only audit events
|- trace and replay (planned)
Expand All @@ -35,8 +36,15 @@ a second conflict check. A database trigger blocks audit mutation and removal. E
returned newest first.

Alembic owns schema versioning. Deployments run migrations as a separate step before the API;
readiness stays unavailable when the schema is missing. Authentication, backup policy, and
retention enforcement are required before production use.
readiness stays unavailable when the schema is missing.

Authentication is an owned adapter boundary. The initial adapter maps opaque bearer-token
fingerprints to subjects and permissions. Protected writes bind the request actor to the
authenticated subject before state reaches the store, so audit identity cannot be selected by
an untrusted request body. Durable mode fails closed without authentication; the unauthenticated
in-memory mode requires an explicit development switch. OIDC remains a future adapter rather
than a route-level dependency. Backup policy and retention enforcement are still required
before production use.

## Adapter policy

Expand Down
4 changes: 4 additions & 0 deletions docs/QUALITY_GATES.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ load, recovery, or security tests.
Database changes additionally require upgrade, integration, downgrade, and re-upgrade evidence
against the supported PostgreSQL version. Migration rehearsal uses disposable data only.

Authentication changes additionally require packaged-service evidence for missing, invalid,
under-scoped, mismatched-actor, and valid credentials. Authentication remains a high-risk
change even when the public request schema is unchanged.

## Dependency maintenance

Dependency pull requests must identify a compatibility, security, or reproducibility benefit.
Expand Down
2 changes: 2 additions & 0 deletions docs/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ Roadmap items advance only when tied to a validated user problem and an acceptan
- [x] Versioned AgentSpec and event contracts.
- [x] API health, readiness, and failure conventions.
- [x] Pull request governance and automated quality gates.
- [x] Authenticated principals, scoped permissions, and audit actor binding.

## Reliability gateway

Expand All @@ -23,6 +24,7 @@ Roadmap items advance only when tied to a validated user problem and an acceptan
- Durable workflow adapter for cross-day tasks.
- Idempotency, retry, compensation, and dead-letter handling.
- Backup, restore, tenant isolation, and disaster exercises.
- OIDC identity and automated credential rotation.

## Governed evolution

Expand Down
7 changes: 6 additions & 1 deletion src/agent_control_plane/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,12 @@


def main() -> None:
uvicorn.run("agent_control_plane.api:app", host="0.0.0.0", port=8000)
uvicorn.run(
"agent_control_plane.api:create_app_from_environment",
host="0.0.0.0",
port=8000,
factory=True,
)


if __name__ == "__main__": # pragma: no cover
Expand Down
Loading