Security API
Security status, tamper-evident audit trail, and the credential vault.
Endpoints
| Method | Path | Action | Description |
|---|---|---|---|
| GET | /security/status | SecurityRead | Security status summary |
| GET | /security/audit | SecurityAudit | Query recent audit entries |
| GET | /security/verify | SecurityRead | Verify the audit hash-chain integrity |
| POST | /audit/append | AuditWrite | Append a manual audit entry |
| GET | /vault | VaultRead | List credential vault key names + timestamps |
| PUT | /vault/{key} | VaultManage | Store/rotate a secret |
| DELETE | /vault/{key} | VaultManage | Remove a secret |
SecurityRead is admin/operator/viewer/auditor; SecurityAudit is admin/auditor only; AuditWrite and both vault actions (VaultRead, VaultManage) are admin-only — no other role, including auditor, can read vault key names or write/delete a secret (issue #475, same bar as KeyManagement).
There is deliberately no GET /vault/{key} — the credential vault is write/rotate/delete only from the API. There is no read-back of a stored secret value over HTTP, by design (issue #475 non-goal / acceptance criterion #8). GET /vault returns key names and timestamps only, never values.
GET /security/status
curl -H "Api-Version: v1" -H "Authorization: Bearer hzn_sk_..." \
http://localhost:4200/security/status
Response (200)
{
"audit_enabled": true,
"audit_entries": 1247,
"auth_mode": "api-key",
"tls_enabled": true
}
auth_mode is "none", "api-key", or "oidc" (derived from the daemon's AuthMode, not the raw config string). There is no active_keys, ip_allowlist_configured, or audit_integrity field in this payload.
GET /security/audit
curl -H "Api-Version: v1" -H "Authorization: Bearer hzn_sk_..." \
"http://localhost:4200/security/audit?limit=5&category=DataAccess&actor=ci-deploy-bot"
Query Parameters
| Param | Type | Default | Description |
|---|---|---|---|
limit | integer | 20 | Max entries to return |
category | string | none | Filter by audit category |
actor | string | none | Filter by identity |
since | string | none | ISO 8601 timestamp lower bound |
Response (200) — AuditEntry[]
{
"entries": [
{
"id": 1247,
"seq": 1247,
"timestamp": "2026-06-04T10:15:02Z",
"identity": "ci-deploy-bot",
"role": "operator",
"action": "POST /agents/550e8400.../invoke",
"target": "550e8400-e29b-41d4-a716-446655440000",
"result": "success",
"hash": "sha256:...",
"metadata": null
}
]
}
Returns {"entries": []} (not an error) when no audit store is configured.
GET /security/verify
Verifies the audit trail's tamper-evident hash chain — each entry's hash covers the previous hash plus every core field (length-prefixed concatenation, not delimiter-joined, so no field's content can be mistaken for a boundary — issue #529).
curl -H "Api-Version: v1" -H "Authorization: Bearer hzn_sk_..." \
http://localhost:4200/security/verify
Response (200 — audit enabled)
{
"valid": true,
"entry_count": 1247,
"chain_anchor": "sha256:genesis...",
"tip_hash": "sha256:...",
"algorithm": "sha256(\"hoziron-audit-chain-v2\" || ...)",
"integrity_covers": "timestamp, identity, role, action, target, result, metadata (via metadata_hash)",
"integrity_excludes": "none — metadata is covered via metadata_hash, not hashed inline",
"verification_note": "..."
}
Response (200 — audit disabled)
{"valid": null, "status": "disabled", "reason": "audit disabled"}
Note this endpoint never returns HTTP error status for a broken chain — valid: false (with entry_count/hashes still populated) is itself the signal, not a 4xx/5xx.
POST /audit/append
Manually append an audit entry (admin-only). Routed through the platform, per ADR-036 ("core owns audit writes").
curl -X POST -H "Api-Version: v1" -H "Authorization: Bearer hzn_sk_admin..." \
-H "Content-Type: application/json" \
-d '{"event_type": "manual.override", "agent_id": "550e8400-...", "detail": "operator manually reviewed"}' \
http://localhost:4200/audit/append
Response (201)
{"status": "recorded", "event_type": "manual.override"}
GET /vault
curl -H "Api-Version: v1" -H "Authorization: Bearer hzn_sk_admin..." \
http://localhost:4200/vault
Response (200)
[
{"key": "ANTHROPIC_API_KEY", "stored_at": "2026-06-01T10:00:00Z"}
]
Note this is a bare JSON array, not wrapped in {"entries": [...]}. Never includes the secret value.
PUT /vault/{key}
curl -X PUT -H "Api-Version: v1" -H "Authorization: Bearer hzn_sk_admin..." \
-H "Content-Type: application/json" \
-d '{"value": "sk-ant-..."}' \
http://localhost:4200/vault/ANTHROPIC_API_KEY
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
value | string | Yes | The secret value to store (zeroized in memory after use) |
Response (200)
{"key": "ANTHROPIC_API_KEY", "stored_at": "2026-06-04T10:00:00Z"}
Storing to an existing key rotates it live — the next credential resolution (e.g. the next LLM call) observes the new value with no daemon restart required.