feat(task-plane): add isolated readonly Codex worker policy

This commit is contained in:
chatgpt-gitops-bot
2026-07-24 21:31:08 +08:00
parent dd34dfaad2
commit 2d802f3d51
3 changed files with 159 additions and 7 deletions
+9 -5
View File
@@ -110,6 +110,7 @@ class TaskPlaneService:
existing = None existing = None
if existing is not None and str(body.get("identity") or "") != existing.identity: if existing is not None and str(body.get("identity") or "") != existing.identity:
raise ConflictError("worker identity takeover rejected") raise ConflictError("worker identity takeover rejected")
self.policy.assert_readonly_worker_registration(actor, body)
self.policy.assert_allowed( self.policy.assert_allowed(
"worker.admin" if is_admin else "worker.register", actor "worker.admin" if is_admin else "worker.register", actor
) )
@@ -127,6 +128,7 @@ class TaskPlaneService:
def worker_heartbeat(self, worker_id: str, payload: dict[str, Any]) -> dict[str, Any]: def worker_heartbeat(self, worker_id: str, payload: dict[str, Any]) -> dict[str, Any]:
interval = payload.get("heartbeat_interval_seconds") interval = payload.get("heartbeat_interval_seconds")
actor = str(payload.get("actor_identity") or "") actor = str(payload.get("actor_identity") or "")
self.policy.assert_readonly_worker_binding(actor, worker_id)
self.policy.assert_allowed("worker.register", actor) self.policy.assert_allowed("worker.register", actor)
return self.workers.heartbeat( return self.workers.heartbeat(
worker_id, worker_id,
@@ -162,6 +164,7 @@ class TaskPlaneService:
raise ValidationError("worker_id required") raise ValidationError("worker_id required")
task = self.store.get_task(task_id) task = self.store.get_task(task_id)
self.policy.assert_readonly_worker_binding(actor, str(worker_id))
self.policy.assert_allowed("claim", actor, task=task) self.policy.assert_allowed("claim", actor, task=task)
lease_seconds = _bounded_int( lease_seconds = _bounded_int(
body.get("lease_seconds", 30), "lease_seconds", 1, 3600 body.get("lease_seconds", 30), "lease_seconds", 1, 3600
@@ -181,10 +184,10 @@ class TaskPlaneService:
if not claim_id: if not claim_id:
raise ValidationError("claim_id required") raise ValidationError("claim_id required")
actor = body.get("actor_identity") or body.get("worker_identity") or "worker" actor = body.get("actor_identity") or body.get("worker_identity") or "worker"
self.policy.assert_allowed("heartbeat", actor)
task = self.store.get_task(task_id) task = self.store.get_task(task_id)
self.policy.assert_allowed("heartbeat", actor, task=task)
claim = self.store.get_claim(claim_id) claim = self.store.get_claim(claim_id)
self.policy.assert_readonly_worker_binding(actor, claim.worker_id)
self.policy.assert_allowed("heartbeat", actor, task=task)
if claim.worker_identity != actor: if claim.worker_identity != actor:
raise ForbiddenError("claim identity mismatch") raise ForbiddenError("claim identity mismatch")
lease_seconds = _bounded_int( lease_seconds = _bounded_int(
@@ -198,13 +201,14 @@ class TaskPlaneService:
if not claim_id: if not claim_id:
raise ValidationError("claim_id required") raise ValidationError("claim_id required")
actor = body.get("actor_identity") or body.get("worker_identity") or "worker" actor = body.get("actor_identity") or body.get("worker_identity") or "worker"
self.policy.assert_allowed("progress", actor)
progress = body.get("progress") progress = body.get("progress")
stage = body.get("stage", "RUNNING") stage = body.get("stage", "RUNNING")
message = body.get("message") message = body.get("message")
if progress is None: if progress is None:
raise ValidationError("progress required") raise ValidationError("progress required")
task = self.store.get_task(task_id) task = self.store.get_task(task_id)
claim = self.store.get_claim(claim_id)
self.policy.assert_readonly_worker_binding(actor, claim.worker_id)
self.policy.assert_allowed("progress", actor, task=task) self.policy.assert_allowed("progress", actor, task=task)
if task.task_plan.data_classification == "credential-secret": if task.task_plan.data_classification == "credential-secret":
if message not in {None, ""} or contains_secret_text(stage): if message not in {None, ""} or contains_secret_text(stage):
@@ -230,9 +234,7 @@ class TaskPlaneService:
if not claim_id: if not claim_id:
raise ValidationError("claim_id required") raise ValidationError("claim_id required")
actor = body.get("actor_identity") or body.get("worker_identity") or "worker" actor = body.get("actor_identity") or body.get("worker_identity") or "worker"
self.policy.assert_allowed("result", actor)
task = self.store.get_task(task_id) task = self.store.get_task(task_id)
self.policy.assert_allowed("result", actor, task=task)
status = body.get("status", "COMPLETED") status = body.get("status", "COMPLETED")
outputs = body.get("outputs", []) outputs = body.get("outputs", [])
@@ -244,6 +246,8 @@ class TaskPlaneService:
if any(not isinstance(ev, dict) for ev in evidences): if any(not isinstance(ev, dict) for ev in evidences):
raise ValidationError("evidence items must be objects") raise ValidationError("evidence items must be objects")
claim = self.store.get_claim(claim_id) claim = self.store.get_claim(claim_id)
self.policy.assert_readonly_worker_binding(actor, claim.worker_id)
self.policy.assert_allowed("result", actor, task=task)
if claim.worker_identity != actor: if claim.worker_identity != actor:
raise ForbiddenError("claim identity mismatch") raise ForbiddenError("claim identity mismatch")
if task.task_plan.data_classification == "credential-secret": if task.task_plan.data_classification == "credential-secret":
+61 -1
View File
@@ -10,6 +10,9 @@ from .models import ProbeEvidence, ResultEnvelope, TaskRecord
RISK_RANK = {"low": 1, "medium": 2, "high": 3} RISK_RANK = {"low": 1, "medium": 2, "high": 3}
READONLY_WORKER_ACTIONS = frozenset(
{"claim", "result", "fail", "progress", "heartbeat"}
)
ALL_ACTION_CAPABILITIES = frozenset( ALL_ACTION_CAPABILITIES = frozenset(
{ {
"task.creator", "task.creator",
@@ -30,6 +33,26 @@ class PolicyDecision:
reason: str = "" reason: str = ""
@dataclass(frozen=True)
class ReadonlyWorkerProfile:
"""A narrowly-scoped identity allowed to process safe Shadow tasks only."""
worker_id: str
identity: str
capabilities: frozenset[str]
task_types: frozenset[str] = frozenset({"code.review", "test.execute"})
def permits_task(self, task: TaskRecord) -> bool:
plan = task.task_plan
if plan.task_type not in self.task_types or plan.risk != "low":
return False
if plan.data_classification not in {"public", "internal"}:
return False
if plan.metadata.get("production_side_effects") is not False:
return False
return set(plan.required_capabilities).issubset(self.capabilities)
class IdentityVerifier: class IdentityVerifier:
def __init__(self, trusted_identities: set[str] | None = None): def __init__(self, trusted_identities: set[str] | None = None):
self.trusted_identities = trusted_identities self.trusted_identities = trusted_identities
@@ -71,6 +94,8 @@ class PolicyEngine:
probe_attestation_verifier: Callable[[ProbeEvidence], bool] | None = None, probe_attestation_verifier: Callable[[ProbeEvidence], bool] | None = None,
identity_capabilities: dict[str, set[str]] | None = None, identity_capabilities: dict[str, set[str]] | None = None,
admin_identities: set[str] | None = None, admin_identities: set[str] | None = None,
readonly_worker_execution_enabled: bool = False,
readonly_worker_profiles: list[ReadonlyWorkerProfile] | None = None,
) -> None: ) -> None:
if max_risk not in RISK_RANK: if max_risk not in RISK_RANK:
raise ValueError("invalid max_risk") raise ValueError("invalid max_risk")
@@ -85,6 +110,40 @@ class PolicyEngine:
for identity, capabilities in (identity_capabilities or {}).items() for identity, capabilities in (identity_capabilities or {}).items()
} }
self.admin_identities = set(admin_identities or ()) self.admin_identities = set(admin_identities or ())
self.readonly_worker_execution_enabled = readonly_worker_execution_enabled
self.readonly_worker_profiles = {
profile.identity: profile for profile in (readonly_worker_profiles or [])
}
def _readonly_worker_allows(
self, action: str, actor: str, task: TaskRecord | None
) -> bool:
profile = self.readonly_worker_profiles.get(actor)
return bool(
self.readonly_worker_execution_enabled
and action in READONLY_WORKER_ACTIONS
and profile is not None
and (task is None or profile.permits_task(task))
)
def assert_readonly_worker_binding(self, actor: str, worker_id: str) -> None:
profile = self.readonly_worker_profiles.get(actor)
if profile is not None and profile.worker_id != worker_id:
raise ForbiddenError("worker identity binding mismatch")
def assert_readonly_worker_registration(self, actor: str, payload: dict) -> None:
profile = self.readonly_worker_profiles.get(actor)
if profile is None:
return
if (
str(payload.get("worker_id") or "") != profile.worker_id
or str(payload.get("identity") or "") != profile.identity
or set(payload.get("capabilities") or []) != set(profile.capabilities)
or str(payload.get("trust_zone") or "") != "isolated-review"
or int(payload.get("max_concurrency") or 0) != 1
or int(payload.get("heartbeat_interval_seconds") or 0) != 15
):
raise ForbiddenError("readonly worker registration mismatch")
def verify_result( def verify_result(
self, result: ResultEnvelope, task: TaskRecord | None = None self, result: ResultEnvelope, task: TaskRecord | None = None
@@ -147,7 +206,8 @@ class PolicyEngine:
return PolicyDecision(False, f"action {action} not permitted") return PolicyDecision(False, f"action {action} not permitted")
if action in {"claim", "result", "fail", "cancel", "progress", "delegate", "heartbeat"} and not self.execution_enabled: if action in {"claim", "result", "fail", "cancel", "progress", "delegate", "heartbeat"} and not self.execution_enabled:
return PolicyDecision(False, "execution disabled") if not self._readonly_worker_allows(action, actor, task):
return PolicyDecision(False, "execution disabled")
if action == "delegate" and task and task.status not in {"COMPLETED", "FAILED_FINAL", "CANCELLED"}: if action == "delegate" and task and task.status not in {"COMPLETED", "FAILED_FINAL", "CANCELLED"}:
risk = str(context.get("risk", task.task_plan.risk)) risk = str(context.get("risk", task.task_plan.risk))
@@ -7,7 +7,12 @@ if os.path.join(ROOT, "active-runtime") not in sys.path:
sys.path.append(os.path.join(ROOT, "active-runtime")) sys.path.append(os.path.join(ROOT, "active-runtime"))
from app.task_plane.api import TaskPlaneService from app.task_plane.api import TaskPlaneService
from app.task_plane.policy import ALL_ACTION_CAPABILITIES, PolicyEngine from app.task_plane.policy import (
ALL_ACTION_CAPABILITIES,
IdentityVerifier,
PolicyEngine,
ReadonlyWorkerProfile,
)
def _policy(**kwargs): def _policy(**kwargs):
@@ -81,6 +86,89 @@ class TaskPlaneAPIBehaviorTests(unittest.TestCase):
self.assertEqual(service.get_children(task["task_id"]), []) self.assertEqual(service.get_children(task["task_id"]), [])
class ReadonlyCodexWorkerPolicyTests(unittest.TestCase):
identity = "spiffe://aios/agent/codex/review-test-01"
worker_id = "codex-review-test-01"
capabilities = frozenset(
{
"code.read",
"git.diff.read",
"code.review",
"review.security",
"review.api-contract",
"test.python",
"test.shell.allowlisted",
"task.progress",
"task.result",
"task.evidence.submit",
}
)
def setUp(self):
profile = ReadonlyWorkerProfile(self.worker_id, self.identity, self.capabilities)
policy = PolicyEngine(
identity_verifier=IdentityVerifier({"anon", self.identity}),
execution_enabled=False,
readonly_worker_execution_enabled=True,
readonly_worker_profiles=[profile],
identity_capabilities={
"anon": {"task.creator"},
self.identity: {"worker.self_register", "task.claim"},
},
)
self.service = TaskPlaneService(TaskPlaneStore(":memory:"), policy=policy)
self.registration = {
"worker_id": self.worker_id,
"worker_type": "codex",
"identity": self.identity,
"capabilities": sorted(self.capabilities),
"trust_zone": "isolated-review",
"max_concurrency": 1,
"heartbeat_interval_seconds": 15,
"supported_protocols": ["aios-task-v1", "a2a"],
"actor_identity": self.identity,
}
self.service.register_worker(self.registration)
def _task(self, *, task_type="code.review", capabilities=None, risk="low"):
return self.service.create_task(
{
"task_type": task_type,
"required_capabilities": capabilities or ["code.read", "code.review"],
"risk": risk,
"metadata": {"production_side_effects": False},
}
)
def test_readonly_profile_can_claim_only_safe_task(self):
task = self._task()
claim = self.service.claim(
task["task_id"],
{"worker_id": self.worker_id, "lease_seconds": 120, "actor_identity": self.identity},
)
self.assertTrue(claim["claim_id"])
def test_readonly_profile_rejects_wrong_worker_or_write_task(self):
task = self._task()
with self.assertRaises(Exception):
self.service.claim(
task["task_id"],
{"worker_id": "other-worker", "lease_seconds": 120, "actor_identity": self.identity},
)
write_task = self._task(task_type="code.change", capabilities=["code.write"])
with self.assertRaises(Exception):
self.service.claim(
write_task["task_id"],
{"worker_id": self.worker_id, "lease_seconds": 120, "actor_identity": self.identity},
)
def test_readonly_registration_cannot_escalate_capabilities(self):
payload = dict(self.registration)
payload["capabilities"] = [*payload["capabilities"], "git.commit"]
with self.assertRaises(Exception):
self.service.register_worker(payload)
class DelegationGuardTests(unittest.TestCase): class DelegationGuardTests(unittest.TestCase):
def setUp(self): def setUp(self):
self.store = TaskPlaneStore(":memory:") self.store = TaskPlaneStore(":memory:")