Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
11f97face6 | ||
|
|
f71d678c2d |
@@ -16,6 +16,8 @@ from typing import Any, Mapping
|
||||
|
||||
CALLER = "chatgpt-governed-implementer-singlehost"
|
||||
SCOPE = "release:candidate"
|
||||
GOVERNED_TASK_SCOPE = "governed-task:project"
|
||||
PROJECTED_SCOPES = frozenset({GOVERNED_TASK_SCOPE, SCOPE})
|
||||
CREDENTIAL_HEADER = "X-AIOS-Caller-Projection-Credential"
|
||||
|
||||
|
||||
@@ -66,7 +68,7 @@ class CallerProjectionClient:
|
||||
) -> ProjectionResult:
|
||||
if caller != CALLER:
|
||||
raise CallerProjectionError("CALLER_MISMATCH", 403)
|
||||
if scope != SCOPE:
|
||||
if scope not in PROJECTED_SCOPES:
|
||||
raise CallerProjectionError("SCOPE_MISMATCH", 403)
|
||||
body = {
|
||||
"caller": caller,
|
||||
@@ -124,7 +126,14 @@ class CallerProjectionClient:
|
||||
raise CallerProjectionError("CREDENTIAL_REHYDRATION_INVALID", 503)
|
||||
if value.get("rehydration_state") not in {"GENERATED", "RECOVERED"}:
|
||||
raise CallerProjectionError("REHYDRATION_STATE_INVALID", 503)
|
||||
if value.get("effective_scopes") != [SCOPE]:
|
||||
effective_scopes = value.get("effective_scopes")
|
||||
if (
|
||||
not isinstance(effective_scopes, list)
|
||||
or len(effective_scopes) != len(set(effective_scopes))
|
||||
or not all(isinstance(item, str) for item in effective_scopes)
|
||||
or scope not in effective_scopes
|
||||
or not set(effective_scopes).issubset(PROJECTED_SCOPES)
|
||||
):
|
||||
raise CallerProjectionError("PROJECTION_SCOPE_INVALID", 503)
|
||||
if value.get("secret_exposed") is not False:
|
||||
raise CallerProjectionError("PROJECTION_SECRET_EXPOSURE", 503)
|
||||
|
||||
@@ -40,6 +40,10 @@ PROJECTION_SCHEMA = "aios.external-capability-projection.request.v1"
|
||||
PROJECTION_LIST_SCHEMA = "aios.external-capability-projection.list.v1"
|
||||
CALLER_PROJECTION_SCHEMA = "aios.caller-admission-projection.v1"
|
||||
CALLER_PROJECTION_TTL_DEFAULT = 300
|
||||
CALLER_PROJECTION_SCOPES = frozenset({
|
||||
"governed-task:project",
|
||||
"release:candidate",
|
||||
})
|
||||
GATE_AMENDMENT_SCHEMA = "aios.release-preparation.gate-amendment.v1"
|
||||
GATE_AMENDMENT_CLASSIFICATION = "SUPERVISOR_GLOBAL_EXTERNAL_ACK_GATE_REMEDIATED"
|
||||
|
||||
@@ -296,13 +300,13 @@ class DurablePolicy:
|
||||
) -> dict[str, Any]:
|
||||
if caller != self.account:
|
||||
raise AuthorizationError("CALLER_MISMATCH", 403)
|
||||
if scope != "release:candidate":
|
||||
if scope not in CALLER_PROJECTION_SCOPES:
|
||||
raise AuthorizationError("SCOPE_MISMATCH", 403)
|
||||
if scope not in self.capabilities:
|
||||
raise AuthorizationError("CAPABILITY_NOT_AUTHORIZED", 403)
|
||||
if not isinstance(request, Mapping):
|
||||
raise AuthorizationError("CALLER_PROJECTION_REQUEST_INVALID")
|
||||
if request:
|
||||
if scope == "release:candidate":
|
||||
if set(request) != {"source_commit", "version"}:
|
||||
raise AuthorizationError("CALLER_PROJECTION_FIELDS_INVALID")
|
||||
source_commit = str(request.get("source_commit") or "")
|
||||
@@ -313,6 +317,8 @@ class DurablePolicy:
|
||||
self.constraints.release_version_prefix
|
||||
):
|
||||
raise AuthorizationError("RELEASE_VERSION_NOT_AUTHORIZED", 403)
|
||||
elif request:
|
||||
raise AuthorizationError("CALLER_PROJECTION_FIELDS_INVALID")
|
||||
return {
|
||||
"schema": CALLER_PROJECTION_SCHEMA,
|
||||
"caller": caller,
|
||||
@@ -566,98 +572,111 @@ class ReceiptStore:
|
||||
grant_id: str,
|
||||
policy_sha256: str,
|
||||
ttl_seconds: int,
|
||||
effective_scopes: Any,
|
||||
) -> dict[str, Any]:
|
||||
if not 30 <= ttl_seconds <= 900:
|
||||
raise AuthorizationError("CALLER_PROJECTION_TTL_INVALID", 503)
|
||||
projection_id = self.caller_projection_id(caller, scope)
|
||||
normalized_scopes = sorted({str(item) for item in effective_scopes})
|
||||
if (
|
||||
not normalized_scopes
|
||||
or scope not in normalized_scopes
|
||||
or not set(normalized_scopes).issubset(CALLER_PROJECTION_SCOPES)
|
||||
):
|
||||
raise AuthorizationError("CALLER_PROJECTION_SCOPES_INVALID", 503)
|
||||
requested_projection_id = self.caller_projection_id(caller, scope)
|
||||
issued = datetime.now(timezone.utc)
|
||||
issued_at = issued.isoformat()
|
||||
expires_at = (issued + timedelta(seconds=ttl_seconds)).isoformat()
|
||||
requested_recovery = False
|
||||
with self._lock, sqlite3.connect(self.path) as con:
|
||||
con.row_factory = sqlite3.Row
|
||||
con.execute("BEGIN IMMEDIATE")
|
||||
row = con.execute(
|
||||
"SELECT * FROM caller_projections WHERE caller=? AND scope=?",
|
||||
(caller, scope),
|
||||
).fetchone()
|
||||
if row is not None and row["projection_state"] not in {"ACKED", "PENDING"}:
|
||||
con.rollback()
|
||||
raise AuthorizationError("PROJECTION_STATE_INVALID", 503)
|
||||
recovery = row is not None
|
||||
if row is None:
|
||||
con.execute(
|
||||
"""
|
||||
INSERT INTO caller_projections(
|
||||
projection_id,caller,scope,grant_id,policy_sha256,
|
||||
projection_state,credential_state,rehydration_state,
|
||||
issued_at,expires_at,updated_at
|
||||
) VALUES(?,?,?,?,?,?,?,?,?,?,?)
|
||||
""",
|
||||
(
|
||||
projection_id,
|
||||
caller,
|
||||
scope,
|
||||
grant_id,
|
||||
policy_sha256,
|
||||
"PENDING",
|
||||
"GENERATING",
|
||||
"RECOVERING" if recovery else "GENERATING",
|
||||
issued_at,
|
||||
expires_at,
|
||||
issued_at,
|
||||
),
|
||||
)
|
||||
else:
|
||||
for projected_scope in normalized_scopes:
|
||||
projection_id = self.caller_projection_id(caller, projected_scope)
|
||||
row = con.execute(
|
||||
"SELECT * FROM caller_projections WHERE caller=? AND scope=?",
|
||||
(caller, projected_scope),
|
||||
).fetchone()
|
||||
if row is not None and row["projection_state"] not in {"ACKED", "PENDING"}:
|
||||
con.rollback()
|
||||
raise AuthorizationError("PROJECTION_STATE_INVALID", 503)
|
||||
recovery = row is not None
|
||||
if projected_scope == scope:
|
||||
requested_recovery = recovery
|
||||
if row is None:
|
||||
con.execute(
|
||||
"""
|
||||
INSERT INTO caller_projections(
|
||||
projection_id,caller,scope,grant_id,policy_sha256,
|
||||
projection_state,credential_state,rehydration_state,
|
||||
issued_at,expires_at,updated_at
|
||||
) VALUES(?,?,?,?,?,?,?,?,?,?,?)
|
||||
""",
|
||||
(
|
||||
projection_id,
|
||||
caller,
|
||||
projected_scope,
|
||||
grant_id,
|
||||
policy_sha256,
|
||||
"PENDING",
|
||||
"GENERATING",
|
||||
"RECOVERING" if recovery else "GENERATING",
|
||||
issued_at,
|
||||
expires_at,
|
||||
issued_at,
|
||||
),
|
||||
)
|
||||
else:
|
||||
con.execute(
|
||||
"""
|
||||
UPDATE caller_projections
|
||||
SET projection_id=?,grant_id=?,policy_sha256=?,
|
||||
projection_state='PENDING',
|
||||
credential_state='GENERATING',
|
||||
rehydration_state=?,
|
||||
issued_at=?,expires_at=?,updated_at=?
|
||||
WHERE caller=? AND scope=?
|
||||
""",
|
||||
(
|
||||
projection_id,
|
||||
grant_id,
|
||||
policy_sha256,
|
||||
"RECOVERING",
|
||||
issued_at,
|
||||
expires_at,
|
||||
issued_at,
|
||||
caller,
|
||||
projected_scope,
|
||||
),
|
||||
)
|
||||
con.execute(
|
||||
"""
|
||||
UPDATE caller_projections
|
||||
SET projection_id=?,grant_id=?,policy_sha256=?,
|
||||
projection_state='PENDING',
|
||||
credential_state='GENERATING',
|
||||
SET projection_state='ACKED',
|
||||
credential_state='REHYDRATED',
|
||||
rehydration_state=?,
|
||||
issued_at=?,expires_at=?,updated_at=?
|
||||
updated_at=?
|
||||
WHERE caller=? AND scope=?
|
||||
""",
|
||||
(
|
||||
projection_id,
|
||||
grant_id,
|
||||
policy_sha256,
|
||||
"RECOVERING",
|
||||
issued_at,
|
||||
expires_at,
|
||||
"RECOVERED" if recovery else "GENERATED",
|
||||
issued_at,
|
||||
caller,
|
||||
scope,
|
||||
projected_scope,
|
||||
),
|
||||
)
|
||||
con.execute(
|
||||
"""
|
||||
UPDATE caller_projections
|
||||
SET projection_state='ACKED',
|
||||
credential_state='REHYDRATED',
|
||||
rehydration_state=?,
|
||||
updated_at=?
|
||||
WHERE caller=? AND scope=?
|
||||
""",
|
||||
(
|
||||
"RECOVERED" if recovery else "GENERATED",
|
||||
issued_at,
|
||||
caller,
|
||||
scope,
|
||||
),
|
||||
)
|
||||
con.commit()
|
||||
return {
|
||||
"schema": CALLER_PROJECTION_SCHEMA,
|
||||
"projection_id": projection_id,
|
||||
"projection_id": requested_projection_id,
|
||||
"caller": caller,
|
||||
"scope": scope,
|
||||
"grant_id": grant_id,
|
||||
"policy_sha256": policy_sha256,
|
||||
"projection_state": "ACKED",
|
||||
"credential_state": "REHYDRATED",
|
||||
"rehydration_state": "RECOVERED" if recovery else "GENERATED",
|
||||
"effective_scopes": [scope],
|
||||
"rehydration_state": "RECOVERED" if requested_recovery else "GENERATED",
|
||||
"effective_scopes": normalized_scopes,
|
||||
"issued_at": issued_at,
|
||||
"expires_at": expires_at,
|
||||
"secret_exposed": False,
|
||||
@@ -972,10 +991,17 @@ class Broker:
|
||||
self.allowed_peer_users = allowed_peer_users or frozenset({expected_peer_user})
|
||||
|
||||
@staticmethod
|
||||
def validate_caller_projection(value: Mapping[str, Any]) -> dict[str, Any]:
|
||||
def validate_caller_projection(
|
||||
value: Mapping[str, Any],
|
||||
*,
|
||||
expected_scopes: Any = None,
|
||||
) -> dict[str, Any]:
|
||||
expected = sorted(
|
||||
set(expected_scopes) if expected_scopes is not None else CALLER_PROJECTION_SCOPES
|
||||
)
|
||||
if value.get("caller") != "chatgpt-governed-implementer-singlehost":
|
||||
raise AuthorizationError("CALLER_MISMATCH", 503)
|
||||
if value.get("scope") != "release:candidate":
|
||||
if value.get("scope") not in expected:
|
||||
raise AuthorizationError("SCOPE_MISMATCH", 503)
|
||||
if value.get("projection_state") != "ACKED":
|
||||
raise AuthorizationError("PROJECTION_STATE_INVALID", 503)
|
||||
@@ -983,7 +1009,7 @@ class Broker:
|
||||
raise AuthorizationError("CREDENTIAL_REHYDRATION_INVALID", 503)
|
||||
if value.get("rehydration_state") not in {"GENERATED", "RECOVERED"}:
|
||||
raise AuthorizationError("REHYDRATION_STATE_INVALID", 503)
|
||||
if value.get("effective_scopes") != ["release:candidate"]:
|
||||
if value.get("effective_scopes") != expected:
|
||||
raise AuthorizationError("PROJECTION_SCOPE_INVALID", 503)
|
||||
if value.get("secret_exposed") is not False:
|
||||
raise AuthorizationError("PROJECTION_SECRET_EXPOSURE", 503)
|
||||
@@ -1001,10 +1027,11 @@ class Broker:
|
||||
raise AuthorizationError("IDEMPOTENCY_KEY_INVALID")
|
||||
if caller != self.expected_account:
|
||||
raise AuthorizationError("CALLER_MISMATCH", 403)
|
||||
if scope != "release:candidate":
|
||||
if scope not in CALLER_PROJECTION_SCOPES:
|
||||
raise AuthorizationError("SCOPE_MISMATCH", 403)
|
||||
policy = self.policy()
|
||||
policy.validate_caller_scope(caller, scope, request)
|
||||
effective_scopes = sorted(policy.capabilities & CALLER_PROJECTION_SCOPES)
|
||||
try:
|
||||
ttl_seconds = int(
|
||||
os.environ.get(
|
||||
@@ -1021,8 +1048,12 @@ class Broker:
|
||||
grant_id=policy.grant_id,
|
||||
policy_sha256=policy.policy_sha256,
|
||||
ttl_seconds=ttl_seconds,
|
||||
effective_scopes=effective_scopes,
|
||||
)
|
||||
value = self.validate_caller_projection(
|
||||
value,
|
||||
expected_scopes=effective_scopes,
|
||||
)
|
||||
value = self.validate_caller_projection(value)
|
||||
value["_credential"] = credential
|
||||
return value
|
||||
|
||||
@@ -1377,6 +1408,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
routes = {
|
||||
"/v1/project-governed-task": "governed-task:project",
|
||||
"/v1/authorize-release-candidate": "release:candidate",
|
||||
"/v1/project-caller": "caller:projection",
|
||||
}
|
||||
capability = routes.get(self.path)
|
||||
if capability is None or self.broker is None:
|
||||
|
||||
@@ -7,6 +7,8 @@ from pathlib import Path
|
||||
from aios_caller_admission_projection import (
|
||||
CALLER,
|
||||
SCOPE,
|
||||
GOVERNED_TASK_SCOPE,
|
||||
PROJECTED_SCOPES,
|
||||
CallerProjectionClient,
|
||||
CallerProjectionError,
|
||||
)
|
||||
@@ -422,9 +424,21 @@ class H(BaseHTTPRequestHandler):
|
||||
except Exception: return {'_raw':raw}
|
||||
def actor(self):
|
||||
auth=self.headers.get('Authorization',''); tok=auth[7:].strip() if auth.startswith('Bearer ') else self.headers.get('X-AIOS-Token','')
|
||||
rec=authenticate(tok)
|
||||
if rec:
|
||||
scopes = set(rec.get('scopes') or [])
|
||||
try:
|
||||
rec=authenticate(tok)
|
||||
except Exception:
|
||||
rec=None
|
||||
if not isinstance(rec, dict) or not rec:
|
||||
return {
|
||||
'ok': False,
|
||||
'account': 'anonymous',
|
||||
'role': 'anonymous',
|
||||
'scopes': set(),
|
||||
'static_scopes': set(),
|
||||
'projection': None,
|
||||
'credential_loaded': False,
|
||||
}
|
||||
scopes = set(rec.get('scopes') or [])
|
||||
return {
|
||||
'ok': True,
|
||||
'account': safe(rec.get('account') or rec.get('name') or 'token-user'),
|
||||
@@ -434,13 +448,12 @@ class H(BaseHTTPRequestHandler):
|
||||
'projection': None,
|
||||
'credential_loaded': False,
|
||||
}
|
||||
return {'ok':False,'account':'anonymous','role':'anonymous','scopes':set()}
|
||||
def need(self, scope, *, projection_request=None):
|
||||
a = self.actor()
|
||||
if not a['ok']:
|
||||
self.sendj({'error': 'unauthorized', 'hint': 'Bearer token required'}, 401)
|
||||
return None
|
||||
if a['account'] == CALLER and scope == SCOPE:
|
||||
if a['account'] == CALLER and scope in PROJECTED_SCOPES:
|
||||
try:
|
||||
result = CALLER_PROJECTION_CLIENT.rehydrate(
|
||||
a['account'],
|
||||
@@ -490,7 +503,7 @@ class H(BaseHTTPRequestHandler):
|
||||
try:
|
||||
result = CALLER_PROJECTION_CLIENT.rehydrate(
|
||||
actor['account'],
|
||||
SCOPE,
|
||||
GOVERNED_TASK_SCOPE,
|
||||
{},
|
||||
)
|
||||
except CallerProjectionError as exc:
|
||||
@@ -980,6 +993,8 @@ class H(BaseHTTPRequestHandler):
|
||||
}
|
||||
if path in governed_routes:
|
||||
route, scope = governed_routes[path]
|
||||
if path == '/createGovernedTask' and self.actor()['account'] == CALLER:
|
||||
scope = GOVERNED_TASK_SCOPE
|
||||
actor = self.need(scope)
|
||||
if not actor:
|
||||
return
|
||||
|
||||
@@ -1,88 +1,154 @@
|
||||
# Caller admission projection and rehydration
|
||||
# Caller admission projection and Bridge execution rehydration
|
||||
|
||||
This runbook covers the exact durable Owner policy and the exact API caller:
|
||||
This runbook covers the exact durable Owner policy and exact API caller:
|
||||
|
||||
- caller: \`chatgpt-governed-implementer-singlehost\`
|
||||
- scope: \`release:candidate\`
|
||||
- policy source: \`/etc/aios-gitops/owner-durable-authorization.json\`
|
||||
- revocation source: \`/etc/aios-gitops/owner-durable-authorization-revocations.json\`
|
||||
- caller: chatgpt-governed-implementer-singlehost
|
||||
- durable policy: /etc/aios-gitops/owner-durable-authorization.json
|
||||
- revocations: /etc/aios-gitops/owner-durable-authorization-revocations.json
|
||||
- grant: owner-permanent-preparation-20260819
|
||||
- durable capabilities: governed-task:project and release:candidate
|
||||
|
||||
## Admission source
|
||||
|
||||
The model API first authenticates the existing caller token from the static
|
||||
registry. For this exact caller and exact release scope, static authentication
|
||||
is not sufficient. The model API calls the existing Unix-socket Owner broker.
|
||||
The broker reloads and validates the durable policy on every projection request,
|
||||
including account, capability, optional expiry, and revocation.
|
||||
The model API authenticates the caller with the existing static token registry
|
||||
only to identify the caller and retain the existing static scopes. For the
|
||||
exact caller, release:candidate and governed-task:project are not permanent
|
||||
static-token grants. The model API asks the Unix-socket Owner broker to
|
||||
rehydrate the requested durable capability.
|
||||
|
||||
Other callers retain their existing static-scope behavior. The target caller
|
||||
does not receive a permanent \`release:candidate\` entry in the static registry,
|
||||
and it is never treated as a release-worker account.
|
||||
The broker reloads the durable policy and revocation file on every request,
|
||||
checks the exact caller, requested capability, policy constraints, optional
|
||||
expiry and revocation state, and rejects all capabilities outside the durable
|
||||
policy. It never uses a release-worker identity or credential.
|
||||
|
||||
## Projection lifecycle and ACK semantics
|
||||
|
||||
The broker records only non-secret projection metadata in its existing durable
|
||||
state database. The state sequence is:
|
||||
The server-side flow is:
|
||||
|
||||
\`PENDING\` -> \`ACKED\`
|
||||
durable Owner policy
|
||||
-> exact caller and capability validation
|
||||
-> caller projection rows for the policy capabilities
|
||||
-> in-memory short-lived credential generation
|
||||
-> projection ACKED / credential REHYDRATED
|
||||
-> model API admission
|
||||
-> Bridge governed-task admission and execution envelope
|
||||
|
||||
The ACK is produced by the broker's server-side projection consumer after the
|
||||
exact policy and request binding have passed validation. The public response
|
||||
contains projection state, effective scope, policy digest, expiry and
|
||||
rehydration state. The short-lived credential material is returned only in a
|
||||
private Unix-socket response header to the model API process. It is not written
|
||||
to the response body, logs, diagnostics, Git, task metadata or terminal output.
|
||||
The existing caller_projections table stores only projection metadata. A
|
||||
rehydration request atomically projects the currently authorized caller
|
||||
capabilities, so a valid current policy containing both capabilities returns
|
||||
both
|
||||
governed-task:project and release:candidate in effective_scopes. No raw
|
||||
credential, bearer, token, or credential hash is stored.
|
||||
|
||||
ACKED means the broker completed the server-side projection transaction and
|
||||
validated the returned public state. It is not an Owner Approval, release
|
||||
approval, deployment authorization, or release-worker identity.
|
||||
|
||||
## Credential rehydration, expiry and revocation
|
||||
|
||||
The credential projection is generated in memory for a short TTL and is
|
||||
discarded after the model API admission check. The durable row stores no raw
|
||||
credential and no bearer token. After a broker or model API restart, the next
|
||||
admission request creates a new short-lived credential from the durable
|
||||
projection metadata; this is reported as \`RECOVERED\`.
|
||||
Credential material is generated server-side, sent only in the private Unix
|
||||
socket response header to the model API process, consumed for admission, and
|
||||
discarded. The public response and introspection never contain it.
|
||||
|
||||
Every request reloads the policy. Missing, malformed, expired or revoked policy
|
||||
fails closed. A caller mismatch, scope mismatch, invalid projection state,
|
||||
broker outage, missing credential header or unexpected secret field also fails
|
||||
closed. No static scope fallback is allowed for the target caller.
|
||||
Each request reloads policy state. Missing, malformed, expired or revoked
|
||||
policy, caller mismatch, capability mismatch, invalid projection state,
|
||||
credential generation failure, or broker outage fails closed. Projection rows
|
||||
are short-lived metadata and are rehydrated after process restart; the
|
||||
rehydration state is GENERATED or RECOVERED.
|
||||
|
||||
## Diagnosis
|
||||
The exact caller uses governed-task:project for createGovernedTask admission.
|
||||
Other callers keep their existing branch:create path. The existing
|
||||
agent-task:control, branch:create and file:write static behavior remains
|
||||
available and is not expanded by this repair.
|
||||
|
||||
Use the read-only API with the caller's existing credential:
|
||||
## Bridge execution projection
|
||||
|
||||
\`GET /api/caller-capabilities\`
|
||||
A governed task created through the existing Owner broker -> Bridge path
|
||||
receives metadata.execution with schema aios.online-agent.execution.v2 and an
|
||||
execution_projection record. A source_capture placeholder with write
|
||||
capabilities is intentionally not claimable. The existing deterministic plan
|
||||
registry and execution-rebind CAS path must produce the deterministic plan
|
||||
before a worker can claim it.
|
||||
|
||||
The legitimate execution chain is:
|
||||
|
||||
QUEUED -> PROJECTED -> deterministic-plan/rebind CAS -> CLAIMED
|
||||
-> heartbeat -> Writer Lease -> CAS branch/file writes -> tests
|
||||
-> official verifier -> immutable runtime evidence.
|
||||
|
||||
Never write metadata.execution, task events, plan records, ACKs, outbox rows or
|
||||
broker database rows by hand. Never bypass the Bridge with SSH/root or a
|
||||
release-worker credential.
|
||||
|
||||
## Restart recovery and failure states
|
||||
|
||||
After broker or model API restart, repeat the read-only capability check and
|
||||
make one normal admission request. The broker must regenerate a short-lived
|
||||
credential from the durable policy and report RECOVERED without manual token
|
||||
operations.
|
||||
|
||||
Useful fail-closed states include:
|
||||
|
||||
- PROJECTION_BROKER_UNAVAILABLE
|
||||
- POLICY_FILE_INVALID
|
||||
- POLICY_EXPIRED
|
||||
- OWNER_GRANT_REVOKED
|
||||
- CALLER_MISMATCH
|
||||
- SCOPE_MISMATCH
|
||||
- CAPABILITY_NOT_AUTHORIZED
|
||||
- PROJECTION_STATE_INVALID
|
||||
- CREDENTIAL_REHYDRATION_MISSING
|
||||
- TASK_EXECUTION_PROJECTION_MISSING
|
||||
- TASK_PERSISTED_BINDING_CONFLICT
|
||||
- EXECUTION_REBIND_CAS_MISMATCH
|
||||
|
||||
A rejected state is not repaired by editing SQLite, projection_outbox,
|
||||
static token registry, task metadata or task events.
|
||||
|
||||
## Whoami and effective-scope diagnosis
|
||||
|
||||
Use the read-only model API with the caller's existing credential:
|
||||
|
||||
GET /api/caller-capabilities
|
||||
|
||||
The response is safe to display and contains:
|
||||
|
||||
- \`caller_identity\`
|
||||
- \`admitted\`
|
||||
- \`effective_scopes\`
|
||||
- \`projection_state\`
|
||||
- \`credential_state\`
|
||||
- \`rehydration_state\`
|
||||
- \`secret_exposed=false\`
|
||||
- caller_identity
|
||||
- admitted
|
||||
- effective_scopes
|
||||
- projection_state
|
||||
- credential_state
|
||||
- rehydration_state
|
||||
- projection_error
|
||||
- secret_exposed=false
|
||||
|
||||
It never contains a bearer, token, credential value or credential hash.
|
||||
For the authorized current policy, effective_scopes must contain:
|
||||
|
||||
For a release candidate request, the model API also binds the exact
|
||||
\`source_commit\` and \`version\` to the durable policy before invoking the
|
||||
trusted release service. The caller cannot supply release evidence fields.
|
||||
- agent-task:control
|
||||
- branch:create
|
||||
- file:write
|
||||
- governed-task:project
|
||||
- release:candidate
|
||||
|
||||
## Failure states and troubleshooting
|
||||
No credential, token, bearer, password or secret value may appear in the
|
||||
response, logs, diagnostics, Git diff or Task metadata.
|
||||
|
||||
- \`PROJECTION_BROKER_UNAVAILABLE\`: verify the broker unit, socket path and
|
||||
the model API supplementary groups; do not add a static scope.
|
||||
- \`OWNER_GRANT_REVOKED\` or \`POLICY_EXPIRED\`: restore a valid Owner policy
|
||||
through governed policy management; do not edit broker state.
|
||||
- \`CALLER_MISMATCH\` or \`SCOPE_MISMATCH\`: verify the authenticated account and
|
||||
requested exact scope.
|
||||
- \`PROJECTION_STATE_INVALID\`: inspect the durable projection through the
|
||||
broker's read-only diagnostics and fail closed until it is reconciled.
|
||||
- \`CREDENTIAL_REHYDRATION_MISSING\`: restart only the related control-plane
|
||||
service after capturing pre-state and then repeat the read-only diagnosis.
|
||||
- \`trusted candidate builder is not configured\`: Gate A admission is working,
|
||||
but the separate existing TrustedCandidateBuilder runtime must be restored
|
||||
before Gate B can create a candidate.
|
||||
For broker-level diagnosis use the read-only /v1/capabilities endpoint through
|
||||
the authorized Unix peer. It must identify the exact account and grant and
|
||||
show both durable capabilities; it must not be used to create manual ACKs.
|
||||
|
||||
No Owner Approval, Deployment, Host Seed, Host Activation, Projector,
|
||||
continuation task, Tag or Baseline action is part of this path.
|
||||
## Release:candidate troubleshooting
|
||||
|
||||
For createReleaseCandidate, the caller must pass only source_commit and
|
||||
version to the model API. The durable policy constraint requires an exact
|
||||
40-hex source commit and version beginning with V2R2C. The model API first
|
||||
rehydrates release:candidate and then invokes the existing release service.
|
||||
|
||||
If release admission fails, check caller-capabilities, policy validity,
|
||||
revocation state, projection_state, credential_state and rehydration_state.
|
||||
Do not add release:candidate to static tokens and do not use
|
||||
online-release-builder-cn-network-mkt-20260819.
|
||||
|
||||
Creating and reading a Release Candidate is the end of this repair. Owner
|
||||
Approval, Deployment, Host Seed, Host Activation, Projector, continuation
|
||||
Task, Tag and Baseline actions are outside this scope.
|
||||
|
||||
@@ -30,6 +30,7 @@ Upstream = BROKER_MODULE.Upstream
|
||||
|
||||
CALLER = "chatgpt-governed-implementer-singlehost"
|
||||
SCOPE = "release:candidate"
|
||||
GOVERNED_TASK_SCOPE = "governed-task:project"
|
||||
SOURCE = "257c7a8df433f75b364bcaf69cbbc32e8c2e5d38"
|
||||
|
||||
|
||||
@@ -38,7 +39,9 @@ def policy(*, expires_at=None, capabilities=None):
|
||||
grant_id="grant-caller-projection-v1",
|
||||
owner_identity="owner-durable-policy",
|
||||
account=CALLER,
|
||||
capabilities=frozenset(capabilities or {SCOPE}),
|
||||
capabilities=frozenset(
|
||||
capabilities or {GOVERNED_TASK_SCOPE, SCOPE}
|
||||
),
|
||||
issued_at=datetime.now(timezone.utc) - timedelta(minutes=1),
|
||||
not_before=datetime.now(timezone.utc) - timedelta(minutes=1),
|
||||
persistent_until_revoked=True,
|
||||
@@ -83,7 +86,27 @@ class CallerProjectionTests(unittest.TestCase):
|
||||
self.assertGreaterEqual(len(credential), 32)
|
||||
self.assertEqual("ACKED", value["projection_state"])
|
||||
self.assertEqual("REHYDRATED", value["credential_state"])
|
||||
self.assertEqual([SCOPE], value["effective_scopes"])
|
||||
self.assertEqual(
|
||||
[GOVERNED_TASK_SCOPE, SCOPE],
|
||||
value["effective_scopes"],
|
||||
)
|
||||
self.assertFalse(value["secret_exposed"])
|
||||
|
||||
def test_governed_task_projection_uses_durable_capability(self):
|
||||
broker = make_broker(Path(tempfile.mkdtemp()))
|
||||
with patch.object(broker, "policy", return_value=policy()):
|
||||
value = broker.project_caller(
|
||||
CALLER,
|
||||
GOVERNED_TASK_SCOPE,
|
||||
{},
|
||||
idempotency_key="caller-projection-governed-task-v1",
|
||||
)
|
||||
value.pop("_credential")
|
||||
self.assertEqual(GOVERNED_TASK_SCOPE, value["scope"])
|
||||
self.assertEqual(
|
||||
[GOVERNED_TASK_SCOPE, SCOPE],
|
||||
value["effective_scopes"],
|
||||
)
|
||||
self.assertFalse(value["secret_exposed"])
|
||||
|
||||
def test_secret_material_is_not_durable(self):
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
import types
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
APP = ROOT / "active-runtime" / "app"
|
||||
if str(APP) not in sys.path:
|
||||
sys.path.insert(0, str(APP))
|
||||
|
||||
try:
|
||||
import orchestrator.action_monitor
|
||||
except ModuleNotFoundError:
|
||||
_orchestrator = types.ModuleType("orchestrator")
|
||||
_action_monitor = types.ModuleType("orchestrator.action_monitor")
|
||||
_action_monitor.ActionMonitor = type("ActionMonitor", (), {})
|
||||
_orchestrator.__path__ = []
|
||||
sys.modules["orchestrator"] = _orchestrator
|
||||
sys.modules["orchestrator.action_monitor"] = _action_monitor
|
||||
|
||||
import aios_durable_owner_authorization as broker_api
|
||||
import aios_model_api_staging as model_api
|
||||
|
||||
|
||||
CALLER = "chatgpt-governed-implementer-singlehost"
|
||||
SCOPE = "release:candidate"
|
||||
GOVERNED_TASK_SCOPE = "governed-task:project"
|
||||
|
||||
|
||||
def _invoke_capabilities(
|
||||
headers,
|
||||
*,
|
||||
auth_result=None,
|
||||
auth_side_effect=None,
|
||||
projection_client=None,
|
||||
):
|
||||
handler = object.__new__(model_api.H)
|
||||
handler.headers = headers
|
||||
responses = []
|
||||
handler.sendj = lambda value, code=200: responses.append((value, code))
|
||||
if auth_side_effect is not None:
|
||||
auth_patch = patch.object(
|
||||
model_api, "authenticate", side_effect=auth_side_effect
|
||||
)
|
||||
else:
|
||||
auth_patch = patch.object(
|
||||
model_api, "authenticate", return_value=auth_result
|
||||
)
|
||||
client = (
|
||||
projection_client
|
||||
if projection_client is not None
|
||||
else model_api.CALLER_PROJECTION_CLIENT
|
||||
)
|
||||
with auth_patch, patch.object(model_api, "CALLER_PROJECTION_CLIENT", client):
|
||||
model_api.H.caller_capabilities(handler)
|
||||
assert len(responses) == 1
|
||||
return responses[0]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"headers",
|
||||
[
|
||||
{},
|
||||
{"Authorization": "Bearer invalid"},
|
||||
{"Authorization": "Bearer unknown"},
|
||||
{"X-AIOS-Token": "rec-none"},
|
||||
],
|
||||
)
|
||||
def test_anonymous_invalid_unknown_and_rec_none_are_structured_401(headers):
|
||||
payload, status = _invoke_capabilities(headers, auth_result=None)
|
||||
assert status == 401
|
||||
assert payload == {"error": "unauthorized", "hint": "Bearer token required"}
|
||||
assert "Traceback" not in json.dumps(payload)
|
||||
|
||||
|
||||
def test_authenticate_exception_is_fail_closed_without_socket_abort():
|
||||
payload, status = _invoke_capabilities(
|
||||
{"Authorization": "Bearer malformed"},
|
||||
auth_side_effect=RuntimeError("malformed"),
|
||||
)
|
||||
assert status == 401
|
||||
assert payload["error"] == "unauthorized"
|
||||
assert "Traceback" not in json.dumps(payload)
|
||||
|
||||
|
||||
def test_authorized_capability_introspection_remains_secret_free():
|
||||
row = {
|
||||
"account": CALLER,
|
||||
"role": "user",
|
||||
"scopes": ["agent-task:control", "branch:create", "file:write"],
|
||||
}
|
||||
result = SimpleNamespace(
|
||||
public={
|
||||
"projection_state": "ACKED",
|
||||
"credential_state": "ACTIVE",
|
||||
"rehydration_state": "REHYDRATED",
|
||||
"effective_scopes": [GOVERNED_TASK_SCOPE, SCOPE],
|
||||
},
|
||||
credential_loaded=True,
|
||||
)
|
||||
|
||||
class _ProjectionClient:
|
||||
def rehydrate(self, caller, scope, request):
|
||||
assert caller == CALLER
|
||||
assert scope == GOVERNED_TASK_SCOPE
|
||||
assert request == {}
|
||||
return result
|
||||
|
||||
payload, status = _invoke_capabilities(
|
||||
{"Authorization": "Bearer valid"},
|
||||
auth_result=row,
|
||||
projection_client=_ProjectionClient(),
|
||||
)
|
||||
assert status == 200
|
||||
assert payload["caller_identity"] == CALLER
|
||||
assert payload["admitted"] is True
|
||||
assert SCOPE in payload["effective_scopes"]
|
||||
assert GOVERNED_TASK_SCOPE in payload["effective_scopes"]
|
||||
assert payload["projection_state"] == "ACKED"
|
||||
assert payload["credential_state"] == "ACTIVE"
|
||||
assert payload["rehydration_state"] == "REHYDRATED"
|
||||
assert payload["secret_exposed"] is False
|
||||
assert "_credential" not in json.dumps(payload)
|
||||
|
||||
|
||||
class _FakeBroker:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
def project_caller(self, caller, scope, request, *, idempotency_key):
|
||||
self.calls.append((caller, scope, request, idempotency_key))
|
||||
return {
|
||||
"caller": caller,
|
||||
"scope": scope,
|
||||
"projection_state": "ACKED",
|
||||
"credential_state": "ACTIVE",
|
||||
"rehydration_state": "REHYDRATED",
|
||||
"effective_scopes": [SCOPE],
|
||||
"_credential": "x" * 40,
|
||||
}
|
||||
|
||||
|
||||
def _dispatch_broker(path="/v1/project-caller", *, peer=True, account=True):
|
||||
broker = _FakeBroker()
|
||||
handler = object.__new__(broker_api.Handler)
|
||||
handler.path = path
|
||||
handler.broker = broker
|
||||
handler.peer_authorized = lambda: peer
|
||||
handler.account_authorized = lambda: account
|
||||
handler.body = lambda: {
|
||||
"caller": CALLER,
|
||||
"scope": SCOPE,
|
||||
"idempotency_key": "runtime-regression-test-20260823",
|
||||
"request": {
|
||||
"repository": "aios/aios",
|
||||
"target_branch": "agent/runtime-regression-test",
|
||||
"release_version": "V2R2C2",
|
||||
},
|
||||
}
|
||||
responses = []
|
||||
handler.send_json = lambda value, status, internal_credential=None: responses.append(
|
||||
(value, status, internal_credential)
|
||||
)
|
||||
broker_api.Handler.do_POST(handler)
|
||||
return responses, broker
|
||||
|
||||
|
||||
def test_existing_project_caller_route_reaches_handler_without_public_secret():
|
||||
responses, broker = _dispatch_broker()
|
||||
assert len(responses) == 1
|
||||
value, status, internal_credential = responses[0]
|
||||
assert status == 200
|
||||
assert broker.calls
|
||||
assert broker.calls[0][0:2] == (CALLER, SCOPE)
|
||||
assert internal_credential == "x" * 40
|
||||
assert "_credential" not in value
|
||||
assert "x" * 40 not in json.dumps(value)
|
||||
|
||||
|
||||
def test_unauthorized_peer_is_denied_and_unregistered_route_is_not_added():
|
||||
responses, broker = _dispatch_broker(peer=False)
|
||||
assert responses[0][1] == 403
|
||||
assert broker.calls == []
|
||||
|
||||
responses, broker = _dispatch_broker(path="/v1/not-registered")
|
||||
assert responses[0][1] == 404
|
||||
assert broker.calls == []
|
||||
Reference in New Issue
Block a user