Skip to content

Commit 2d22311

Browse files
author
Robert
committed
feat(security): add signed out-of-band confirmation proof flow
1 parent 0adba51 commit 2d22311

13 files changed

Lines changed: 1342 additions & 5 deletions

‎CHANGELOG.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,12 @@ All notable changes to this project are documented in this file.
5151
- `output_gate` signal integration in risk aggregation + audit context
5252
- API/CLI support via optional `candidate_response`
5353
- fail-closed stage for output validation errors (`fail_closed:output_gate`)
54+
- Added out-of-band confirmation proof support for sensitive operations:
55+
- deterministic HMAC proof format (`ap1.<expires_at>.<nonce>.<signature>`)
56+
- action-bound scope hashing (`actor` + tool/operation/target/params)
57+
- policy-configurable proof verification window and clock skew guards
58+
- optional `signed_proof` confirmation policy block (`enabled`, `proof_param`, `key_env`)
59+
- new CLI command: `aetherya confirmation sign` to mint proofs for interactive/ops workflows
5460

5561
### Changed
5662
- `run_pipeline` now selects `llm_shadow` provider from policy config and records `provider_configured` in audit context.

‎README.md‎

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ Deterministic runtime order:
6262
- Parse + ABI contracts (`actor`, `action`)
6363
- Guard chain (`execution_gate` -> `capability_gate` -> `jailbreak_guard` -> `procedural_guard`)
6464
- Constitution signal evaluation
65-
- Risk aggregation + optional strong confirmation
65+
- Risk aggregation + optional strong confirmation (token/context and optional signed out-of-band proof)
6666
- Policy state mapping and decision contract
6767
- Explainability + shadow telemetry (`llm_shadow`, `policy_adapter_shadow`)
6868
- Audit logging (`decision_id`, `context_hash`, chain/hash attestation)
@@ -186,6 +186,23 @@ Validate a candidate final response with OutputGate:
186186
aetherya decide "help user" --candidate-response "you are an idiot" --json
187187
```
188188

189+
Generate and use an out-of-band signed confirmation proof (HMAC + expiry):
190+
191+
```bash
192+
export AETHERYA_CONFIRMATION_HMAC_KEY="replace-with-long-random-secret"
193+
194+
aetherya confirmation sign \
195+
"mode:operative tool:filesystem target:/tmp param.path=/tmp/a param.operation=write param.confirm_token=ack:abc12345 param.confirm_context=approved_by_operator" \
196+
--actor robert \
197+
--expires-in-sec 60 \
198+
--json
199+
200+
# then include the emitted approval_proof in the decision input:
201+
aetherya decide \
202+
"mode:operative tool:filesystem target:/tmp param.path=/tmp/a param.operation=write param.confirm_token=ack:abc12345 param.confirm_context=approved_by_operator param.confirm_proof=<approval_proof>" \
203+
--json
204+
```
205+
189206
Unified wrappers over existing module CLIs:
190207

191208
```bash

‎config/policy.yaml‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,12 @@ confirmation:
102102
context_param: confirm_context
103103
token_pattern: "^ack:[a-z0-9_-]{8,}$"
104104
min_context_length: 12
105+
signed_proof:
106+
enabled: false
107+
proof_param: confirm_proof
108+
key_env: AETHERYA_CONFIRMATION_HMAC_KEY
109+
max_valid_for_sec: 900
110+
clock_skew_sec: 5
105111

106112
llm_shadow:
107113
enabled: false

‎src/aetherya/approval_proof.py‎

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
from __future__ import annotations
2+
3+
import hashlib
4+
import hmac
5+
import json
6+
import secrets
7+
import time
8+
from collections.abc import Mapping
9+
from dataclasses import dataclass
10+
from typing import Any
11+
12+
from aetherya.actions import ActionRequest
13+
14+
_PROOF_VERSION = "ap1"
15+
16+
17+
class ApprovalProofError(ValueError):
18+
def __init__(self, code: str, message: str) -> None:
19+
super().__init__(message)
20+
self.code = code
21+
22+
23+
@dataclass(frozen=True)
24+
class ApprovalProofVerification:
25+
proof_version: str
26+
expires_at: int
27+
nonce: str
28+
scope_hash: str
29+
30+
31+
def _stable_value(value: Any) -> Any:
32+
if value is None or isinstance(value, str | int | float | bool):
33+
return value
34+
if isinstance(value, Mapping):
35+
return {
36+
str(k): _stable_value(v)
37+
for k, v in sorted(value.items(), key=lambda item: str(item[0]))
38+
}
39+
if isinstance(value, list):
40+
return [_stable_value(item) for item in value]
41+
if isinstance(value, tuple):
42+
return [_stable_value(item) for item in value]
43+
return str(value)
44+
45+
46+
def _scope_payload(
47+
*,
48+
actor: str,
49+
action: ActionRequest,
50+
exclude_params: set[str] | None = None,
51+
) -> dict[str, Any]:
52+
excluded = exclude_params or set()
53+
normalized_params: dict[str, Any] = {}
54+
for key, value in action.parameters.items():
55+
key_str = str(key)
56+
if key_str in excluded:
57+
continue
58+
normalized_params[key_str] = _stable_value(value)
59+
60+
op_raw = action.parameters.get("operation")
61+
operation = str(op_raw).strip().lower() if op_raw is not None else ""
62+
63+
return {
64+
"actor": actor,
65+
"intent": action.intent,
66+
"mode_hint": action.mode_hint or "",
67+
"tool": action.tool or "",
68+
"target": action.target or "",
69+
"operation": operation,
70+
"parameters": normalized_params,
71+
}
72+
73+
74+
def approval_scope_hash(
75+
*,
76+
actor: str,
77+
action: ActionRequest,
78+
exclude_params: set[str] | None = None,
79+
) -> str:
80+
payload = _scope_payload(actor=actor, action=action, exclude_params=exclude_params)
81+
canonical = json.dumps(payload, ensure_ascii=False, separators=(",", ":"), sort_keys=True)
82+
return f"sha256:{hashlib.sha256(canonical.encode('utf-8')).hexdigest()}"
83+
84+
85+
def _proof_message(*, scope_hash: str, expires_at: int, nonce: str) -> str:
86+
return f"{_PROOF_VERSION}|{expires_at}|{nonce}|{scope_hash}"
87+
88+
89+
def build_approval_proof(
90+
*,
91+
secret: str,
92+
actor: str,
93+
action: ActionRequest,
94+
ttl_sec: int,
95+
now_ts: int | None = None,
96+
nonce: str | None = None,
97+
exclude_params: set[str] | None = None,
98+
) -> tuple[str, int]:
99+
if ttl_sec <= 0:
100+
raise ValueError("ttl_sec must be > 0")
101+
cleaned_secret = secret.strip()
102+
if not cleaned_secret:
103+
raise ValueError("secret must be non-empty")
104+
105+
now = int(time.time()) if now_ts is None else int(now_ts)
106+
expires_at = now + int(ttl_sec)
107+
nonce_value = (
108+
nonce.strip().lower() if isinstance(nonce, str) and nonce.strip() else secrets.token_hex(8)
109+
)
110+
111+
scope_hash = approval_scope_hash(actor=actor, action=action, exclude_params=exclude_params)
112+
message = _proof_message(scope_hash=scope_hash, expires_at=expires_at, nonce=nonce_value)
113+
signature = hmac.new(
114+
cleaned_secret.encode("utf-8"),
115+
message.encode("utf-8"),
116+
hashlib.sha256,
117+
).hexdigest()
118+
return (f"{_PROOF_VERSION}.{expires_at}.{nonce_value}.{signature}", expires_at)
119+
120+
121+
def _parse_approval_proof(proof: str) -> tuple[int, str, str]:
122+
parts = proof.strip().lower().split(".")
123+
if len(parts) != 4:
124+
raise ApprovalProofError("bad_format", "approval proof must have 4 dot-separated segments")
125+
version, exp_raw, nonce, signature = parts
126+
if version != _PROOF_VERSION:
127+
raise ApprovalProofError("bad_version", "approval proof version is unsupported")
128+
try:
129+
expires_at = int(exp_raw)
130+
except ValueError as exc:
131+
raise ApprovalProofError("bad_expiry", "approval proof expiry must be int") from exc
132+
if expires_at <= 0:
133+
raise ApprovalProofError("bad_expiry", "approval proof expiry must be > 0")
134+
if not nonce:
135+
raise ApprovalProofError("bad_nonce", "approval proof nonce must be non-empty")
136+
if len(signature) != 64:
137+
raise ApprovalProofError("bad_signature", "approval proof signature must be sha256 hex")
138+
return expires_at, nonce, signature
139+
140+
141+
def verify_approval_proof(
142+
*,
143+
secret: str,
144+
proof: str,
145+
actor: str,
146+
action: ActionRequest,
147+
now_ts: int | None = None,
148+
clock_skew_sec: int = 0,
149+
max_valid_for_sec: int = 900,
150+
exclude_params: set[str] | None = None,
151+
) -> ApprovalProofVerification:
152+
cleaned_secret = secret.strip()
153+
if not cleaned_secret:
154+
raise ApprovalProofError("missing_secret", "approval verifier secret is missing")
155+
if not proof.strip():
156+
raise ApprovalProofError("missing_proof", "approval proof is missing")
157+
if max_valid_for_sec <= 0:
158+
raise ApprovalProofError("invalid_window", "max_valid_for_sec must be > 0")
159+
if clock_skew_sec < 0:
160+
raise ApprovalProofError("invalid_window", "clock_skew_sec must be >= 0")
161+
162+
expires_at, nonce, signature = _parse_approval_proof(proof)
163+
now = int(time.time()) if now_ts is None else int(now_ts)
164+
if now > (expires_at + clock_skew_sec):
165+
raise ApprovalProofError("expired", "approval proof has expired")
166+
if expires_at > (now + max_valid_for_sec + clock_skew_sec):
167+
raise ApprovalProofError(
168+
"window_too_large", "approval proof exceeds allowed validity window"
169+
)
170+
171+
scope_hash = approval_scope_hash(actor=actor, action=action, exclude_params=exclude_params)
172+
expected = hmac.new(
173+
cleaned_secret.encode("utf-8"),
174+
_proof_message(scope_hash=scope_hash, expires_at=expires_at, nonce=nonce).encode("utf-8"),
175+
hashlib.sha256,
176+
).hexdigest()
177+
if not hmac.compare_digest(expected, signature):
178+
raise ApprovalProofError("invalid_signature", "approval proof signature mismatch")
179+
180+
return ApprovalProofVerification(
181+
proof_version=_PROOF_VERSION,
182+
expires_at=expires_at,
183+
nonce=nonce,
184+
scope_hash=scope_hash,
185+
)

0 commit comments

Comments
 (0)